4.5 KiB
Nutrition and health score — the app contract
GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=
Each product in details[] may now carry two extra keys. Both come from the
catalogue-intelligence service (mcp.nearle.ai.in) — the same records behind the
health score card in the console — fetched server-side, so the app needs no
second host, no second failure mode, and no copy of the rules below.
nutrition
"nutrition": {
"per": "100g",
"servingsize": "1 mini (11 g)",
"items": [
{ "name": "Energy", "value": 545, "unit": "kcal" },
{ "name": "Protein", "value": 7.5, "unit": "g" }
]
}
- Absent when unknown. Not
null, not{}. A missing key means "we do not know", never "this food has no nutrition". itemsis never empty whennutritionis present.peris"100g"for everything the service returns today.servingsizeis often absent — show the basis only when it is there.valuemay be a decimal.unitis free text and may be absent.- Rows appear only when the service stated them. A null field is omitted; a stated zero is kept, because "no fibre" is a fact and a dash is not.
healthscore
"healthscore": {
"score": 65,
"band": "good",
"label": "Healthy",
"positives": ["Good source of protein (7.5 g per 100 g)."],
"cautions": ["High in saturated fat (14.4 g per 100 g)."],
"diettags": ["High Fiber", "Vegetarian"],
"allergens": [],
"allergensunconfirmed": true,
"caveat": "Matched to a reference product with 61% confidence — treat these figures as a guide.",
"source": { "label": "openfoodfacts", "url": "https://..." }
}
- Absent when there is nothing safe to show — unscored, not food, or no record at all. All three read as "not rated yet".
bandis one ofexcellent|good|fair|poor, for styling.labelis what a shopper reads. Use the label; do not re-derive it.scoreis 0–100, already rounded.positives,cautionsanddiettagsare sentences the service wrote for a person. Render as given.
Two rules the app MUST honour
caveat, when present, has to be on screen. It means the underlying source
match was weak — most are; the service matches down to 0.32 confidence. A
nutrition table presented as fact on a 61% match is a claim the data does not
support.
allergensunconfirmed: true means an empty allergens list must NOT be
rendered as "contains none". Say "not confirmed — check the packet". Silence
standing in for "none" is the one failure here that can put somebody in hospital.
A declared allergen is always sent and must always be shown.
Why the judgement is server-side
The service returns a raw number and, from this endpoint, no band. Deciding which band, whether the match is strong enough to state plainly, and whether the product is even food is a set of rules that already exists in the console. Two implementations would drift and disagree about the same product on two screens.
The edibility guard is the sharpest of them. The upstream per-product endpoint is
not gated for it: on 4 Sep 2026 it rated Godrej Hit insecticide 80/100 with
data_status: "verified", and soap and shampoo both scored 37.5. Those records
now read "unavailable", and the guard stays — this tenant sells soap and
toothpaste beside its biscuits.
Coverage today
Measured 29 Sep 2026 against tenant 1147: 6 of 15 catalogue-linked products have nutrition, and fewer have a score. The Patanjali ghee this work started from has neither.
Test with product 7101, Balaji Wafers Simply Salted — a full panel and a 65/100 score.
go run ./scratch/nutritionproof # three real products
go run ./scratch/nutritionproof <brand> <image_id> # any product
Known bad data upstream
Balaji Wafers reports sodium_mg: 0.967 — under 1 mg per 100 g, for salted
crisps, where 500–900 mg is normal. The Salt figure of 0.002 g is wrong the
same way. It looks like a grams/milligrams mix-up at the source.
Fiesta passes the value through as given rather than scaling it: silently "correcting" a food label is how wrong data becomes invisible. It will look wrong in the app until the agent team fixes the unit.
Configuration
NUTRITION_BASE=https://mcp.nearle.ai.in/api, in .env. Unset means neither key
is ever sent and nothing else changes. Lookups are cached six hours, capped at
three seconds, and every failure costs that product its panel rather than the
response.