fix(embeddings): fall back to text-bearing file nodes

This commit is contained in:
Eva 2026-07-14 01:40:47 +07:00
parent c6445096eb
commit 554c5181cf
2 changed files with 116 additions and 1 deletions

View file

@ -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<any[]>,
): Promise<EmbeddableNode[]> => {
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 [];
}
};
/**

View file

@ -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();