Files
backend_fiesta/services/nutritionService.go
abhishek c49f5372a5 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>
2026-09-30 11:48:27 +05:30

508 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"nearle/models"
"regexp"
"sort"
"strings"
"sync"
"time"
)
/*
Nutrition for the customer app's product screen.
── Where the figures come from ─────────────────────────────────────────────
`mcp.nearle.ai.in` — the catalogue-intelligence service, the same one that
scrapes the global catalogue and backs the health score card in the console.
Not Fiesta's own database: nothing on `products` has ever held nutrition, and
the service already has it keyed by brand and image_id, which is exactly what a
tenant's product carries from import.
So this reads it rather than copying it. A second store of the same figures is a
second thing to keep in step, and the one that drifts is the one on a food label.
── Why the console does this in the browser and the app cannot ─────────────
The console calls the service directly from `api/nutrition.ts`. The customer app
could too, in principle, and should not have to: it would mean a second base
URL, a second failure mode and the brand-spelling problem below reimplemented in
whatever the app is written in. The product screen already calls Fiesta, so
Fiesta answers the whole question.
*/
// NutritionSource is the shape the catalogue-intelligence service answers with.
//
// Only the fields the panel needs. The service returns roughly forty, including
// scores, insights, allergens and diet tags — all of which belong to the health
// score card in the console and none of which the app asked for.
type NutritionSource struct {
// "verified" when the source record was confirmed, "unavailable" when the
// service knows the product and has nothing for it.
DataStatus string `json:"data_status"`
// The health score half of the same record.
HealthScore *float64 `json:"health_score"`
// Absent from this endpoint — only the list sends it — so the band is
// derived. See models.BandFor.
HealthBand string `json:"health_band"`
// What the service thinks the product IS. The edibility guard reads this,
// and it is routinely null, which the guard treats as "not food".
Category string `json:"category"`
// 0–1. How sure the service is it matched the right source record. Below
// models.LowConfidence the figures are a guide, not a fact.
MatchConfidence *float64 `json:"match_confidence"`
PositiveInsights []string `json:"positive_insights"`
NutritionalCautions []string `json:"nutritional_cautions"`
DietTags []string `json:"diet_tags"`
Allergens []string `json:"allergens"`
DataSource string `json:"data_source"`
SourceURL string `json:"source_url"`
ServingSizeLabel string `json:"serving_size_label"`
// Per 100g, every one of them. The service's own insights say so — "6.8 g
// per 100 g" — and the console prints "per 100 g" beneath the same numbers.
Calories *float64 `json:"calories_kcal"`
Protein *float64 `json:"protein_g"`
Carbohydrates *float64 `json:"carbohydrates_g"`
TotalSugar *float64 `json:"total_sugar_g"`
AddedSugar *float64 `json:"added_sugar_g"`
DietaryFiber *float64 `json:"dietary_fiber_g"`
TotalFat *float64 `json:"total_fat_g"`
SaturatedFat *float64 `json:"saturated_fat_g"`
TransFat *float64 `json:"trans_fat_g"`
Cholesterol *float64 `json:"cholesterol_mg"`
Sodium *float64 `json:"sodium_mg"`
// Whatever else the source record stated, as name → {unit, value}. The
// 5 Star record carries `{"Salt": {"unit":"g","value":0.268}}`. Carried
// through rather than filtered: it is label text, and this code is not the
// authority on what belongs on a food label.
Extended map[string]struct {
Unit string `json:"unit"`
Value float64 `json:"value"`
} `json:"extended_nutrients"`
}
// Panel maps the source record onto what the app renders.
//
// Order is the order a label prints: energy, the macros, then what the source
// added. Only fields the service actually stated — a table of dashes is worse
// than a short table, and the service genuinely omits things (`added_sugar_g`
// is null on the 5 Star record).
//
// Returns nil when nothing was stated, so "no panel" and "an empty panel" stay
// different answers.
func (s *NutritionSource) Panel() *models.NutritionPanel {
if s == nil {
return nil
}
rows := []struct {
name string
value *float64
unit string
}{
{"Energy", s.Calories, "kcal"},
{"Protein", s.Protein, "g"},
{"Carbohydrate", s.Carbohydrates, "g"},
{"Total Sugars", s.TotalSugar, "g"},
{"Added Sugars", s.AddedSugar, "g"},
{"Dietary Fibre", s.DietaryFiber, "g"},
{"Total Fat", s.TotalFat, "g"},
{"Saturated Fat", s.SaturatedFat, "g"},
{"Trans Fat", s.TransFat, "g"},
{"Cholesterol", s.Cholesterol, "mg"},
{"Sodium", s.Sodium, "mg"},
}
items := make([]models.NutritionItem, 0, len(rows)+len(s.Extended))
for _, row := range rows {
if row.value == nil {
continue
}
items = append(items, models.NutritionItem{
Name: row.name, Value: *row.value, Unit: row.unit,
})
}
// Sorted, because a Go map has no order and a nutrition panel that
// reshuffles between two requests for the same product looks broken.
for _, name := range sortedKeys(s.Extended) {
extra := s.Extended[name]
items = append(items, models.NutritionItem{
Name: name, Value: extra.Value, Unit: extra.Unit,
})
}
if len(items) == 0 {
return nil
}
return &models.NutritionPanel{
Per: "100g",
Servingsize: strings.TrimSpace(s.ServingSizeLabel),
Items: items,
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
/*
NutritionService fetches one product's panel.
── Everything here is about not hurting the product screen ─────────────────
This runs inside `getproductbyvariant`, which a shopper is waiting on, and it
calls a service Fiesta does not own. So:
- a short timeout, because a slow third party must not become a slow shop;
- a cache, because nutrition for a packaged product does not change during a
trading day and a variant group asks for several products at once;
- every failure returns nil, never an error. A product page without a
nutrition panel is a product page. A product page that 500s is not.
*/
type NutritionService interface {
// ForProduct returns the nutrition panel and the health score for one
// product. Either may be nil, independently: a product can be scored with
// no figures published, and carry figures with no score.
//
// Never returns an error: see above.
ForProduct(brand, imageID string) ProductNutrition
}
// ProductNutrition is both halves of one product's record.
//
// One value because they come from ONE request. Fetching them separately would
// double the traffic to a third party on a screen a shopper is waiting on, to
// split a record the service returns whole.
type ProductNutrition struct {
Panel *models.NutritionPanel
Health *models.HealthScore
}
// nutritionTimeout is deliberately short.
//
// The alternative is a shopper watching a spinner because somebody else's
// service is having a bad afternoon. A missing panel costs a section of one
// screen; a slow response costs the screen.
const nutritionTimeout = 3 * time.Second
// nutritionTTL is how long a fetched panel is reused.
//
// A packaged product's nutrition does not change during a trading day, and the
// console's own panel uses the same reasoning. Long enough to matter, short
// enough that a correction at the source reaches shoppers the same day.
const nutritionTTL = 6 * time.Hour
type nutritionService struct {
base string
client *http.Client
mu sync.RWMutex
cache map[string]cachedPanel
brands []string
// Zero until the brand list has been read once.
brandsAt time.Time
}
type cachedPanel struct {
value ProductNutrition
at time.Time
}
// NewNutritionService builds the client, or returns nil when no base URL is set.
//
// Nil is a working configuration, like the mailer: a deployment without the
// catalogue-intelligence service still serves every product screen, without a
// nutrition panel on it. `PanelFor` is nil-safe so no caller has to check.
func NewNutritionService(base string) NutritionService {
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
return nil
}
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 {
if s == nil {
return ProductNutrition{}
}
brand, imageID = strings.TrimSpace(brand), strings.TrimSpace(imageID)
if brand == "" || imageID == "" {
// No join key. Sheet-imported products have no image_id, and there is
// nothing to look up rather than something that failed to be found.
return ProductNutrition{}
}
key := brand + "/" + imageID
s.mu.RLock()
hit, ok := s.cache[key]
s.mu.RUnlock()
if ok && time.Since(hit.at) < nutritionTTL {
return hit.value
}
value, resolved := s.fetch(brand, imageID)
// 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()
return value
}
// 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(name), url.PathEscape(imageID)), &source) {
return ProductNutrition{}, resolved
}
return ProductNutrition{Panel: source.Panel(), Health: source.Health()}, resolved
}
/*
resolveBrand turns our spelling of a brand into theirs.
The two catalogues agree on every brand and disagree on how to write it:
ours theirs
patanjali → Patanjali
coca_cola → Coca-Cola underscore becomes a HYPHEN
brooke_bond → Brooke Bond underscore becomes a SPACE
Which separator an underscore becomes cannot be derived — hyphen for Coca-Cola
and Colgate-Palmolive, space for everything else — so the list is fetched and
matched on a normalised form rather than guessed at.
This is not cosmetic. A wrong spelling returns a well-formed record with every
figure null, which is indistinguishable from a product nobody has scored. Get it
wrong and nutrition is simply absent, everywhere, forever, with nothing in any
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, bool) {
wanted := normaliseBrand(raw)
s.mu.RLock()
brands, at := s.brands, s.brandsAt
s.mu.RUnlock()
// Refreshed on the same clock as a panel: a brand list changes when the
// scraper learns a new brand, which is not often and not urgently.
if at.IsZero() || time.Since(at) > nutritionTTL {
var payload struct {
Brands []string `json:"brands"`
}
if s.get("/brands", &payload) {
brands = payload.Brands
s.mu.Lock()
s.brands, s.brandsAt = brands, time.Now()
s.mu.Unlock()
}
}
// 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, true
}
}
// 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]`)
func normaliseBrand(brand string) string {
return brandNoise.ReplaceAllString(strings.ToLower(strings.TrimSpace(brand)), "")
}
// get reads one JSON document. Reports whether it got one.
//
// Every failure is a false and a log line, never an error returned upward: the
// caller's job is to draw a product screen and it can do that without this.
func (s *nutritionService) get(path string, into any) bool {
response, err := s.client.Get(s.base + path)
if err != nil {
log.Printf("nutrition: %s%s: %v", s.base, path, err)
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
// 404 is a normal answer here — the service does not know this product.
// Logged at the same level as the rest because a sudden wall of them is
// how a renamed path or a moved host gets noticed.
log.Printf("nutrition: %s%s: HTTP %d", s.base, path, response.StatusCode)
return false
}
// Capped: this is an upstream Fiesta does not control, and an unbounded
// read from one is how a memory limit gets found in production.
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
log.Printf("nutrition: %s%s: %v", s.base, path, err)
return false
}
if err := json.Unmarshal(body, into); err != nil {
log.Printf("nutrition: %s%s: malformed response: %v", s.base, path, err)
return false
}
return true
}
/*
Health returns the score, or nil when there is nothing safe to show.
── The three ways this returns nothing ─────────────────────────────────────
- the service has no score for the product, which is most of them;
- the product is not food. The per-product endpoint is not gated for
edibility and has rated insecticide 80/100. See models.IsEdible — the
guard stays even though those records now read "unavailable", because the
tenant this was built for sells soap next to its biscuits;
- there is no record at all.
All three render as "not scored yet", which is honest in every case.
── What it sends that the raw record does not ──────────────────────────────
A band, a label, and — when the match is weak — a caveat and an allergen
warning. Those are judgements, and they already exist in the console. Sending
the raw number instead would mean the app re-deriving them, and two screens
disagreeing about the same product.
*/
func (s *NutritionSource) Health() *models.HealthScore {
if s == nil || s.HealthScore == nil {
return nil
}
if !models.IsEdible(s.Category) {
return nil
}
value := *s.HealthScore
band := models.BandFor(value, s.HealthBand)
// Stated plainly only when the match supports it.
var caveat string
if s.MatchConfidence != nil && *s.MatchConfidence < models.LowConfidence {
caveat = fmt.Sprintf(
"Matched to a reference product with %d%% confidence — treat these figures as a guide.",
int(math.Round(*s.MatchConfidence*100)))
}
allergens := clean(s.Allergens)
return &models.HealthScore{
Score: int(math.Round(value)),
Band: band,
Label: models.BandLabel(band),
Positives: clean(s.PositiveInsights),
Cautions: clean(s.NutritionalCautions),
Diettags: clean(s.DietTags),
Allergens: allergens,
// An empty list is only trustworthy when the match is. Silence must not
// stand in for "contains none".
Allergensunconfirmed: len(allergens) == 0 && caveat != "",
Caveat: caveat,
Source: sourceOf(s),
}
}
func sourceOf(s *NutritionSource) *models.HealthSource {
url := strings.TrimSpace(s.SourceURL)
if url == "" {
return nil
}
label := strings.TrimSpace(s.DataSource)
if label == "" {
label = "source"
}
return &models.HealthSource{Label: label, URL: url}
}
// clean drops blanks, which the service sends for an empty ai_summary and
// others.
func clean(list []string) []string {
kept := make([]string, 0, len(list))
for _, entry := range list {
if trimmed := strings.TrimSpace(entry); trimmed != "" {
kept = append(kept, trimmed)
}
}
if len(kept) == 0 {
return nil
}
return kept
}