Files
backend_fiesta/services/nutritionService.go
2026-09-29 22:40:36 +05:30

469 lines
15 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
}
return &nutritionService{
base: base,
client: &http.Client{Timeout: nutritionTimeout},
cache: map[string]cachedPanel{},
}
}
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 := 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.
s.mu.Lock()
s.cache[key] = cachedPanel{value: value, at: time.Now()}
s.mu.Unlock()
return value
}
func (s *nutritionService) fetch(brand, imageID string) ProductNutrition {
resolved := s.resolveBrand(brand)
var source NutritionSource
if !s.get(fmt.Sprintf("/nutrition/%s/%s",
url.PathEscape(resolved), url.PathEscape(imageID)), &source) {
return ProductNutrition{}
}
return ProductNutrition{Panel: source.Panel(), Health: source.Health()}
}
/*
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 {
wanted := normaliseBrand(raw)
if wanted == "" {
return 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()
}
}
for _, candidate := range brands {
if normaliseBrand(candidate) == wanted {
return candidate
}
}
return raw
}
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
}