health score
This commit is contained in:
468
services/nutritionService.go
Normal file
468
services/nutritionService.go
Normal file
@@ -0,0 +1,468 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user