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()
|
||||
}
|
||||
Reference in New Issue
Block a user