health score
This commit is contained in:
115
docs/NUTRITION_API.md
Normal file
115
docs/NUTRITION_API.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user