health score

This commit is contained in:
2026-09-29 22:40:36 +05:30
parent f9fb405974
commit fb859ecda1
21 changed files with 1590 additions and 703 deletions

View File

@@ -26,12 +26,9 @@ type CatalogueProduct struct {
FSSAILicense string `json:"fssai_license,omitempty"`
Highlights PGStringArray `json:"highlights,omitempty"`
Nutrients PGStringArray `json:"nutrients,omitempty"`
// The structured panel, when the catalogue carries one. Nil falls back to
// parsing Nutrients above — see NutritionFromLines.
Nutrition *NutritionPanel `json:"nutrition,omitempty"`
SearchQuery string `json:"search_query,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
SearchQuery string `json:"search_query,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// CatalogueBrand describes a brand available in the catalogue DB.

164
models/healthscore.go Normal file
View File

@@ -0,0 +1,164 @@
package models
import "strings"
/*
The health score, as the customer app renders it.
The figures come from the catalogue-intelligence service — the same record the
nutrition panel comes from, and the same one behind the health score card in the
console. See services/nutritionService.go.
── Why the judgement is made here and not in the app ───────────────────────
The service returns a raw number and, from this endpoint, no band. Deciding what
that number means — which band, whether the match is sure enough to state
plainly, whether the product is even food — is a set of rules that already exists
in the console. Sending the raw number and letting the app re-derive them would
mean two implementations of the same judgement, drifting apart, disagreeing about
the same product on two screens. The rules travel with the answer instead.
*/
type HealthScore struct {
// 0–100, rounded. The service sends one decimal and nobody reads it.
Score int `json:"score"`
// "excellent" | "good" | "fair" | "poor" — for styling.
Band string `json:"band"`
// What a shopper reads, rather than what a nutritionist would call it.
Label string `json:"label"`
// Sentences the service already wrote for a person. Rendered as given.
Positives []string `json:"positives,omitempty"`
Cautions []string `json:"cautions,omitempty"`
Diettags []string `json:"diettags,omitempty"`
// Declared allergens. A false positive sends somebody to read the packet; a
// false negative sends them to hospital, so a declared one is always shown.
Allergens []string `json:"allergens,omitempty"`
// True when an EMPTY allergen list must NOT be read as "contains none".
//
// The service accepts a source match down to 0.32 confidence, and
// `data_status: "verified"` speaks to the numbers being real, not to the
// record being this product. What must never happen is silence standing in
// for "none" — which is exactly what an empty list rendered as nothing looks
// like. An app MUST say "not confirmed" rather than draw nothing here.
Allergensunconfirmed bool `json:"allergensunconfirmed,omitempty"`
// Set when the match is not sure enough to state plainly. When present the
// app must show it: a nutrition table presented as fact on a 61% match is a
// claim the data does not support.
Caveat string `json:"caveat,omitempty"`
// Where the figures came from, for a shopper who wants to check.
Source *HealthSource `json:"source,omitempty"`
}
type HealthSource struct {
Label string `json:"label"`
URL string `json:"url"`
}
// LowConfidence is the line below which a match is a guide, not a fact.
//
// The same 0.7 the console uses. Sampling 40 scored products: 37 matched below
// 0.7 and 26 below 0.5, so this fires often — which is the point.
const LowConfidence = 0.7
/*
BandFor turns a score into a band.
The service's own `health_band` wins when it sends one. It does NOT send one
from the per-product endpoint — only from the list — so for this response the
fallback is not an edge case, it is the only path, which makes these thresholds
load-bearing rather than cosmetic.
They are the SERVICE'S thresholds, not ours. Derived from its output and since
confirmed by that team in writing:
excellent >= 80
good 60 – 79.9
fair 40 – 59.9 confirmed 40, not 50
poor < 40
Delete this fallback once `health_band` is on the per-product response — it is
on their list. Until then, picking our own numbers would be one API disagreeing
with itself depending which endpoint a screen called.
*/
func BandFor(score float64, sent string) string {
switch strings.ToLower(strings.TrimSpace(sent)) {
case "excellent", "good", "fair", "poor":
return strings.ToLower(strings.TrimSpace(sent))
}
switch {
case score >= 80:
return "excellent"
case score >= 60:
return "good"
case score >= 40:
return "fair"
default:
return "poor"
}
}
// BandLabel is what a shopper reads.
func BandLabel(band string) string {
switch band {
case "excellent":
return "Very healthy"
case "good":
return "Healthy"
case "fair":
return "Okay"
default:
return "Less healthy"
}
}
/*
foodCategoryWords mean "this is food or drink".
An ALLOWLIST, and the asymmetry of the two failure modes is why. Withholding a
score on real food costs a shopper a badge they never had. Showing one on
something inedible is a different order of mistake, and the service has made it:
measured 4 Sep 2026, `GET /nutrition/Godrej/godrej_hit_spray_1101d017` returned
`health_score: 80.0, data_status: "verified"` — an "excellent" rating for
insecticide. Palmolive soap and Pantene shampoo both scored 37.5 the same way.
Re-measured 29 Sep 2026: those records now answer `unavailable`, so the purge
their team described has run. The guard stays anyway. It costs nothing when the
data is clean, and the tenant this was built for stocks soap, shampoo and
toothpaste alongside its food.
*/
var foodCategoryWords = []string{
"beverage", "drink", "juice", "water", "tea", "coffee",
"chocolate", "candy", "confection", "sweet", "dessert",
"dairy", "milk", "cheese", "butter", "ghee", "curd", "yogurt",
"snack", "biscuit", "cookie", "wafer", "chips", "namkeen",
"atta", "staple", "flour", "rice", "dal", "pulse", "grain", "cereal",
"pasta", "noodle", "bread", "bakery",
"oil", "masala", "spice", "sauce", "pickle", "jam", "honey",
"food", "nutrition", "breakfast", "fruit", "vegetable", "egg", "meat",
}
// IsEdible reports whether a score is attached to something a person eats.
//
// An unrecognised category is treated as NOT food. On screen that reads as "not
// scored yet", which is honest — we genuinely do not know — and is what most
// products show anyway.
func IsEdible(category string) bool {
value := strings.ToLower(strings.TrimSpace(category))
if value == "" {
return false
}
// "General" carries soap and household goods alongside anything else the
// scraper could not place. Ambiguous is not good enough for this decision.
if value == "general" {
return false
}
for _, word := range foodCategoryWords {
if strings.Contains(value, word) {
return true
}
}
return false
}

View File

@@ -1,137 +1,52 @@
package models
import (
"regexp"
"strconv"
"strings"
)
/*
The nutrition panel, as the customer app renders it.
── Why this is a type and not a list of strings ────────────────────────────
The catalogue already carries `nutrients`, a text[] of display lines like
"Energy 350kcal". That is enough to print bullets, which is what the console
does with it today, and not enough for an app: it cannot sort by a value, show
a per-serving column beside a per-100g one, or put the unit in a different
style from the number. It also has nowhere to say what the figures are PER,
which is the one piece of context that makes the rest meaningful — 520 kcal is
a fact about a quantity, and without "per 100g" it is a fact about nothing.
So this is the shape the agent team fills and the app reads. See
docs/NUTRITION_DATA.md for the contract.
The figures come from the catalogue-intelligence service — the same one behind
the health score card in the console — and this is the shape they reach the app
in. See services/nutritionService.go for the fetch and the mapping.
── Why the field names are ugly ────────────────────────────────────────────
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
developer asked for, and an API is a promise to a client that has already been
written against it. Consistency with the rest of Fiesta — which is itself
inconsistent, `productid` beside `image_id` beside `sku_source` — is worth less
than not breaking the caller.
developer asked for, and an API is a promise to a client already written against
it. Consistency with the rest of Fiesta — itself inconsistent, `productid`
beside `image_id` beside `sku_source` — is worth less than not breaking the
caller.
*/
type NutritionPanel struct {
// What the figures are measured against: "100g", "100ml", "1 serving".
// What the figures are measured against. "100g" for everything the service
// returns today: its top-level values are per 100g, which is what the
// console's own panel prints beneath them.
Per string `json:"per,omitempty"`
// What the pack calls one serving: "30g". Separate from `Per` because a
// label routinely states both, and the app shows them in different places.
// What the pack calls one serving — "1 mini (11 g)". Absent when the
// service did not state one, rather than defaulted: a serving size is a
// claim about the food, and a guessed one is a false claim.
Servingsize string `json:"servingsize,omitempty"`
// Never nil when this panel exists — see `HasValues`. An app that receives
// `items: null` has to branch; one that receives `[]` does not, and a panel
// with no rows should not have been sent at all.
// Never nil when this panel exists — see HasValues. An app receiving
// `items: null` has to branch; one receiving `[]` does not, and a panel with
// no rows should not have been sent at all.
Items []NutritionItem `json:"items"`
}
// NutritionItem is one line of the panel.
type NutritionItem struct {
Name string `json:"name"`
// The figure. A float because saturated fat is 11.5g as often as it is 11g,
// and rounding it to please a type would be changing a label.
// The figure. A float because saturated fat is 18.7g as often as it is 19g,
// and rounding it to please a type would be editing a label.
Value float64 `json:"value"`
// "kcal", "g", "mg". Free text on purpose: the label is the authority and a
// closed list here would mean refusing to carry whatever it actually says.
// "kcal", "g", "mg". Free text on purpose: `extended_nutrients` carries its
// own units from the source, and a closed list here would mean refusing to
// carry whatever the label actually says.
Unit string `json:"unit,omitempty"`
}
// HasValues reports whether this panel is worth sending.
//
// A panel with no rows is not a panel — it is an empty box on the product page,
// which reads as "this product has no nutrition" rather than "we do not know
// yet". The endpoint omits it instead.
// A panel with no rows is not a panel — it is an empty box on a product page,
// which a shopper reads as "this food has no nutrition" rather than "we do not
// know yet". The endpoint omits it instead.
func (p *NutritionPanel) HasValues() bool {
return p != nil && len(p.Items) > 0
}
/*
nutrientLine pulls "Energy 350kcal" apart.
── Why parsing exists at all ────────────────────────────────────────────────
Every catalogue row on the platform today holds nutrition as those display
strings and nothing else. Waiting for the agent team to refill all of them
before the app can show anything would mean shipping a field that is null for
every product, for as long as that takes.
So a structured panel is used when one exists, and one is derived from the
strings when it does not. The derived panel is strictly worse — it has no `per`
and no serving size, because the strings never carried them — and it is still
the difference between an app screen with values on it and an empty one.
── What it refuses to do ───────────────────────────────────────────────────
A line it cannot read is kept WHOLE as the name, with no value and no unit,
rather than dropped or guessed at. "Contains permitted natural colour" is a
real nutrition line and it has no number in it; binning it would quietly lose
label text, and forcing a 0 into it would state something false about the food.
*/
var nutrientLine = regexp.MustCompile(`^(.*?)[\s:]*(-?\d+(?:\.\d+)?)\s*([a-zA-Zµ%]*)$`)
// NutritionFromLines derives a panel from the catalogue's display strings.
//
// Returns nil when nothing usable is found, so a caller can tell "no nutrition"
// from "a panel with no numbers in it".
func NutritionFromLines(lines []string) *NutritionPanel {
items := make([]NutritionItem, 0, len(lines))
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
match := nutrientLine.FindStringSubmatch(line)
if match == nil {
// No number anywhere. Kept as written — see above.
items = append(items, NutritionItem{Name: line})
continue
}
name := strings.TrimSpace(strings.Trim(match[1], "-–—:"))
if name == "" {
// The whole line was a number. Nothing sensible to label it with,
// and an unnamed row on a nutrition panel is noise.
continue
}
value, err := strconv.ParseFloat(match[2], 64)
if err != nil {
items = append(items, NutritionItem{Name: line})
continue
}
items = append(items, NutritionItem{
Name: name,
Value: value,
Unit: strings.TrimSpace(match[3]),
})
}
if len(items) == 0 {
return nil
}
// No `per` and no serving size, deliberately left empty rather than guessed
// at. "100g" is the common case and it is not the only one, and a wrong
// basis is worse than an absent one — it makes every figure beneath it a
// misstatement rather than an unknown.
return &NutritionPanel{Items: items}
}

View File

@@ -1,141 +0,0 @@
package models
import "testing"
/*
Reading the catalogue's nutrition strings.
The catalogue holds nutrition as display lines — "Energy 350kcal" — and the app
needs figures. These are the shapes those lines actually come in, and the ones
they come in when a scrape goes sideways.
The rule throughout: never invent a number, and never lose label text. A line
that cannot be read is carried whole rather than dropped, because it is
something a manufacturer printed on a packet and this code is not the authority
on what belongs on a food label.
*/
func TestAPlainLineBecomesAFigure(t *testing.T) {
panel := NutritionFromLines([]string{"Energy 350kcal"})
if !panel.HasValues() {
t.Fatal("nothing parsed")
}
got := panel.Items[0]
if got.Name != "Energy" || got.Value != 350 || got.Unit != "kcal" {
t.Fatalf("got %+v", got)
}
}
func TestTheSeparatorsThatActuallyOccur(t *testing.T) {
// Colons, multi-word names and a space before the unit are all in the wild,
// and each one used to be a whole line lost.
for _, tc := range []struct {
line string
name string
value float64
unit string
}{
{"Protein: 6.5 g", "Protein", 6.5, "g"},
{"Total Sugars 22g", "Total Sugars", 22, "g"},
{"Saturated Fat 11.5 g", "Saturated Fat", 11.5, "g"},
{"Sodium 310mg", "Sodium", 310, "mg"},
{"Energy - 520 kcal", "Energy", 520, "kcal"},
} {
panel := NutritionFromLines([]string{tc.line})
if !panel.HasValues() {
t.Errorf("%q parsed to nothing", tc.line)
continue
}
got := panel.Items[0]
if got.Name != tc.name || got.Value != tc.value || got.Unit != tc.unit {
t.Errorf("%q → %+v, want {%s %v %s}", tc.line, got, tc.name, tc.value, tc.unit)
}
}
}
func TestADecimalSurvives(t *testing.T) {
// Saturated fat is 11.5g as often as 11g. Rounding to please a type would be
// editing a food label.
panel := NutritionFromLines([]string{"Saturated Fat 11.5g"})
if panel.Items[0].Value != 11.5 {
t.Fatalf("got %v, want 11.5", panel.Items[0].Value)
}
}
func TestALineWithNoNumberIsKeptWhole(t *testing.T) {
// Real label text. Dropping it loses something a manufacturer printed;
// forcing a 0 into it states something false about the food.
panel := NutritionFromLines([]string{"Contains permitted natural colour"})
if !panel.HasValues() {
t.Fatal("the line was dropped")
}
got := panel.Items[0]
if got.Name != "Contains permitted natural colour" {
t.Fatalf("the text was mangled: %+v", got)
}
if got.Value != 0 || got.Unit != "" {
t.Fatalf("a figure was invented for a line that had none: %+v", got)
}
}
func TestAUnitlessFigureKeepsItsNumber(t *testing.T) {
// "Servings per pack 4" has a real number and no unit.
panel := NutritionFromLines([]string{"Servings per pack 4"})
got := panel.Items[0]
if got.Name != "Servings per pack" || got.Value != 4 || got.Unit != "" {
t.Fatalf("got %+v", got)
}
}
func TestPercentAndMicrogramsAreUnits(t *testing.T) {
for _, tc := range []struct{ line, unit string }{
{"Vitamin C 45%", "%"},
{"Vitamin B12 1.2µg", "µg"},
} {
panel := NutritionFromLines([]string{tc.line})
if !panel.HasValues() || panel.Items[0].Unit != tc.unit {
t.Errorf("%q → %+v, want unit %q", tc.line, panel.Items[0], tc.unit)
}
}
}
func TestBlanksAndBareNumbersAreNotRows(t *testing.T) {
// A blank is nothing. A bare "100" has no label, and an unnamed row on a
// nutrition panel is noise a shopper cannot use.
if panel := NutritionFromLines([]string{"", " ", "100"}); panel != nil {
t.Fatalf("made a panel out of nothing: %+v", panel)
}
}
func TestNoLinesMeansNoPanel(t *testing.T) {
// nil rather than an empty panel, so a caller can tell "no nutrition known"
// from "a panel that happens to be empty" — the endpoint omits the first.
if NutritionFromLines(nil) != nil {
t.Fatal("an absent panel was reported as present")
}
if NutritionFromLines([]string{}) != nil {
t.Fatal("an absent panel was reported as present")
}
}
func TestAnEmptyPanelIsNotWorthSending(t *testing.T) {
// `items: []` on a product page renders as an empty box, which reads as
// "this food has no nutrition" rather than "we do not know yet".
var absent *NutritionPanel
if absent.HasValues() {
t.Fatal("nil reported as having values")
}
if (&NutritionPanel{Per: "100g"}).HasValues() {
t.Fatal("a panel with a basis and no rows reported as having values")
}
}
func TestTheDerivedPanelDoesNotGuessItsBasis(t *testing.T) {
// The strings never carried one. "100g" is the common case and not the only
// one, and a wrong basis makes every figure beneath it a misstatement rather
// than an unknown.
panel := NutritionFromLines([]string{"Energy 350kcal"})
if panel.Per != "" || panel.Servingsize != "" {
t.Fatalf("invented a basis: per=%q servingsize=%q", panel.Per, panel.Servingsize)
}
}

View File

@@ -186,6 +186,17 @@ type Products struct {
// Set by the service, not the repository — see decorateNutrition.
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
// The health score, for the same product screen and from the same record.
//
// `gorm:"-"`, absent when there is nothing safe to show, and independent of
// Nutrition above — a product can be scored with no figures published, and
// carry figures with no score.
//
// WITHHELD on anything that is not food. The upstream per-product endpoint
// is not gated for edibility and has rated insecticide 80/100; see
// models.IsEdible.
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"`