`"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>
173 lines
7.7 KiB
Go
173 lines
7.7 KiB
Go
package models
|
|
|
|
// Scan-to-order.
|
|
//
|
|
// A customer points the app at a packet, Google Lens (on the phone) reads a
|
|
// label off it, and the app asks: "which of MY shops has this, in what sizes,
|
|
// and which one should I buy from?" These are the shapes on both sides of
|
|
// that conversation.
|
|
|
|
// ScanLookupRequest is what the app sends once Lens has produced a label.
|
|
type ScanLookupRequest struct {
|
|
Customerid int `json:"customerid"`
|
|
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
|
// 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.
|
|
Latitude FlexibleString `json:"latitude"`
|
|
Longitude FlexibleString `json:"longitude"`
|
|
// The tenants the app believes the customer has scanned into. Optional and
|
|
// never trusted on its own: the server intersects it with the
|
|
// tenantcustomers table and reports anything it dropped.
|
|
Tenantids []int `json:"tenantids"`
|
|
// How many stores to return. 0 = all registered stores that stock it.
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
// ScanStore is one of the customer's registered outlets.
|
|
type ScanStore struct {
|
|
Tenantid int `json:"tenantid"`
|
|
Tenantname string `json:"tenantname"`
|
|
Locationid int `json:"locationid"`
|
|
Locationname string `json:"locationname"`
|
|
Address string `json:"address,omitempty"`
|
|
Latitude float64 `json:"latitude"`
|
|
Longitude float64 `json:"longitude"`
|
|
// Kilometres from the customer, or -1 when either side has no usable
|
|
// coordinates. Never omitted: a missing number is easy to misread as 0.
|
|
DistanceKm float64 `json:"distance_km"`
|
|
// Delivery reach in the outlet's own units, straight from tenantlocations.
|
|
Deliveryradius int `json:"deliveryradius"`
|
|
Deliverymins int `json:"deliverymins"`
|
|
Open bool `json:"open"`
|
|
}
|
|
|
|
// ScanOption is one thing the customer can actually put in the basket at one
|
|
// store: the matched product itself, or one of its sizes. Each is a real
|
|
// product row with its own price and stock, which is why they are flat.
|
|
type ScanOption struct {
|
|
Productid int `json:"productid"`
|
|
Productname string `json:"productname"`
|
|
Size string `json:"size"` // "500 g", "1 kg" — unitvalue + productunit
|
|
Price float64 `json:"price"`
|
|
Stock int `json:"stock"`
|
|
Available bool `json:"available"`
|
|
Image string `json:"image,omitempty"`
|
|
// Is this the product that matched, or a size hanging under it?
|
|
IsVariant bool `json:"is_variant"`
|
|
Variantname string `json:"variantname,omitempty"`
|
|
// How the row was tied back to the catalogue: "imageid",
|
|
// "brand+catalogueid", "name" or "variant-of:<productid>".
|
|
MatchedBy string `json:"matched_by"`
|
|
}
|
|
|
|
// ScanStoreOffer is one store and what it can sell.
|
|
type ScanStoreOffer struct {
|
|
ScanStore
|
|
// Nearest store with at least one option in stock. Exactly one offer
|
|
// carries this, and only when something is in stock somewhere.
|
|
Recommended bool `json:"recommended"`
|
|
// Any option in stock here.
|
|
Available bool `json:"available"`
|
|
Options []ScanOption `json:"options"`
|
|
}
|
|
|
|
// ScanCatalogueMatch is what the catalogue search settled on.
|
|
type ScanCatalogueMatch struct {
|
|
Brand string `json:"brand"`
|
|
Catalogueid int64 `json:"catalogueid"`
|
|
Imageid string `json:"imageid,omitempty"`
|
|
ProductName string `json:"product_name"`
|
|
Title string `json:"title,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
Size string `json:"size,omitempty"`
|
|
VariantKey string `json:"variant_key,omitempty"`
|
|
Image string `json:"image,omitempty"`
|
|
Score float64 `json:"score"`
|
|
// "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.
|
|
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. 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.
|
|
Stores []ScanStoreOffer `json:"stores"`
|
|
Available bool `json:"available"`
|
|
// The locationid of the store marked Recommended, or 0.
|
|
RecommendedLocationid int `json:"recommended_locationid"`
|
|
// Tenant ids the app sent that the customer is not actually registered
|
|
// with. Empty normally; non-empty means the app's local list is stale.
|
|
UnregisteredTenantids []int `json:"unregistered_tenantids,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ScanConfirmRequest is sent when the customer taps a store and a size.
|
|
type ScanConfirmRequest struct {
|
|
Customerid int `json:"customerid"`
|
|
Tenantid int `json:"tenantid"`
|
|
Locationid int `json:"locationid"`
|
|
Productid int `json:"productid"`
|
|
Quantity int `json:"quantity"`
|
|
Latitude FlexibleString `json:"latitude"`
|
|
Longitude FlexibleString `json:"longitude"`
|
|
}
|
|
|
|
// ScanConfirmResponse says whether the pick still holds, and where to go if
|
|
// it does not.
|
|
type ScanConfirmResponse struct {
|
|
Ok bool `json:"ok"`
|
|
// "in_stock", "insufficient_stock", "out_of_stock", "not_sold_here",
|
|
// "store_not_registered".
|
|
Reason string `json:"reason"`
|
|
Store *ScanStore `json:"store,omitempty"`
|
|
Option *ScanOption `json:"option,omitempty"`
|
|
Requested int `json:"requested"`
|
|
// The next-nearest registered store with enough of the same product, when
|
|
// the chosen one has run out. Nil when there is none.
|
|
Alternative *ScanStoreOffer `json:"alternative,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|