main had moved on with retrieval work validated against real queries — minTokenHits (the word match needs two thirds of the label, not all of it), separator folding so "Parle G"/"Parle-G"/"ParleG" all reach Parle-G, the floor at 0.50 after "Paracetamol" came back as "Paneer Makhni 500ml" at 0.304, and ties broken on cosine distance instead of name. All of that is kept exactly as it was. The conflict was in textScore: this branch replaced the substring rule with a coverage formula to stop a bare brand name resolving to one arbitrary product. That is the wrong half to change. The substring rule scores every product of a brand 0.95 IDENTICALLY, and that tie is not the bug — it is the signal. isAmbiguous reads it, so the branch's coverage rewrite is dropped and the ambiguity layer alone does the work: "britannia" → all 258 rows tie at 0.95 → ambiguous: true + candidates "Parle G" → folding and the single-character token still land it a real name → runner-up far behind → match, unchanged Dropped with it: scanSpecificEnough, the per-hit text score, and the proportional confirmation bonus — the flat +0.10 is back. Simpler, and it leaves main's tuning untouched. TestTextScoreRewardsSpecificityNotJustOverlap tested the removed formula and is replaced by TestABrandNameScoresItsProductsIdentically, which guards the tie itself: a formula that broke it on name length or word count would bring the bug back. Docs carry both rationales, and now say plainly that confidence stays high on the ambiguous path — gate on `ambiguous`, never on `confidence`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
963 lines
31 KiB
Go
963 lines
31 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. 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
|
|
// How close the runner-up may 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
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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
|
|
}
|
|
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.
|
|
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))
|
|
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 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"
|
|
}
|
|
|
|
if cached, ok := s.repo.CachedHits(ctx, method+":"+s.modelName(), label); ok {
|
|
return scoreCachedHits(cached), method, nil
|
|
}
|
|
|
|
tokens := utils.SearchTokens(label)
|
|
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 {
|
|
existing.score = math.Min(1, math.Max(existing.score, ts)+0.10)
|
|
continue
|
|
}
|
|
byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: 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
|
|
}
|
|
|
|
func scoreCachedHits(cached []repositories.CatalogueHit) []scoredHit {
|
|
hits := make([]scoredHit, 0, len(cached))
|
|
for _, c := range cached {
|
|
hits = append(hits, scoredHit{CatalogueHit: c, score: 1 - c.Distance})
|
|
}
|
|
sortHits(hits)
|
|
return hits
|
|
}
|
|
|
|
// sortHits ranks by blended score, then by the model's own similarity, and
|
|
// only then by name. Name alone used to break every tie, which quietly made
|
|
// punctuation decide relevance: "Parle Monaco Classic" sorts above "Parle-G
|
|
// Original …" because a space precedes a hyphen in ASCII, so equal-scoring
|
|
// crackers beat the biscuit that was actually scanned.
|
|
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
|
|
}
|
|
di, dj := vectorRank(hits[i].Distance), vectorRank(hits[j].Distance)
|
|
if di != dj {
|
|
return di < dj
|
|
}
|
|
return hits[i].ProductName < hits[j].ProductName
|
|
})
|
|
}
|
|
|
|
// vectorRank orders by cosine distance, nearest first, with a row the model
|
|
// never saw (-1, text-only) sorting behind every row it did.
|
|
func vectorRank(d float64) float64 {
|
|
if d < 0 {
|
|
return math.MaxFloat64
|
|
}
|
|
return d
|
|
}
|
|
|
|
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's name matches the words Lens read.
|
|
// The whole label as a substring of the name is near-certain; otherwise the
|
|
// share of label words found in name+title, scaled so that "all of them"
|
|
// stops short of the substring case.
|
|
//
|
|
// A label that is a substring of MANY names — a bare brand, "britannia" —
|
|
// therefore scores them all 0.95, identically. That tie is not a flaw to
|
|
// score around: it is the signal, and isAmbiguous reads it to answer "did
|
|
// you mean?" rather than letting the sort order pick a winner.
|
|
func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 {
|
|
name := strings.ToLower(h.ProductName)
|
|
hay := name + " " + strings.ToLower(h.Title)
|
|
label = strings.ToLower(strings.TrimSpace(label))
|
|
// The substring test compares separator-folded forms, so the brand's own
|
|
// punctuation does not decide the match: "Parle G", "Parle-G" and
|
|
// "ParleG" all have to reach "Parle-G Original Glucose Biscuits".
|
|
if label != "" {
|
|
foldedName, foldedLabel := utils.FoldSeparators(name), utils.FoldSeparators(label)
|
|
if foldedLabel != "" && strings.Contains(foldedName, foldedLabel) {
|
|
return 0.95
|
|
}
|
|
// Separators dropped rather than folded. Only for a label long enough
|
|
// that a run of letters means something — "lay" inside "malayalam" is
|
|
// not a match anyone wants.
|
|
if tight := utils.TightenLabel(label); len(tight) >= 4 && strings.Contains(utils.TightenLabel(name), tight) {
|
|
return 0.95
|
|
}
|
|
}
|
|
if len(tokens) == 0 {
|
|
return 0
|
|
}
|
|
found := 0
|
|
for _, t := range tokens {
|
|
if strings.Contains(hay, t) {
|
|
found++
|
|
}
|
|
}
|
|
return 0.8 * float64(found) / float64(len(tokens))
|
|
}
|
|
|
|
// 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, because something else is level with it.
|
|
//
|
|
// A margin rather than an absolute threshold: what matters is not how high
|
|
// the best score is but whether anything is tied with it. A bare brand name
|
|
// is a substring of every one of that brand's names, so textScore gives them
|
|
// all 0.95 — a perfect tie at a HIGH score, which no floor would catch.
|
|
//
|
|
// 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. A label that names one product leaves the
|
|
// runner-up far behind, so the common case is unaffected.
|
|
func isAmbiguous(distinct []scoredHit) bool {
|
|
return len(distinct) >= 2 && distinct[1].score >= distinct[0].score-scanAmbiguityMargin
|
|
}
|
|
|
|
// 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 }
|