Merge pull request #76 from colbymchenry/fix/liquid-callers-and-context-relevance

fix: Fix Liquid template callers and context relevance
This commit is contained in:
Colby Mchenry
2026-04-03 13:31:55 -05:00
committed by GitHub
3 changed files with 57 additions and 18 deletions
+49 -15
View File
@@ -23,9 +23,9 @@ import { QueryBuilder } from '../db/queries';
import { GraphTraverser } from '../graph'; import { GraphTraverser } from '../graph';
import { VectorManager } from '../vectors'; import { VectorManager } from '../vectors';
import { formatContextAsMarkdown, formatContextAsJson } from './formatter'; import { formatContextAsMarkdown, formatContextAsJson } from './formatter';
import { logDebug, logWarn } from '../errors'; import { logDebug } from '../errors';
import { validatePathWithinRoot } from '../utils'; import { validatePathWithinRoot } from '../utils';
import { isTestFile } from '../search/query-utils'; import { isTestFile, extractSearchTerms } from '../search/query-utils';
/** /**
* Extract likely symbol names from a natural language query * Extract likely symbol names from a natural language query
@@ -298,21 +298,47 @@ export class ContextBuilder {
} }
} }
// Step 4: Fall back to text search if no semantic results // Step 4: Always run text search for natural language term matching
if (semanticResults.length === 0 && exactMatches.length === 0) { // This catches file-name and node-name matches that semantic search may miss,
try { // which is critical for template-heavy codebases (e.g., Liquid/Shopify themes)
const textResults = this.queries.searchNodes(query, { // where file names are the primary identifiers.
limit: opts.searchLimit, let textResults: SearchResult[] = [];
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, try {
}); const searchTerms = extractSearchTerms(query);
semanticResults = textResults; if (searchTerms.length > 0) {
} catch (error) { // Search each term individually to get broader coverage,
logWarn('Text search failed', { query, error: String(error) }); // then boost results that match multiple terms
// Return empty results const termResultsMap = new Map<string, { result: SearchResult; termHits: number }>();
for (const term of searchTerms) {
const termResults = this.queries.searchNodes(term, {
limit: opts.searchLimit * 2,
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
});
for (const r of termResults) {
const existing = termResultsMap.get(r.node.id);
if (existing) {
existing.termHits++;
existing.result.score = Math.max(existing.result.score, r.score);
} else {
termResultsMap.set(r.node.id, { result: r, termHits: 1 });
}
}
}
// Boost results matching multiple terms and sort
textResults = Array.from(termResultsMap.values())
.map(({ result, termHits }) => ({
...result,
score: result.score + (termHits - 1) * 5,
}))
.sort((a, b) => b.score - a.score)
.slice(0, opts.searchLimit * 2);
} }
logDebug('Text search results', { count: textResults.length });
} catch (error) {
logDebug('Text search failed', { query, error: String(error) });
} }
// Step 5: Merge results, prioritizing exact matches // Step 5: Merge results, prioritizing exact matches, then text (path-boosted), then semantic
const seenIds = new Set<string>(); const seenIds = new Set<string>();
let searchResults: SearchResult[] = []; let searchResults: SearchResult[] = [];
@@ -324,7 +350,15 @@ export class ContextBuilder {
} }
} }
// Add semantic/text results // Add text search results (includes path relevance scoring from searchNodes)
for (const result of textResults) {
if (!seenIds.has(result.node.id)) {
seenIds.add(result.node.id);
searchResults.push(result);
}
}
// Add semantic results
for (const result of semanticResults) { for (const result of semanticResults) {
if (!seenIds.has(result.node.id)) { if (!seenIds.has(result.node.id)) {
seenIds.add(result.node.id); seenIds.add(result.node.id);
+2 -2
View File
@@ -248,7 +248,7 @@ export class GraphTraverser {
} }
visited.add(nodeId); visited.add(nodeId);
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls']); const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports']);
for (const edge of incomingEdges) { for (const edge of incomingEdges) {
const callerNode = this.queries.getNodeById(edge.source); const callerNode = this.queries.getNodeById(edge.source);
@@ -287,7 +287,7 @@ export class GraphTraverser {
} }
visited.add(nodeId); visited.add(nodeId);
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls']); const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports']);
for (const edge of outgoingEdges) { for (const edge of outgoingEdges) {
const calleeNode = this.queries.getNodeById(edge.target); const calleeNode = this.queries.getNodeById(edge.target);
+6 -1
View File
@@ -874,7 +874,12 @@ export class ToolHandler {
return { nodes: [], note: '' }; return { nodes: [], note: '' };
} }
const exactMatches = results.filter(r => r.node.name === symbol); // Match by exact name, OR by file basename without extension
// (e.g., "product-card" matches file node named "product-card.liquid")
const exactMatches = results.filter(r =>
r.node.name === symbol ||
(r.node.kind === 'file' && r.node.name.replace(/\.[^.]+$/, '') === symbol)
);
if (exactMatches.length <= 1) { if (exactMatches.length <= 1) {
const node = exactMatches[0]?.node ?? results[0]!.node; const node = exactMatches[0]?.node ?? results[0]!.node;