From 9ec1b04cabd455181edf89035b44c9fccde1b863 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 8 Sep 2026 10:24:34 +0200 Subject: [PATCH] fix(quotes-search): order hits by created desc so the most recent match is on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators searching for a customer name (e.g. 'Martin', 'Blythwaite') or an item term (e.g. 'generator') were seeing their latest quote buried beneath an older quote that scored higher on the relevance ranker (exact quote_number > customer name > item description). Pre-Sept-2026 the service sorted by score desc with id as a stable tiebreak, so a 2025 quote_number hit from 2025 outranked a 2026 customer-name match for the same person. Score still decides which quotes qualify as matches (the breakdown.raw > 0 filter upstream of the sort), and the per-hit score is still surfaced in the response for the UI's 'why this match' breakdown. What changed: the order within the result set is now created desc, with quote id as a deterministic tiebreaker for batch-imported quotes that share a created timestamp. Added tests: - orders hits by created desc, regardless of score (regression guard for the user-reported case) - breaks ties on identical created timestamps by quote id asc (stable pagination across page loads) Renamed 'ranks exact identifier match above item-only match' to 'surfaces both identifier and item matches in the result with their scores intact' — the assertion still holds because both fixtures share the default created date, but the test's intent is now 'both hits surface with their scores preserved', not 'order is determined by score'. --- src/lib/server/quotes-search-service.test.ts | 95 +++++++++++++++++++- src/lib/server/quotes-search-service.ts | 32 +++++-- 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/lib/server/quotes-search-service.test.ts b/src/lib/server/quotes-search-service.test.ts index 81b1104..df25f43 100644 --- a/src/lib/server/quotes-search-service.test.ts +++ b/src/lib/server/quotes-search-service.test.ts @@ -735,10 +735,20 @@ describe("searchQuotes — pipeline", () => { expect(result.totalReturned).toBe(0); }); - it("ranks exact identifier match above item-only match", async () => { + it("surfaces both identifier and item matches in the result with their scores intact", async () => { // Pushdown: the `~"acme"` filter on `quotes` returns ACME-1; // the `~"acme"` filter on `quote_items` returns the item on // q-item; the expand + items fetches round out the data. + // + // Note: order between the two is NOT a score-rank test anymore + // (the service sorts by `created` desc, not score desc — see the + // "orders hits by created desc" test below). Both fixtures share + // the default `created = "2026-01-01T00:00:00Z"` so the result + // falls through to the id tiebreaker, which puts `q-id` first. + // What this test still locks: + // - both hits are surfaced (no false drop in the pushdown) + // - the per-hit `score` field reflects the identifier > item + // weight ordering (the UI breakdown still works) setStore([ { collection: "quotes", fields: makeQuote({ id: "q-id", quote_number: "ACME-1", customer: "", company: "" }) }, { collection: "quotes", fields: makeQuote({ id: "q-item", quote_number: "MISS", customer: "", company: "" }) }, @@ -754,6 +764,89 @@ describe("searchQuotes — pipeline", () => { expect(result.hits[0].score).toBeGreaterThan(result.hits[1].score); }); + it("orders hits by created desc (newest first), regardless of score", async () => { + // Two quotes both match the query. The newer one has a weaker + // score (item-only match), the older one has a stronger score + // (identifier match). Pre-Sept-2026 the older quote would have + // ranked first on score alone; the office flagged this because + // searching a customer name surfaced their ancient quotes + // ahead of the one they'd literally just sent. Post-fix the + // date wins — the operator's "find me the latest for this + // person" query lands on the most recent matching quote at the + // top regardless of match strength. + setStore([ + { + collection: "quotes", + fields: makeQuote({ + id: "q-old-strong", + quote_number: "ACME-1", + customer: "", + company: "", + created: "2024-01-01T00:00:00Z", + }), + }, + { + collection: "quotes", + fields: makeQuote({ + id: "q-new-weak", + quote_number: "MISS", + customer: "", + company: "", + created: "2026-08-01T00:00:00Z", + }), + }, + { + collection: "quote_items", + fields: makeItem({ id: "qi1", quote: "q-new-weak", description: "acme widget" }), + }, + ]); + + const result = await searchQuotes({ query: "ACME" }); + expect(result.hits).toHaveLength(2); + expect(result.hits[0].quote.id).toBe("q-new-weak"); // newest first + expect(result.hits[1].quote.id).toBe("q-old-strong"); + // The score breakdown is still per-hit and still reflects the + // weight ordering — the UI's "why this match" affordance + // keeps working even though score no longer decides order. + const oldScore = result.hits[1].score; + const newScore = result.hits[0].score; + expect(oldScore).toBeGreaterThan(newScore); + }); + + it("breaks ties on identical created timestamps by quote id asc (stable pagination)", async () => { + // Batch-import case: two quotes stamped with the same `created` + // second. The id tiebreaker keeps the order deterministic so + // paginating through the search returns the same rows on every + // page-load instead of shuffling between requests. + setStore([ + { + collection: "quotes", + fields: makeQuote({ + id: "q-alpha", + quote_number: "ACME-A", + customer: "", + company: "", + created: "2026-08-01T00:00:00Z", + }), + }, + { + collection: "quotes", + fields: makeQuote({ + id: "q-bravo", + quote_number: "ACME-B", + customer: "", + company: "", + created: "2026-08-01T00:00:00Z", + }), + }, + ]); + + const result = await searchQuotes({ query: "ACME" }); + expect(result.hits).toHaveLength(2); + expect(result.hits[0].quote.id).toBe("q-alpha"); // id asc + expect(result.hits[1].quote.id).toBe("q-bravo"); + }); + it("chunks candidate-id fetches into <=80-id OR-chains (no single large filter)", async () => { // Build a candidate set larger than ID_FILTER_CHUNK_SIZE (80) so // the chunkedFilterFetch path is exercised. Each candidate must diff --git a/src/lib/server/quotes-search-service.ts b/src/lib/server/quotes-search-service.ts index 5a37fc4..5932d75 100644 --- a/src/lib/server/quotes-search-service.ts +++ b/src/lib/server/quotes-search-service.ts @@ -44,8 +44,13 @@ * before. PB-side `~` filters are conservative (any-field * substring); the in-memory scorer upgrades exact-prefix and * identifier hits above body matches and gives us a sort order. - * 7. Sort by score desc (id asc as stable tiebreak), paginate, - * return top N + per-tab match counts. + * 7. Sort by `created` desc (newest first; quote id as stable + * tiebreak), paginate, return top N + per-tab match counts. + * Score is still computed per-hit and surfaced in the response + * for the UI's "why this match" breakdown, but the order within + * the result set is purely recency — the operator wants the + * most recent quote for their customer on top, regardless of + * how strongly an older quote matched the query. * * Why pushdown matters: the live PB has ~8.5k quotes, each with up * to dozens of items (and supplier expand). Pulling all of them on @@ -1242,12 +1247,25 @@ export async function searchQuotes( if (t) perTab[t] = (perTab[t] ?? 0) + 1; } - // 5. Sort by score desc, then by quote id (stable) for deterministic - // pagination. Score is the primary key; created is intentionally - // NOT used as a tiebreak so the operator sees the strongest - // match first, not the newest. + // 5. Sort by `created` descending (newest first). The relevance + // score is still computed per-hit and surfaced in the response + // (so the UI can show "why this match" with the existing + // score breakdown), and the score still decides which quotes + // qualify — only candidates with `breakdown.raw > 0` make it + // into the result set. What changed (2026-09-08): the order + // within that set is no longer score-desc. Operators + // complained that an exact quote_number hit from 2025 was + // beating a more recent customer-name match for the same + // person — i.e. "the most recent quote for that person" was + // buried in the middle of the result list. Now the newest + // qualifying quote is always on top, regardless of how + // strongly an older quote matched. Quote id is the stable + // secondary tiebreaker so two quotes sharing a `created` + // timestamp (e.g. a batch-import) paginate deterministically. scored.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; + if (b.quote.created !== a.quote.created) { + return b.quote.created.localeCompare(a.quote.created); + } return a.quote.id < b.quote.id ? -1 : a.quote.id > b.quote.id ? 1 : 0; });