Files
pi-map/src/llm/llm-extract.ts
T
alex 2f198ea0d2 feat: remove heuristics, go LLM-only with mocks + restructure
BREAKING: Heuristic fallback removed — LLM client now required

Changes:
- Remove extractFileHeuristic, extractPackageHeuristic, all helpers
- extractFileLLM / extractPackageLLM now throw LLMError when client missing
- Add hybrid binary detection: extension blacklist + content sniffing
- Increase file size limit: 50KB → 500KB (text files only)
- Restructure src/ into subdirectories:
  - src/llm/ — all LLM clients, extract, batch, cache, error
  - src/ast/ — AST extraction
  - src/cli/ — CLI entry point
- Update all imports across codebase and tests
- Add tests/mock-llm.ts helper for deterministic mock clients
- Update all tests to use mock LLM clients (no heuristics dependency)
- All 52 tests passing (including 8 real LLM integration tests)
2026-06-10 17:34:37 +02:00

218 lines
6.2 KiB
TypeScript

import { readFileSync, statSync } from "fs";
import { createHash } from "crypto";
import { extname, basename } from "path";
import type { LLMClient } from "./llm-client.js";
import { getCached, setCached } from "./llm-cache.js";
import { LLMError } from "./llm-error.js";
interface LLMFileData {
purpose: string;
exports: string[];
deps: string[];
concepts: string[];
}
interface LLMPackageData {
role: string;
arch: string;
}
const MAX_FILE_SIZE = 500 * 1024; // 500KB
const CONTEXT_BUDGET = 4000; // tokens
const CHARS_PER_TOKEN = 4; // approximate for ASCII
// Known binary extensions — skip without reading content
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".svgz",
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv",
".wav", ".ogg", ".flac", ".aac", ".wma",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".exe", ".dll", ".so", ".dylib", ".bin",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".wasm", ".class", ".jar", ".o", ".a",
".ttf", ".otf", ".woff", ".woff2", ".eot",
".db", ".sqlite", ".sqlite3",
]);
function isBinaryFile(filePath: string): boolean {
// Fast-path: check extension
const ext = extname(filePath).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) return true;
// Fallback: sniff first 8KB for null bytes or non-printable ratio
try {
const buf = readFileSync(filePath).subarray(0, 8192);
let nonPrintable = 0;
for (let i = 0; i < buf.length; i++) {
const b = buf[i];
if (b === 0) return true; // null byte = definitely binary
if (b < 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d) {
nonPrintable++;
}
}
// If >30% non-printable, treat as binary
return nonPrintable / buf.length > 0.3;
} catch {
return false;
}
}
function truncateForContext(content: string, promptLength: number): string {
const maxChars = CONTEXT_BUDGET * CHARS_PER_TOKEN - promptLength;
if (content.length <= maxChars) return content;
return `${content.slice(0, maxChars - 20)}\n[...truncated]`;
}
function buildFilePrompt(filePath: string, content: string): string {
const name = basename(filePath);
return `Analyze this file. Respond in this exact format (one line each):
PURPOSE: <concise one-sentence description of what this file does>
DEPS: <comma-separated list of key dependencies/modules it relies on, or "none">
CONCEPTS: <comma-separated list of key concepts/patterns used, or "none">
File: ${name}
\`\`\`
${content}
\`\`\`
`;
}
function parseFileResponse(response: string): { purpose: string; deps: string[]; concepts: string[] } {
const lines = response.split("\n");
let purpose = "";
let deps: string[] = [];
let concepts: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("PURPOSE:")) {
purpose = trimmed.slice("PURPOSE:".length).trim();
} else if (trimmed.startsWith("DEPS:")) {
const depsStr = trimmed.slice("DEPS:".length).trim();
deps = depsStr === "none" ? [] : depsStr.split(",").map((s) => s.trim()).filter(Boolean);
} else if (trimmed.startsWith("CONCEPTS:")) {
const conceptsStr = trimmed.slice("CONCEPTS:".length).trim();
concepts = conceptsStr === "none" ? [] : conceptsStr.split(",").map((s) => s.trim()).filter(Boolean);
}
}
return { purpose, deps, concepts };
}
function buildPackagePrompt(relativePath: string, fileSummaries: { name: string; purpose: string }[]): string {
const filesList = fileSummaries.map((f) => `- ${f.name}: ${f.purpose}`).join("\n");
return `Analyze this code package/directory. Respond in this exact format (one line each):
ROLE: <concise one-sentence description of this package's role in the project>
ARCH: <concise description of architecture/patterns used in this package>
Directory: ${relativePath}
Files:
${filesList}
`;
}
function parsePackageResponse(response: string): { role: string; arch: string } {
const lines = response.split("\n");
let role = "";
let arch = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("ROLE:")) {
role = trimmed.slice("ROLE:".length).trim();
} else if (trimmed.startsWith("ARCH:")) {
arch = trimmed.slice("ARCH:".length).trim();
}
}
return { role, arch };
}
export async function extractFileLLM(
filePath: string,
client?: LLMClient,
cacheDir?: string,
): Promise<LLMFileData> {
// LLM-only: require a client
if (!client) {
throw new LLMError(
"No LLM client configured. " +
"Set OPENAI_API_KEY / KIMI_API_KEY environment variable, " +
"or run inside Pi with a configured model.",
);
}
// Skip binary files
if (isBinaryFile(filePath)) {
return {
purpose: "Binary file",
exports: [],
deps: [],
concepts: [],
};
}
const content = readFileSync(filePath, "utf8");
const hash = createHash("sha256").update(content).digest("hex");
// Check disk cache
const cached = getCached(hash, cacheDir);
if (cached) {
const parsed = parseFileResponse(cached);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
// Skip very large files
const size = statSync(filePath).size;
if (size > MAX_FILE_SIZE) {
return {
purpose: "Large file",
exports: [],
deps: [],
concepts: [],
};
}
const prompt = buildFilePrompt(filePath, truncateForContext(content, 200));
const response = await client.complete(prompt);
setCached(hash, response, cacheDir);
const parsed = parseFileResponse(response);
return {
purpose: parsed.purpose,
exports: [], // AST provides precise exports
deps: parsed.deps,
concepts: parsed.concepts,
};
}
export async function extractPackageLLM(
relativePath: string,
fileData: { name: string; purpose: string }[],
client?: LLMClient,
_cacheDir?: string,
): Promise<LLMPackageData> {
// LLM-only: require a client
if (!client) {
throw new LLMError(
"No LLM client configured. " +
"Set OPENAI_API_KEY / KIMI_API_KEY environment variable, " +
"or run inside Pi with a configured model.",
);
}
const prompt = buildPackagePrompt(relativePath, fileData);
const response = await client.complete(prompt);
const parsed = parsePackageResponse(response);
return {
role: parsed.role || `Package ${basename(relativePath)}`,
arch: parsed.arch || `Contains ${fileData.length} files.`,
};
}