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:
225
utils/embedding.go
Normal file
225
utils/embedding.go
Normal 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
106
utils/embedding_test.go
Normal 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
104
utils/geo.go
Normal 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
65
utils/geo_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user