health score
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user