health score

This commit is contained in:
2026-09-29 22:40:36 +05:30
parent f9fb405974
commit fb859ecda1
21 changed files with 1590 additions and 703 deletions

115
docs/NUTRITION_API.md Normal file
View File

@@ -0,0 +1,115 @@
# 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
```json
"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".
- `items` is never empty when `nutrition` is present.
- `per` is `"100g"` for everything the service returns today. `servingsize` is
often absent — show the basis only when it is there.
- `value` may be a decimal. `unit` is 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
```json
"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".
- `band` is one of `excellent` | `good` | `fair` | `poor`, for styling. `label`
is what a shopper reads. Use the label; do not re-derive it.
- `score` is 0–100, already rounded.
- `positives`, `cautions` and `diettags` are 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.
```sh
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.

View File

@@ -1,136 +0,0 @@
# Nutrition data — the contract between the agent team, Fiesta and the app
The customer app shows a nutrition panel on the product screen. The data comes
from the global catalogue, which the agent team fills. This is what each side
has to produce and can rely on.
---
## 1. What the app receives
`GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=`
Each product in `details[]` carries:
```json
"nutrition": {
"per": "100g",
"servingsize": "30g",
"items": [
{ "name": "Energy", "value": 520, "unit": "kcal" },
{ "name": "Protein", "value": 6.5, "unit": "g" },
{ "name": "Total Sugars", "value": 22, "unit": "g" },
{ "name": "Sodium", "value": 310, "unit": "mg" }
]
}
```
Rules the app can build on:
- **The key is ABSENT when nothing is known.** Not `null`, not `{}`. A missing
key means "we do not know", never "this food has no nutrition".
- **`items` is never empty when `nutrition` is present.** A panel with no rows
is not sent, because an empty box on a product page reads as a claim.
- **`per` and `servingsize` are each optional** and often absent — see §3. Show
the basis only when it is there. Do not default it to `100g`: a wrong basis
makes every figure beneath it a misstatement rather than an unknown.
- **`value` is a number, and may be a decimal.** Saturated fat is 11.5 g as
often as 11 g.
- **`unit` is free text and may be absent.** "Servings per pack 4" has a figure
and no unit.
- **A row may have a `name` and a `value` of 0 with no unit.** That is real
label text with no number in it — "Contains permitted natural colour". Render
the name and leave the figure column blank; do not print `0`.
- Every member of a variant group carries its own panel, so switching from
500 ml to 1 L does not blank the screen.
## 2. What the agent team fills
A `nutrition` column of type **`jsonb`** on each `brand_*` table in the
catalogue database, holding exactly the object above.
```sql
ALTER TABLE brand_patanjali ADD COLUMN IF NOT EXISTS nutrition jsonb;
```
Notes:
- **The column is optional and Fiesta already handles its absence.** Catalogue
reads discover columns per table and substitute NULL for any that are missing,
so brands can be filled one at a time and a table without the column keeps
working. Nothing has to be co-ordinated with a deploy.
- **Spelling is `servingsize`**, one word, lowercase. It is the shape the app
was written against.
- **Write what the label says.** If the pack states per 100 g, `per` is `"100g"`.
If it states per serving, say so. If it states neither, omit the field rather
than assuming.
- `nutrition` and the older `nutrients` may both exist on a row. Keep both —
the console shows the lines, the app shows the panel, and they are different
readers with different needs.
## 3. Where today's data comes from, and why it is thinner
Every catalogue row currently holds nutrition as `nutrients`, a `text[]` of
display lines:
```
{"Energy 350kcal", "Protein 7g"}
```
Fiesta parses those into the same panel shape when no structured `nutrition`
exists, so the app shows figures for products the agent team has not reached
yet. That derived panel has **no `per` and no `servingsize`** — the strings
never carried them — which is the visible difference between a filled brand and
one still waiting.
Parsing rules, in `models/nutrition.go`:
| line | becomes |
|---|---|
| `Energy 350kcal` | `{Energy, 350, kcal}` |
| `Protein: 6.5 g` | `{Protein, 6.5, g}` |
| `Energy - 520 kcal` | `{Energy, 520, kcal}` |
| `Vitamin B12 1.2µg` | `{Vitamin B12, 1.2, µg}` |
| `Servings per pack 4` | `{Servings per pack, 4, ""}` |
| `Contains permitted colour` | `{Contains permitted colour, 0, ""}` — kept whole |
| `100` | dropped — a figure with no label is noise |
Structured always wins over derived where both exist.
## 4. How it reaches a shop's product
The catalogue is the source; a tenant's product is a **snapshot** taken at
import, because the catalogue is re-scraped and rows retire. Chain:
```
brand_*.nutrition (agent team)
└─ catalogue read ─ repositories/catalogueRepository.go: nutritionOf
└─ import ─ services/productService.go: catalogueFactsOf
└─ products.cataloguefacts → {"nutrition": {...}, "nutrients": [...]}
└─ endpoint ─ services/productService.go: decorateNutrition
```
`getproductbyvariant` does **not** query the catalogue database. That would be a
cross-database lookup per product on a screen a shopper is waiting on. It reads
the snapshot only.
## 5. Products imported before the snapshot existed
**This is the one thing that must be done before any of it shows.**
`cataloguefacts` is empty on every product imported before that column existed —
measured on tenant 1147: 17 products, 15 catalogue-imported, **0 with facts**.
Those products will show no nutrition no matter what the catalogue holds.
The fix is a one-off backfill, which also restores their FSSAI licence,
highlights and provider list:
```sh
go run ./scratch/cataloguefactsbackfill # dry run — prints every change
go run ./scratch/cataloguefactsbackfill apply # writes, then prints the undo
```
It touches only products with an `imageid` and a NULL `cataloguefacts`, so
re-running it is a no-op rather than a second opinion. Run it **after** the agent
team fills a brand to pick up that brand's structured panels; it is safe to run
repeatedly as more brands are filled.