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 }