fix(quotes-search): order hits by created desc so the most recent match is on top

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'.
This commit is contained in:
Dave 2026-09-08 10:24:34 +02:00
parent 3de1d622b8
commit 9ec1b04cab
2 changed files with 119 additions and 8 deletions

View file

@ -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

View file

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