Files
backend_fiesta/models/healthscore.go
2026-09-29 22:40:36 +05:30

165 lines
6.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}