diff --git a/docs/SCAN_TO_ORDER.md b/docs/SCAN_TO_ORDER.md index ba74af6..452a0d2 100644 --- a/docs/SCAN_TO_ORDER.md +++ b/docs/SCAN_TO_ORDER.md @@ -100,6 +100,10 @@ How to read it: cart/order calls exactly as you would from the catalogue screen. - `distance_km: -1` means the distance is unknown (no fix from the phone and no saved address, or the store has no coordinates). Do not render it as 0. + Send `latitude`/`longitude` on `/confirm` too if you display distance from + its reply: the saved address is only consulted there when the shelf is + empty and alternatives have to be ranked, so without a fix the store you + tapped comes back `-1`. ## `POST /confirm` @@ -115,7 +119,7 @@ nothing is cached on this path. { "ok": false, "reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered - "store": { "…the store they tapped…" }, + "store": { "…the store they tapped…" }, // distance_km filled from the fix you send "option": { "productid": 100, "stock": 0, "…": "…" }, "requested": 2, "alternative": { // absent when nobody has enough @@ -167,10 +171,12 @@ Same `ScanStore` shape as inside `stores[]` above, without options. EMBEDDING_DIMENSIONS=384 ``` A bare label ("Milk Bikis") scores ~0.92 against its product's stored - vector and ~0.23 against an unrelated one, which is what the 0.30 floor in - `scanService.go` is set against. If the catalogue team ever re-embeds - with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here and - nothing else. + vector and ~0.23 against an unrelated one, which is what the 0.50 floor in + `scanService.go` is set against — the middle of that split, not the edge of + the noise. It was 0.30 until a near-miss got through in production + ("Paracetamol" → "Paneer Makhni 500ml", 0.304). If the catalogue team ever + re-embeds with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here + and nothing else. - **Speed**: the label's vector (7 days) and the ranked catalogue hits (30 min) are cached in Redis and in-process, so a popular product costs one model call platform-wide. Customer, stores and catalogue are read @@ -232,7 +238,7 @@ you change ranking — it is the spec. |---|---|---| | `scanLookupTimeout` | 5 s | whole lookup, including the model call | | `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge | -| `scanMinScore` | 0.30 | below this the best hit is not shown as a match | +| `scanMinScore` | 0.50 | below this the best hit is not shown as a match | | `embedTimeout` (`utils/embedding.go`) | 4 s | one model call | | `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes | diff --git a/services/scanService.go b/services/scanService.go index 4695212..da3b837 100644 --- a/services/scanService.go +++ b/services/scanService.go @@ -47,8 +47,13 @@ const ( scanLookupTimeout = 5 * time.Second scanMaxLabelLen = 200 scanCatalogueTopK = 15 - // Below this the best hit is not shown as a match at all. - scanMinScore = 0.30 + // Below this the best hit is not shown as a match at all. A correct label + // scores ~0.92 against its own product's vector and ~0.23 against an + // unrelated one, so the floor sits in the empty middle of that split + // rather than just above the unrelated band: at 0.30, "Paracetamol" + // came back as "Paneer Makhni 500ml" (0.304) — a near-miss on an + // unrelated row clears a floor set that close to the noise. + scanMinScore = 0.50 ) // ScanErrors the controller maps to statuses. Everything else is a 500. @@ -241,6 +246,14 @@ func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest } resp.Store = chosen + // Distance on the store they tapped, for every outcome and not just the + // out-of-stock one below: the app renders this store from the reply it + // gets. The phone's fix is free to parse; the saved address costs a + // query, so it is only reached for on the path that also ranks other + // outlets. Without either, distanceKm leaves the -1 the repository set. + lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)) + chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos) + row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid) if err != nil { return nil, err @@ -268,10 +281,10 @@ func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest } // The same product elsewhere, nearest first, with enough of it. - lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)) if !hasPos { if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok { lat, lng, hasPos = hl, hg, true + chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos) } } others := make([]models.ScanStore, 0, len(stores)) diff --git a/services/scan_test.go b/services/scan_test.go index 6c5fe98..7df6144 100644 --- a/services/scan_test.go +++ b/services/scan_test.go @@ -436,3 +436,68 @@ func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) { t.Errorf("name match should join 1 and 2 only, got %+v", family) } } + +// A vector neighbour that is merely not-quite-unrelated used to clear the old +// 0.30 floor: in production "Paracetamol" came back as "Paneer Makhni 500ml" +// on a 0.304 similarity. Correct labels land near 0.92, so nothing this weak +// is a match. +func TestLookupRefusesANearMissAboveTheOldFloor(t *testing.T) { + repo := newLookupFixture() + repo.vector = []repositories.CatalogueHit{{Brand: "amul", ID: 4, ProductName: "Paneer Makhni 500ml", Distance: 0.696}} // score 0.304 + repo.text = nil + svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}}) + + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Paracetamol"}) + if err != nil { + t.Fatal(err) + } + if resp.Match != nil { + t.Fatalf("0.304 is a near-miss, not a match; got %+v", resp.Match) + } + if resp.Available || len(resp.Stores) != 0 { + t.Fatalf("nothing should be offered without a match; got %+v", resp) + } +} + +// Confirm answers about the store the customer tapped, so that store carries a +// distance on every outcome — not only on the out-of-stock path that ranks +// alternatives. Absent any position it stays -1, the documented "unknown". +func TestConfirmReportsDistanceToTheChosenStore(t *testing.T) { + repo := newLookupFixture() + repo.at = map[int]*repositories.StoreOptionRow{200: &repo.options[1]} + svc := NewScanService(repo, nil) + req := models.ScanConfirmRequest{Customerid: 5, Tenantid: 2, Locationid: 20, Productid: 200, Quantity: 4} + + withPos := req + withPos.Latitude, withPos.Longitude = "11.035", "77.035" + resp, err := svc.Confirm(context.Background(), withPos) + if err != nil { + t.Fatal(err) + } + if !resp.Ok || resp.Store == nil { + t.Fatalf("expected the in-stock answer, got %+v", resp) + } + if resp.Store.DistanceKm <= 0 { + t.Fatalf("the phone sent a fix, so the tapped store has a distance; got %v", resp.Store.DistanceKm) + } + + // No fix from the phone, but a saved address on file. + repo.homeLat, repo.homeLng, repo.homeOK = 11.035, 77.035, true + resp, err = svc.Confirm(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if resp.Store == nil || resp.Store.DistanceKm != -1 { + t.Fatalf("in stock is answered without reaching for the saved address; got %v", resp.Store) + } + + // Neither: unknown, and the app sorts it last. + repo.homeOK = false + resp, err = svc.Confirm(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if resp.Store == nil || resp.Store.DistanceKm != -1 { + t.Fatalf("no position at all is -1; got %v", resp.Store) + } +}