165 lines
6.1 KiB
Go
165 lines
6.1 KiB
Go
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
|
||
}
|