Scan-to-order: label from the customer's camera to "buy it here"

POST /v1/mob/scan/lookup   label + customer → catalogue match, sizes, and
                           every registered store that sells it with live
                           stock, in-stock first / nearest first, one
                           recommended
POST /v1/mob/scan/confirm  chosen store + size + qty → re-read the ledger;
                           ok, or the next-nearest store with enough of the
                           same product
GET  /v1/mob/scan/stores   registered stores nearest first

Recognition is pgvector cosine search over every brand_* table (each
with its own index, merged) plus a word match that settles near-ties
and works alone when no model is configured. The embedder is chosen by
EMBEDDING_PROVIDER (OpenAI-compatible or Gemini) and must be the model
that indexed the catalogue: verified 2026-09-15 as all-MiniLM-L6-v2 over
search_query, served by the cluster's Ollama as `all-minilm`; the first
search refuses a width mismatch by name.

Customer, stores and catalogue are read concurrently under a 5 s cap; a
slow model degrades to a text answer. Vectors and ranked hits are cached
in Redis and in-process; live stock never is. Availability uses the same
rules as the customer catalogue (approve, publishedat, ledger balance,
outlet price else retail). No stock reservation: confirm re-reads.

scratch/cataloguedims reports the catalogue's embedding width and fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:04:34 +05:30
parent 1633617dc4
commit 72907dae74
14 changed files with 2841 additions and 1 deletions

225
utils/embedding.go Normal file
View File

@@ -0,0 +1,225 @@
package utils
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"nearle/config"
)
// Embedder turns a short piece of text — what Google Lens read off a packet —
// into the vector the catalogue was indexed with.
//
// One method on purpose. The scan pipeline needs exactly one thing from the
// model and nothing about which model it is; the provider is an environment
// decision (config.EmbeddingConfig) and the tests supply a fake.
type Embedder interface {
// Embed returns the vector for text. It must be the same length as the
// catalogue's `embedding` column or pgvector refuses the comparison.
Embed(ctx context.Context, text string) ([]float32, error)
// Model names what produced the vector, so a cache key can include it: a
// vector cached under one model must never be served for another.
Model() string
}
// ErrEmbedderNotConfigured is what the scan search sees when no provider is
// set. It falls back to text matching rather than failing the request.
var ErrEmbedderNotConfigured = errors.New("embedding provider is not configured")
// embedTimeout bounds one call to the provider. The scan endpoint has a
// customer waiting with a phone in their hand; a slow model is worse than a
// text-only answer, and the caller falls back on error.
const embedTimeout = 4 * time.Second
// NewEmbedder builds the provider named in the config, or returns nil when
// none is configured. A nil Embedder is a supported state everywhere it is
// used: the search degrades to text matching and says so in the response.
func NewEmbedder(cfg config.EmbeddingConfig) (Embedder, error) {
if !cfg.Enabled() {
return nil, nil
}
client := &http.Client{Timeout: embedTimeout}
switch cfg.Provider {
case "openai":
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
base = "https://api.openai.com/v1"
}
return &openAIEmbedder{cfg: cfg, base: base, client: client}, nil
case "gemini":
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
base = "https://generativelanguage.googleapis.com/v1beta"
}
return &geminiEmbedder{cfg: cfg, base: base, client: client}, nil
}
return nil, fmt.Errorf("embedding provider %q is not supported", cfg.Provider)
}
// ── OpenAI-compatible ───────────────────────────────────────────────────────
//
// POST {base}/embeddings — the shape OpenAI, Azure OpenAI (with a base URL),
// Ollama, vLLM, LM Studio and most hosted models all accept.
type openAIEmbedder struct {
cfg config.EmbeddingConfig
base string
client *http.Client
}
func (e *openAIEmbedder) Model() string { return e.cfg.Model }
func (e *openAIEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
body := map[string]interface{}{
"model": e.cfg.Model,
"input": text,
}
if e.cfg.Dimensions > 0 {
body["dimensions"] = e.cfg.Dimensions
}
var out struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := postJSON(ctx, e.client, e.base+"/embeddings", "Bearer "+e.cfg.APIKey, body, &out); err != nil {
return nil, err
}
if out.Error != nil {
return nil, fmt.Errorf("embedding: %s", out.Error.Message)
}
if len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
return nil, errors.New("embedding: provider returned no vector")
}
return out.Data[0].Embedding, nil
}
// ── Gemini ──────────────────────────────────────────────────────────────────
//
// POST {base}/models/{model}:embedContent with the key as a header.
type geminiEmbedder struct {
cfg config.EmbeddingConfig
base string
client *http.Client
}
func (e *geminiEmbedder) Model() string { return e.cfg.Model }
func (e *geminiEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
model := e.cfg.Model
if !strings.HasPrefix(model, "models/") {
model = "models/" + model
}
body := map[string]interface{}{
"model": model,
"content": map[string]interface{}{"parts": []map[string]string{{"text": text}}},
"taskType": "RETRIEVAL_QUERY",
}
if e.cfg.Dimensions > 0 {
body["outputDimensionality"] = e.cfg.Dimensions
}
var out struct {
Embedding struct {
Values []float32 `json:"values"`
} `json:"embedding"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
url := fmt.Sprintf("%s/%s:embedContent", e.base, model)
if err := postJSON(ctx, e.client, url, "", body, &out, "x-goog-api-key", e.cfg.APIKey); err != nil {
return nil, err
}
if out.Error != nil {
return nil, fmt.Errorf("embedding: %s", out.Error.Message)
}
if len(out.Embedding.Values) == 0 {
return nil, errors.New("embedding: provider returned no vector")
}
return out.Embedding.Values, nil
}
// postJSON is the one HTTP call both providers make. Extra header pairs
// follow the body; `auth` is sent as Authorization when non-empty.
func postJSON(ctx context.Context, client *http.Client, url, auth string, body, out interface{}, headers ...string) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if auth != "" {
req.Header.Set("Authorization", auth)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("embedding: %w", err)
}
defer resp.Body.Close()
// Bounded: an error page from a misconfigured proxy should not be read to
// the end of the internet.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return fmt.Errorf("embedding: %w", err)
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("embedding: HTTP %d, unreadable body: %w", resp.StatusCode, err)
}
if resp.StatusCode/100 != 2 {
// The decoded body carries the provider's message where there is one;
// this is the fallback for a bare status.
if msg := extractMessage(raw); msg != "" {
return fmt.Errorf("embedding: HTTP %d: %s", resp.StatusCode, msg)
}
return fmt.Errorf("embedding: HTTP %d", resp.StatusCode)
}
return nil
}
func extractMessage(raw []byte) string {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(raw, &e) == nil {
return e.Error.Message
}
return ""
}
// VectorLiteral renders a vector the way pgvector reads one: `[0.1,0.2,...]`.
func VectorLiteral(v []float32) string {
var b strings.Builder
b.Grow(len(v)*10 + 2)
b.WriteByte('[')
for i, f := range v {
if i > 0 {
b.WriteByte(',')
}
fmt.Fprintf(&b, "%g", f)
}
b.WriteByte(']')
return b.String()
}

106
utils/embedding_test.go Normal file
View File

@@ -0,0 +1,106 @@
package utils
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"nearle/config"
)
func TestOpenAIEmbedderSendsTheRequestTheAPIExpects(t *testing.T) {
var got map[string]interface{}
var auth, path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth, path = r.Header.Get("Authorization"), r.URL.Path
json.NewDecoder(r.Body).Decode(&got)
w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3]}]}`))
}))
defer srv.Close()
e, err := NewEmbedder(config.EmbeddingConfig{Provider: "openai", Model: "text-embedding-3-small", APIKey: "sk-test", BaseURL: srv.URL + "/v1/", Dimensions: 3})
if err != nil {
t.Fatal(err)
}
vec, err := e.Embed(context.Background(), "Milk Bikis")
if err != nil {
t.Fatal(err)
}
if len(vec) != 3 || vec[2] != 0.3 {
t.Errorf("vector = %v", vec)
}
if path != "/v1/embeddings" || auth != "Bearer sk-test" {
t.Errorf("path=%s auth=%s", path, auth)
}
if got["model"] != "text-embedding-3-small" || got["input"] != "Milk Bikis" || got["dimensions"] != float64(3) {
t.Errorf("body = %v", got)
}
if e.Model() != "text-embedding-3-small" {
t.Errorf("Model() = %q", e.Model())
}
}
func TestGeminiEmbedderSendsTheRequestTheAPIExpects(t *testing.T) {
var got map[string]interface{}
var key, path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, path = r.Header.Get("x-goog-api-key"), r.URL.Path
json.NewDecoder(r.Body).Decode(&got)
w.Write([]byte(`{"embedding":{"values":[0.5,0.6]}}`))
}))
defer srv.Close()
e, err := NewEmbedder(config.EmbeddingConfig{Provider: "gemini", Model: "gemini-embedding-001", APIKey: "g-test", BaseURL: srv.URL})
if err != nil {
t.Fatal(err)
}
vec, err := e.Embed(context.Background(), "Milk Bikis")
if err != nil {
t.Fatal(err)
}
if len(vec) != 2 || vec[0] != 0.5 {
t.Errorf("vector = %v", vec)
}
if path != "/models/gemini-embedding-001:embedContent" || key != "g-test" {
t.Errorf("path=%s key=%s", path, key)
}
if got["model"] != "models/gemini-embedding-001" || got["taskType"] != "RETRIEVAL_QUERY" {
t.Errorf("body = %v", got)
}
}
func TestEmbedderSurfacesProviderErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(429)
w.Write([]byte(`{"error":{"message":"Rate limit reached"}}`))
}))
defer srv.Close()
e, _ := NewEmbedder(config.EmbeddingConfig{Provider: "openai", Model: "m", APIKey: "k", BaseURL: srv.URL})
_, err := e.Embed(context.Background(), "x")
if err == nil || !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "Rate limit") {
t.Fatalf("want a 429 with the provider's message, got %v", err)
}
}
func TestNoProviderMeansNoEmbedder(t *testing.T) {
e, err := NewEmbedder(config.EmbeddingConfig{})
if err != nil || e != nil {
t.Fatalf("got %v / %v", e, err)
}
if _, err := NewEmbedder(config.EmbeddingConfig{Provider: "cohere", Model: "m", APIKey: "k"}); err == nil {
t.Fatal("an unknown provider must be refused")
}
}
func TestVectorLiteral(t *testing.T) {
if got := VectorLiteral([]float32{0.1, -2, 3.5}); got != "[0.1,-2,3.5]" {
t.Errorf("got %q", got)
}
if got := VectorLiteral(nil); got != "[]" {
t.Errorf("got %q", got)
}
}

104
utils/geo.go Normal file
View File

@@ -0,0 +1,104 @@
package utils
import (
"math"
"strconv"
"strings"
"time"
)
// ParseLatLng reads the coordinate strings this schema stores — customers,
// customerlocations and tenantlocations all hold latitude/longitude as text
// — and says whether they name a real place.
//
// "0,0" is rejected along with blanks: nothing on this platform is in the
// Gulf of Guinea, and it is what an empty map picker saves.
func ParseLatLng(lat, lng string) (float64, float64, bool) {
la, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
lo, err2 := strconv.ParseFloat(strings.TrimSpace(lng), 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
if la < -90 || la > 90 || lo < -180 || lo > 180 || (la == 0 && lo == 0) {
return 0, 0, false
}
return la, lo, true
}
// HaversineKm is the great-circle distance between two points.
func HaversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const earthRadiusKm = 6371.0
toRad := func(d float64) float64 { return d * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLng := toRad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
return 2 * earthRadiusKm * math.Asin(math.Sqrt(a))
}
// OpenNow reads tenantlocations.opentime/closetime ("09:00", "21:30",
// "9:00 AM") against the wall clock. Unparsable or blank hours are treated as
// open: a shop that never filled the field in should not vanish from the
// list, and the ordering flow re-checks at checkout anyway.
func OpenNow(open, closeAt string, now time.Time) bool {
o, ok1 := parseClock(open)
c, ok2 := parseClock(closeAt)
if !ok1 || !ok2 || o == c {
return true
}
cur := now.Hour()*60 + now.Minute()
if o < c {
return cur >= o && cur < c
}
// Past midnight: "20:00" – "02:00".
return cur >= o || cur < c
}
func parseClock(s string) (int, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
for _, layout := range []string{"15:04", "15:04:05", "3:04 PM", "3:04PM", "03:04 PM", "15.04"} {
if t, err := time.Parse(layout, strings.ToUpper(s)); err == nil {
return t.Hour()*60 + t.Minute(), true
}
}
return 0, false
}
// SearchTokens splits a label into the words worth matching on: lowercased,
// punctuation stripped, single characters and pack-size noise dropped. "Milk
// Bikis 100g" → ["milk", "bikis"]; the size is matched separately, if at all.
func SearchTokens(label string) []string {
var tokens []string
seen := make(map[string]bool)
for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
}) {
if len(raw) < 2 || isPackSize(raw) || seen[raw] {
continue
}
seen[raw] = true
tokens = append(tokens, raw)
}
return tokens
}
// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit
// glued on, or a bare number.
func isPackSize(tok string) bool {
digits := 0
for digits < len(tok) && tok[digits] >= '0' && tok[digits] <= '9' {
digits++
}
if digits == 0 {
return false
}
switch tok[digits:] {
case "", "g", "gm", "gms", "kg", "ml", "l", "ltr", "pcs", "pc", "x", "n":
return true
}
return false
}

65
utils/geo_test.go Normal file
View File

@@ -0,0 +1,65 @@
package utils
import (
"testing"
"time"
)
func TestParseLatLng(t *testing.T) {
if _, _, ok := ParseLatLng("", ""); ok {
t.Error("blank must not parse")
}
if _, _, ok := ParseLatLng("0", "0"); ok {
t.Error("0,0 is an empty map picker, not a place")
}
if _, _, ok := ParseLatLng("91", "10"); ok {
t.Error("out of range")
}
lat, lng, ok := ParseLatLng(" 11.0168 ", "76.9558")
if !ok || lat != 11.0168 || lng != 76.9558 {
t.Errorf("got %v %v %v", lat, lng, ok)
}
}
func TestHaversineKm(t *testing.T) {
// Coimbatore railway station to Peelamedu, roughly 8 km.
d := HaversineKm(11.0018, 76.9660, 11.0290, 77.0290)
if d < 7 || d > 9 {
t.Errorf("got %.2f km", d)
}
if HaversineKm(1, 1, 1, 1) != 0 {
t.Error("same point")
}
}
func TestOpenNow(t *testing.T) {
at := func(h, m int) time.Time { return time.Date(2026, 9, 15, h, m, 0, 0, time.UTC) }
if !OpenNow("09:00", "21:00", at(12, 0)) || OpenNow("09:00", "21:00", at(22, 0)) {
t.Error("plain hours")
}
if !OpenNow("20:00", "02:00", at(1, 0)) || OpenNow("20:00", "02:00", at(12, 0)) {
t.Error("past midnight")
}
if !OpenNow("9:00 AM", "9:30 PM", at(21, 0)) || OpenNow("9:00 AM", "9:30 PM", at(21, 45)) {
t.Error("12-hour clock")
}
if !OpenNow("", "", at(3, 0)) || !OpenNow("always", "", at(3, 0)) {
t.Error("unknown hours mean open")
}
}
func TestSearchTokens(t *testing.T) {
got := SearchTokens("Milk Bikis 100g, Britannia (2 x 50gm)")
want := []string{"milk", "bikis", "britannia"}
if len(got) != len(want) {
t.Fatalf("got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v want %v", got, want)
}
}
if len(SearchTokens("500ml 1kg 2")) != 0 {
t.Error("pack sizes alone are not searchable")
}
}