Ask instead of guessing when a label fits several products

`"britannia"` is a substring of all 258 Britannia product names, and
textScore returned 0.95 for any product whose name contained the label.
So every one of them tied, the tie broke alphabetically, and the customer
was shown one arbitrary biscuit with "confidence": 0.95 and a price. Lens
hands back a bare wordmark often — it is usually the biggest thing printed
on a packet — so this was the common case, not an edge one. Found via the
example request in the mobile team's own proposal.

Scoring now asks both questions. A hit carries `score` (ranks) and `text`
(how specifically the label names THIS product: the harmonic mean of how
much of the label the product explains and how much of the product's name
the label explains, pack sizes dropped from both sides). A brand name
scores its products ~0.33 equally instead of 0.95 arbitrarily. The
"vector and text agree" bonus is now proportional to the text score, so a
weak match can no longer inflate a whole brand.

isAmbiguous reads that: the leader is a guess if anything is level with it
(margin) or if the label names no one product (specificity), and then the
response carries `ambiguous: true` with `candidates` — distinct products,
not pack sizes, at most ten, each marked with whether one of the
customer's stores has it in stock, available ones first. `match` is nil
and `stores` empty on that path: no price for a product nobody chose.
Erring towards asking is deliberate — a tap versus the wrong biscuit.

To act on a pick, /lookup now accepts `brand` + `catalogueid` instead of a
label and skips recognition entirely (also serves deep links and re-order).
New: ScanRepository.CatalogueRef, resolving via the brand tables discovered
from information_schema, never a name built from the request.

Also: scratch/cataloguedims now reports every vector column, not just
`embedding` — which is how we learned the catalogue also carries
img_vector(1024), filled on 1885 of 2124 rows. SCAN_TO_ORDER.md records
why that column stays unread for now and what would change it, alongside
why the app is not asked to compute vectors on the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:56:02 +05:30
parent aaea1bfc00
commit 01bc89ab77
6 changed files with 821 additions and 75 deletions

View File

@@ -11,8 +11,16 @@ package models
type ScanLookupRequest struct {
Customerid int `json:"customerid"`
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
// and capped by the service.
// and capped by the service. Not required when Brand and Catalogueid
// name a product outright.
Label string `json:"label"`
// A product the customer has already chosen, by its catalogue key —
// which is how the app resolves a `candidates` list from an earlier
// ambiguous lookup, and how a deep link or a re-order skips recognition
// altogether. When both are set the label is ignored and no catalogue
// search runs.
Brand string `json:"brand"`
Catalogueid int64 `json:"catalogueid"`
// Where the customer is right now. Optional: without it the customer's
// saved primary address is used, and without that stores are listed in
// registration order with no distance.
@@ -86,9 +94,15 @@ type ScanCatalogueMatch struct {
VariantKey string `json:"variant_key,omitempty"`
Image string `json:"image,omitempty"`
Score float64 `json:"score"`
// "vector", "vector+text" or "text" — how the score was produced. The app
// can be more cautious with a text-only match.
// "vector+text", "text" or "direct" — how the score was produced. The app
// can be more cautious with a text-only match; "direct" means the caller
// named the product by its catalogue key and nothing was recognised.
Method string `json:"method"`
// Set only on entries of `candidates`: at least one of the customer's
// registered stores has this product in stock right now. Candidates are
// ordered with the available ones first, so a "did you mean?" list can
// show what is actually buyable before what is not.
Available bool `json:"available,omitempty"`
}
// ScanLookupResponse is the answer to a scan.
@@ -96,10 +110,27 @@ type ScanLookupResponse struct {
Label string `json:"label"`
// The best catalogue product for the label, and the sizes of it the
// catalogue knows about (each a separate catalogue row).
//
// Match is nil when nothing was recognised, and also when several
// products matched equally well — see Ambiguous.
Match *ScanCatalogueMatch `json:"match"`
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
// Several products fit the label and no one of them is a clear winner —
// which is what a bare brand name ("britannia") or a generic word
// ("biscuits") produces, and Lens returns those often because a
// wordmark is the most legible thing on a packet.
//
// When true: Match is nil, Stores is empty, and Candidates holds the
// products to offer as "did you mean?". Picking one means calling
// /lookup again with that candidate's `brand` and `catalogueid`.
//
// Guessing instead would mean showing a confident price for a product
// the customer did not photograph.
Ambiguous bool `json:"ambiguous"`
Candidates []ScanCatalogueMatch `json:"candidates"`
// 0..1. Below ~0.5 the app should confirm with the customer before
// showing prices.
// showing prices. With Ambiguous set this is the leader's score, which
// by definition the runner-up nearly equals.
Confidence float64 `json:"confidence"`
// Registered stores that stock the product, nearest first, in-stock
// first. Empty with Available=false when none does.