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