From cb3d860fe29cd6da0f68d66381d0a157cf9b5715 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 20:13:11 +0800 Subject: [PATCH] feat(core): add tour generation engine with LLM and heuristic strategies Add three exported functions for generating guided codebase tours: - buildTourGenerationPrompt: builds LLM prompt with project metadata - parseTourGenerationResponse: parses LLM JSON response with validation - generateHeuristicTour: topology-based tour using Kahn's algorithm, with layer grouping and concept node separation Co-Authored-By: Claude Opus 4.6 --- .../core/src/__tests__/tour-generator.test.ts | 269 ++++++++++++++++ packages/core/src/analyzer/tour-generator.ts | 293 ++++++++++++++++++ packages/core/src/index.ts | 5 + 3 files changed, 567 insertions(+) create mode 100644 packages/core/src/__tests__/tour-generator.test.ts create mode 100644 packages/core/src/analyzer/tour-generator.ts diff --git a/packages/core/src/__tests__/tour-generator.test.ts b/packages/core/src/__tests__/tour-generator.test.ts new file mode 100644 index 0000000..4a3b878 --- /dev/null +++ b/packages/core/src/__tests__/tour-generator.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from "vitest"; +import { + buildTourGenerationPrompt, + parseTourGenerationResponse, + generateHeuristicTour, +} from "../analyzer/tour-generator.js"; +import type { KnowledgeGraph } from "../types.js"; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript"], + frameworks: ["express"], + description: "A test project", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { id: "file:src/index.ts", type: "file", name: "index.ts", filePath: "src/index.ts", summary: "Application entry point", tags: ["entry", "server"], complexity: "simple" }, + { id: "file:src/routes.ts", type: "file", name: "routes.ts", filePath: "src/routes.ts", summary: "Route definitions", tags: ["routes", "api"], complexity: "moderate" }, + { id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Business logic", tags: ["service"], complexity: "complex" }, + { id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database connection", tags: ["database"], complexity: "simple" }, + { id: "concept:auth-flow", type: "concept", name: "Auth Flow", summary: "Authentication concept", tags: ["concept", "auth"], complexity: "moderate" }, + ], + edges: [ + { source: "file:src/index.ts", target: "file:src/routes.ts", type: "imports", direction: "forward", weight: 0.9 }, + { source: "file:src/routes.ts", target: "file:src/service.ts", type: "calls", direction: "forward", weight: 0.8 }, + { source: "file:src/service.ts", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.7 }, + ], + layers: [ + { id: "layer:api", name: "API Layer", description: "HTTP routes", nodeIds: ["file:src/index.ts", "file:src/routes.ts"] }, + { id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts"] }, + { id: "layer:data", name: "Data Layer", description: "Database", nodeIds: ["file:src/db.ts"] }, + ], + tour: [], +}; + +describe("tour-generator", () => { + describe("buildTourGenerationPrompt", () => { + it("includes project name and description", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("test-project"); + expect(prompt).toContain("A test project"); + }); + + it("includes all node summaries", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("Application entry point"); + expect(prompt).toContain("Route definitions"); + expect(prompt).toContain("Business logic"); + expect(prompt).toContain("Database connection"); + expect(prompt).toContain("Authentication concept"); + }); + + it("includes layer information", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("API Layer"); + expect(prompt).toContain("Service Layer"); + expect(prompt).toContain("Data Layer"); + }); + + it("requests JSON output format", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("JSON"); + expect(prompt).toContain("steps"); + }); + }); + + describe("parseTourGenerationResponse", () => { + it("parses valid JSON response with tour steps", () => { + const response = JSON.stringify({ + steps: [ + { + order: 1, + title: "Entry Point", + description: "Start here", + nodeIds: ["file:src/index.ts"], + }, + { + order: 2, + title: "Routes", + description: "API routes", + nodeIds: ["file:src/routes.ts"], + }, + ], + }); + const steps = parseTourGenerationResponse(response); + expect(steps).toHaveLength(2); + expect(steps[0].order).toBe(1); + expect(steps[0].title).toBe("Entry Point"); + expect(steps[0].nodeIds).toEqual(["file:src/index.ts"]); + expect(steps[1].order).toBe(2); + }); + + it("extracts JSON from markdown code blocks", () => { + const response = `Here is the tour: +\`\`\`json +{ + "steps": [ + { + "order": 1, + "title": "Start", + "description": "The beginning", + "nodeIds": ["file:src/index.ts"] + } + ] +} +\`\`\``; + const steps = parseTourGenerationResponse(response); + expect(steps).toHaveLength(1); + expect(steps[0].title).toBe("Start"); + }); + + it("returns empty array for unparseable response", () => { + expect(parseTourGenerationResponse("not json at all")).toEqual([]); + expect(parseTourGenerationResponse("")).toEqual([]); + expect(parseTourGenerationResponse("random text here")).toEqual([]); + }); + + it("filters out steps with missing required fields", () => { + const response = JSON.stringify({ + steps: [ + { + order: 1, + title: "Valid Step", + description: "Has everything", + nodeIds: ["file:src/index.ts"], + }, + { + order: 2, + // missing title + description: "Missing title", + nodeIds: ["file:src/routes.ts"], + }, + { + order: 3, + title: "Missing description", + // missing description + nodeIds: ["file:src/routes.ts"], + }, + { + order: 4, + title: "Missing nodeIds", + description: "No nodes", + // missing nodeIds + }, + { + // missing order + title: "Missing order", + description: "No order", + nodeIds: ["file:src/db.ts"], + }, + ], + }); + const steps = parseTourGenerationResponse(response); + expect(steps).toHaveLength(1); + expect(steps[0].title).toBe("Valid Step"); + }); + }); + + describe("generateHeuristicTour", () => { + it("starts with entry-point nodes", () => { + const tour = generateHeuristicTour(sampleGraph); + // Entry point node (0 incoming edges) is file:src/index.ts + // It should appear in the first step's nodeIds + const firstStepNodeIds = tour[0].nodeIds; + expect(firstStepNodeIds).toContain("file:src/index.ts"); + }); + + it("follows topological order", () => { + const tour = generateHeuristicTour(sampleGraph); + // Collect all code node IDs in order across steps (excluding concept steps) + const codeSteps = tour.filter( + (s) => !s.title.toLowerCase().includes("concept"), + ); + const orderedNodeIds = codeSteps.flatMap((s) => s.nodeIds); + + // index.ts must appear before routes.ts + const indexPos = orderedNodeIds.indexOf("file:src/index.ts"); + const routesPos = orderedNodeIds.indexOf("file:src/routes.ts"); + const servicePos = orderedNodeIds.indexOf("file:src/service.ts"); + const dbPos = orderedNodeIds.indexOf("file:src/db.ts"); + + expect(indexPos).toBeLessThan(routesPos); + expect(routesPos).toBeLessThan(servicePos); + expect(servicePos).toBeLessThan(dbPos); + }); + + it("includes concept nodes in separate steps", () => { + const tour = generateHeuristicTour(sampleGraph); + // There should be a step containing the concept node + const conceptStep = tour.find((s) => + s.nodeIds.includes("concept:auth-flow"), + ); + expect(conceptStep).toBeDefined(); + // Concept step should not contain file nodes + const fileNodeIds = sampleGraph.nodes + .filter((n) => n.type === "file") + .map((n) => n.id); + for (const fileId of fileNodeIds) { + expect(conceptStep!.nodeIds).not.toContain(fileId); + } + }); + + it("assigns order numbers sequentially", () => { + const tour = generateHeuristicTour(sampleGraph); + for (let i = 0; i < tour.length; i++) { + expect(tour[i].order).toBe(i + 1); + } + }); + + it("groups nodes by layer when layers exist", () => { + const tour = generateHeuristicTour(sampleGraph); + // With layers, steps should reference layer names + const stepTitles = tour.map((s) => s.title); + // Should have steps that reference the layer names + const hasApiLayer = stepTitles.some((t) => t.includes("API Layer")); + const hasServiceLayer = stepTitles.some((t) => t.includes("Service Layer")); + const hasDataLayer = stepTitles.some((t) => t.includes("Data Layer")); + expect(hasApiLayer).toBe(true); + expect(hasServiceLayer).toBe(true); + expect(hasDataLayer).toBe(true); + }); + + it("produces valid TourStep objects", () => { + const tour = generateHeuristicTour(sampleGraph); + for (const step of tour) { + expect(typeof step.order).toBe("number"); + expect(typeof step.title).toBe("string"); + expect(step.title.length).toBeGreaterThan(0); + expect(typeof step.description).toBe("string"); + expect(step.description.length).toBeGreaterThan(0); + expect(Array.isArray(step.nodeIds)).toBe(true); + expect(step.nodeIds.length).toBeGreaterThan(0); + } + }); + + it("handles graph with no edges gracefully", () => { + const noEdgesGraph: KnowledgeGraph = { + ...sampleGraph, + edges: [], + layers: [], + }; + const tour = generateHeuristicTour(noEdgesGraph); + expect(tour.length).toBeGreaterThan(0); + // All code nodes should still appear somewhere + const allNodeIds = tour.flatMap((s) => s.nodeIds); + for (const node of noEdgesGraph.nodes) { + expect(allNodeIds).toContain(node.id); + } + }); + + it("handles graph with no layers", () => { + const noLayersGraph: KnowledgeGraph = { + ...sampleGraph, + layers: [], + }; + const tour = generateHeuristicTour(noLayersGraph); + expect(tour.length).toBeGreaterThan(0); + // Should batch code nodes (3 per step) instead of grouping by layer + const codeSteps = tour.filter( + (s) => !s.title.toLowerCase().includes("concept"), + ); + // With 4 code nodes and batches of 3, expect 2 code steps + expect(codeSteps.length).toBe(2); + }); + }); +}); diff --git a/packages/core/src/analyzer/tour-generator.ts b/packages/core/src/analyzer/tour-generator.ts new file mode 100644 index 0000000..8ba7fd2 --- /dev/null +++ b/packages/core/src/analyzer/tour-generator.ts @@ -0,0 +1,293 @@ +import type { KnowledgeGraph, TourStep } from "../types.js"; + +/** + * Builds an LLM prompt asking for a guided tour of the project. + * Includes project metadata, node summaries, edges, and layer info. + */ +export function buildTourGenerationPrompt(graph: KnowledgeGraph): string { + const { project, nodes, edges, layers } = graph; + + const nodeList = nodes + .map( + (n) => + ` - [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`, + ) + .join("\n"); + + const edgeList = edges + .slice(0, 50) + .map((e) => ` - ${e.source} --${e.type}--> ${e.target}`) + .join("\n"); + + const layerList = + layers.length > 0 + ? layers + .map( + (l) => + ` - ${l.name}: ${l.description} (nodes: ${l.nodeIds.join(", ")})`, + ) + .join("\n") + : " (no layers detected)"; + + return `You are a software architecture educator. Generate a guided tour of the following project that helps a newcomer understand the codebase step by step. + +Project: ${project.name} +Description: ${project.description} +Languages: ${project.languages.join(", ")} +Frameworks: ${project.frameworks.join(", ")} + +Nodes: +${nodeList} + +Edges (dependencies/relationships): +${edgeList} + +Layers: +${layerList} + +Create a logical tour that: +1. Starts with entry points or high-level overview files +2. Follows the natural dependency flow +3. Groups related files together +4. Ends with supporting utilities or concepts + +Return a JSON object with a "steps" array. Each step must have: +- "order": sequential number starting from 1 +- "title": a short descriptive title for this tour stop +- "description": 2-3 sentences explaining what the reader will learn at this step +- "nodeIds": array of node IDs to highlight for this step +- "languageLesson" (optional): a brief note about language-specific patterns seen in these files + +Respond ONLY with the JSON object, no additional text.`; +} + +/** + * Parses an LLM response for tour generation. + * Handles raw JSON and JSON wrapped in markdown code fences. + * Filters out steps missing required fields. + * Returns empty array if parsing fails. + */ +export function parseTourGenerationResponse(response: string): TourStep[] { + if (!response || response.trim().length === 0) { + return []; + } + + try { + // Try to extract from markdown code fences + const fenceMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/); + const jsonStr = fenceMatch ? fenceMatch[1].trim() : response.trim(); + + // Try to find a JSON object with steps + const objectMatch = jsonStr.match(/\{[\s\S]*\}/); + if (!objectMatch) { + return []; + } + + const parsed = JSON.parse(objectMatch[0]); + + if (!parsed || !Array.isArray(parsed.steps)) { + return []; + } + + // Filter and validate each step + const steps: TourStep[] = []; + for (const item of parsed.steps) { + if (typeof item !== "object" || item === null) continue; + if (typeof item.order !== "number") continue; + if (typeof item.title !== "string" || item.title.length === 0) continue; + if (typeof item.description !== "string" || item.description.length === 0) + continue; + if (!Array.isArray(item.nodeIds) || item.nodeIds.length === 0) continue; + + const step: TourStep = { + order: item.order, + title: item.title, + description: item.description, + nodeIds: item.nodeIds.filter((id: unknown) => typeof id === "string"), + }; + + if (typeof item.languageLesson === "string") { + step.languageLesson = item.languageLesson; + } + + steps.push(step); + } + + return steps; + } catch { + return []; + } +} + +/** + * Generates a tour heuristically (without an LLM) using graph topology. + * + * Strategy: + * 1. Separate concept nodes from code nodes + * 2. Build adjacency info from edges + * 3. Find entry points (nodes with 0 incoming edges) + * 4. Topological sort (Kahn's algorithm) + * 5. If layers exist: group by layer in topological order + * 6. If no layers: batch by 3 nodes per step + * 7. Add concept nodes as final "Key Concepts" step + * 8. Assign sequential order numbers + */ +export function generateHeuristicTour(graph: KnowledgeGraph): TourStep[] { + const { nodes, edges, layers } = graph; + + // Separate concept nodes from code nodes + const conceptNodes = nodes.filter((n) => n.type === "concept"); + const codeNodes = nodes.filter((n) => n.type !== "concept"); + const codeNodeIds = new Set(codeNodes.map((n) => n.id)); + + // Build adjacency info (only for code nodes) + const inDegree = new Map(); + const adjacency = new Map(); + + for (const node of codeNodes) { + inDegree.set(node.id, 0); + adjacency.set(node.id, []); + } + + for (const edge of edges) { + if (!codeNodeIds.has(edge.source) || !codeNodeIds.has(edge.target)) + continue; + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1); + adjacency.get(edge.source)!.push(edge.target); + } + + // Kahn's algorithm for topological sort + const queue: string[] = []; + for (const [nodeId, degree] of inDegree) { + if (degree === 0) { + queue.push(nodeId); + } + } + + const topoOrder: string[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + topoOrder.push(current); + + for (const neighbor of adjacency.get(current) ?? []) { + const newDegree = (inDegree.get(neighbor) ?? 1) - 1; + inDegree.set(neighbor, newDegree); + if (newDegree === 0) { + queue.push(neighbor); + } + } + } + + // Add any nodes not reached by topological sort (isolated nodes or cycles) + for (const node of codeNodes) { + if (!topoOrder.includes(node.id)) { + topoOrder.push(node.id); + } + } + + // Build tour steps + const steps: TourStep[] = []; + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + if (layers.length > 0) { + // Group by layer in topological order + const nodeToLayer = new Map(); + for (const layer of layers) { + for (const nodeId of layer.nodeIds) { + nodeToLayer.set(nodeId, layer.id); + } + } + + // Determine layer order from topological sort + const layerOrder: string[] = []; + const layerNodes = new Map(); + + for (const nodeId of topoOrder) { + const layerId = nodeToLayer.get(nodeId); + if (layerId) { + if (!layerNodes.has(layerId)) { + layerNodes.set(layerId, []); + layerOrder.push(layerId); + } + layerNodes.get(layerId)!.push(nodeId); + } + } + + // Create steps for each layer + const layerMap = new Map(layers.map((l) => [l.id, l])); + for (const layerId of layerOrder) { + const layer = layerMap.get(layerId); + const nodeIds = layerNodes.get(layerId) ?? []; + if (layer && nodeIds.length > 0) { + const nodeSummaries = nodeIds + .map((id) => nodeMap.get(id)?.name) + .filter(Boolean) + .join(", "); + steps.push({ + order: 0, // assigned later + title: layer.name, + description: `${layer.description}. Key files: ${nodeSummaries}.`, + nodeIds, + }); + } + } + + // Add unlayered code nodes as "Supporting Components" + const layeredNodeIds = new Set( + layers.flatMap((l) => l.nodeIds), + ); + const unlayeredNodes = topoOrder.filter( + (id) => !layeredNodeIds.has(id), + ); + if (unlayeredNodes.length > 0) { + const nodeSummaries = unlayeredNodes + .map((id) => nodeMap.get(id)?.name) + .filter(Boolean) + .join(", "); + steps.push({ + order: 0, + title: "Supporting Components", + description: `Additional supporting files: ${nodeSummaries}.`, + nodeIds: unlayeredNodes, + }); + } + } else { + // No layers: batch by 3 nodes per step + for (let i = 0; i < topoOrder.length; i += 3) { + const batch = topoOrder.slice(i, i + 3); + const nodeSummaries = batch + .map((id) => { + const node = nodeMap.get(id); + return node ? `${node.name} (${node.summary})` : id; + }) + .join("; "); + const stepNumber = Math.floor(i / 3) + 1; + steps.push({ + order: 0, // assigned later + title: `Step ${stepNumber}: Code Walkthrough`, + description: `Exploring: ${nodeSummaries}.`, + nodeIds: batch, + }); + } + } + + // Add concept nodes as final step if any exist + if (conceptNodes.length > 0) { + const conceptSummaries = conceptNodes + .map((n) => `${n.name} (${n.summary})`) + .join("; "); + steps.push({ + order: 0, + title: "Key Concepts", + description: `Important architectural concepts: ${conceptSummaries}.`, + nodeIds: conceptNodes.map((n) => n.id), + }); + } + + // Assign sequential order numbers + for (let i = 0; i < steps.length; i++) { + steps[i].order = i + 1; + } + + return steps; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6cceea8..df3e52c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,3 +24,8 @@ export { applyLLMLayers, } from "./analyzer/layer-detector.js"; export type { LLMLayerResponse } from "./analyzer/layer-detector.js"; +export { + buildTourGenerationPrompt, + parseTourGenerationResponse, + generateHeuristicTour, +} from "./analyzer/tour-generator.js";