mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat(core): add heuristic and LLM-based layer auto-detection
Add layer-detector module with four exported functions: - detectLayers: heuristic detection using directory path patterns - buildLayerDetectionPrompt: generates LLM prompt for layer identification - parseLayerDetectionResponse: parses LLM JSON response with fence handling - applyLLMLayers: applies LLM-provided layer definitions to the graph Includes 10 tests covering all four functions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
detectLayers,
|
||||
buildLayerDetectionPrompt,
|
||||
parseLayerDetectionResponse,
|
||||
applyLLMLayers,
|
||||
} from "../analyzer/layer-detector.js";
|
||||
import type { KnowledgeGraph, GraphNode } from "../types.js";
|
||||
|
||||
const makeNode = (
|
||||
overrides: Partial<GraphNode> & { id: string; name: string },
|
||||
): GraphNode => ({
|
||||
type: "file",
|
||||
summary: "",
|
||||
tags: [],
|
||||
complexity: "simple",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGraph = (nodes: GraphNode[]): KnowledgeGraph => ({
|
||||
version: "1.0.0",
|
||||
project: {
|
||||
name: "test-project",
|
||||
languages: ["typescript"],
|
||||
frameworks: [],
|
||||
description: "A test project",
|
||||
analyzedAt: new Date().toISOString(),
|
||||
gitCommitHash: "abc123",
|
||||
},
|
||||
nodes,
|
||||
edges: [],
|
||||
layers: [],
|
||||
tour: [],
|
||||
});
|
||||
|
||||
describe("detectLayers", () => {
|
||||
it("detects API/routes layer from file paths", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "users.ts", filePath: "src/routes/users.ts" }),
|
||||
makeNode({ id: "f2", name: "auth.ts", filePath: "src/controllers/auth.ts" }),
|
||||
makeNode({ id: "f3", name: "health.ts", filePath: "src/api/health.ts" }),
|
||||
]);
|
||||
const layers = detectLayers(graph);
|
||||
const apiLayer = layers.find((l) => l.name === "API Layer");
|
||||
expect(apiLayer).toBeDefined();
|
||||
expect(apiLayer!.nodeIds).toContain("f1");
|
||||
expect(apiLayer!.nodeIds).toContain("f2");
|
||||
expect(apiLayer!.nodeIds).toContain("f3");
|
||||
});
|
||||
|
||||
it("detects Data layer from model/entity/repository paths", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "User.ts", filePath: "src/models/User.ts" }),
|
||||
makeNode({ id: "f2", name: "Post.ts", filePath: "src/entity/Post.ts" }),
|
||||
makeNode({ id: "f3", name: "UserRepo.ts", filePath: "src/repository/UserRepo.ts" }),
|
||||
]);
|
||||
const layers = detectLayers(graph);
|
||||
const dataLayer = layers.find((l) => l.name === "Data Layer");
|
||||
expect(dataLayer).toBeDefined();
|
||||
expect(dataLayer!.nodeIds).toContain("f1");
|
||||
expect(dataLayer!.nodeIds).toContain("f2");
|
||||
expect(dataLayer!.nodeIds).toContain("f3");
|
||||
});
|
||||
|
||||
it("puts unmatched file nodes in Core layer", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "main.ts", filePath: "src/main.ts" }),
|
||||
makeNode({ id: "f2", name: "app.ts", filePath: "src/app.ts" }),
|
||||
]);
|
||||
const layers = detectLayers(graph);
|
||||
const coreLayer = layers.find((l) => l.name === "Core");
|
||||
expect(coreLayer).toBeDefined();
|
||||
expect(coreLayer!.nodeIds).toContain("f1");
|
||||
expect(coreLayer!.nodeIds).toContain("f2");
|
||||
});
|
||||
|
||||
it("assigns unique kebab-case IDs to each layer", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "users.ts", filePath: "src/routes/users.ts" }),
|
||||
makeNode({ id: "f2", name: "User.ts", filePath: "src/models/User.ts" }),
|
||||
makeNode({ id: "f3", name: "main.ts", filePath: "src/main.ts" }),
|
||||
]);
|
||||
const layers = detectLayers(graph);
|
||||
const ids = layers.map((l) => l.id);
|
||||
|
||||
// All IDs should start with "layer:"
|
||||
for (const id of ids) {
|
||||
expect(id).toMatch(/^layer:/);
|
||||
}
|
||||
|
||||
// All IDs should be unique
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("only assigns file-type nodes, ignoring functions and classes", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "users.ts", type: "file", filePath: "src/routes/users.ts" }),
|
||||
makeNode({ id: "fn1", name: "getUser", type: "function", filePath: "src/routes/users.ts" }),
|
||||
makeNode({ id: "c1", name: "UserController", type: "class", filePath: "src/routes/users.ts" }),
|
||||
]);
|
||||
const layers = detectLayers(graph);
|
||||
const allNodeIds = layers.flatMap((l) => l.nodeIds);
|
||||
expect(allNodeIds).toContain("f1");
|
||||
expect(allNodeIds).not.toContain("fn1");
|
||||
expect(allNodeIds).not.toContain("c1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLayerDetectionPrompt", () => {
|
||||
it("contains file paths and mentions JSON in the prompt", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "index.ts", filePath: "src/index.ts" }),
|
||||
makeNode({ id: "f2", name: "app.ts", filePath: "src/app.ts" }),
|
||||
]);
|
||||
const prompt = buildLayerDetectionPrompt(graph);
|
||||
expect(prompt).toContain("src/index.ts");
|
||||
expect(prompt).toContain("src/app.ts");
|
||||
expect(prompt).toContain("JSON");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLayerDetectionResponse", () => {
|
||||
it("parses a valid JSON response", () => {
|
||||
const response = JSON.stringify([
|
||||
{
|
||||
name: "API",
|
||||
description: "Handles HTTP requests",
|
||||
filePatterns: ["src/routes/", "src/controllers/"],
|
||||
},
|
||||
{
|
||||
name: "Data",
|
||||
description: "Database models and queries",
|
||||
filePatterns: ["src/models/"],
|
||||
},
|
||||
]);
|
||||
const result = parseLayerDetectionResponse(response);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBe(2);
|
||||
expect(result![0].name).toBe("API");
|
||||
expect(result![0].filePatterns).toEqual(["src/routes/", "src/controllers/"]);
|
||||
});
|
||||
|
||||
it("parses JSON wrapped in markdown fences", () => {
|
||||
const response = `Here are the layers:
|
||||
\`\`\`json
|
||||
[
|
||||
{ "name": "UI", "description": "Frontend components", "filePatterns": ["src/components/"] }
|
||||
]
|
||||
\`\`\``;
|
||||
const result = parseLayerDetectionResponse(response);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBe(1);
|
||||
expect(result![0].name).toBe("UI");
|
||||
});
|
||||
|
||||
it("returns null for invalid/unparseable input", () => {
|
||||
expect(parseLayerDetectionResponse("not json at all")).toBeNull();
|
||||
expect(parseLayerDetectionResponse("{}")).toBeNull();
|
||||
expect(parseLayerDetectionResponse("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLLMLayers", () => {
|
||||
it("assigns file nodes to LLM-provided layers and puts unmatched in Other", () => {
|
||||
const graph = makeGraph([
|
||||
makeNode({ id: "f1", name: "users.ts", filePath: "src/routes/users.ts" }),
|
||||
makeNode({ id: "f2", name: "User.ts", filePath: "src/models/User.ts" }),
|
||||
makeNode({ id: "f3", name: "main.ts", filePath: "src/main.ts" }),
|
||||
]);
|
||||
const llmLayers = [
|
||||
{ name: "API", description: "HTTP endpoints", filePatterns: ["src/routes/"] },
|
||||
{ name: "Data", description: "Models", filePatterns: ["src/models/"] },
|
||||
];
|
||||
const layers = applyLLMLayers(graph, llmLayers);
|
||||
|
||||
const apiLayer = layers.find((l) => l.name === "API");
|
||||
expect(apiLayer).toBeDefined();
|
||||
expect(apiLayer!.nodeIds).toContain("f1");
|
||||
|
||||
const dataLayer = layers.find((l) => l.name === "Data");
|
||||
expect(dataLayer).toBeDefined();
|
||||
expect(dataLayer!.nodeIds).toContain("f2");
|
||||
|
||||
const otherLayer = layers.find((l) => l.name === "Other");
|
||||
expect(otherLayer).toBeDefined();
|
||||
expect(otherLayer!.nodeIds).toContain("f3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { KnowledgeGraph, Layer } from "../types.js";
|
||||
|
||||
/**
|
||||
* LLM layer response structure — what the LLM returns for each layer.
|
||||
*/
|
||||
export interface LLMLayerResponse {
|
||||
name: string;
|
||||
description: string;
|
||||
filePatterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory-pattern to layer-name mapping for heuristic detection.
|
||||
* Order matters: first match wins.
|
||||
*/
|
||||
const LAYER_PATTERNS: Array<{ patterns: string[]; layerName: string; description: string }> = [
|
||||
{
|
||||
patterns: ["routes", "controller", "handler", "endpoint", "api"],
|
||||
layerName: "API Layer",
|
||||
description: "HTTP endpoints, route handlers, and API controllers",
|
||||
},
|
||||
{
|
||||
patterns: ["service", "usecase", "use-case", "business"],
|
||||
layerName: "Service Layer",
|
||||
description: "Business logic and application services",
|
||||
},
|
||||
{
|
||||
patterns: ["model", "entity", "schema", "database", "db", "migration", "repository", "repo"],
|
||||
layerName: "Data Layer",
|
||||
description: "Data models, database access, and persistence",
|
||||
},
|
||||
{
|
||||
patterns: ["component", "view", "page", "screen", "layout", "widget", "ui"],
|
||||
layerName: "UI Layer",
|
||||
description: "User interface components and views",
|
||||
},
|
||||
{
|
||||
patterns: ["middleware", "interceptor", "guard", "filter", "pipe"],
|
||||
layerName: "Middleware Layer",
|
||||
description: "Request/response middleware and interceptors",
|
||||
},
|
||||
{
|
||||
patterns: ["util", "helper", "lib", "common", "shared"],
|
||||
layerName: "Utility Layer",
|
||||
description: "Shared utilities, helpers, and common libraries",
|
||||
},
|
||||
{
|
||||
patterns: ["test", "spec", "__test__", "__spec__"],
|
||||
layerName: "Test Layer",
|
||||
description: "Test files and test utilities",
|
||||
},
|
||||
{
|
||||
patterns: ["config", "setting", "env"],
|
||||
layerName: "Configuration Layer",
|
||||
description: "Application configuration and environment settings",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert a layer name to a kebab-case layer ID.
|
||||
*/
|
||||
function toLayerId(name: string): string {
|
||||
return `layer:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which layer a file path belongs to based on directory patterns.
|
||||
* Returns the layer name or null if no pattern matches.
|
||||
*/
|
||||
function matchFileToLayer(filePath: string): string | null {
|
||||
// Normalize path separators and split into segments
|
||||
const normalizedPath = filePath.replace(/\\/g, "/").toLowerCase();
|
||||
const segments = normalizedPath.split("/");
|
||||
|
||||
for (const { patterns, layerName } of LAYER_PATTERNS) {
|
||||
for (const segment of segments) {
|
||||
// Check if any directory segment matches a pattern (plural forms too)
|
||||
for (const pattern of patterns) {
|
||||
if (segment === pattern || segment === pattern + "s") {
|
||||
return layerName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic layer detection — assigns file nodes to layers based on
|
||||
* directory path patterns. Unmatched files go to a "Core" layer.
|
||||
*
|
||||
* Only FILE-type nodes are assigned to layers.
|
||||
*/
|
||||
export function detectLayers(graph: KnowledgeGraph): Layer[] {
|
||||
const layerMap = new Map<string, string[]>(); // layerName -> nodeIds
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (node.type !== "file") continue;
|
||||
if (!node.filePath) continue;
|
||||
|
||||
const layerName = matchFileToLayer(node.filePath) ?? "Core";
|
||||
const existing = layerMap.get(layerName) ?? [];
|
||||
existing.push(node.id);
|
||||
layerMap.set(layerName, existing);
|
||||
}
|
||||
|
||||
// Also catch file nodes without filePath
|
||||
for (const node of graph.nodes) {
|
||||
if (node.type !== "file") continue;
|
||||
if (node.filePath) continue;
|
||||
|
||||
const existing = layerMap.get("Core") ?? [];
|
||||
existing.push(node.id);
|
||||
layerMap.set("Core", existing);
|
||||
}
|
||||
|
||||
const layers: Layer[] = [];
|
||||
for (const [name, nodeIds] of layerMap) {
|
||||
const description =
|
||||
name === "Core"
|
||||
? "Core application files"
|
||||
: LAYER_PATTERNS.find((p) => p.layerName === name)?.description ?? "";
|
||||
|
||||
layers.push({
|
||||
id: toLayerId(name),
|
||||
name,
|
||||
description,
|
||||
nodeIds,
|
||||
});
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an LLM prompt that asks the model to identify logical layers
|
||||
* from a list of file paths in the knowledge graph.
|
||||
*/
|
||||
export function buildLayerDetectionPrompt(graph: KnowledgeGraph): string {
|
||||
const filePaths = graph.nodes
|
||||
.filter((n) => n.type === "file" && n.filePath)
|
||||
.map((n) => n.filePath!);
|
||||
|
||||
const fileListStr = filePaths.map((f) => ` - ${f}`).join("\n");
|
||||
|
||||
return `You are a software architecture analyst. Given the following list of file paths from a codebase, identify the logical architectural layers.
|
||||
|
||||
File paths:
|
||||
${fileListStr}
|
||||
|
||||
Return a JSON array of 3-7 layers. Each layer object must have:
|
||||
- "name": A short layer name (e.g., "API", "Data", "UI")
|
||||
- "description": What this layer is responsible for (1 sentence)
|
||||
- "filePatterns": An array of path prefixes that belong to this layer (e.g., ["src/routes/", "src/controllers/"])
|
||||
|
||||
Every file should belong to exactly one layer. Use the most specific pattern possible.
|
||||
|
||||
Respond ONLY with the JSON array, no additional text.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an LLM response for layer detection.
|
||||
* Handles markdown code fences and raw JSON.
|
||||
* Returns the parsed array or null on failure.
|
||||
*/
|
||||
export function parseLayerDetectionResponse(
|
||||
response: string,
|
||||
): LLMLayerResponse[] | null {
|
||||
if (!response || response.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 array
|
||||
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
|
||||
if (!arrayMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(arrayMatch[0]);
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate and normalize each layer entry
|
||||
const layers: LLMLayerResponse[] = [];
|
||||
for (const item of parsed) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
if (typeof item.name !== "string") continue;
|
||||
|
||||
layers.push({
|
||||
name: item.name,
|
||||
description: typeof item.description === "string" ? item.description : "",
|
||||
filePatterns: Array.isArray(item.filePatterns)
|
||||
? item.filePatterns.filter((p: unknown) => typeof p === "string")
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
return layers.length > 0 ? layers : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies LLM-provided layer definitions to a knowledge graph.
|
||||
* Matches file nodes against LLM filePatterns (path prefix matching).
|
||||
* Unassigned file nodes go to an "Other" layer.
|
||||
*/
|
||||
export function applyLLMLayers(
|
||||
graph: KnowledgeGraph,
|
||||
llmLayers: LLMLayerResponse[],
|
||||
): Layer[] {
|
||||
const layerMap = new Map<string, string[]>(); // layerName -> nodeIds
|
||||
|
||||
// Initialize all LLM layers
|
||||
for (const llmLayer of llmLayers) {
|
||||
layerMap.set(llmLayer.name, []);
|
||||
}
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (node.type !== "file") continue;
|
||||
if (!node.filePath) continue;
|
||||
|
||||
const normalizedPath = node.filePath.replace(/\\/g, "/");
|
||||
let assigned = false;
|
||||
|
||||
for (const llmLayer of llmLayers) {
|
||||
for (const pattern of llmLayer.filePatterns) {
|
||||
if (normalizedPath.startsWith(pattern) || normalizedPath.includes("/" + pattern)) {
|
||||
const existing = layerMap.get(llmLayer.name)!;
|
||||
existing.push(node.id);
|
||||
assigned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (assigned) break;
|
||||
}
|
||||
|
||||
if (!assigned) {
|
||||
const other = layerMap.get("Other") ?? [];
|
||||
other.push(node.id);
|
||||
layerMap.set("Other", other);
|
||||
}
|
||||
}
|
||||
|
||||
const layers: Layer[] = [];
|
||||
for (const [name, nodeIds] of layerMap) {
|
||||
if (nodeIds.length === 0) continue; // Skip empty layers
|
||||
|
||||
const llmLayer = llmLayers.find((l) => l.name === name);
|
||||
layers.push({
|
||||
id: toLayerId(name),
|
||||
name,
|
||||
description: llmLayer?.description ?? "Uncategorized files",
|
||||
nodeIds,
|
||||
});
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -17,3 +17,10 @@ export {
|
||||
mergeGraphUpdate,
|
||||
type StalenessResult,
|
||||
} from "./staleness.js";
|
||||
export {
|
||||
detectLayers,
|
||||
buildLayerDetectionPrompt,
|
||||
parseLayerDetectionResponse,
|
||||
applyLLMLayers,
|
||||
} from "./analyzer/layer-detector.js";
|
||||
export type { LLMLayerResponse } from "./analyzer/layer-detector.js";
|
||||
|
||||
Reference in New Issue
Block a user