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 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-14 20:13:11 +08:00
Unverified
parent 45bbbe1557
commit cb3d860fe2
3 changed files with 567 additions and 0 deletions
@@ -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);
});
});
});
@@ -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<string, number>();
const adjacency = new Map<string, string[]>();
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<string, string>();
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<string, string[]>();
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;
}
+5
View File
@@ -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";