image search

This commit is contained in:
2026-09-16 11:34:18 +05:30
parent 76bff883ec
commit 42ea007fe7
3 changed files with 93 additions and 9 deletions

View File

@@ -100,6 +100,10 @@ How to read it:
cart/order calls exactly as you would from the catalogue screen. 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 - `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. 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` ## `POST /confirm`
@@ -115,7 +119,7 @@ nothing is cached on this path.
{ {
"ok": false, "ok": false,
"reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered "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, "…": "…" }, "option": { "productid": 100, "stock": 0, "…": "…" },
"requested": 2, "requested": 2,
"alternative": { // absent when nobody has enough "alternative": { // absent when nobody has enough
@@ -167,10 +171,12 @@ Same `ScanStore` shape as inside `stores[]` above, without options.
EMBEDDING_DIMENSIONS=384 EMBEDDING_DIMENSIONS=384
``` ```
A bare label ("Milk Bikis") scores ~0.92 against its product's stored 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 vector and ~0.23 against an unrelated one, which is what the 0.50 floor in
`scanService.go` is set against. If the catalogue team ever re-embeds `scanService.go` is set against — the middle of that split, not the edge of
with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here and the noise. It was 0.30 until a near-miss got through in production
nothing else. ("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 - **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 (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 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 | | `scanLookupTimeout` | 5 s | whole lookup, including the model call |
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge | | `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 | | `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes | | `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |

View File

@@ -47,8 +47,13 @@ const (
scanLookupTimeout = 5 * time.Second scanLookupTimeout = 5 * time.Second
scanMaxLabelLen = 200 scanMaxLabelLen = 200
scanCatalogueTopK = 15 scanCatalogueTopK = 15
// Below this the best hit is not shown as a match at all. // Below this the best hit is not shown as a match at all. A correct label
scanMinScore = 0.30 // 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. // 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 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) row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid)
if err != nil { if err != nil {
return nil, err 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. // The same product elsewhere, nearest first, with enough of it.
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
if !hasPos { if !hasPos {
if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok { if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok {
lat, lng, hasPos = hl, hg, true lat, lng, hasPos = hl, hg, true
chosen.DistanceKm = distanceKm(*chosen, lat, lng, hasPos)
} }
} }
others := make([]models.ScanStore, 0, len(stores)) others := make([]models.ScanStore, 0, len(stores))

View File

@@ -436,3 +436,68 @@ func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
t.Errorf("name match should join 1 and 2 only, got %+v", family) 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)
}
}