From 7663bcc6a3db9e057fd6fa664d4f54cd335195fa Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 13 Jul 2026 17:57:18 +0700 Subject: [PATCH] fix(embeddings): fall back to text-bearing file nodes --- .../src/core/embeddings/embedding-pipeline.ts | 45 +++++++++++- gitnexus/test/unit/embedding-pipeline.test.ts | 72 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index 7b09e7314c..d1fa900345 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -180,7 +180,50 @@ const queryEmbeddableNodes = async ( } } - return allNodes; + return allNodes.length > 0 ? allNodes : queryFallbackFileNodes(executeQuery); +}; + +/** + * Static and documentation repositories may contain no code symbols while + * still persisting useful text on File nodes. Keep File embeddings as a + * zero-symbol fallback so code repositories retain symbol-first selection. + */ +const queryFallbackFileNodes = async ( + executeQuery: (cypher: string) => Promise, +): Promise => { + try { + const rows = await executeQuery(` + MATCH (n:File) + RETURN n.id AS id, n.name AS name, 'File' AS label, + n.filePath AS filePath, n.content AS content + `); + + return rows + .map((row) => { + const content = row.content ?? row[4] ?? ''; + return { + id: row.id ?? row[0], + name: row.name ?? row[1], + label: row.label ?? row[2] ?? 'File', + filePath: row.filePath ?? row[3], + content, + startLine: 1, + endLine: Math.max(1, content.split('\n').length), + }; + }) + .filter( + (node) => + node.id && + node.filePath && + node.content.trim() && + node.content !== '[Binary file - content not stored]', + ); + } catch (error) { + if (isDev) { + logger.warn({ error }, 'Fallback File-node embedding query failed:'); + } + return []; + } }; /** diff --git a/gitnexus/test/unit/embedding-pipeline.test.ts b/gitnexus/test/unit/embedding-pipeline.test.ts index 570593a2dd..50443c724e 100644 --- a/gitnexus/test/unit/embedding-pipeline.test.ts +++ b/gitnexus/test/unit/embedding-pipeline.test.ts @@ -289,6 +289,78 @@ describe('runEmbeddingPipeline incremental filter', () => { progressUpdates.push({ ...p }); }; + it('falls back to text-bearing File nodes when a repo has no code symbols', async () => { + mockEmbedderSetup(); + + const fileNode = makeNode({ + id: 'File:README.md', + name: 'README.md', + label: 'File', + filePath: 'README.md', + content: '# Static Site\n\nDeployment and recovery notes.', + startLine: 1, + endLine: 3, + }); + const emptyFile = makeNode({ + id: 'File:empty.txt', + name: 'empty.txt', + label: 'File', + filePath: 'empty.txt', + content: ' ', + }); + const binaryFile = makeNode({ + id: 'File:logo.png', + name: 'logo.png', + label: 'File', + filePath: 'logo.png', + content: '[Binary file - content not stored]', + }); + const executeQuery = mockExecuteQuery([fileNode, emptyFile, binaryFile]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + const result = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, onProgress); + + expect(queryCalls.some((cypher) => cypher.includes('MATCH (n:File)'))).toBe(true); + const insertedNodeIds = stmtCalls + .filter((call) => call.cypher.includes('CREATE')) + .flatMap((call) => call.params.map((param) => param.nodeId)); + expect(insertedNodeIds).toContain(fileNode.id); + expect(insertedNodeIds).not.toContain(emptyFile.id); + expect(insertedNodeIds).not.toContain(binaryFile.id); + expect(result.nodesProcessed).toBe(1); + }); + + it('retains symbol-first selection when code symbols exist', async () => { + mockEmbedderSetup(); + + const functionNode = makeNode(); + const fileNode = makeNode({ + id: 'File:src/main.ts', + name: 'main.ts', + label: 'File', + filePath: 'src/main.ts', + content: 'function foo() { return 1; }', + }); + const executeQuery = mockExecuteQuery([functionNode, fileNode]); + const executeWithReusedStatement = mockExecuteWithReusedStatement(); + + const { runEmbeddingPipeline } = + await import('../../src/core/embeddings/embedding-pipeline.js'); + + const result = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, onProgress); + + expect(queryCalls.some((cypher) => cypher.includes('MATCH (n:File)'))).toBe(false); + const insertedNodeIds = stmtCalls + .filter((call) => call.cypher.includes('CREATE')) + .flatMap((call) => call.params.map((param) => param.nodeId)); + expect(insertedNodeIds).toContain(functionNode.id); + expect(insertedNodeIds).not.toContain(fileNode.id); + expect(result.nodesProcessed).toBe(1); + }); + it('skips unchanged nodes when hash matches', async () => { mockEmbedderSetup();