const fs = require('fs'); const path = require('path'); const http = require('http'); const https = require('https'); const GraphStore = require('./graph.js'); const { semanticDiff, formatSummary } = require('./semantic-diff.js'); /** * Developer Intelligence Pipeline v2 - Phase 6: LLM Doc Generation * Uses semantic diff context to generate targeted, intelligent documentation. * Supports Ollama and OpenAI-compatible APIs. */ const LLM_URL = process.env.LLM_URL || 'http://192.168.86.172:11434'; const LLM_MODEL = process.env.LLM_MODEL || 'qwen2.5:7b'; const LLM_BACKEND = process.env.LLM_BACKEND || 'ollama'; // 'ollama' or 'openai' /** * Call LLM API (Ollama or OpenAI-compatible). */ function callLLM(prompt, maxTokens = 1024) { return new Promise((resolve, reject) => { let url, body, headers; if (LLM_BACKEND === 'ollama') { url = new URL('/api/generate', LLM_URL); body = JSON.stringify({ model: LLM_MODEL, prompt, stream: false, options: { num_predict: maxTokens, temperature: 0.3 }, }); headers = { 'Content-Type': 'application/json' }; } else { url = new URL('/v1/chat/completions', LLM_URL); body = JSON.stringify({ model: LLM_MODEL, messages: [{ role: 'user', content: prompt }], max_tokens: maxTokens, temperature: 0.3, }); headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.OPENAI_API_KEY || 'not-needed'}`, }; } const client = url.protocol === 'https:' ? https : http; const req = client.request(url, { method: 'POST', headers }, (res) => { let data = ''; res.on('data', (chunk) => data += chunk); res.on('end', () => { try { const parsed = JSON.parse(data); if (LLM_BACKEND === 'ollama') { resolve(parsed.response || ''); } else { resolve(parsed.choices?.[0]?.message?.content || ''); } } catch (e) { reject(new Error(`LLM parse error: ${e.message}`)); } }); }); req.on('error', reject); req.setTimeout(60000, () => { req.destroy(); reject(new Error('LLM timeout')); }); req.write(body); req.end(); }); } /** * Generate documentation for a single entity using graph context. */ async function generateEntityDoc(entityId, graph, sourceCode) { const entity = graph.nodes.get(entityId); if (!entity) return null; // Gather context: callers, callees, container const outgoing = graph.edges.filter(e => e.source === entityId); const incoming = graph.edges.filter(e => e.target === entityId); const calls = outgoing.filter(e => e.type === 'CALLS').map(e => e.target); const calledBy = incoming.filter(e => e.type === 'CALLS').map(e => e.source); const containedBy = incoming.filter(e => e.type === 'CONTAINS').map(e => e.source); // Extract source snippet if available let snippet = ''; if (sourceCode && entity.line_range) { const lines = sourceCode.split('\n'); const [start, end] = entity.line_range; snippet = lines.slice(start - 1, Math.min(end, start + 50)).join('\n'); if (end - start > 50) snippet += '\n// ... truncated'; } const prompt = `You are a senior engineer writing concise documentation. Describe what this ${entity.type.toLowerCase()} does in 2-3 sentences. Be specific about domain logic, not syntax. Entity: ${entity.name} (${entity.type}, ${entity.kind}) Visibility: ${entity.visibility} Location: ${entityId} ${containedBy.length > 0 ? `Part of: ${containedBy.join(', ')}` : ''} ${calls.length > 0 ? `Calls: ${calls.slice(0, 10).join(', ')}${calls.length > 10 ? ` (+${calls.length - 10} more)` : ''}` : ''} ${calledBy.length > 0 ? `Called by: ${calledBy.slice(0, 10).join(', ')}${calledBy.length > 10 ? ` (+${calledBy.length - 10} more)` : ''}` : ''} ${snippet ? `Source:\n\`\`\`\n${snippet}\n\`\`\`` : ''} Documentation:`; return callLLM(prompt); } /** * Generate a change summary using semantic diff context. */ async function generateDiffDoc(diff) { const summary = formatSummary(diff); const prompt = `You are a senior engineer writing a changelog entry for a code review. Given this semantic diff, write a concise 3-5 sentence summary suitable for a PR description or release note. Focus on: - What changed and why it matters - Breaking changes that need attention - Impact on downstream consumers Semantic Diff: ${summary} Changelog entry:`; return callLLM(prompt, 512); } /** * Generate docs for all public entities in a file. */ async function generateFileDocs(filePath, graph, repoRoot) { const entityIds = graph.fileIndex.get(filePath); if (!entityIds) return []; let sourceCode = ''; try { sourceCode = fs.readFileSync(filePath, 'utf8'); } catch {} const docs = []; for (const id of entityIds) { const entity = graph.nodes.get(id); if (!entity || entity.visibility !== 'public' || entity.type === 'Dependency') continue; try { const doc = await generateEntityDoc(id, graph, sourceCode); if (doc) { docs.push({ entityId: id, name: entity.name, type: entity.type, doc }); } } catch (err) { console.error(` Failed to generate doc for ${id}: ${err.message}`); } } return docs; } /** * Batch generate docs for changed entities in a diff. */ async function generateDiffDocs(diff, oldGraph, newGraph, repoRoot) { const results = { changeSummary: '', entityDocs: [] }; // Generate overall change summary try { results.changeSummary = await generateDiffDoc(diff); } catch (err) { console.error(`Failed to generate change summary: ${err.message}`); } // Generate docs for new/modified public entities const entitiesToDoc = []; for (const item of diff.categorized.significant) { if (item.entity && item.entity.visibility === 'public') { entitiesToDoc.push(item.entity.id); } if (item.new && item.new.visibility === 'public') { entitiesToDoc.push(item.new.id); } } for (const id of entitiesToDoc) { const entity = newGraph.nodes.get(id); if (!entity || entity.type === 'Dependency') continue; let sourceCode = ''; if (entity._file) { try { sourceCode = fs.readFileSync(entity._file, 'utf8'); } catch {} } try { const doc = await generateEntityDoc(id, newGraph, sourceCode); if (doc) { results.entityDocs.push({ entityId: id, name: entity.name, type: entity.type, doc }); } } catch (err) { console.error(` Failed to generate doc for ${id}: ${err.message}`); } } return results; } // --- CLI --- if (require.main === module) { const args = process.argv.slice(2); const command = args[0]; if (command === 'entity') { const snapshotPath = args[1]; const entityId = args[2]; const filePath = args[3]; // optional source file if (!snapshotPath || !entityId) { console.error('Usage: node docgen.js entity [source-file]'); process.exit(1); } const graph = GraphStore.loadSnapshot(snapshotPath); let source = ''; if (filePath) { try { source = fs.readFileSync(filePath, 'utf8'); } catch {} } generateEntityDoc(entityId, graph, source).then(doc => { console.log(doc); }).catch(err => { console.error(err.message); process.exit(1); }); } else if (command === 'diff') { const oldPath = args[1]; const newPath = args[2]; if (!oldPath || !newPath) { console.error('Usage: node docgen.js diff '); process.exit(1); } const oldGraph = GraphStore.loadSnapshot(oldPath); const newGraph = GraphStore.loadSnapshot(newPath); const diff = semanticDiff(oldGraph, newGraph); generateDiffDocs(diff, oldGraph, newGraph).then(results => { console.log('=== Change Summary ==='); console.log(results.changeSummary); console.log(''); if (results.entityDocs.length > 0) { console.log('=== Entity Documentation ==='); for (const d of results.entityDocs) { console.log(`\n[${d.type}] ${d.name} (${d.entityId})`); console.log(d.doc); } } }).catch(err => { console.error(err.message); process.exit(1); }); } else if (command === 'file') { const snapshotPath = args[1]; const filePath = args[2]; if (!snapshotPath || !filePath) { console.error('Usage: node docgen.js file '); process.exit(1); } const graph = GraphStore.loadSnapshot(snapshotPath); generateFileDocs(filePath, graph).then(docs => { for (const d of docs) { console.log(`\n[${d.type}] ${d.name} (${d.entityId})`); console.log(d.doc); } if (docs.length === 0) console.log('No public entities found in file.'); }).catch(err => { console.error(err.message); process.exit(1); }); } else { console.error('Unknown command. Available: entity, diff, file'); process.exit(1); } } module.exports = { generateEntityDoc, generateDiffDoc, generateFileDocs, generateDiffDocs, callLLM };