nutrition: do not cache a lookup made under an unresolved brand

Brand case decides whether the catalogue-intelligence service answers at all.
Measured 30 Sep 2026:

    /nutrition/Balaji/balaji_..._135g   -> health_score 65.3, 545 kcal
    /nutrition/balaji/...  (our spelling) -> every field null

The brand list resolves ours to theirs, and /brands has slowed to 0.2-2.3s,
which exceeded the 3s client timeout on a cold start. The fallback then asked
under our own spelling, received a well-formed empty record, and cached it as
"no nutrition" for six hours -- so one slow moment silently removed nutrition
and health scores from every product of every brand, looking exactly like data
the agent team had not supplied.

Two changes:

  - a result reached without a resolved brand is no longer cached, so the next
    request retries rather than inheriting a wrong answer for six hours. A
    genuine miss on a resolved brand is still cached, which is the case that
    matters for traffic.
  - the brand list is warmed in the background at startup, so no shopper is
    ever in the path of that call.

Also logs which state the feature is in at boot, the way mail does. With
NUTRITION_BASE unset the endpoint simply omits `nutrition` and `healthscore`,
which is indistinguishable from an unscored product -- this deploy went out
without the variable set and had to be diagnosed by probing the API from
outside.

scratch/nutritionlive prints the exact response for any product by running this
code against the live product row and the live service.

NUTRITION_BASE=https://mcp.nearle.ai.in/api must be set in the deployment
environment. Unset, nothing changes and no product carries either key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:48:27 +05:30
parent fb859ecda1
commit c49f5372a5
4 changed files with 222 additions and 16 deletions

15
main.go
View File

@@ -465,9 +465,22 @@ func main() {
// NUTRITION_BASE is the catalogue-intelligence host — the same service the // NUTRITION_BASE is the catalogue-intelligence host — the same service the
// console reads its health score card from. Unset means product screens // console reads its health score card from. Unset means product screens
// carry no nutrition panel, and nothing else changes. // 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, f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat,
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail, os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail,
os.Getenv("NUTRITION_BASE")) nutritionBase)
routes.RegisterRoutes(app, f) routes.RegisterRoutes(app, f)

View File

@@ -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 <tenantid> <productid>
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))
}

View File

@@ -240,11 +240,26 @@ func NewNutritionService(base string) NutritionService {
if base == "" { if base == "" {
return nil return nil
} }
return &nutritionService{ service := &nutritionService{
base: base, base: base,
client: &http.Client{Timeout: nutritionTimeout}, client: &http.Client{Timeout: nutritionTimeout},
cache: map[string]cachedPanel{}, 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 { func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition {
@@ -266,10 +281,26 @@ func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition {
return hit.value 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 // NOT cached when the brand could not be resolved.
// every tap would mean the least useful answer costing the most requests. //
// 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.mu.Lock()
s.cache[key] = cachedPanel{value: value, at: time.Now()} s.cache[key] = cachedPanel{value: value, at: time.Now()}
s.mu.Unlock() s.mu.Unlock()
@@ -277,15 +308,18 @@ func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition {
return value return value
} }
func (s *nutritionService) fetch(brand, imageID string) ProductNutrition { // fetch returns the record, and whether the brand name it asked under was the
resolved := s.resolveBrand(brand) // 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 var source NutritionSource
if !s.get(fmt.Sprintf("/nutrition/%s/%s", if !s.get(fmt.Sprintf("/nutrition/%s/%s",
url.PathEscape(resolved), url.PathEscape(imageID)), &source) { url.PathEscape(name), url.PathEscape(imageID)), &source) {
return ProductNutrition{} 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 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. 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) wanted := normaliseBrand(raw)
if wanted == "" {
return raw
}
s.mu.RLock() s.mu.RLock()
brands, at := s.brands, s.brandsAt 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 { for _, candidate := range brands {
if normaliseBrand(candidate) == wanted { 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]`) var brandNoise = regexp.MustCompile(`[^a-z0-9]`)

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"nearle/models" "nearle/models"
@@ -369,3 +370,80 @@ func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
t.Fatalf("an absent panel was sent as a key: %s", encoded) 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)
}
}