Files
backend_fiesta/services/scanService.go
Suriyakumarvijayanayagam 01bc89ab77 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>
2026-09-23 10:56:02 +05:30

999 lines
32 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"log"
"math"
"nearle/models"
"nearle/repositories"
"nearle/utils"
"sort"
"strconv"
"strings"
"sync"
"time"
)
/*
Scan-to-order: from a label Lens read off a packet to "buy it here".
The pipeline, in the order it runs:
1. Who is asking, and where can they buy? The customer's registered outlets
(tenantcustomers → tenantlocations) and their position — the phone's
fix if it sent one, else the saved address.
2. What did they scan? The label goes to the catalogue: embedded and ranked
by pgvector when a model is configured, matched on words when it is not,
and both when it is (the text match settles near-ties). The vector and
the hits are cached; a second person scanning the same packet today does
not pay for the model call.
3. Which of THEIR outlets sell it, in which sizes, with how many on the
shelf right now? One read of nearledb keyed on the catalogue links every
imported product carries. Live stock is never cached.
4. Rank: in-stock outlets first, nearest first, and the first of those is
the recommendation. The customer may still tap any other.
Steps 1 and 2 touch different databases and run concurrently; the whole
lookup is bounded by scanLookupTimeout so a slow model degrades to a text
answer rather than a spinner.
What this deliberately does not do: reserve stock. The confirm call
re-reads the ledger and, if the shelf emptied in between, points at the next
outlet — the same answer a hold would give, without a hold to expire.
*/
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
// How close the runner-up has to be before the leader stops being an
// answer and the two become a question. See isAmbiguous.
scanAmbiguityMargin = 0.06
// A "did you mean?" list longer than this is not a choice, it is a
// catalogue — the customer is standing in a shop holding a packet.
scanMaxCandidates = 10
// How much of the winning product's name the label has to account for
// before it counts as having identified it. See isAmbiguous and
// textScore.
scanSpecificEnough = 0.55
)
// ScanErrors the controller maps to statuses. Everything else is a 500.
var (
ErrScanBadRequest = errors.New("scan: bad request")
ErrScanCustomerNotFound = errors.New("scan: customer not found")
ErrScanCatalogueDown = errors.New("scan: catalogue unavailable")
)
type ScanService interface {
Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error)
Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error)
Stores(ctx context.Context, customerid int, lat, lng models.FlexibleString) ([]models.ScanStore, error)
}
type scanService struct {
repo repositories.ScanRepository
embedder utils.Embedder // nil → text matching only
}
func NewScanService(repo repositories.ScanRepository, embedder utils.Embedder) ScanService {
return &scanService{repo: repo, embedder: embedder}
}
// ── Lookup ──────────────────────────────────────────────────────────────────
func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) {
label := strings.TrimSpace(req.Label)
// The caller can name the product outright instead of describing it —
// how the app resolves a candidate the customer picked.
direct := strings.TrimSpace(req.Brand) != "" && req.Catalogueid > 0
if req.Customerid <= 0 {
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
}
if label == "" && !direct {
return nil, fmt.Errorf("%w: label, or brand and catalogueid, is required", ErrScanBadRequest)
}
if len(label) > scanMaxLabelLen {
label = label[:scanMaxLabelLen]
}
ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout)
defer cancel()
// Steps 1 and 2 in parallel — different databases, no dependency.
var (
wg sync.WaitGroup
stores []models.ScanStore
exists bool
homeLat float64
homeLng float64
homeOK bool
custErr error
hits []scoredHit
method string
matchErr error
)
wg.Add(2)
go func() {
defer wg.Done()
exists, custErr = s.repo.CustomerExists(ctx, req.Customerid)
if custErr != nil || !exists {
return
}
stores, custErr = s.repo.RegisteredStores(ctx, req.Customerid)
if custErr != nil {
return
}
// Only read the saved address when the phone sent nothing usable.
if _, _, ok := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)); !ok {
homeLat, homeLng, homeOK, custErr = s.repo.CustomerHome(ctx, req.Customerid)
}
}()
go func() {
defer wg.Done()
if direct {
hits, method, matchErr = s.resolveRef(ctx, req.Brand, req.Catalogueid)
return
}
hits, method, matchErr = s.searchCatalogue(ctx, label)
}()
wg.Wait()
if custErr != nil {
return nil, custErr
}
if !exists {
return nil, ErrScanCustomerNotFound
}
if matchErr != nil {
return nil, matchErr
}
lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude))
if !hasPos && homeOK {
lat, lng, hasPos = homeLat, homeLng, true
}
resp := &models.ScanLookupResponse{
Label: label,
Stores: []models.ScanStoreOffer{},
Variants: []models.ScanCatalogueMatch{},
Candidates: []models.ScanCatalogueMatch{},
}
// Verify the app's idea of the customer's tenants against the truth.
stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids)
switch {
case len(hits) == 0 && direct:
resp.Message = "That product is no longer in the catalogue."
return resp, nil
case len(hits) == 0, hits[0].score < scanMinScore:
resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack."
return resp, nil
}
distinct := distinctProducts(hits)
// Several products fit and none of them clearly wins — a bare brand name
// or a generic word. Ask rather than guess: naming one of them would put
// a confident price on a product the customer did not photograph.
if !direct && isAmbiguous(distinct) {
return s.candidatesResponse(ctx, resp, hits, distinct, method, stores)
}
best := distinct[0]
family := catalogueFamily(hits, best)
resp.Match = ptr(best.toMatch(method))
resp.Confidence = round3(best.score)
for _, h := range family {
resp.Variants = append(resp.Variants, h.toMatch(method))
}
if len(stores) == 0 {
resp.Message = "You haven't joined a store yet. Scan a store's QR code in the app to shop from it."
return resp, nil
}
// Step 3: what those outlets have.
keys := make([]repositories.CatalogueKey, 0, len(family))
names := make([]string, 0, len(family))
for _, h := range family {
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
names = append(names, h.ProductName)
}
rows, err := s.repo.StoreOptions(ctx, locationIDs(stores), keys, names)
if err != nil {
return nil, err
}
// Step 4: rank.
offers := buildOffers(stores, rows, keys, lat, lng, hasPos)
if req.Limit > 0 && len(offers) > req.Limit {
offers = offers[:req.Limit]
}
resp.Stores = offers
for i := range offers {
if offers[i].Recommended {
resp.Available = true
resp.RecommendedLocationid = offers[i].Locationid
break
}
}
switch {
case resp.Available:
resp.Message = fmt.Sprintf("Available at %d of your stores.", countAvailable(offers))
case len(offers) > 0:
resp.Message = "Your stores sell this but it's out of stock right now."
default:
resp.Message = "None of your stores sell this product yet."
}
return resp, nil
}
// candidatesResponse answers an ambiguous label with the products to choose
// between, marking which of them the customer can actually buy right now.
//
// The availability read is the same StoreOptions query the confident path
// runs, widened to every candidate — so a "did you mean?" list can put the
// three that are in stock above the five that are not, instead of sending
// somebody to a shelf that has none of them.
func (s *scanService) candidatesResponse(ctx context.Context, resp *models.ScanLookupResponse,
hits, distinct []scoredHit, method string, stores []models.ScanStore) (*models.ScanLookupResponse, error) {
candidates := distinct
if len(candidates) > scanMaxCandidates {
candidates = candidates[:scanMaxCandidates]
}
resp.Ambiguous = true
resp.Confidence = round3(distinct[0].score)
// Every catalogue row belonging to a candidate, and a way back from what
// a tenant's product row carries to the candidate it stands for.
inCandidates := make(map[string]bool, len(candidates))
for _, c := range candidates {
inCandidates[c.productKey()] = true
}
var keys []repositories.CatalogueKey
var names []string
byImage := make(map[string]string)
byRef := make(map[string]string)
byName := make(map[string]string)
for _, h := range hits {
key := h.productKey()
if !inCandidates[key] {
continue
}
keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID})
names = append(names, h.ProductName)
if h.ImageID != "" {
byImage[h.ImageID] = key
}
byRef[refKey(h.Brand, h.ID)] = key
if n := strings.ToLower(strings.TrimSpace(h.ProductName)); n != "" {
byName[n] = key
}
}
stocked := make(map[string]bool)
if len(stores) > 0 && len(keys) > 0 {
rows, err := s.repo.StoreOptions(ctx, locationIDs(stores), keys, names)
if err != nil {
return nil, err
}
for _, row := range rows {
if row.Stock <= 0 {
continue
}
// Same precedence as optionFromRow: the stable key first.
if row.Imageid != "" {
if key, ok := byImage[row.Imageid]; ok {
stocked[key] = true
continue
}
}
if row.Catalogueid > 0 {
if key, ok := byRef[refKey(row.Productbrand, row.Catalogueid)]; ok {
stocked[key] = true
continue
}
}
if key, ok := byName[strings.ToLower(strings.TrimSpace(row.Productname))]; ok {
stocked[key] = true
}
}
}
for _, c := range candidates {
m := c.toMatch(method)
m.Available = stocked[c.productKey()]
resp.Candidates = append(resp.Candidates, m)
}
// Buyable first; within each group the search's own ranking stands.
sort.SliceStable(resp.Candidates, func(i, j int) bool {
return resp.Candidates[i].Available && !resp.Candidates[j].Available
})
available := 0
for _, c := range resp.Candidates {
if c.Available {
available++
}
}
if available > 0 {
resp.Message = fmt.Sprintf("Which one is it? %d of these %d are in stock near you.",
available, len(resp.Candidates))
} else {
resp.Message = fmt.Sprintf("Which one is it? We found %d products that could match.",
len(resp.Candidates))
}
return resp, nil
}
// resolveRef reads the product the caller named, with its pack sizes. No
// recognition, so every row scores 1 and the method says so.
func (s *scanService) resolveRef(ctx context.Context, brand string, id int64) ([]scoredHit, string, error) {
rows, err := s.repo.CatalogueRef(ctx, brand, id)
if err != nil {
switch {
case errors.Is(err, repositories.ErrCatalogueDBUnavailable):
return nil, "direct", ErrScanCatalogueDown
case errors.Is(err, repositories.ErrUnknownBrand):
return nil, "direct", fmt.Errorf("%w: unknown brand %q", ErrScanBadRequest, brand)
}
return nil, "direct", err
}
hits := make([]scoredHit, 0, len(rows))
for _, row := range rows {
hits = append(hits, scoredHit{CatalogueHit: row, score: 1})
}
return hits, "direct", nil
}
func refKey(brand string, id int64) string {
return strings.ToLower(strings.TrimSpace(brand)) + "#" + strconv.FormatInt(id, 10)
}
func locationIDs(stores []models.ScanStore) []int {
ids := make([]int, 0, len(stores))
for _, st := range stores {
ids = append(ids, st.Locationid)
}
return ids
}
// ── Confirm ─────────────────────────────────────────────────────────────────
func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) {
if req.Customerid <= 0 || req.Tenantid <= 0 || req.Locationid <= 0 || req.Productid <= 0 {
return nil, fmt.Errorf("%w: customerid, tenantid, locationid and productid are required", ErrScanBadRequest)
}
if req.Quantity <= 0 {
req.Quantity = 1
}
ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout)
defer cancel()
stores, err := s.repo.RegisteredStores(ctx, req.Customerid)
if err != nil {
return nil, err
}
resp := &models.ScanConfirmResponse{Requested: req.Quantity}
var chosen *models.ScanStore
for i := range stores {
if stores[i].Tenantid == req.Tenantid && stores[i].Locationid == req.Locationid {
chosen = &stores[i]
break
}
}
if chosen == nil {
resp.Reason = "store_not_registered"
resp.Message = "You're not registered with that store. Scan its QR code first."
return resp, nil
}
resp.Store = chosen
row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid)
if err != nil {
return nil, err
}
if row == nil {
resp.Reason = "not_sold_here"
resp.Message = "That store doesn't sell this product."
return resp, nil
}
opt := optionFromRow(*row, nil)
resp.Option = &opt
if row.Stock >= req.Quantity {
resp.Ok = true
resp.Reason = "in_stock"
resp.Message = "In stock."
return resp, nil
}
if row.Stock > 0 {
resp.Reason = "insufficient_stock"
resp.Message = fmt.Sprintf("Only %d left at %s.", row.Stock, chosen.Locationname)
} else {
resp.Reason = "out_of_stock"
resp.Message = fmt.Sprintf("Out of stock at %s.", chosen.Locationname)
}
// 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
}
}
others := make([]models.ScanStore, 0, len(stores))
locationids := make([]int, 0, len(stores))
for _, st := range stores {
if st.Locationid == req.Locationid {
continue
}
others = append(others, st)
locationids = append(locationids, st.Locationid)
}
if len(others) == 0 {
return resp, nil
}
key := repositories.CatalogueKey{Brand: row.Productbrand, Catalogueid: row.Catalogueid, Imageid: row.Imageid}
rows, err := s.repo.StoreOptions(ctx, locationids, []repositories.CatalogueKey{key}, []string{row.Productname})
if err != nil {
return nil, err
}
// Only the product itself — not its other sizes: the customer chose a
// size and a different one is not a substitute they asked for.
var same []repositories.StoreOptionRow
for _, r := range rows {
if r.Stock >= req.Quantity && sameStoreProduct(*row, r) {
same = append(same, r)
}
}
offers := buildOffers(others, same, []repositories.CatalogueKey{key}, lat, lng, hasPos)
for i := range offers {
if offers[i].Available {
offers[i].Recommended = true
resp.Alternative = &offers[i]
resp.Message += fmt.Sprintf(" %s has it", offers[i].Locationname)
if offers[i].DistanceKm >= 0 {
resp.Message += fmt.Sprintf(" (%.1f km away)", offers[i].DistanceKm)
}
resp.Message += "."
break
}
}
return resp, nil
}
// ── Stores ──────────────────────────────────────────────────────────────────
func (s *scanService) Stores(ctx context.Context, customerid int, latStr, lngStr models.FlexibleString) ([]models.ScanStore, error) {
if customerid <= 0 {
return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest)
}
ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout)
defer cancel()
exists, err := s.repo.CustomerExists(ctx, customerid)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrScanCustomerNotFound
}
stores, err := s.repo.RegisteredStores(ctx, customerid)
if err != nil {
return nil, err
}
lat, lng, hasPos := utils.ParseLatLng(string(latStr), string(lngStr))
if !hasPos {
if hl, hg, ok, err := s.repo.CustomerHome(ctx, customerid); err == nil && ok {
lat, lng, hasPos = hl, hg, true
}
}
for i := range stores {
stores[i].DistanceKm = distanceKm(stores[i], lat, lng, hasPos)
}
sort.SliceStable(stores, func(i, j int) bool { return nearer(stores[i].DistanceKm, stores[j].DistanceKm) })
if stores == nil {
stores = []models.ScanStore{}
}
return stores, nil
}
// ── Catalogue search ────────────────────────────────────────────────────────
type scoredHit struct {
repositories.CatalogueHit
// score ranks; text says how specifically the label names THIS product.
// Kept apart because they answer different questions: a vector neighbour
// can rank first while the label ("britannia") names no one product, and
// only the second number knows that.
score float64
text float64
}
func (h scoredHit) toMatch(method string) models.ScanCatalogueMatch {
return models.ScanCatalogueMatch{
Brand: h.Brand,
Catalogueid: h.ID,
Imageid: h.ImageID,
ProductName: h.ProductName,
Title: h.Title,
Category: h.Category,
Size: h.Size,
VariantKey: h.VariantKey,
Image: h.ImageURL,
Score: round3(h.score),
Method: method,
}
}
// searchCatalogue returns hits best-first and the method that produced them.
//
// Vector and text are combined as max(vector, text) with a small bonus when
// both agree. Max rather than a weighted sum so that a text-only hit — the
// exact product name typed on the pack — is never dragged below a vaguely
// similar vector neighbour, and a vector hit is never punished for a label
// Lens spelled slightly differently from the catalogue.
func (s *scanService) searchCatalogue(ctx context.Context, label string) ([]scoredHit, string, error) {
method := "text"
useVector := s.embedder != nil && s.repo.VectorSearchAvailable()
if useVector {
method = "vector+text"
}
tokens := utils.SearchTokens(label)
if cached, ok := s.repo.CachedHits(ctx, method+":"+s.modelName(), label); ok {
return scoreCachedHits(cached, label, tokens), method, nil
}
byKey := make(map[string]*scoredHit)
keyOf := func(h repositories.CatalogueHit) string { return h.Brand + "#" + fmt.Sprint(h.ID) }
if useVector {
vec, err := s.embed(ctx, label)
if err != nil {
// Degrade, loudly in the log and quietly to the customer: a text
// answer now beats a vector answer never.
log.Printf("scan: embedding %q failed, falling back to text: %v", label, err)
method = "text"
} else {
vhits, err := s.repo.VectorSearch(ctx, vec, scanCatalogueTopK)
if err != nil {
log.Printf("scan: vector search failed, falling back to text: %v", err)
method = "text"
}
for _, h := range vhits {
sim := 1 - h.Distance // cosine distance → similarity
if sim < 0 {
sim = 0
}
byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: sim}
}
}
}
thits, err := s.repo.TextSearch(ctx, label, scanCatalogueTopK)
if err != nil {
if errors.Is(err, repositories.ErrCatalogueDBUnavailable) {
return nil, method, ErrScanCatalogueDown
}
if len(byKey) == 0 {
return nil, method, err
}
log.Printf("scan: text search failed, vector only: %v", err)
}
for _, h := range thits {
ts := textScore(h, label, tokens)
if existing, ok := byKey[keyOf(h)]; ok {
// The bonus is proportional: only a text match that actually
// names the product confirms a vector hit. A flat +0.10 let a
// bare brand name — which matches every one of that brand's
// products weakly — inflate all of them equally.
existing.score = math.Min(1, math.Max(existing.score, ts)+0.10*ts)
existing.text = ts
continue
}
byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: ts, text: ts}
}
hits := make([]scoredHit, 0, len(byKey))
for _, h := range byKey {
hits = append(hits, *h)
}
sortHits(hits)
// Remember the ranked rows with their score folded into Distance, so the
// cache does not need a second shape.
toCache := make([]repositories.CatalogueHit, 0, len(hits))
for _, h := range hits {
c := h.CatalogueHit
c.Distance = 1 - h.score
toCache = append(toCache, c)
}
s.repo.CacheHits(ctx, method+":"+s.modelName(), label, toCache)
return hits, method, nil
}
// scoreCachedHits restores the ranking the cache holds, and recomputes the
// text score from the row itself — the cache carries one number per row, and
// recomputing costs nothing while leaving out the specificity signal would
// make every cached lookup read as ambiguous.
func scoreCachedHits(cached []repositories.CatalogueHit, label string, tokens []string) []scoredHit {
hits := make([]scoredHit, 0, len(cached))
for _, c := range cached {
hits = append(hits, scoredHit{
CatalogueHit: c,
score: 1 - c.Distance,
text: textScore(c, label, tokens),
})
}
sortHits(hits)
return hits
}
func sortHits(hits []scoredHit) {
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].ProductName < hits[j].ProductName
})
}
func (s *scanService) modelName() string {
if s.embedder == nil {
return "none"
}
return s.embedder.Model()
}
func (s *scanService) embed(ctx context.Context, label string) ([]float32, error) {
if v, ok := s.repo.CachedVector(ctx, s.embedder.Model(), label); ok {
return v, nil
}
v, err := s.embedder.Embed(ctx, label)
if err != nil {
return nil, err
}
s.repo.CacheVector(ctx, s.embedder.Model(), label, v)
return v, nil
}
// textScore is how well a catalogue row matches the words Lens read.
//
// Both directions count, and that is the whole point:
//
// - labelCoverage — how much of what the customer said this product
// accounts for. "Milk Bikis" against "Milk Bikis 100g" is all of it.
// - nameCoverage — how much of the product the label accounts for, which
// is what makes the match SPECIFIC. "britannia" explains one word of
// "Britannia Good Day Cashew Cookies", so it does not identify it.
//
// The score is their harmonic mean, so a high score needs both.
//
// This replaces `strings.Contains(name, label) → 0.95`, which asked only the
// first question. A bare brand name is a substring of every one of that
// brand's products, so all 258 Britannia rows scored 0.95, the tie broke
// alphabetically, and the customer was shown one arbitrary biscuit with
// "confidence": 0.95. Lens returns a bare wordmark often — it is usually the
// most legible thing on a packet — so that was not an edge case.
//
// Now those rows score ~0.33 and, crucially, score it EQUALLY, which is what
// isAmbiguous reads to answer "did you mean?" instead of guessing.
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
if len(tokens) == 0 {
return 0
}
hay := strings.ToLower(h.ProductName + " " + h.Title)
found := 0
for _, t := range tokens {
if strings.Contains(hay, t) {
found++
}
}
if found == 0 {
return 0
}
labelCoverage := float64(found) / float64(len(tokens))
// Pack sizes are dropped from both sides (SearchTokens), so "100g" never
// counts as a word the label failed to explain.
nameTokens := utils.SearchTokens(h.ProductName)
if len(nameTokens) == 0 {
return 0.5 * labelCoverage
}
explained := 0
for _, n := range nameTokens {
for _, t := range tokens {
if tokenMatch(n, t) {
explained++
break
}
}
}
if explained == 0 {
// Matched the title but not the name. Weak, not zero.
return 0.4 * labelCoverage
}
nameCoverage := float64(explained) / float64(len(nameTokens))
return 2 * labelCoverage * nameCoverage / (labelCoverage + nameCoverage)
}
// tokenMatch is equality, plus containment for words long enough that a
// shared prefix means something ("cookie"/"cookies", "chocolate"/"choco").
// Short tokens must match exactly, or "day" would match "daybreak".
func tokenMatch(a, b string) bool {
if a == b {
return true
}
if len(a) >= 5 && strings.Contains(b, a) {
return true
}
return len(b) >= 5 && strings.Contains(a, b)
}
// productKey identifies a product across its pack sizes: the catalogue's own
// variant_key where it assigned one, the name otherwise, always within a
// brand. Two rows sharing it are 100 g and 200 g of one thing; two rows that
// do not are different products to choose between.
func productKey(h repositories.CatalogueHit) string {
if k := strings.TrimSpace(h.VariantKey); k != "" {
return h.Brand + "/" + strings.ToLower(k)
}
return h.Brand + "/" + strings.ToLower(strings.TrimSpace(h.ProductName))
}
// productKey of a scored hit — scoredHit embeds the row it scored.
func (h scoredHit) productKey() string { return productKey(h.CatalogueHit) }
// distinctProducts keeps the best-scoring row of each product, in rank
// order — the list of things the customer could actually be shown to choose
// between, as opposed to the same product listed four times in four sizes.
func distinctProducts(hits []scoredHit) []scoredHit {
seen := make(map[string]bool, len(hits))
out := make([]scoredHit, 0, len(hits))
for _, h := range hits {
key := h.productKey()
if seen[key] {
continue
}
seen[key] = true
out = append(out, h)
}
return out
}
// isAmbiguous reports that naming the leader as THE match would be a guess
// dressed up as an answer. Two ways that happens:
//
// 1. Something else is level with it. A margin rather than an absolute
// threshold, because what matters is not how high the best score is but
// whether anything is tied with it.
// 2. Nothing is level, but the label does not actually name a product —
// a bare brand, a generic word, or a spelling the catalogue does not
// carry. The leader may still rank first on vector similarity, and
// ranking first among vague matches is not identification.
//
// Erring towards asking is deliberate. Asking costs the customer one tap on
// a picture; guessing wrong costs them the wrong biscuit and costs us the
// belief that the scanner works. An exact product name still scores ~1.0 on
// specificity, so the common case is unaffected.
func isAmbiguous(distinct []scoredHit) bool {
if len(distinct) < 2 {
return false
}
if distinct[1].score >= distinct[0].score-scanAmbiguityMargin {
return true
}
return distinct[0].text < scanSpecificEnough
}
// catalogueFamily is `of` and its other pack sizes, drawn from hits.
func catalogueFamily(hits []scoredHit, of scoredHit) []scoredHit {
key := of.productKey()
family := make([]scoredHit, 0, 4)
for _, h := range hits {
if h.productKey() == key {
family = append(family, h)
}
}
if len(family) == 0 {
return []scoredHit{of}
}
return family
}
// ── Offers ──────────────────────────────────────────────────────────────────
// buildOffers turns outlet rows into ranked offers: one per outlet that had
// any row, in-stock outlets first, nearest first, the first in-stock one
// recommended.
func buildOffers(stores []models.ScanStore, rows []repositories.StoreOptionRow, keys []repositories.CatalogueKey, lat, lng float64, hasPos bool) []models.ScanStoreOffer {
byLocation := make(map[int][]repositories.StoreOptionRow)
for _, r := range rows {
byLocation[r.Locationid] = append(byLocation[r.Locationid], r)
}
offers := make([]models.ScanStoreOffer, 0, len(byLocation))
for _, st := range stores {
rs := byLocation[st.Locationid]
if len(rs) == 0 {
continue
}
st.DistanceKm = distanceKm(st, lat, lng, hasPos)
offer := models.ScanStoreOffer{ScanStore: st, Options: []models.ScanOption{}}
// A product can arrive twice — as a direct match and as a size of
// another match. Keep the direct one; it carries the better label.
seen := make(map[int]int)
for _, r := range rs {
opt := optionFromRow(r, keys)
if idx, dup := seen[r.Productid]; dup {
if offer.Options[idx].IsVariant && !opt.IsVariant {
offer.Options[idx] = opt
}
continue
}
seen[r.Productid] = len(offer.Options)
offer.Options = append(offer.Options, opt)
if opt.Available {
offer.Available = true
}
}
// Direct matches first, then sizes; in stock before out.
sort.SliceStable(offer.Options, func(i, j int) bool {
a, b := offer.Options[i], offer.Options[j]
if a.Available != b.Available {
return a.Available
}
if a.IsVariant != b.IsVariant {
return !a.IsVariant
}
return a.Productname < b.Productname
})
offers = append(offers, offer)
}
sort.SliceStable(offers, func(i, j int) bool {
a, b := offers[i], offers[j]
if a.Available != b.Available {
return a.Available
}
if a.DistanceKm != b.DistanceKm {
return nearer(a.DistanceKm, b.DistanceKm)
}
return a.Locationname < b.Locationname
})
for i := range offers {
if offers[i].Available {
offers[i].Recommended = true
break
}
}
return offers
}
func optionFromRow(r repositories.StoreOptionRow, keys []repositories.CatalogueKey) models.ScanOption {
opt := models.ScanOption{
Productid: r.Productid,
Productname: r.Productname,
Size: strings.TrimSpace(r.Unitvalue + " " + r.Productunit),
Price: r.Price,
Stock: r.Stock,
Available: r.Stock > 0,
Image: r.Productimage,
IsVariant: r.Parentid > 0,
Variantname: r.Variantname,
MatchedBy: "name",
}
if r.Parentid > 0 {
opt.MatchedBy = fmt.Sprintf("variant-of:%d", r.Parentid)
return opt
}
for _, k := range keys {
if k.Imageid != "" && k.Imageid == r.Imageid {
opt.MatchedBy = "imageid"
return opt
}
if k.Brand != "" && strings.EqualFold(k.Brand, r.Productbrand) && k.Catalogueid == r.Catalogueid && k.Catalogueid > 0 {
opt.MatchedBy = "brand+catalogueid"
return opt
}
}
return opt
}
// sameStoreProduct is the cross-tenant identity of a product: the catalogue key
// when both rows carry one, the name otherwise.
func sameStoreProduct(a, b repositories.StoreOptionRow) bool {
if a.Imageid != "" && b.Imageid != "" {
return a.Imageid == b.Imageid
}
if a.Catalogueid > 0 && b.Catalogueid > 0 && a.Productbrand != "" {
return a.Catalogueid == b.Catalogueid && strings.EqualFold(a.Productbrand, b.Productbrand)
}
return strings.EqualFold(strings.TrimSpace(a.Productname), strings.TrimSpace(b.Productname))
}
// restrictToTenants keeps the outlets whose tenant the app named, and
// reports the names it got wrong. An app list that matches nothing is
// treated as stale rather than as "no stores": all registered outlets are
// used and every id it sent is reported.
func restrictToTenants(stores []models.ScanStore, tenantids []int) ([]models.ScanStore, []int) {
if len(tenantids) == 0 {
return stores, nil
}
registered := make(map[int]bool)
for _, st := range stores {
registered[st.Tenantid] = true
}
wanted := make(map[int]bool)
var unregistered []int
for _, id := range tenantids {
if registered[id] {
wanted[id] = true
} else {
unregistered = append(unregistered, id)
}
}
if len(wanted) == 0 {
return stores, unregistered
}
kept := make([]models.ScanStore, 0, len(stores))
for _, st := range stores {
if wanted[st.Tenantid] {
kept = append(kept, st)
}
}
return kept, unregistered
}
func distanceKm(st models.ScanStore, lat, lng float64, hasPos bool) float64 {
if !hasPos || (st.Latitude == 0 && st.Longitude == 0) {
return -1
}
return math.Round(utils.HaversineKm(lat, lng, st.Latitude, st.Longitude)*100) / 100
}
// nearer orders distances with -1 (unknown) last.
func nearer(a, b float64) bool {
if a < 0 {
return false
}
if b < 0 {
return true
}
return a < b
}
func countAvailable(offers []models.ScanStoreOffer) int {
n := 0
for _, o := range offers {
if o.Available {
n++
}
}
return n
}
func round3(f float64) float64 { return math.Round(f*1000) / 1000 }
func ptr[T any](v T) *T { return &v }