feat(core): add language lesson prompt builder for contextual code teaching

Add detectLanguageConcepts, buildLanguageLessonPrompt, and
parseLanguageLessonResponse to support language-specific lessons tied
to individual graph nodes. Includes 12 concept detection patterns
(async/await, generics, middleware, etc.) and structured LLM prompt
generation with JSON response parsing. 10 new tests, 104 total passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 20:48:04 +08:00
co-authored by Claude Opus 4.6
parent 8747ed30b4
commit 3b298c1a7a
3 changed files with 335 additions and 0 deletions
@@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import {
buildLanguageLessonPrompt,
parseLanguageLessonResponse,
detectLanguageConcepts,
} from "../analyzer/language-lesson.js";
import type { GraphNode, GraphEdge } from "../types.js";
const sampleNode: GraphNode = {
id: "func:auth:verifyToken",
type: "function",
name: "verifyToken",
filePath: "src/auth/verify.ts",
lineRange: [10, 35],
summary: "Verifies JWT tokens and extracts user payload using async/await",
tags: ["auth", "jwt", "async"],
complexity: "moderate",
};
const sampleEdges: GraphEdge[] = [
{
source: "func:auth:verifyToken",
target: "file:src/config.ts",
type: "reads_from",
direction: "forward",
weight: 0.6,
},
{
source: "file:src/middleware.ts",
target: "func:auth:verifyToken",
type: "calls",
direction: "forward",
weight: 0.8,
},
];
describe("language-lesson", () => {
describe("buildLanguageLessonPrompt", () => {
it("includes the node name and summary", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("verifyToken");
expect(prompt).toContain("JWT tokens");
});
it("includes the target language", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("TypeScript");
});
it("includes relationship context", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("reads_from");
});
it("requests JSON output", () => {
const prompt = buildLanguageLessonPrompt(
sampleNode,
sampleEdges,
"typescript",
);
expect(prompt).toContain("JSON");
});
});
describe("parseLanguageLessonResponse", () => {
it("parses a valid response", () => {
const response = JSON.stringify({
languageNotes:
"Uses async/await for non-blocking token verification.",
concepts: [
{
name: "async/await",
explanation:
"The function uses async/await to handle asynchronous JWT verification.",
},
],
});
const result = parseLanguageLessonResponse(response);
expect(result.languageNotes).toBe(
"Uses async/await for non-blocking token verification.",
);
expect(result.concepts).toHaveLength(1);
expect(result.concepts[0].name).toBe("async/await");
expect(result.concepts[0].explanation).toContain("async/await");
});
it("extracts JSON from code blocks", () => {
const response = `Here is the analysis:
\`\`\`json
{
"languageNotes": "TypeScript generics used here.",
"concepts": [
{ "name": "generics", "explanation": "Type parameters enable reuse." }
]
}
\`\`\``;
const result = parseLanguageLessonResponse(response);
expect(result.languageNotes).toBe("TypeScript generics used here.");
expect(result.concepts).toHaveLength(1);
expect(result.concepts[0].name).toBe("generics");
});
it("returns empty result for invalid response", () => {
const result = parseLanguageLessonResponse("");
expect(result).toEqual({ languageNotes: "", concepts: [] });
});
});
describe("detectLanguageConcepts", () => {
it("detects async patterns from tags", () => {
const concepts = detectLanguageConcepts(sampleNode, "typescript");
expect(concepts).toContain("async/await");
});
it("detects middleware pattern", () => {
const middlewareNode: GraphNode = {
id: "func:middleware:auth",
type: "function",
name: "authMiddleware",
filePath: "src/middleware/auth.ts",
summary: "Express middleware for authentication",
tags: ["middleware", "auth"],
complexity: "moderate",
};
const concepts = detectLanguageConcepts(middlewareNode, "typescript");
expect(concepts).toContain("middleware pattern");
});
it("returns empty for nodes with no detectable concepts", () => {
const plainNode: GraphNode = {
id: "file:src/config.ts",
type: "file",
name: "config.ts",
filePath: "src/config.ts",
summary: "Exports configuration values from environment variables",
tags: ["config"],
complexity: "simple",
};
const concepts = detectLanguageConcepts(plainNode, "typescript");
expect(concepts).toEqual([]);
});
});
});
@@ -0,0 +1,174 @@
import type { GraphNode, GraphEdge } from "../types.js";
export interface LanguageLessonResult {
languageNotes: string;
concepts: Array<{ name: string; explanation: string }>;
}
const CONCEPT_PATTERNS: Record<string, string[]> = {
"async/await": ["async", "await", "promise", "asynchronous"],
"middleware pattern": ["middleware", "interceptor", "pipe"],
"generics": ["generic", "type parameter", "template"],
"decorators": ["decorator", "@", "annotation"],
"dependency injection": ["inject", "provider", "container", "di"],
"observer pattern": [
"subscribe",
"publish",
"event",
"observable",
"listener",
],
"singleton": ["singleton", "instance", "shared client"],
"type guards": ["type guard", "is", "narrowing", "discriminated union"],
"higher-order functions": [
"callback",
"factory",
"higher-order",
"closure",
],
"error handling": [
"try/catch",
"error boundary",
"exception",
"Result type",
],
"streams": ["stream", "pipe", "transform", "readable", "writable"],
"concurrency": ["goroutine", "channel", "thread", "worker", "mutex"],
};
/**
* Detects language concepts present in a graph node based on its tags, summary, and languageNotes.
*/
export function detectLanguageConcepts(
node: GraphNode,
language: string,
): string[] {
const text = [
...node.tags,
node.summary.toLowerCase(),
node.languageNotes?.toLowerCase() ?? "",
].join(" ");
const detected: string[] = [];
for (const [concept, keywords] of Object.entries(CONCEPT_PATTERNS)) {
const found = keywords.some((keyword) =>
text.toLowerCase().includes(keyword.toLowerCase()),
);
if (found) {
detected.push(concept);
}
}
return detected;
}
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
typescript: "TypeScript",
javascript: "JavaScript",
coffeescript: "CoffeeScript",
};
/**
* Builds a prompt that asks an LLM to produce a language-specific lesson for a given node.
*/
export function buildLanguageLessonPrompt(
node: GraphNode,
edges: GraphEdge[],
language: string,
): string {
const capitalizedLanguage =
LANGUAGE_DISPLAY_NAMES[language.toLowerCase()] ??
language.charAt(0).toUpperCase() + language.slice(1);
const concepts = detectLanguageConcepts(node, language);
const relationships = edges
.map((edge) => {
const arrow = edge.direction === "forward" ? "->" : "<-";
const other =
edge.source === node.id ? edge.target : edge.source;
return ` ${arrow} ${edge.type} ${other}`;
})
.join("\n");
const conceptSection =
concepts.length > 0
? `\nDetected concepts to explain:\n${concepts.map((c) => ` - ${c}`).join("\n")}`
: `\nNo specific concepts were pre-detected. Please identify any ${capitalizedLanguage} patterns or idioms present.`;
return `You are a programming teacher specializing in ${capitalizedLanguage}. Analyze the following code component and create a language-specific lesson.
Component: ${node.name}
Type: ${node.type}
File: ${node.filePath ?? "N/A"}
Summary: ${node.summary}
Tags: ${node.tags.join(", ")}
Relationships:
${relationships}
${conceptSection}
Return a JSON object with the following fields:
- "languageNotes": A concise explanation of the ${capitalizedLanguage}-specific patterns and idioms used in this component.
- "concepts": An array of objects, each with:
- "name": The concept name (e.g., "async/await", "generics").
- "explanation": A beginner-friendly explanation of this concept as it applies to this component.
Respond ONLY with the JSON object, no additional text.`;
}
/**
* Extracts a JSON block from an LLM response, handling markdown fences.
*/
function extractJson(response: string): string {
const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
return fenceMatch[1].trim();
}
const objectMatch = response.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0].trim();
}
return response.trim();
}
/**
* Parses an LLM response for language lesson content.
* Returns a safe default on parse failure.
*/
export function parseLanguageLessonResponse(
response: string,
): LanguageLessonResult {
try {
const jsonStr = extractJson(response);
const parsed = JSON.parse(jsonStr);
const languageNotes =
typeof parsed.languageNotes === "string" ? parsed.languageNotes : "";
const concepts = Array.isArray(parsed.concepts)
? parsed.concepts
.filter(
(
c: unknown,
): c is { name: string; explanation: string } =>
typeof c === "object" &&
c !== null &&
typeof (c as Record<string, unknown>).name === "string" &&
typeof (c as Record<string, unknown>).explanation ===
"string",
)
.map((c: { name: string; explanation: string }) => ({
name: c.name,
explanation: c.explanation,
}))
: [];
return { languageNotes, concepts };
} catch {
return { languageNotes: "", concepts: [] };
}
}
+6
View File
@@ -29,3 +29,9 @@ export {
parseTourGenerationResponse,
generateHeuristicTour,
} from "./analyzer/tour-generator.js";
export {
buildLanguageLessonPrompt,
parseLanguageLessonResponse,
detectLanguageConcepts,
type LanguageLessonResult,
} from "./analyzer/language-lesson.js";