fix(coding-agent): filtered weak pure-fuzzy session matches from ranking
- Updated fuzzy-search indexing to track compact word starts and required phrase matches to start at word boundaries. - Adjusted token scoring and compact/phrase matching rules so exact and boundary-aware matches now rank above noisy substring matches. - Filtered session search results to drop weak pure-fuzzy matches unless they contained literal tokens, and updated ranking tests for the new behavior.
This commit is contained in:
@@ -67,13 +67,15 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
|
||||
return b.modified.getTime() - a.modified.getTime();
|
||||
}
|
||||
|
||||
const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
|
||||
|
||||
/**
|
||||
* Filter and rank session picker search results.
|
||||
*
|
||||
* Resume search narrows a recency-sorted list: once every query token appears
|
||||
* as a literal substring, newer sessions should beat a slightly better fuzzy
|
||||
* position match. Pure fuzzy/acronym matches still sort by fuzzy score after
|
||||
* literal matches.
|
||||
* literal matches, but weak pure fuzzy tokens are dropped as noise.
|
||||
*/
|
||||
export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
|
||||
const tokens = tokenizeSessionQuery(query);
|
||||
@@ -85,6 +87,7 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
|
||||
const text = sessionSearchText(session);
|
||||
const textLower = text.toLowerCase();
|
||||
let score = 0;
|
||||
let worstTokenScore = Number.NEGATIVE_INFINITY;
|
||||
let literal = true;
|
||||
let matches = true;
|
||||
|
||||
@@ -95,10 +98,13 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
|
||||
break;
|
||||
}
|
||||
score += match.score;
|
||||
worstTokenScore = Math.max(worstTokenScore, match.score);
|
||||
if (!textLower.includes(token)) literal = false;
|
||||
}
|
||||
|
||||
if (matches) results.push({ session, score, literal, index });
|
||||
if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
|
||||
results.push({ session, score, literal, index });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ interface FakeAcpBuiltinSession {
|
||||
formatSessionAsText: () => string;
|
||||
getLastAssistantText: () => string | undefined;
|
||||
messages: unknown[];
|
||||
settings: Settings;
|
||||
model: { provider: string; id: string } | undefined;
|
||||
newSession(opts?: { drop?: boolean; parentSession?: string }): Promise<boolean>;
|
||||
fork(): Promise<boolean>;
|
||||
@@ -46,6 +47,7 @@ interface FakeAcpBuiltinSession {
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const settings = Settings.isolated();
|
||||
const output: string[] = [];
|
||||
const session: FakeAcpBuiltinSession = {
|
||||
fastMode: false,
|
||||
@@ -98,6 +100,7 @@ function createRuntime() {
|
||||
getLastAssistantText: () => undefined,
|
||||
messages: [],
|
||||
model: undefined,
|
||||
settings,
|
||||
getToolByName: (_name: string) => undefined,
|
||||
async compact(_args?: string) {},
|
||||
getContextUsage: () => undefined,
|
||||
@@ -152,7 +155,7 @@ function createRuntime() {
|
||||
runtime: {
|
||||
session: typedSession,
|
||||
sessionManager: fakeSessionManager as unknown as SessionManager,
|
||||
settings: Settings.isolated(),
|
||||
settings,
|
||||
cwd: "/tmp/project",
|
||||
output: (text: string) => {
|
||||
output.push(text);
|
||||
@@ -886,9 +889,6 @@ describe("wave 5 — adapters and polish", () => {
|
||||
(session as unknown as Record<string, unknown>).skills = [];
|
||||
(session as unknown as Record<string, unknown>).agent = { state: { tools: [] } };
|
||||
(session as unknown as Record<string, unknown>).systemPrompt = ["You are a helpful assistant."];
|
||||
(session as unknown as Record<string, unknown>).settings = {
|
||||
getGroup: () => ({ enabled: false, strategy: "off" }),
|
||||
};
|
||||
session.messages = [
|
||||
{ role: "user", content: "Hello, how are you?" },
|
||||
{ role: "assistant", content: "I am doing well." },
|
||||
|
||||
@@ -49,18 +49,26 @@ describe("rankSessionSearchMatches", () => {
|
||||
|
||||
it("keeps literal substring matches ahead of pure fuzzy matches", () => {
|
||||
const fuzzyRecent = makeSession("fuzzy-recent", {
|
||||
title: "Render Shape Index Zone Endpoint",
|
||||
title: "Render Buffer",
|
||||
modified: new Date("2024-01-03T00:00:00Z"),
|
||||
});
|
||||
const literalOld = makeSession("literal-old", {
|
||||
title: "Resize Buffer Issue",
|
||||
title: "RB Notes",
|
||||
modified: new Date("2024-01-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(ids(rankSessionSearchMatches([fuzzyRecent, literalOld], "resize"))).toEqual([
|
||||
"literal-old",
|
||||
"fuzzy-recent",
|
||||
]);
|
||||
expect(ids(rankSessionSearchMatches([fuzzyRecent, literalOld], "rb"))).toEqual(["literal-old", "fuzzy-recent"]);
|
||||
});
|
||||
|
||||
it("filters low-quality pure fuzzy matches while keeping exact matches", () => {
|
||||
const exact = makeSession("exact", {
|
||||
title: "MN Discussion",
|
||||
});
|
||||
const lowQuality = makeSession("low-quality", {
|
||||
title: "Random Notes",
|
||||
});
|
||||
|
||||
expect(ids(rankSessionSearchMatches([exact, lowQuality], "mn"))).toEqual(["exact"]);
|
||||
});
|
||||
|
||||
it("returns all sessions unchanged for an empty query", () => {
|
||||
|
||||
@@ -33,6 +33,8 @@ interface SearchWord {
|
||||
interface SearchIndex {
|
||||
normalized: string;
|
||||
compact: string;
|
||||
/** Start offsets of each word within `compact` (cumulative word lengths). */
|
||||
compactWordStarts: Set<number>;
|
||||
words: SearchWord[];
|
||||
}
|
||||
|
||||
@@ -53,19 +55,23 @@ function normalizeForSearch(value: string): string {
|
||||
function buildSearchIndex(text: string): SearchIndex {
|
||||
const normalized = normalizeForSearch(text);
|
||||
if (normalized.length === 0) {
|
||||
return { normalized, compact: "", words: [] };
|
||||
return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
|
||||
}
|
||||
|
||||
const words: SearchWord[] = [];
|
||||
const compactWordStarts = new Set<number>();
|
||||
let index = 0;
|
||||
let compactIndex = 0;
|
||||
let ordinal = 0;
|
||||
for (const word of normalized.split(" ")) {
|
||||
words.push({ text: word, index, ordinal });
|
||||
compactWordStarts.add(compactIndex);
|
||||
index += word.length + 1;
|
||||
compactIndex += word.length;
|
||||
ordinal++;
|
||||
}
|
||||
|
||||
return { normalized, compact: normalized.replaceAll(" ", ""), words };
|
||||
return { normalized, compact: normalized.replaceAll(" ", ""), compactWordStarts, words };
|
||||
}
|
||||
|
||||
function scoreCharacters(queryLower: string, textLower: string): CharacterMatch {
|
||||
@@ -129,6 +135,13 @@ function withPosition(score: number, index: number): number {
|
||||
return score + index * 0.01;
|
||||
}
|
||||
|
||||
function isWordBoundaryPhrase(normalized: string, index: number, length: number): boolean {
|
||||
const before = index === 0 || normalized[index - 1] === " ";
|
||||
const afterIndex = index + length;
|
||||
const after = afterIndex === normalized.length || normalized[afterIndex] === " ";
|
||||
return before && after;
|
||||
}
|
||||
|
||||
function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
|
||||
if (word.text === token) {
|
||||
return { matches: true, score: withPosition(-200, word.index) };
|
||||
@@ -144,7 +157,7 @@ function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | nu
|
||||
|
||||
const substringIndex = word.text.indexOf(token);
|
||||
if (substringIndex >= 0) {
|
||||
return { matches: true, score: withPosition(-120 + substringIndex, word.index) };
|
||||
return { matches: true, score: withPosition(-20 + substringIndex, word.index) };
|
||||
}
|
||||
|
||||
const characterMatch = scoreCharacters(token, word.text);
|
||||
@@ -188,7 +201,7 @@ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
|
||||
|
||||
let best: FuzzyMatch | null = null;
|
||||
const compactIndex = index.compact.indexOf(token);
|
||||
if (compactIndex >= 0) {
|
||||
if (compactIndex >= 0 && index.compactWordStarts.has(compactIndex)) {
|
||||
best = { matches: true, score: withPosition(-140, compactIndex) };
|
||||
}
|
||||
|
||||
@@ -236,14 +249,14 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
||||
|
||||
let totalScore = 0;
|
||||
const phraseIndex = index.normalized.indexOf(normalizedQuery);
|
||||
if (phraseIndex >= 0) {
|
||||
if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, normalizedQuery.length)) {
|
||||
totalScore -= PHRASE_BONUS;
|
||||
totalScore += phraseIndex * 0.01;
|
||||
}
|
||||
|
||||
const compactQuery = normalizedQuery.replaceAll(" ", "");
|
||||
const compactPhraseIndex = index.compact.indexOf(compactQuery);
|
||||
if (compactPhraseIndex >= 0) {
|
||||
if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
|
||||
totalScore -= COMPACT_PHRASE_BONUS;
|
||||
totalScore += compactPhraseIndex * 0.01;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user