469 lines
15 KiB
Go
469 lines
15 KiB
Go
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
|
||
}
|