health score
This commit is contained in:
@@ -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
164
models/healthscore.go
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user