/** * 🧠 KNOWLEDGE GRAPH SERVICE * Gemmer harvested filer i Neo4j som et selvbevidst knowledge graph */ import neo4j, { Driver, Session } from 'neo4j-driver'; interface FileNode { path: string; name: string; extension: string; content: string; hash: string; lines: number; size: number; } interface GraphStats { totalNodes: number; totalRelationships: number; filesByType: Record; importGraph: { from: string; to: string }[]; } export class KnowledgeGraph { private driver: Driver; constructor() { const uri = process.env.NEO4J_URI || 'bolt://neo4j:7687'; const user = process.env.NEO4J_USER || 'neo4j'; const password = process.env.NEO4J_PASSWORD || 'password'; this.driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); console.log('🧠 KnowledgeGraph connected to Neo4j'); } /** * Ingest alle filer som nodes i grafen */ async ingestFiles(files: FileNode[]): Promise<{ created: number; updated: number }> { const session = this.driver.session(); let created = 0; let updated = 0; try { // Batch ingest for performance for (const file of files) { const result = await session.run(` MERGE (f:File {path: $path}) ON CREATE SET f.name = $name, f.extension = $extension, f.hash = $hash, f.lines = $lines, f.size = $size, f.content = $content, f.createdAt = datetime(), f.updatedAt = datetime() ON MATCH SET f.hash = $hash, f.lines = $lines, f.size = $size, f.content = $content, f.updatedAt = datetime() RETURN f.path AS path, CASE WHEN f.createdAt = f.updatedAt THEN 'created' ELSE 'updated' END AS action `, { path: file.path, name: file.name, extension: file.extension, hash: file.hash, lines: file.lines, size: file.size, content: file.content.substring(0, 50000) // Truncate for Neo4j }); const action = result.records[0]?.get('action'); if (action === 'created') created++; else updated++; } // Create directory hierarchy await this.createDirectoryHierarchy(session); // Extract and create import relationships await this.extractImportRelationships(session); console.log(`✅ Ingested ${created} new, ${updated} updated files`); return { created, updated }; } finally { await session.close(); } } /** * Opret mappe-hierarki som nodes */ private async createDirectoryHierarchy(session: Session): Promise { await session.run(` MATCH (f:File) WITH f, split(f.path, '/') AS parts UNWIND range(1, size(parts)-1) AS idx WITH f, reduce(s = '', i IN range(0, idx-1) | s + '/' + parts[i]) AS dirPath WHERE dirPath <> '' MERGE (d:Directory {path: dirPath}) MERGE (f)-[:IN_DIRECTORY]->(d) `); // Parent directory relationships await session.run(` MATCH (d:Directory) WITH d, split(d.path, '/') AS parts WHERE size(parts) > 2 WITH d, reduce(s = '', i IN range(0, size(parts)-2) | s + '/' + parts[i]) AS parentPath WHERE parentPath <> '' MERGE (p:Directory {path: parentPath}) MERGE (d)-[:SUBDIRECTORY_OF]->(p) `); } /** * Ekstraher import statements og opret relationer */ private async extractImportRelationships(session: Session): Promise { // TypeScript/JavaScript imports await session.run(` MATCH (f:File) WHERE f.extension IN ['.ts', '.tsx', '.js', '.jsx'] WITH f, f.content AS content // Match import statements WITH f, [x IN split(content, '\n') WHERE x CONTAINS 'import ' AND x CONTAINS 'from'] AS imports UNWIND imports AS importLine // Extract the path from import WITH f, importLine, trim(split(split(importLine, 'from')[1], ';')[0]) AS rawPath WHERE rawPath IS NOT NULL AND rawPath <> '' // Clean quotes WITH f, replace(replace(rawPath, '"', ''), "'", '') AS importPath WHERE importPath STARTS WITH '.' OR importPath STARTS WITH '/' // Try to find matching file OPTIONAL MATCH (target:File) WHERE target.path CONTAINS split(importPath, '/')[-1] FOREACH (_ IN CASE WHEN target IS NOT NULL THEN [1] ELSE [] END | MERGE (f)-[:IMPORTS]->(target) ) `); } /** * Hent graf statistikker */ async getStats(): Promise { const session = this.driver.session(); try { // Total counts const countResult = await session.run(` MATCH (f:File) RETURN count(f) AS files, count { MATCH ()-[r:IMPORTS]->() RETURN r } AS imports, count { MATCH ()-[r:IN_DIRECTORY]->() RETURN r } AS dirRels `); const counts = countResult.records[0]; // Files by extension const extResult = await session.run(` MATCH (f:File) RETURN f.extension AS ext, count(f) AS count ORDER BY count DESC `); const filesByType: Record = {}; extResult.records.forEach(r => { filesByType[r.get('ext')] = r.get('count').toNumber(); }); // Import graph (top connections) const importResult = await session.run(` MATCH (a:File)-[:IMPORTS]->(b:File) RETURN a.name AS from, b.name AS to LIMIT 50 `); const importGraph = importResult.records.map(r => ({ from: r.get('from'), to: r.get('to') })); return { totalNodes: counts.get('files').toNumber(), totalRelationships: counts.get('imports').toNumber() + counts.get('dirRels').toNumber(), filesByType, importGraph }; } finally { await session.close(); } } /** * Søg i grafen med Cypher */ async query(cypher: string, params: Record = {}): Promise { const session = this.driver.session(); try { const result = await session.run(cypher, params); return result.records.map(r => r.toObject()); } finally { await session.close(); } } /** * Find relaterede filer (imports, same directory, etc.) */ async findRelated(filePath: string): Promise<{ imports: string[]; importedBy: string[]; sameDir: string[] }> { const session = this.driver.session(); try { const result = await session.run(` MATCH (f:File {path: $path}) OPTIONAL MATCH (f)-[:IMPORTS]->(imported:File) OPTIONAL MATCH (importer:File)-[:IMPORTS]->(f) OPTIONAL MATCH (f)-[:IN_DIRECTORY]->(d:Directory)<-[:IN_DIRECTORY]-(sibling:File) WHERE sibling <> f RETURN collect(DISTINCT imported.path) AS imports, collect(DISTINCT importer.path) AS importedBy, collect(DISTINCT sibling.path)[0..10] AS sameDir `, { path: filePath }); const record = result.records[0]; return { imports: record.get('imports').filter(Boolean), importedBy: record.get('importedBy').filter(Boolean), sameDir: record.get('sameDir').filter(Boolean) }; } finally { await session.close(); } } async close(): Promise { await this.driver.close(); } }