diff --git a/main.go b/main.go index 4332049..4c8ea17 100644 --- a/main.go +++ b/main.go @@ -465,9 +465,22 @@ func main() { // NUTRITION_BASE is the catalogue-intelligence host — the same service the // console reads its health score card from. Unset means product screens // carry no nutrition panel, and nothing else changes. + // + // Logged for the same reason mail is, and learned the same way: with it + // unset, `getproductbyvariant` simply omits `nutrition` and `healthscore`, + // which is indistinguishable from a product the service has not scored. + // A deploy that silently does nothing is one somebody has to reverse + // engineer from the outside, and this line is the difference. + nutritionBase := strings.TrimSpace(os.Getenv("NUTRITION_BASE")) + if nutritionBase == "" { + log.Printf("nutrition: OFF — NUTRITION_BASE is not set, so no product carries a nutrition panel or health score") + } else { + log.Printf("nutrition: reading panels and scores from %s", nutritionBase) + } + f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat, os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail, - os.Getenv("NUTRITION_BASE")) + nutritionBase) routes.RegisterRoutes(app, f) diff --git a/scratch/nutritionlive/main.go b/scratch/nutritionlive/main.go new file mode 100644 index 0000000..5c1a019 --- /dev/null +++ b/scratch/nutritionlive/main.go @@ -0,0 +1,76 @@ +// Shows the exact `getproductbyvariant` response once NUTRITION_BASE is set. +// +// Takes the LIVE Fiesta response for a product, runs the same decoration the +// endpoint runs, and prints the result. Nothing here is hand-assembled: the +// product row is production's, the panel and score are the live catalogue- +// intelligence service's, and the code between them is what is deployed. +// +// go run ./scratch/nutritionlive # product 7101 +// go run ./scratch/nutritionlive +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "nearle/models" + "nearle/services" +) + +const fiesta = "https://fiesta.nearle.app/live/api/v1/mob/products/getproductbyvariant" + +type envelope struct { + Code int `json:"code"` + Details []models.Products `json:"details"` + Message string `json:"message"` + Status bool `json:"status"` +} + +func main() { + tenant, product := "1147", "7101" + if len(os.Args) == 3 { + tenant, product = os.Args[1], os.Args[2] + } + + base := os.Getenv("NUTRITION_BASE") + if base == "" { + base = "https://mcp.nearle.ai.in/api" + } + nutrition := services.NewNutritionService(base) + + url := fmt.Sprintf("%s?tenantid=%s&productid=%s&variantid=0", fiesta, tenant, product) + client := &http.Client{Timeout: 30 * time.Second} + + response, err := client.Get(url) + if err != nil { + fmt.Println("fetching the live product:", err) + return + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + fmt.Println("reading the live product:", err) + return + } + + var out envelope + if err := json.Unmarshal(body, &out); err != nil { + fmt.Println("parsing the live product:", err) + return + } + + // The same call the endpoint makes, on the same rows. + for i := range out.Details { + found := nutrition.ForProduct(out.Details[i].Productbrand, out.Details[i].Imageid) + out.Details[i].Nutrition = found.Panel + out.Details[i].Healthscore = found.Health + } + + encoded, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(encoded)) +} diff --git a/services/nutritionService.go b/services/nutritionService.go index d4e841d..d0368d5 100644 --- a/services/nutritionService.go +++ b/services/nutritionService.go @@ -240,11 +240,26 @@ func NewNutritionService(base string) NutritionService { if base == "" { return nil } - return &nutritionService{ + service := &nutritionService{ base: base, client: &http.Client{Timeout: nutritionTimeout}, cache: map[string]cachedPanel{}, } + + // The brand list is warmed in the background, and this is not an + // optimisation — it removes a shopper from the path of a call that has to + // succeed. + // + // Brand case decides whether a lookup returns anything at all: measured + // 30 Sep 2026, /brands answers in 0.2s to 2.3s, and a cold product lookup + // otherwise pays for it before its own request. Two slow hops inside one + // product screen is how a nutrition panel becomes "sometimes there". + // + // Nothing waits on this. A request arriving before it lands still tries, + // still gets an answer, and simply does not cache it. + go service.resolveBrand("") + + return service } func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition { @@ -266,10 +281,26 @@ func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition { return hit.value } - value := s.fetch(brand, imageID) + value, resolved := s.fetch(brand, imageID) - // A miss is cached too. Most products are not scored yet, and re-asking on - // every tap would mean the least useful answer costing the most requests. + // NOT cached when the brand could not be resolved. + // + // Brand case is load-bearing: measured 30 Sep 2026, + // `/nutrition/Balaji/balaji_..._135g` returns a 65.3 score and 545 kcal + // while `/nutrition/balaji/...` — our own spelling — returns a well-formed + // record with every field null. So a lookup made before the brand list + // arrived does not mean "this product has no nutrition", it means we asked + // the wrong question. + // + // Caching that answer for six hours would turn one slow moment on the + // brand list into six hours of silently missing nutrition across every + // product of every brand. Left uncached, the next request retries. + if !resolved { + return value + } + + // A genuine miss IS cached. Most products are not scored yet, and re-asking + // on every tap would mean the least useful answer costing the most requests. s.mu.Lock() s.cache[key] = cachedPanel{value: value, at: time.Now()} s.mu.Unlock() @@ -277,15 +308,18 @@ func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition { return value } -func (s *nutritionService) fetch(brand, imageID string) ProductNutrition { - resolved := s.resolveBrand(brand) +// fetch returns the record, and whether the brand name it asked under was the +// service's own. An unresolved brand makes the answer untrustworthy — see +// ForProduct. +func (s *nutritionService) fetch(brand, imageID string) (ProductNutrition, bool) { + name, resolved := s.resolveBrand(brand) var source NutritionSource if !s.get(fmt.Sprintf("/nutrition/%s/%s", - url.PathEscape(resolved), url.PathEscape(imageID)), &source) { - return ProductNutrition{} + url.PathEscape(name), url.PathEscape(imageID)), &source) { + return ProductNutrition{}, resolved } - return ProductNutrition{Panel: source.Panel(), Health: source.Health()} + return ProductNutrition{Panel: source.Panel(), Health: source.Health()}, resolved } /* @@ -310,11 +344,8 @@ log to say why. Our own spelling is returned when theirs is unknown, so a brand they have not listed still gets a real attempt rather than being dropped. */ -func (s *nutritionService) resolveBrand(raw string) string { +func (s *nutritionService) resolveBrand(raw string) (string, bool) { wanted := normaliseBrand(raw) - if wanted == "" { - return raw - } s.mu.RLock() brands, at := s.brands, s.brandsAt @@ -334,12 +365,20 @@ func (s *nutritionService) resolveBrand(raw string) string { } } + // Called with "" by the startup warm, which wants the fetch above and + // nothing else. + if wanted == "" { + return raw, false + } + for _, candidate := range brands { if normaliseBrand(candidate) == wanted { - return candidate + return candidate, true } } - return raw + // Either the list never arrived, or they do not carry this brand. Both are + // worth one attempt under our own spelling — but neither is worth caching. + return raw, false } var brandNoise = regexp.MustCompile(`[^a-z0-9]`) diff --git a/services/nutritionService_test.go b/services/nutritionService_test.go index 0daf0e6..bae6eb2 100644 --- a/services/nutritionService_test.go +++ b/services/nutritionService_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "nearle/models" @@ -369,3 +370,80 @@ func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) { t.Fatalf("an absent panel was sent as a key: %s", encoded) } } + +/* ── Not caching an answer we do not trust ───────────────────────────────── */ + +func TestAnUnresolvedBrandIsNotCachedAsAMiss(t *testing.T) { + // The failure this prevents, measured 30 Sep 2026: + // `/nutrition/Balaji/balaji_..._135g` returns a 65.3 score and 545 kcal; + // `/nutrition/balaji/...` — our own spelling — returns a well-formed record + // with every field null. So a lookup made before the brand list arrived + // does not mean "no nutrition", it means we asked the wrong question. + // + // Cached, one slow moment on /brands would silently remove nutrition from + // every product of every brand for six hours. + var brandsUp bool + var productHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/brands" { + if !brandsUp { + w.WriteHeader(http.StatusGatewayTimeout) + return + } + _, _ = w.Write([]byte(`{"brands":["Balaji"]}`)) + return + } + productHits++ + if strings.Contains(r.URL.Path, "/Balaji/") { + _, _ = w.Write([]byte(balajiJSON)) + return + } + // What the service actually answers for the wrong spelling. + _, _ = w.Write([]byte(unavailableJSON)) + })) + defer server.Close() + + service := NewNutritionService(server.URL) + + // Brand list down: the answer is a miss, and must not stick. + if found := service.ForProduct("balaji", "balaji_wafers_135g"); found.Panel != nil { + t.Fatal("got a panel from the wrong spelling") + } + + // Brand list back: the same product must be asked again, correctly. + brandsUp = true + found := service.ForProduct("balaji", "balaji_wafers_135g") + if !found.Panel.HasValues() { + t.Fatal("the miss was cached — a slow /brands has cost six hours of nutrition") + } + if found.Health == nil || found.Health.Score != 65 { + t.Fatalf("score lost: %+v", found.Health) + } + if productHits < 2 { + t.Fatalf("the product was only asked for %d time(s)", productHits) + } +} + +func TestAResolvedMissIsStillCached(t *testing.T) { + // The other half: when the brand IS theirs and they simply have nothing, + // that is a real answer and re-asking on every tap wastes the most + // requests on the least useful result. + var productHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/brands" { + _, _ = w.Write([]byte(`{"brands":["Patanjali"]}`)) + return + } + productHits++ + _, _ = w.Write([]byte(unavailableJSON)) + })) + defer server.Close() + + service := NewNutritionService(server.URL) + for i := 0; i < 4; i++ { + service.ForProduct("patanjali", "patanjali_cow_ghee_500ml") + } + if productHits != 1 { + t.Fatalf("asked %d times for a product the service has answered about", productHits) + } +}