fix(session-search): repair a prose phrase without dropping its stop words

Typo repair re-planned the query from `plan.body`, which prose has already
had its stop words removed from. `relay is droppng frames` therefore came
back as the plan for `relay dropping frames`, and the phrase rung searched
for a sentence nobody wrote: the transcript holds `relay is dropping
frames`, so the exact match fell through to AND.

The repair now maps over `plan.phrase`, the tokens as typed, and re-plans
from those. Only terms the body holds are offered to the corrector, so a
stop word is still never repaired, and the re-plan recomputes the body
from the corrected sentence exactly as before.
This commit is contained in:
Jinwoo-H
2026-09-16 11:56:15 -04:00
parent b47772b7b1
commit 4ec2dffcd3
2 changed files with 25 additions and 5 deletions
@@ -66,6 +66,18 @@ describe('the route ladder tries phrase, then AND, then repair, then OR', () =>
expect(ids(result).sort()).toEqual(['1', '2'])
})
it('keeps the stop words a repaired prose phrase was typed with', async () => {
const { db, engine } = await open('ss-engine-typo-phrase')
// Two copies, so the repair only suggests a term the index really holds.
addSyntheticSession(db, { id: 1, text: 'relay is dropping frames' })
addSyntheticSession(db, { id: 2, text: 'dropping frames again here' })
// Repairing the body alone would re-plan `relay dropping frames`, which no
// phrase in the index can match, and the answer would fall to AND.
const result = engine.search({ query: 'relay is droppng frames' })
expect(result.planner.route).toBe('typo+phrase')
expect(ids(result)).toEqual(['1'])
})
it('keeps every term a repaired literal was typed with', async () => {
const { db, engine } = await open('ss-engine-typo-literal')
addSyntheticSession(db, { id: 1, text: 'parseJson the data' })
@@ -180,20 +180,28 @@ export class SessionSearchRetrieval {
): SessionSearchQueryPlan | null {
const typoRepair = this.typoRepair
let changed = false
const body = plan.body.map((term) => {
// Only the body is a candidate for a correction, but the re-plan is fed the
// tokens as typed: re-planning the body alone would hand the phrase rung a
// sentence with its stop words already gone, and `relay dropping frames`
// cannot match the `relay is dropping frames` that is in the transcript.
const repairable = new Set(plan.body.map((term) => term.toLowerCase()))
const phrase = plan.phrase.map((token) => {
if (!repairable.has(token.toLowerCase())) {
return token
}
// Repaired inside the scope the search will run in, so a spelling only
// tool output carries neither suppresses a repair nor becomes one.
const fix = typoRepair.correct(term, scope)
if (fix && fix !== term.toLowerCase()) {
const fix = typoRepair.correct(token, scope)
if (fix && fix !== token.toLowerCase()) {
changed = true
return fix
}
return term
return token
})
// The repair changes spellings, not the query's character: the re-plan is
// told what the original decided so a corrected literal keeps every term it
// was typed with.
return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null
return changed ? planSessionSearchQuery(phrase.join(' '), plan.literal) : null
}
/**