diff --git a/packages/core/src/__tests__/language-lesson.test.ts b/packages/core/src/__tests__/language-lesson.test.ts new file mode 100644 index 0000000..7a6d8e7 --- /dev/null +++ b/packages/core/src/__tests__/language-lesson.test.ts @@ -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([]); + }); + }); +}); diff --git a/packages/core/src/analyzer/language-lesson.ts b/packages/core/src/analyzer/language-lesson.ts new file mode 100644 index 0000000..cd1bc56 --- /dev/null +++ b/packages/core/src/analyzer/language-lesson.ts @@ -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 = { + "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 = { + 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).name === "string" && + typeof (c as Record).explanation === + "string", + ) + .map((c: { name: string; explanation: string }) => ({ + name: c.name, + explanation: c.explanation, + })) + : []; + + return { languageNotes, concepts }; + } catch { + return { languageNotes: "", concepts: [] }; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df3e52c..6d566f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,3 +29,9 @@ export { parseTourGenerationResponse, generateHeuristicTour, } from "./analyzer/tour-generator.js"; +export { + buildLanguageLessonPrompt, + parseLanguageLessonResponse, + detectLanguageConcepts, + type LanguageLessonResult, +} from "./analyzer/language-lesson.js";