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

5
.env
View File

@@ -87,3 +87,8 @@ MAIL_PASSWORD=
MAIL_FROM=care@nearledaily.com
MAIL_FROM_NAME=Nearle
MAIL_CONSOLE_URL=https://app.nearledaily.com
# ── Nutrition ───────────────────────────────────────────────────────────────
# The catalogue-intelligence host behind the health score card. The customer
# app product screen reads its nutrition panel from here. Unset means no panel.
NUTRITION_BASE=https://mcp.nearle.ai.in/api

View File

@@ -120,6 +120,20 @@ EMBEDDING_DIMENSIONS=0
# ── Geocoding ───────────────────────────────────────────────────────────────
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
GEOCODER_API_KEY=
# ── Nutrition ───────────────────────────────────────────────────────────────
#
# The catalogue-intelligence service — the same host the console reads its
# health score card from. The customer app product screen gets its nutrition
# panel from here, through `getproductbyvariant`.
#
# Read server-side rather than by the app: the brand-spelling resolution below
# would otherwise have to be reimplemented in the app, and a wrong spelling
# returns a well-formed record with every figure null — indistinguishable from
# a product nobody has scored.
#
# Unset means product screens carry no nutrition panel and nothing else changes.
NUTRITION_BASE=https://mcp.nearle.ai.in/api
# ── Email ───────────────────────────────────────────────────────────────────
#
# Sending the first-password invitation a newly onboarded merchant receives.

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.

View File

@@ -49,7 +49,7 @@ type Facade struct {
// it may be nil if catalogue env vars are not configured, in which case
// catalogue endpoints will error at query time rather than at startup.
// embedder may be nil too: scan-to-order then matches on words alone.
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig) *Facade {
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig, nutritionBase string) *Facade {
// The invitation, built first because two modules need it.
//
@@ -82,8 +82,14 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
catalogueController := controllers.NewCatalogueController(catalogueService)
// Product Module
//
// The nutrition service is the catalogue-intelligence host — the same one
// behind the health score card in the console — read by the product screen
// for its nutrition panel. Nil when NUTRITION_BASE is unset, which serves
// every product screen exactly as before, without a panel.
productRepo := repositories.NewProductRepository(db)
productService := services.NewProductService(productRepo, catalogueService)
productService := services.NewProductService(
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
productController := controllers.NewProductController(productService)
// Order Module

View File

@@ -462,8 +462,12 @@ func main() {
log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address())
}
// NUTRITION_BASE is the catalogue-intelligence host — the same service the
// console reads its health score card from. Unset means product screens
// carry no nutrition panel, and nothing else changes.
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat,
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail)
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail,
os.Getenv("NUTRITION_BASE"))
routes.RegisterRoutes(app, f)

View File

@@ -26,9 +26,6 @@ type CatalogueProduct struct {
FSSAILicense string `json:"fssai_license,omitempty"`
Highlights PGStringArray `json:"highlights,omitempty"`
Nutrients PGStringArray `json:"nutrients,omitempty"`
// The structured panel, when the catalogue carries one. Nil falls back to
// parsing Nutrients above — see NutritionFromLines.
Nutrition *NutritionPanel `json:"nutrition,omitempty"`
SearchQuery string `json:"search_query,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`

164
models/healthscore.go Normal file
View File

@@ -0,0 +1,164 @@
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
}

View File

@@ -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}
}

View File

@@ -1,141 +0,0 @@
package models
import "testing"
/*
Reading the catalogue's nutrition strings.
The catalogue holds nutrition as display lines — "Energy 350kcal" — and the app
needs figures. These are the shapes those lines actually come in, and the ones
they come in when a scrape goes sideways.
The rule throughout: never invent a number, and never lose label text. A line
that cannot be read is carried whole rather than dropped, because it is
something a manufacturer printed on a packet and this code is not the authority
on what belongs on a food label.
*/
func TestAPlainLineBecomesAFigure(t *testing.T) {
panel := NutritionFromLines([]string{"Energy 350kcal"})
if !panel.HasValues() {
t.Fatal("nothing parsed")
}
got := panel.Items[0]
if got.Name != "Energy" || got.Value != 350 || got.Unit != "kcal" {
t.Fatalf("got %+v", got)
}
}
func TestTheSeparatorsThatActuallyOccur(t *testing.T) {
// Colons, multi-word names and a space before the unit are all in the wild,
// and each one used to be a whole line lost.
for _, tc := range []struct {
line string
name string
value float64
unit string
}{
{"Protein: 6.5 g", "Protein", 6.5, "g"},
{"Total Sugars 22g", "Total Sugars", 22, "g"},
{"Saturated Fat 11.5 g", "Saturated Fat", 11.5, "g"},
{"Sodium 310mg", "Sodium", 310, "mg"},
{"Energy - 520 kcal", "Energy", 520, "kcal"},
} {
panel := NutritionFromLines([]string{tc.line})
if !panel.HasValues() {
t.Errorf("%q parsed to nothing", tc.line)
continue
}
got := panel.Items[0]
if got.Name != tc.name || got.Value != tc.value || got.Unit != tc.unit {
t.Errorf("%q → %+v, want {%s %v %s}", tc.line, got, tc.name, tc.value, tc.unit)
}
}
}
func TestADecimalSurvives(t *testing.T) {
// Saturated fat is 11.5g as often as 11g. Rounding to please a type would be
// editing a food label.
panel := NutritionFromLines([]string{"Saturated Fat 11.5g"})
if panel.Items[0].Value != 11.5 {
t.Fatalf("got %v, want 11.5", panel.Items[0].Value)
}
}
func TestALineWithNoNumberIsKeptWhole(t *testing.T) {
// Real label text. Dropping it loses something a manufacturer printed;
// forcing a 0 into it states something false about the food.
panel := NutritionFromLines([]string{"Contains permitted natural colour"})
if !panel.HasValues() {
t.Fatal("the line was dropped")
}
got := panel.Items[0]
if got.Name != "Contains permitted natural colour" {
t.Fatalf("the text was mangled: %+v", got)
}
if got.Value != 0 || got.Unit != "" {
t.Fatalf("a figure was invented for a line that had none: %+v", got)
}
}
func TestAUnitlessFigureKeepsItsNumber(t *testing.T) {
// "Servings per pack 4" has a real number and no unit.
panel := NutritionFromLines([]string{"Servings per pack 4"})
got := panel.Items[0]
if got.Name != "Servings per pack" || got.Value != 4 || got.Unit != "" {
t.Fatalf("got %+v", got)
}
}
func TestPercentAndMicrogramsAreUnits(t *testing.T) {
for _, tc := range []struct{ line, unit string }{
{"Vitamin C 45%", "%"},
{"Vitamin B12 1.2µg", "µg"},
} {
panel := NutritionFromLines([]string{tc.line})
if !panel.HasValues() || panel.Items[0].Unit != tc.unit {
t.Errorf("%q → %+v, want unit %q", tc.line, panel.Items[0], tc.unit)
}
}
}
func TestBlanksAndBareNumbersAreNotRows(t *testing.T) {
// A blank is nothing. A bare "100" has no label, and an unnamed row on a
// nutrition panel is noise a shopper cannot use.
if panel := NutritionFromLines([]string{"", " ", "100"}); panel != nil {
t.Fatalf("made a panel out of nothing: %+v", panel)
}
}
func TestNoLinesMeansNoPanel(t *testing.T) {
// nil rather than an empty panel, so a caller can tell "no nutrition known"
// from "a panel that happens to be empty" — the endpoint omits the first.
if NutritionFromLines(nil) != nil {
t.Fatal("an absent panel was reported as present")
}
if NutritionFromLines([]string{}) != nil {
t.Fatal("an absent panel was reported as present")
}
}
func TestAnEmptyPanelIsNotWorthSending(t *testing.T) {
// `items: []` on a product page renders as an empty box, which reads as
// "this food has no nutrition" rather than "we do not know yet".
var absent *NutritionPanel
if absent.HasValues() {
t.Fatal("nil reported as having values")
}
if (&NutritionPanel{Per: "100g"}).HasValues() {
t.Fatal("a panel with a basis and no rows reported as having values")
}
}
func TestTheDerivedPanelDoesNotGuessItsBasis(t *testing.T) {
// The strings never carried one. "100g" is the common case and not the only
// one, and a wrong basis makes every figure beneath it a misstatement rather
// than an unknown.
panel := NutritionFromLines([]string{"Energy 350kcal"})
if panel.Per != "" || panel.Servingsize != "" {
t.Fatalf("invented a basis: per=%q servingsize=%q", panel.Per, panel.Servingsize)
}
}

View File

@@ -186,6 +186,17 @@ type Products struct {
// Set by the service, not the repository — see decorateNutrition.
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
// The health score, for the same product screen and from the same record.
//
// `gorm:"-"`, absent when there is nothing safe to show, and independent of
// Nutrition above — a product can be scored with no figures published, and
// carry figures with no score.
//
// WITHHELD on anything that is not food. The upstream per-product endpoint
// is not gated for edibility and has rated insecticide 80/100; see
// models.IsEdible.
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"`

View File

@@ -37,12 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
// catalogueProductColumns casts the text[] columns to text: GORM's raw
// scan-into-struct silently drops slice-kind destination fields, so they
// are read as text here and parsed into []string in scanProductRow.
//
// DELIBERATELY WITHOUT `nutrition`. This list is the fallback for when column
// discovery has not run, and it asserts that every column it names exists — so
// naming one that no brand table has yet would fail every read on this path
// rather than cost one field. `nutrition` lives in catalogueOptionalColumns
// below, which is what lets it ship before the agent team creates it.
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
@@ -73,12 +68,6 @@ var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{
{"fssai_license", "fssai_license", "NULL::text AS fssai_license"},
{"highlights", "highlights::text AS highlights", "NULL::text AS highlights"},
{"nutrients", "nutrients::text AS nutrients", "NULL::text AS nutrients"},
// The structured panel the agent team fills: per, serving size and typed
// rows. Optional like everything else here, and that is what makes it
// shippable before the column exists — a brand table without it reads NULL
// and falls back to parsing `nutrients` above, rather than going dark.
{"nutrition", "nutrition::text AS nutrition", "NULL::text AS nutrition"},
{"search_query", "search_query", "NULL::text AS search_query"},
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
@@ -146,7 +135,6 @@ type catalogueProductRow struct {
FSSAILicense string
Highlights string
Nutrients string
Nutrition string
SearchQuery string
CreatedAt time.Time
UpdatedAt time.Time
@@ -230,7 +218,6 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
FSSAILicense: row.FSSAILicense,
Highlights: models.ParsePGArray(row.Highlights),
Nutrients: models.ParsePGArray(row.Nutrients),
Nutrition: nutritionOf(row),
SearchQuery: row.SearchQuery,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
@@ -741,29 +728,3 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
product := row.toModel(strings.ToLower(brand))
return &product, nil
}
// nutritionOf decides which nutrition a catalogue row actually has.
//
// Two sources, and the structured one wins wherever it exists. The agent team
// fills `nutrition` — a JSON panel with the basis, the serving size and typed
// rows — and that is the one the app wants. Everything already in the catalogue
// has only `nutrients`, a text[] of display lines, so those are parsed into the
// same shape rather than left unusable.
//
// The derived panel is strictly worse: the strings never carried a basis, so it
// has no "per 100g" on it. It is still the difference between a product page
// with figures and an empty one, for every row on the platform today.
//
// A malformed `nutrition` value falls back rather than failing the read. One
// bad JSON blob in one row should cost that row its panel, not take out the
// brand listing it appears in.
func nutritionOf(row catalogueProductRow) *models.NutritionPanel {
if raw := strings.TrimSpace(row.Nutrition); raw != "" && raw != "null" {
var panel models.NutritionPanel
if err := json.Unmarshal([]byte(raw), &panel); err == nil && panel.HasValues() {
return &panel
}
log.Printf("catalogue: product %d has nutrition that could not be read; falling back to its nutrient lines", row.ID)
}
return models.NutritionFromLines(models.ParsePGArray(row.Nutrients))
}

View File

@@ -43,7 +43,7 @@ func testFacade(t *testing.T) *facade.Facade {
// No database, no catalogue, no embedder, no model. A deployment with none
// of those must still boot and say what it is missing, rather than failing
// somewhere the operator cannot see.
return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{})
return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{}, "")
}
func TestTheServerCanBeBuilt(t *testing.T) {

View File

@@ -64,17 +64,6 @@ var scalarFacts = []string{
var arrayFacts = []string{"providers", "highlights", "nutrients"}
// The structured nutrition panel: a jsonb column the agent team fills, which
// the customer app renders. Its own category because it is neither a scalar
// nor a text[] — stored as a string it would reach the app as an escaped blob
// inside the snapshot instead of an object, and every reader would have to
// decode it twice.
//
// Absent on every brand table today, which costs this fact and nothing else.
// The lines in `nutrients` above are parsed into a panel at read time, so a
// product backfilled before the column exists still shows figures.
var jsonFacts = []string{"nutrition"}
type product struct {
Productid int
Productbrand string
@@ -323,7 +312,7 @@ func factColumnsOf(db *gorm.DB, table string) []string {
}
var keep []string
for _, c := range append(append(append([]string{}, scalarFacts...), arrayFacts...), jsonFacts...) {
for _, c := range append(append([]string{}, scalarFacts...), arrayFacts...) {
if present[c] {
keep = append(keep, c)
}
@@ -343,9 +332,7 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str
selects := make([]string, 0, len(cols))
for _, c := range cols {
// jsonb and text[] are both cast to text for the same reason: GORM's
// raw scan cannot land either one in a Go value directly.
if isArrayFact(c) || isJSONFact(c) {
if isArrayFact(c) {
selects = append(selects, c+"::text AS "+c)
continue
}
@@ -369,21 +356,6 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str
if text == "" {
continue
}
if isJSONFact(c) {
// Embedded as an object, not as the text the column scanned to.
// Stored as a string it would reach the app as an escaped blob
// inside the snapshot, and every reader would decode it twice.
//
// Unreadable JSON is skipped rather than stored: an absent panel
// falls back to the nutrient lines at read time, a broken one does
// not.
var panel models.NutritionPanel
if err := json.Unmarshal([]byte(text), &panel); err != nil || !panel.HasValues() {
continue
}
facts[c] = panel
continue
}
if isArrayFact(c) {
values := models.ParsePGArray(text)
kept := make([]string, 0, len(values))
@@ -402,15 +374,6 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str
return facts, true
}
func isJSONFact(name string) bool {
for _, c := range jsonFacts {
if c == name {
return true
}
}
return false
}
func isArrayFact(name string) bool {
for _, c := range arrayFacts {
if c == name {

View File

@@ -0,0 +1,59 @@
// Proves the nutrition panel and health score end to end against the LIVE
// catalogue-intelligence service — no database, no Fiesta, just the piece that
// was built.
//
// go run ./scratch/nutritionproof # known products
// go run ./scratch/nutritionproof <brand> <image_id> # any product
//
// Prints what `getproductbyvariant` will put on the product once deployed.
package main
import (
"encoding/json"
"fmt"
"os"
"nearle/models"
"nearle/services"
)
func main() {
base := os.Getenv("NUTRITION_BASE")
if base == "" {
base = "https://mcp.nearle.ai.in/api"
}
service := services.NewNutritionService(base)
// Real products from tenant 1147, checked against the live service on
// 29 Sep 2026: one the service has scored, one it has nothing for, and one
// that is not food — which must come back with no score whatever the
// service says, because that endpoint is not gated for edibility.
products := []models.Products{
{Productid: 7101, Productbrand: "balaji", Imageid: "balaji_balaji_wafers_simply_salted_135g",
Productname: "Balaji Wafers Simply Salted 135g (scored)"},
{Productid: 7093, Productbrand: "patanjali", Imageid: "patanjali_patanjali_cow_ghee_500ml",
Productname: "Patanjali Cow Ghee 500ml (no data)"},
{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_godrej_no1_sandal_and_turmeric_soap_100g",
Productname: "Godrej No.1 Soap 100g (not food)"},
}
if len(os.Args) == 3 {
products = []models.Products{{
Productbrand: os.Args[1],
Imageid: os.Args[2],
Productname: os.Args[1] + "/" + os.Args[2],
}}
}
for i := range products {
found := service.ForProduct(products[i].Productbrand, products[i].Imageid)
products[i].Nutrition = found.Panel
products[i].Healthscore = found.Health
fmt.Printf("\n---- %s ----\n", products[i].Productname)
encoded, _ := json.MarshalIndent(map[string]any{
"nutrition": products[i].Nutrition,
"healthscore": products[i].Healthscore,
}, "", " ")
fmt.Println(string(encoded))
}
}

View File

@@ -0,0 +1,292 @@
package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"nearle/models"
)
/*
The health score on the product screen.
Most of this file is about what the score REFUSES to say. The number itself is
the service's; the judgements around it — which band, whether the match is sure
enough to state plainly, whether the product is even food — are decisions this
code makes on a shopper's behalf, and each one has a way of being wrong that
matters more than being absent.
The fixture is the service's own record for Balaji Wafers Simply Salted, read
from mcp.nearle.ai.in on 29 Sep 2026: a real score of 65.3 on a real product in
a real tenant's catalogue, matched at 0.607 confidence — below the line.
*/
const balajiJSON = `{
"data_status": "verified", "category": "Snacks",
"health_score": 65.3, "nutrition_score": 61.0, "match_confidence": 0.607,
"positive_insights": ["Good source of fibre (9.3 g per 100 g)."],
"nutritional_cautions": ["High in saturated fat (14.4 g per 100 g)."],
"diet_tags": ["Vegetarian"], "allergens": [],
"data_source": "openfoodfacts", "source_url": "https://world.openfoodfacts.org/product/1",
"calories_kcal": 545, "protein_g": 7.5, "total_fat_g": 30.7
}`
func healthOf(t *testing.T, raw string) *models.HealthScore {
t.Helper()
var source NutritionSource
if err := json.Unmarshal([]byte(raw), &source); err != nil {
t.Fatalf("fixture: %v", err)
}
return source.Health()
}
func TestARealScoreReachesTheApp(t *testing.T) {
score := healthOf(t, balajiJSON)
if score == nil {
t.Fatal("no score from a verified, edible, scored product")
}
if score.Score != 65 {
// Rounded: the service sends 65.3 and nobody reads the decimal.
t.Errorf("score = %d, want 65", score.Score)
}
if score.Band != "good" || score.Label != "Healthy" {
t.Errorf("band = %q / %q", score.Band, score.Label)
}
if len(score.Positives) != 1 || len(score.Cautions) != 1 {
t.Errorf("insights lost: %+v / %+v", score.Positives, score.Cautions)
}
if score.Source == nil || score.Source.Label != "openfoodfacts" {
t.Errorf("source lost: %+v", score.Source)
}
}
/* ── What it refuses to say ──────────────────────────────────────────────── */
func TestSomethingThatIsNotFoodGetsNoScore(t *testing.T) {
// The reason this guard exists. Measured 4 Sep 2026, the upstream
// per-product endpoint rated Godrej Hit insecticide 80/100 with
// data_status "verified"; soap and shampoo both scored 37.5 the same way.
// Those records now read "unavailable", and the guard stays: this tenant
// sells soap and toothpaste next to its biscuits.
for _, category := range []string{"Household", "Personal Care", "Insecticide", "Cosmetics"} {
raw := strings.Replace(balajiJSON, `"category": "Snacks"`, `"category": "`+category+`"`, 1)
if score := healthOf(t, raw); score != nil {
t.Errorf("%s was rated %d/100", category, score.Score)
}
}
}
func TestAnUnknownCategoryIsTreatedAsNotFood(t *testing.T) {
// An allowlist, because the two failure modes are not symmetric. Withholding
// a badge from real food costs a shopper something they never had; putting
// one on bleach is a different order of mistake.
for _, category := range []string{`null`, `""`, `"General"`, `"Miscellaneous"`} {
raw := strings.Replace(balajiJSON, `"category": "Snacks"`, `"category": `+category, 1)
if score := healthOf(t, raw); score != nil {
t.Errorf("category %s was scored %d/100", category, score.Score)
}
}
}
func TestAnUnscoredProductGetsNoScore(t *testing.T) {
// The common case: the service knows the product and has not rated it.
raw := strings.Replace(balajiJSON, `"health_score": 65.3`, `"health_score": null`, 1)
if score := healthOf(t, raw); score != nil {
t.Fatalf("invented a score: %+v", score)
}
}
/* ── Saying how sure it is ───────────────────────────────────────────────── */
func TestAWeakMatchIsDeclared(t *testing.T) {
// 0.607 is below the 0.7 line. A nutrition table presented as fact on a 61%
// match is a claim the data does not support.
score := healthOf(t, balajiJSON)
if score.Caveat == "" {
t.Fatal("a 0.607 match was presented as fact")
}
if !strings.Contains(score.Caveat, "61%") {
t.Errorf("the caveat does not say how sure: %q", score.Caveat)
}
}
func TestAStrongMatchNeedsNoApology(t *testing.T) {
raw := strings.Replace(balajiJSON, `"match_confidence": 0.607`, `"match_confidence": 0.94`, 1)
if score := healthOf(t, raw); score.Caveat != "" {
t.Errorf("apologised for a 94%% match: %q", score.Caveat)
}
}
func TestAnEmptyAllergenListOnAWeakMatchIsFlagged(t *testing.T) {
// The one that could actually hurt somebody. The service matches sources
// down to 0.32 confidence, and "verified" speaks to the numbers being real,
// not to the record being THIS product. Silence must not stand in for
// "contains none" — the app has to say "not confirmed" rather than draw
// nothing.
score := healthOf(t, balajiJSON)
if !score.Allergensunconfirmed {
t.Fatal("an empty allergen list on a 61% match was left to speak for itself")
}
}
func TestADeclaredAllergenIsAlwaysShown(t *testing.T) {
// A false positive sends somebody to read the packet. A false negative sends
// them to hospital. So a declared allergen survives a weak match.
raw := strings.Replace(balajiJSON, `"allergens": []`, `"allergens": ["Milk", "Soy"]`, 1)
score := healthOf(t, raw)
if len(score.Allergens) != 2 {
t.Fatalf("a declared allergen was dropped: %+v", score.Allergens)
}
if score.Allergensunconfirmed {
t.Error("a list with entries in it was flagged as unconfirmed")
}
}
func TestAnEmptyListOnAStrongMatchIsNotFlagged(t *testing.T) {
raw := strings.Replace(balajiJSON, `"match_confidence": 0.607`, `"match_confidence": 0.94`, 1)
if healthOf(t, raw).Allergensunconfirmed {
t.Error("flagged an empty list that the match actually supports")
}
}
/* ── Bands ───────────────────────────────────────────────────────────────── */
func TestTheBandThresholdsAreTheServices(t *testing.T) {
// Confirmed by that team in writing. The per-product endpoint sends no
// band, so these are the only path — picking our own would be one API
// disagreeing with itself depending which endpoint a screen called.
for _, tc := range []struct {
score float64
band string
label string
}{
{92, "excellent", "Very healthy"},
{80, "excellent", "Very healthy"},
{79.9, "good", "Healthy"},
{60, "good", "Healthy"},
{59.9, "fair", "Okay"},
{40, "fair", "Okay"},
{39.9, "poor", "Less healthy"},
{0, "poor", "Less healthy"},
} {
band := models.BandFor(tc.score, "")
if band != tc.band || models.BandLabel(band) != tc.label {
t.Errorf("%v → %q/%q, want %q/%q",
tc.score, band, models.BandLabel(band), tc.band, tc.label)
}
}
}
func TestTheServicesOwnBandWinsWhenItSendsOne(t *testing.T) {
// It does not today, from this endpoint. When it starts, its answer is the
// authority and the thresholds above become the fallback they were meant to
// be.
if got := models.BandFor(20, "excellent"); got != "excellent" {
t.Errorf("ignored the service's own band: %q", got)
}
if got := models.BandFor(20, " GOOD "); got != "good" {
t.Errorf("did not normalise the service's band: %q", got)
}
if got := models.BandFor(85, "nonsense"); got != "excellent" {
t.Errorf("trusted a band it does not recognise: %q", got)
}
}
/* ── On the wire ─────────────────────────────────────────────────────────── */
func TestTheScoreAndThePanelAreIndependent(t *testing.T) {
// A product can be scored with no figures published, and carry figures with
// no score. Tying them together would blank one because the other was
// missing.
noFigures := `{"category":"Snacks","health_score":65.3,"match_confidence":0.9}`
var source NutritionSource
if err := json.Unmarshal([]byte(noFigures), &source); err != nil {
t.Fatalf("fixture: %v", err)
}
if source.Panel() != nil {
t.Error("made a panel with no figures")
}
if source.Health() == nil {
t.Error("withheld a score because there were no figures")
}
}
func TestBothHalvesComeFromOneRequest(t *testing.T) {
// Splitting a record the service returns whole would double the traffic to
// a third party on a screen a shopper is waiting on.
var hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Balaji"]}`))
return
}
hits++
_, _ = w.Write([]byte(balajiJSON))
}))
defer server.Close()
found := NewNutritionService(server.URL).ForProduct("balaji", "balaji_wafers_135g")
if hits != 1 {
t.Fatalf("made %d requests for one product", hits)
}
if !found.Panel.HasValues() || found.Health == nil {
t.Fatal("one of the two halves was lost")
}
}
func TestTheWireNamesForTheScore(t *testing.T) {
row := models.Products{Productid: 7101}
row.Healthscore = healthOf(t, balajiJSON)
encoded, err := json.Marshal(row)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(encoded, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
health, ok := out["healthscore"].(map[string]any)
if !ok {
t.Fatalf("no `healthscore` key: %s", encoded)
}
for _, key := range []string{"score", "band", "label", "caveat", "allergensunconfirmed"} {
if _, ok := health[key]; !ok {
t.Errorf("missing %q: %v", key, health)
}
}
}
func TestAProductWithNoScoreHasNoKeyAtAll(t *testing.T) {
// Not `"healthscore": null`. Most products have none.
encoded, _ := json.Marshal(models.Products{Productid: 1})
var out map[string]any
_ = json.Unmarshal(encoded, &out)
if _, present := out["healthscore"]; present {
t.Fatalf("an absent score was sent as a key: %s", encoded)
}
}
func TestSoapOnTheProductScreenCarriesNoScore(t *testing.T) {
// End to end, through the decoration the endpoint actually calls. This is
// the failure a shopper would see: a health rating on a bar of soap.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Godrej"]}`))
return
}
_, _ = w.Write([]byte(strings.Replace(balajiJSON,
`"category": "Snacks"`, `"category": "Personal Care"`, 1)))
}))
defer server.Close()
rows := []models.Products{{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_soap_100g"}}
decorateNutrition(NewNutritionService(server.URL), rows)
if rows[0].Healthscore != nil {
t.Fatalf("a bar of soap was rated %d/100", rows[0].Healthscore.Score)
}
}

View File

@@ -1,148 +0,0 @@
package services
import (
"encoding/json"
"testing"
"nearle/models"
)
/*
What the product screen receives.
The app renders a nutrition panel off `getproductbyvariant`. These are about the
contract that screen is built on — the exact field names, and what happens for
the overwhelming majority of products, which have no structured nutrition at all
and are not going to for a while.
*/
func TestTheStructuredPanelIsUsedWhereItExists(t *testing.T) {
facts := `{"nutrition":{"per":"100g","servingsize":"30g","items":[
{"name":"Energy","value":520,"unit":"kcal"},
{"name":"Protein","value":6.5,"unit":"g"}]}}`
panel := nutritionFromFacts(facts)
if !panel.HasValues() {
t.Fatal("no panel")
}
if panel.Per != "100g" || panel.Servingsize != "30g" {
t.Fatalf("basis lost: per=%q servingsize=%q", panel.Per, panel.Servingsize)
}
if len(panel.Items) != 2 || panel.Items[1].Value != 6.5 {
t.Fatalf("items wrong: %+v", panel.Items)
}
}
func TestTheOldNutrientLinesStillProduceAPanel(t *testing.T) {
// This is the case that actually matters on day one: every product on the
// platform carries display lines and nothing else. Without this the app
// ships a nutrition screen that is empty for every product in every shop.
panel := nutritionFromFacts(`{"nutrients":["Energy 350kcal","Protein 7g"]}`)
if !panel.HasValues() {
t.Fatal("the lines were not used")
}
if panel.Items[0].Name != "Energy" || panel.Items[0].Value != 350 {
t.Fatalf("got %+v", panel.Items[0])
}
}
func TestStructuredBeatsLinesWhenBothArePresent(t *testing.T) {
// The import keeps both — the console shows the lines, the app shows the
// panel. The structured one is the agent team's work and carries a basis,
// so it wins.
facts := `{"nutrients":["Energy 350kcal"],
"nutrition":{"per":"100g","items":[{"name":"Energy","value":520,"unit":"kcal"}]}}`
panel := nutritionFromFacts(facts)
if panel.Per != "100g" || panel.Items[0].Value != 520 {
t.Fatalf("the derived panel won: %+v", panel)
}
}
func TestNothingKnownMeansNoPanel(t *testing.T) {
for _, facts := range []string{"", " ", "{}", `{"highlights":["Crunchy"]}`} {
if p := nutritionFromFacts(facts); p != nil {
t.Errorf("%q produced a panel: %+v", facts, p)
}
}
}
func TestAMalformedSnapshotCostsThePanelAndNothingElse(t *testing.T) {
// One bad blob must not fail the response for the variant group it is in —
// the shopper tapped a product and is owed the screen.
if p := nutritionFromFacts(`{"nutrition":{"items":`); p != nil {
t.Fatalf("read a panel out of broken JSON: %+v", p)
}
}
func TestAPanelWithNoRowsIsNotSent(t *testing.T) {
// `items: []` renders as an empty box, which a shopper reads as "this food
// has no nutrition" rather than "we do not know".
if p := nutritionFromFacts(`{"nutrition":{"per":"100g","items":[]}}`); p != nil {
t.Fatalf("sent an empty panel: %+v", p)
}
}
func TestEveryRowInAVariantGroupIsDecorated(t *testing.T) {
// A shopper switching from 500ml to 1L must not watch the nutrition vanish.
rows := []models.Products{
{Productid: 1, Cataloguefacts: `{"nutrients":["Energy 350kcal"]}`},
{Productid: 2, Cataloguefacts: `{"nutrition":{"per":"100g","items":[{"name":"Energy","value":700,"unit":"kcal"}]}}`},
{Productid: 3},
}
decorateNutrition(rows)
if !rows[0].Nutrition.HasValues() || !rows[1].Nutrition.HasValues() {
t.Fatal("a sibling was left without its panel")
}
if rows[2].Nutrition != nil {
t.Fatal("invented a panel for a product with no facts")
}
}
func TestTheWireNamesAreTheOnesTheAppAskedFor(t *testing.T) {
// An API is a promise to a client already written against it. `servingsize`
// is not how the rest of this file would spell it, and it is what was asked
// for, which outranks house style.
rows := []models.Products{{
Productid: 7093,
Cataloguefacts: `{"nutrition":{"per":"100g","servingsize":"30g","items":[{"name":"Energy","value":520,"unit":"kcal"}]}}`,
}}
decorateNutrition(rows)
encoded, err := json.Marshal(rows[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(encoded, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
nutrition, ok := out["nutrition"].(map[string]any)
if !ok {
t.Fatalf("no `nutrition` key on the product: %s", encoded)
}
for _, key := range []string{"per", "servingsize", "items"} {
if _, ok := nutrition[key]; !ok {
t.Errorf("missing %q: %v", key, nutrition)
}
}
item := nutrition["items"].([]any)[0].(map[string]any)
for _, key := range []string{"name", "value", "unit"} {
if _, ok := item[key]; !ok {
t.Errorf("item missing %q: %v", key, item)
}
}
}
func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
// Not `"nutrition": null`. Most products have none, and a null on every row
// of a mobile response is payload spent saying nothing.
encoded, _ := json.Marshal(models.Products{Productid: 1})
var out map[string]any
_ = json.Unmarshal(encoded, &out)
if _, present := out["nutrition"]; present {
t.Fatalf("an absent panel was sent as a key: %s", encoded)
}
}

View File

@@ -0,0 +1,468 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"nearle/models"
"regexp"
"sort"
"strings"
"sync"
"time"
)
/*
Nutrition for the customer app's product screen.
── Where the figures come from ─────────────────────────────────────────────
`mcp.nearle.ai.in` — the catalogue-intelligence service, the same one that
scrapes the global catalogue and backs the health score card in the console.
Not Fiesta's own database: nothing on `products` has ever held nutrition, and
the service already has it keyed by brand and image_id, which is exactly what a
tenant's product carries from import.
So this reads it rather than copying it. A second store of the same figures is a
second thing to keep in step, and the one that drifts is the one on a food label.
── Why the console does this in the browser and the app cannot ─────────────
The console calls the service directly from `api/nutrition.ts`. The customer app
could too, in principle, and should not have to: it would mean a second base
URL, a second failure mode and the brand-spelling problem below reimplemented in
whatever the app is written in. The product screen already calls Fiesta, so
Fiesta answers the whole question.
*/
// NutritionSource is the shape the catalogue-intelligence service answers with.
//
// Only the fields the panel needs. The service returns roughly forty, including
// scores, insights, allergens and diet tags — all of which belong to the health
// score card in the console and none of which the app asked for.
type NutritionSource struct {
// "verified" when the source record was confirmed, "unavailable" when the
// service knows the product and has nothing for it.
DataStatus string `json:"data_status"`
// The health score half of the same record.
HealthScore *float64 `json:"health_score"`
// Absent from this endpoint — only the list sends it — so the band is
// derived. See models.BandFor.
HealthBand string `json:"health_band"`
// What the service thinks the product IS. The edibility guard reads this,
// and it is routinely null, which the guard treats as "not food".
Category string `json:"category"`
// 0–1. How sure the service is it matched the right source record. Below
// models.LowConfidence the figures are a guide, not a fact.
MatchConfidence *float64 `json:"match_confidence"`
PositiveInsights []string `json:"positive_insights"`
NutritionalCautions []string `json:"nutritional_cautions"`
DietTags []string `json:"diet_tags"`
Allergens []string `json:"allergens"`
DataSource string `json:"data_source"`
SourceURL string `json:"source_url"`
ServingSizeLabel string `json:"serving_size_label"`
// Per 100g, every one of them. The service's own insights say so — "6.8 g
// per 100 g" — and the console prints "per 100 g" beneath the same numbers.
Calories *float64 `json:"calories_kcal"`
Protein *float64 `json:"protein_g"`
Carbohydrates *float64 `json:"carbohydrates_g"`
TotalSugar *float64 `json:"total_sugar_g"`
AddedSugar *float64 `json:"added_sugar_g"`
DietaryFiber *float64 `json:"dietary_fiber_g"`
TotalFat *float64 `json:"total_fat_g"`
SaturatedFat *float64 `json:"saturated_fat_g"`
TransFat *float64 `json:"trans_fat_g"`
Cholesterol *float64 `json:"cholesterol_mg"`
Sodium *float64 `json:"sodium_mg"`
// Whatever else the source record stated, as name → {unit, value}. The
// 5 Star record carries `{"Salt": {"unit":"g","value":0.268}}`. Carried
// through rather than filtered: it is label text, and this code is not the
// authority on what belongs on a food label.
Extended map[string]struct {
Unit string `json:"unit"`
Value float64 `json:"value"`
} `json:"extended_nutrients"`
}
// Panel maps the source record onto what the app renders.
//
// Order is the order a label prints: energy, the macros, then what the source
// added. Only fields the service actually stated — a table of dashes is worse
// than a short table, and the service genuinely omits things (`added_sugar_g`
// is null on the 5 Star record).
//
// Returns nil when nothing was stated, so "no panel" and "an empty panel" stay
// different answers.
func (s *NutritionSource) Panel() *models.NutritionPanel {
if s == nil {
return nil
}
rows := []struct {
name string
value *float64
unit string
}{
{"Energy", s.Calories, "kcal"},
{"Protein", s.Protein, "g"},
{"Carbohydrate", s.Carbohydrates, "g"},
{"Total Sugars", s.TotalSugar, "g"},
{"Added Sugars", s.AddedSugar, "g"},
{"Dietary Fibre", s.DietaryFiber, "g"},
{"Total Fat", s.TotalFat, "g"},
{"Saturated Fat", s.SaturatedFat, "g"},
{"Trans Fat", s.TransFat, "g"},
{"Cholesterol", s.Cholesterol, "mg"},
{"Sodium", s.Sodium, "mg"},
}
items := make([]models.NutritionItem, 0, len(rows)+len(s.Extended))
for _, row := range rows {
if row.value == nil {
continue
}
items = append(items, models.NutritionItem{
Name: row.name, Value: *row.value, Unit: row.unit,
})
}
// Sorted, because a Go map has no order and a nutrition panel that
// reshuffles between two requests for the same product looks broken.
for _, name := range sortedKeys(s.Extended) {
extra := s.Extended[name]
items = append(items, models.NutritionItem{
Name: name, Value: extra.Value, Unit: extra.Unit,
})
}
if len(items) == 0 {
return nil
}
return &models.NutritionPanel{
Per: "100g",
Servingsize: strings.TrimSpace(s.ServingSizeLabel),
Items: items,
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
/*
NutritionService fetches one product's panel.
── Everything here is about not hurting the product screen ─────────────────
This runs inside `getproductbyvariant`, which a shopper is waiting on, and it
calls a service Fiesta does not own. So:
- a short timeout, because a slow third party must not become a slow shop;
- a cache, because nutrition for a packaged product does not change during a
trading day and a variant group asks for several products at once;
- every failure returns nil, never an error. A product page without a
nutrition panel is a product page. A product page that 500s is not.
*/
type NutritionService interface {
// ForProduct returns the nutrition panel and the health score for one
// product. Either may be nil, independently: a product can be scored with
// no figures published, and carry figures with no score.
//
// Never returns an error: see above.
ForProduct(brand, imageID string) ProductNutrition
}
// ProductNutrition is both halves of one product's record.
//
// One value because they come from ONE request. Fetching them separately would
// double the traffic to a third party on a screen a shopper is waiting on, to
// split a record the service returns whole.
type ProductNutrition struct {
Panel *models.NutritionPanel
Health *models.HealthScore
}
// nutritionTimeout is deliberately short.
//
// The alternative is a shopper watching a spinner because somebody else's
// service is having a bad afternoon. A missing panel costs a section of one
// screen; a slow response costs the screen.
const nutritionTimeout = 3 * time.Second
// nutritionTTL is how long a fetched panel is reused.
//
// A packaged product's nutrition does not change during a trading day, and the
// console's own panel uses the same reasoning. Long enough to matter, short
// enough that a correction at the source reaches shoppers the same day.
const nutritionTTL = 6 * time.Hour
type nutritionService struct {
base string
client *http.Client
mu sync.RWMutex
cache map[string]cachedPanel
brands []string
// Zero until the brand list has been read once.
brandsAt time.Time
}
type cachedPanel struct {
value ProductNutrition
at time.Time
}
// NewNutritionService builds the client, or returns nil when no base URL is set.
//
// Nil is a working configuration, like the mailer: a deployment without the
// catalogue-intelligence service still serves every product screen, without a
// nutrition panel on it. `PanelFor` is nil-safe so no caller has to check.
func NewNutritionService(base string) NutritionService {
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
return nil
}
return &nutritionService{
base: base,
client: &http.Client{Timeout: nutritionTimeout},
cache: map[string]cachedPanel{},
}
}
func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition {
if s == nil {
return ProductNutrition{}
}
brand, imageID = strings.TrimSpace(brand), strings.TrimSpace(imageID)
if brand == "" || imageID == "" {
// No join key. Sheet-imported products have no image_id, and there is
// nothing to look up rather than something that failed to be found.
return ProductNutrition{}
}
key := brand + "/" + imageID
s.mu.RLock()
hit, ok := s.cache[key]
s.mu.RUnlock()
if ok && time.Since(hit.at) < nutritionTTL {
return hit.value
}
value := s.fetch(brand, imageID)
// A miss is cached too. Most products are not scored yet, and re-asking on
// every tap would mean the least useful answer costing the most requests.
s.mu.Lock()
s.cache[key] = cachedPanel{value: value, at: time.Now()}
s.mu.Unlock()
return value
}
func (s *nutritionService) fetch(brand, imageID string) ProductNutrition {
resolved := s.resolveBrand(brand)
var source NutritionSource
if !s.get(fmt.Sprintf("/nutrition/%s/%s",
url.PathEscape(resolved), url.PathEscape(imageID)), &source) {
return ProductNutrition{}
}
return ProductNutrition{Panel: source.Panel(), Health: source.Health()}
}
/*
resolveBrand turns our spelling of a brand into theirs.
The two catalogues agree on every brand and disagree on how to write it:
ours theirs
patanjali → Patanjali
coca_cola → Coca-Cola underscore becomes a HYPHEN
brooke_bond → Brooke Bond underscore becomes a SPACE
Which separator an underscore becomes cannot be derived — hyphen for Coca-Cola
and Colgate-Palmolive, space for everything else — so the list is fetched and
matched on a normalised form rather than guessed at.
This is not cosmetic. A wrong spelling returns a well-formed record with every
figure null, which is indistinguishable from a product nobody has scored. Get it
wrong and nutrition is simply absent, everywhere, forever, with nothing in any
log to say why.
Our own spelling is returned when theirs is unknown, so a brand they have not
listed still gets a real attempt rather than being dropped.
*/
func (s *nutritionService) resolveBrand(raw string) string {
wanted := normaliseBrand(raw)
if wanted == "" {
return raw
}
s.mu.RLock()
brands, at := s.brands, s.brandsAt
s.mu.RUnlock()
// Refreshed on the same clock as a panel: a brand list changes when the
// scraper learns a new brand, which is not often and not urgently.
if at.IsZero() || time.Since(at) > nutritionTTL {
var payload struct {
Brands []string `json:"brands"`
}
if s.get("/brands", &payload) {
brands = payload.Brands
s.mu.Lock()
s.brands, s.brandsAt = brands, time.Now()
s.mu.Unlock()
}
}
for _, candidate := range brands {
if normaliseBrand(candidate) == wanted {
return candidate
}
}
return raw
}
var brandNoise = regexp.MustCompile(`[^a-z0-9]`)
func normaliseBrand(brand string) string {
return brandNoise.ReplaceAllString(strings.ToLower(strings.TrimSpace(brand)), "")
}
// get reads one JSON document. Reports whether it got one.
//
// Every failure is a false and a log line, never an error returned upward: the
// caller's job is to draw a product screen and it can do that without this.
func (s *nutritionService) get(path string, into any) bool {
response, err := s.client.Get(s.base + path)
if err != nil {
log.Printf("nutrition: %s%s: %v", s.base, path, err)
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
// 404 is a normal answer here — the service does not know this product.
// Logged at the same level as the rest because a sudden wall of them is
// how a renamed path or a moved host gets noticed.
log.Printf("nutrition: %s%s: HTTP %d", s.base, path, response.StatusCode)
return false
}
// Capped: this is an upstream Fiesta does not control, and an unbounded
// read from one is how a memory limit gets found in production.
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
log.Printf("nutrition: %s%s: %v", s.base, path, err)
return false
}
if err := json.Unmarshal(body, into); err != nil {
log.Printf("nutrition: %s%s: malformed response: %v", s.base, path, err)
return false
}
return true
}
/*
Health returns the score, or nil when there is nothing safe to show.
── The three ways this returns nothing ─────────────────────────────────────
- the service has no score for the product, which is most of them;
- the product is not food. The per-product endpoint is not gated for
edibility and has rated insecticide 80/100. See models.IsEdible — the
guard stays even though those records now read "unavailable", because the
tenant this was built for sells soap next to its biscuits;
- there is no record at all.
All three render as "not scored yet", which is honest in every case.
── What it sends that the raw record does not ──────────────────────────────
A band, a label, and — when the match is weak — a caveat and an allergen
warning. Those are judgements, and they already exist in the console. Sending
the raw number instead would mean the app re-deriving them, and two screens
disagreeing about the same product.
*/
func (s *NutritionSource) Health() *models.HealthScore {
if s == nil || s.HealthScore == nil {
return nil
}
if !models.IsEdible(s.Category) {
return nil
}
value := *s.HealthScore
band := models.BandFor(value, s.HealthBand)
// Stated plainly only when the match supports it.
var caveat string
if s.MatchConfidence != nil && *s.MatchConfidence < models.LowConfidence {
caveat = fmt.Sprintf(
"Matched to a reference product with %d%% confidence — treat these figures as a guide.",
int(math.Round(*s.MatchConfidence*100)))
}
allergens := clean(s.Allergens)
return &models.HealthScore{
Score: int(math.Round(value)),
Band: band,
Label: models.BandLabel(band),
Positives: clean(s.PositiveInsights),
Cautions: clean(s.NutritionalCautions),
Diettags: clean(s.DietTags),
Allergens: allergens,
// An empty list is only trustworthy when the match is. Silence must not
// stand in for "contains none".
Allergensunconfirmed: len(allergens) == 0 && caveat != "",
Caveat: caveat,
Source: sourceOf(s),
}
}
func sourceOf(s *NutritionSource) *models.HealthSource {
url := strings.TrimSpace(s.SourceURL)
if url == "" {
return nil
}
label := strings.TrimSpace(s.DataSource)
if label == "" {
label = "source"
}
return &models.HealthSource{Label: label, URL: url}
}
// clean drops blanks, which the service sends for an empty ai_summary and
// others.
func clean(list []string) []string {
kept := make([]string, 0, len(list))
for _, entry := range list {
if trimmed := strings.TrimSpace(entry); trimmed != "" {
kept = append(kept, trimmed)
}
}
if len(kept) == 0 {
return nil
}
return kept
}

View File

@@ -0,0 +1,371 @@
package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"nearle/models"
)
/*
The nutrition panel on the product screen.
The fixture is the catalogue-intelligence service's own record for Cadbury
5 Star, read from mcp.nearle.ai.in on 29 Sep 2026 — including the awkward parts:
a null `added_sugar_g`, a `dietary_fiber_g` of exactly 0, and an
`extended_nutrients` map carrying its own unit.
*/
const fiveStarJSON = `{
"data_status": "verified",
"serving_size_label": "1 mini (11 g)",
"serving_size_g": 11,
"calories_kcal": 525.0, "protein_g": 6.8, "carbohydrates_g": 62.6,
"total_sugar_g": 58.8, "added_sugar_g": null, "dietary_fiber_g": 0.0,
"total_fat_g": 27.9, "saturated_fat_g": 18.7, "trans_fat_g": null,
"cholesterol_mg": null, "sodium_mg": 107,
"extended_nutrients": {"Salt": {"unit": "g", "value": 0.268}}
}`
// The answer for a product the service knows nothing about — which is most of
// them, including the Patanjali ghee this work started from.
const unavailableJSON = `{
"data_status": "unavailable", "serving_size_label": null,
"calories_kcal": null, "protein_g": null, "sodium_mg": null,
"extended_nutrients": null
}`
func panelOf(t *testing.T, raw string) *models.NutritionPanel {
t.Helper()
var source NutritionSource
if err := json.Unmarshal([]byte(raw), &source); err != nil {
t.Fatalf("fixture: %v", err)
}
return source.Panel()
}
func TestARealRecordBecomesTheAppsPanel(t *testing.T) {
panel := panelOf(t, fiveStarJSON)
if !panel.HasValues() {
t.Fatal("no panel from a verified record")
}
if panel.Per != "100g" {
// The service's top-level figures are per 100g — its own insights say
// "6.8 g per 100 g" and the console prints the same basis.
t.Errorf("per = %q, want 100g", panel.Per)
}
if panel.Servingsize != "1 mini (11 g)" {
t.Errorf("servingsize = %q", panel.Servingsize)
}
want := map[string][2]any{
"Energy": {525.0, "kcal"},
"Protein": {6.8, "g"},
"Carbohydrate": {62.6, "g"},
"Total Sugars": {58.8, "g"},
"Dietary Fibre": {0.0, "g"},
"Total Fat": {27.9, "g"},
"Saturated Fat": {18.7, "g"},
"Sodium": {107.0, "mg"},
"Salt": {0.268, "g"},
}
got := map[string][2]any{}
for _, item := range panel.Items {
got[item.Name] = [2]any{item.Value, item.Unit}
}
for name, expected := range want {
if got[name] != expected {
t.Errorf("%s = %v, want %v", name, got[name], expected)
}
}
if len(panel.Items) != len(want) {
t.Errorf("got %d rows, want %d: %+v", len(panel.Items), len(want), panel.Items)
}
}
func TestAFieldTheServiceDidNotStateIsNotARow(t *testing.T) {
// `added_sugar_g` is null on this record. A table of dashes is worse than a
// shorter table, and a 0 would claim the product has no added sugar.
for _, item := range panelOf(t, fiveStarJSON).Items {
if item.Name == "Added Sugars" || item.Name == "Trans Fat" || item.Name == "Cholesterol" {
t.Errorf("invented a row the service left null: %+v", item)
}
}
}
func TestAGenuineZeroIsKept(t *testing.T) {
// `dietary_fiber_g` is 0.0, not null. "No fibre" is a fact the label states
// and dropping it would lose it — the opposite of the null case above.
var found bool
for _, item := range panelOf(t, fiveStarJSON).Items {
if item.Name == "Dietary Fibre" {
found = true
if item.Value != 0 {
t.Errorf("got %v, want 0", item.Value)
}
}
}
if !found {
t.Error("a stated zero was dropped")
}
}
func TestAnUnavailableProductGetsNoPanel(t *testing.T) {
// The common case today: the service knows the product and has nothing. An
// empty panel on the screen reads as "this food has no nutrition".
if panel := panelOf(t, unavailableJSON); panel != nil {
t.Fatalf("made a panel out of nulls: %+v", panel)
}
}
func TestTheRowOrderIsStable(t *testing.T) {
// `extended_nutrients` is a map, and Go map order is randomised. A panel
// that reshuffles between two requests for the same product looks broken.
first := panelOf(t, fiveStarJSON)
for i := 0; i < 20; i++ {
next := panelOf(t, fiveStarJSON)
for j := range first.Items {
if next.Items[j].Name != first.Items[j].Name {
t.Fatalf("row %d moved: %s then %s", j, first.Items[j].Name, next.Items[j].Name)
}
}
}
}
/* ── The client ──────────────────────────────────────────────────────────── */
func TestTheBrandSpellingIsResolvedBeforeTheLookup(t *testing.T) {
// Ours is `patanjali`, theirs is `Patanjali`. A wrong spelling returns a
// well-formed record with every figure null — indistinguishable from an
// unscored product — so this failing is silent and total.
var asked string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Patanjali","Coca-Cola","Brooke Bond"]}`))
return
}
asked = r.URL.Path
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
if found := service.ForProduct("patanjali", "patanjali_cow_ghee_500ml"); !found.Panel.HasValues() {
t.Fatal("no panel")
}
if asked != "/nutrition/Patanjali/patanjali_cow_ghee_500ml" {
t.Fatalf("asked for %q", asked)
}
}
func TestAnUnderscoreIsNotGuessedAt(t *testing.T) {
// It becomes a hyphen for Coca-Cola and a space for Brooke Bond. Which one
// cannot be derived, so the list is matched rather than transformed.
var asked string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Coca-Cola","Brooke Bond"]}`))
return
}
asked = r.URL.Path
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
NewNutritionService(server.URL).ForProduct("brooke_bond", "bb_tea_250g")
if asked != "/nutrition/Brooke Bond/bb_tea_250g" {
t.Fatalf("asked for %q", asked)
}
}
func TestAnUnknownBrandStillGetsATry(t *testing.T) {
// A brand they have not listed yet is attempted with our spelling rather
// than dropped — the list is their vocabulary, not a gate.
var asked string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Amul"]}`))
return
}
asked = r.URL.Path
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
NewNutritionService(server.URL).ForProduct("newbrand", "nb_thing_1kg")
if asked != "/nutrition/newbrand/nb_thing_1kg" {
t.Fatalf("asked for %q", asked)
}
}
func TestAProductScreenSurvivesTheServiceBeingDown(t *testing.T) {
// The whole reason this returns a panel and never an error. A shopper
// tapped a product; they are owed the screen with or without nutrition.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
server.Close() // refused connections, not merely 500s
if found := NewNutritionService(server.URL).ForProduct("cadbury", "x"); found.Panel != nil {
t.Fatalf("got a panel from a dead service: %+v", found.Panel)
}
}
func TestAMalformedResponseIsNotAPanel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":[]}`))
return
}
_, _ = w.Write([]byte(`{"calories_kcal":`))
}))
defer server.Close()
if found := NewNutritionService(server.URL).ForProduct("cadbury", "x"); found.Panel != nil {
t.Fatalf("read a panel out of broken JSON: %+v", found.Panel)
}
}
func TestTheSameProductIsNotFetchedTwice(t *testing.T) {
// This runs on a screen a shopper is waiting on, once per member of a
// variant group. Nutrition for a packaged product does not change during a
// trading day.
var hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Cadbury"]}`))
return
}
hits++
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
for i := 0; i < 5; i++ {
service.ForProduct("cadbury", "cadbury_5_star_200g")
}
if hits != 1 {
t.Fatalf("fetched %d times, want 1", hits)
}
}
func TestAMissIsCachedToo(t *testing.T) {
// Most products are unscored. Re-asking on every tap would mean the least
// useful answer costing the most requests.
var hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Patanjali"]}`))
return
}
hits++
_, _ = w.Write([]byte(unavailableJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
for i := 0; i < 4; i++ {
service.ForProduct("patanjali", "patanjali_cow_ghee_500ml")
}
if hits != 1 {
t.Fatalf("fetched %d times, want 1", hits)
}
}
func TestAProductWithNoJoinKeyIsNotLookedUp(t *testing.T) {
// Sheet-imported products have no image_id. There is nothing to look up,
// which is different from something that failed to be found.
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Error("called the service for a product with no image_id")
}))
defer server.Close()
service := NewNutritionService(server.URL)
service.ForProduct("cadbury", "")
service.ForProduct("", "some_image_id")
}
func TestNoServiceConfiguredIsNotACrash(t *testing.T) {
// Nil is a working configuration: a deployment without the service serves
// every product screen, without a panel.
if NewNutritionService("") != nil {
t.Fatal("built a client with no base URL")
}
rows := []models.Products{{Productid: 1, Productbrand: "cadbury", Imageid: "x"}}
decorateNutrition(nil, rows) // must not panic
if rows[0].Nutrition != nil {
t.Fatal("invented a panel with no service")
}
}
func TestEverySiblingInAVariantGroupIsDecorated(t *testing.T) {
// A shopper switching from 500ml to 1L must not watch the panel vanish.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Cadbury"]}`))
return
}
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
rows := []models.Products{
{Productid: 1, Productbrand: "cadbury", Imageid: "a"},
{Productid: 2, Productbrand: "cadbury", Imageid: "b"},
{Productid: 3, Productbrand: "cadbury", Imageid: ""},
}
decorateNutrition(NewNutritionService(server.URL), rows)
if !rows[0].Nutrition.HasValues() || !rows[1].Nutrition.HasValues() {
t.Fatal("a sibling lost its panel")
}
if rows[2].Nutrition != nil {
t.Fatal("invented a panel for a product with no join key")
}
}
func TestTheWireNamesAreTheOnesTheAppAskedFor(t *testing.T) {
// An API is a promise to a client already written against it. `servingsize`
// is not how the rest of this codebase would spell it, and it is what was
// asked for, which outranks house style.
rows := []models.Products{{Productid: 7093}}
rows[0].Nutrition = panelOf(t, fiveStarJSON)
encoded, err := json.Marshal(rows[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(encoded, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
nutrition, ok := out["nutrition"].(map[string]any)
if !ok {
t.Fatalf("no `nutrition` key: %s", encoded)
}
for _, key := range []string{"per", "servingsize", "items"} {
if _, ok := nutrition[key]; !ok {
t.Errorf("missing %q", key)
}
}
item := nutrition["items"].([]any)[0].(map[string]any)
for _, key := range []string{"name", "value", "unit"} {
if _, ok := item[key]; !ok {
t.Errorf("item missing %q", key)
}
}
}
func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
// Not `"nutrition": null`. Most products have none, and a null on every row
// is payload spent saying nothing.
encoded, _ := json.Marshal(models.Products{Productid: 1})
var out map[string]any
_ = json.Unmarshal(encoded, &out)
if _, present := out["nutrition"]; present {
t.Fatalf("an absent panel was sent as a key: %s", encoded)
}
}

View File

@@ -50,10 +50,17 @@ type ProductService interface {
type productService struct {
repo repositories.ProductRepository
catalogueService CatalogueService
// May be nil. A deployment without the catalogue-intelligence service still
// serves every product screen, without a nutrition panel on it.
nutrition NutritionService
}
func NewProductService(repo repositories.ProductRepository, catalogueService CatalogueService) ProductService {
return &productService{repo: repo, catalogueService: catalogueService}
func NewProductService(
repo repositories.ProductRepository,
catalogueService CatalogueService,
nutrition NutritionService,
) ProductService {
return &productService{repo: repo, catalogueService: catalogueService, nutrition: nutrition}
}
func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) {
@@ -251,7 +258,7 @@ func (s *productService) GetProductByVariant(tenantid, variantid, locationid, pr
// `productstock` on each row is what the app disables on. A single ungrouped
// product comes back as a one-member group, so the app has one code path:
// count the rows, and skip the picker when there is only one.
decorateNutrition(result)
decorateNutrition(s.nutrition, result)
return result, nil
}
@@ -375,19 +382,6 @@ func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
putList("highlights", p.Highlights)
putList("nutrients", p.Nutrients)
// The structured panel, snapshotted like everything else here.
//
// Kept BESIDE `nutrients` rather than instead of it. The lines are what the
// console has always shown and what a re-scrape can still change; the panel
// is what the app renders. Dropping either would break a reader that exists.
//
// `Nutrition` is already the resolved one — structured where the catalogue
// has it, derived from the lines where it does not — so a product imported
// today carries a panel whether or not the agent team has reached its brand.
if p.Nutrition.HasValues() {
facts["nutrition"] = p.Nutrition
}
return facts
}
@@ -634,61 +628,34 @@ func (s *productService) UnpublishProduct(tenantID, productID int) (int, error)
}
/*
decorateNutrition unpacks each product's nutrition panel for the app.
decorateNutrition attaches each product's nutrition panel.
── Why the endpoint does this and the query does not ───────────────────────
The panel lives inside `cataloguefacts`, a jsonb blob the import writes to hold
everything the catalogue knew that `products` has no column for. Reading it in
SQL would mean a jsonb path expression inside a query that already carries four
correlated subqueries, and it would have to be repeated in every read that ever
wants nutrition. Unpacking it once, here, keeps the query about the shelf.
The figures are not in Fiesta's database. They live in the catalogue-
intelligence service, keyed by brand and image_id — the two things a tenant's
product already carries from import — so there is nothing to join to and nothing
for the SQL to select. See nutritionService.go.
── The two shapes it accepts ───────────────────────────────────────────────
── Why a loop of network calls is acceptable here and nowhere else ─────────
`nutrition` is the structured panel and is used wherever it exists. `nutrients`
is the older text[] of display lines — "Energy 350kcal" — which is what every
product on the platform actually carries today, and it is parsed into the same
shape rather than left unreadable by the app.
This endpoint answers one tapped product plus its variant siblings: one row for
most products, a handful for a size family. Never a page of sixty. Each lookup
is cached for six hours and capped at three seconds, and a failure costs that
product its panel and nothing else.
So a product shows figures if ANYTHING is known about it, and the field is
absent only when genuinely nothing is.
── What it will not do ─────────────────────────────────────────────────────
It does not go back to the catalogue DB for a product whose snapshot is empty.
That is a cross-database lookup on a hot mobile endpoint, per product, on a
screen a shopper is waiting for. Products imported before `cataloguefacts`
existed have empty snapshots — measured: all 15 catalogue-imported products on
tenant 1147 — and the fix for those is the backfill in
`scratch/nutritionbackfill`, run once, not a lookup paid for on every tap.
It is deliberately NOT on the browse lists. Sixty products would be sixty
lookups on a screen that shows no nutrition anyway.
*/
func decorateNutrition(products []models.Products) {
func decorateNutrition(nutrition NutritionService, products []models.Products) {
if nutrition == nil {
return
}
for i := range products {
products[i].Nutrition = nutritionFromFacts(products[i].Cataloguefacts)
// One call per product, not two: the panel and the score come from the
// same upstream record.
found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid)
products[i].Nutrition = found.Panel
products[i].Healthscore = found.Health
}
}
// nutritionFromFacts reads a panel out of one product's catalogue snapshot.
//
// Every failure returns nil rather than an error: the product screen is worth
// drawing without a nutrition panel, and a malformed blob on one row must not
// fail the response for the variant group it belongs to.
func nutritionFromFacts(raw string) *models.NutritionPanel {
if strings.TrimSpace(raw) == "" {
return nil
}
var facts struct {
Nutrition *models.NutritionPanel `json:"nutrition"`
Nutrients []string `json:"nutrients"`
}
if err := json.Unmarshal([]byte(raw), &facts); err != nil {
return nil
}
if facts.Nutrition.HasValues() {
return facts.Nutrition
}
return models.NutritionFromLines(facts.Nutrients)
}

View File

@@ -170,7 +170,7 @@ func indexOf(calls []string, want string) int {
// showed the stock climbing and the app never listed the product.
func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135,
@@ -200,7 +200,7 @@ func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
// a freshly created row reading "outofstock" until the NEXT delivery.
func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
if err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 25, Stocktype: "in",
@@ -225,7 +225,7 @@ func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
// correctly derived as outofstock — not be silently dropped.
func TestAnOutwardMovementAlsoShelves(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
if err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 3, Stocktype: "out",
@@ -245,7 +245,7 @@ func TestReimportingCorrectsAnUncategorisedProduct(t *testing.T) {
repo.existing = &models.Products{Productid: 7086, Tenantid: 1135, Categoryid: 0}
svc := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 42, Brand: "cadbury", ProductName: "Cadbury Dairy Milk 100g"},
})
}, nil)
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1135, Locationid: 1170, Brand: "cadbury", Catalogueid: 42,
@@ -272,7 +272,7 @@ func TestReimportWithoutACategoryLeavesTheExistingOneAlone(t *testing.T) {
repo.existing = &models.Products{Productid: 7043, Tenantid: 1135, Categoryid: 2}
svc := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 9, Brand: "generic", ProductName: "Apple"},
})
}, nil)
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1135, Locationid: 1170, Brand: "generic", Catalogueid: 9,
@@ -383,7 +383,7 @@ func (f *fakeProductRepo) GetProducts(params models.ProductFilter) ([]models.Pro
// hits and asserts on what a shopper would be shown.
func TestTheBrowseEndpointOffersOnlyStockedProducts(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
CategoryID: 2, TenantID: 1135, LocationID: 1170,
@@ -434,7 +434,7 @@ func TestOrderingAnUngroupedProductAsksByProductId(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7086, Productname: "Cadbury Dairy Milk 100g", Productstock: 25},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1135, 0, 1170, 7086)
if err != nil {
@@ -455,7 +455,7 @@ func TestOrderingAGroupedProductOffersEveryVariant(t *testing.T) {
{Productid: 6995, Productname: "Strawberries 250g", Productstock: 4},
{Productid: 6996, Productname: "Strawberries 500g", Productstock: 9},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1087, 44, 1097, 0)
if err != nil {
@@ -478,7 +478,7 @@ func TestTheProductScreenStillShowsAnEmptyProduct(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7085, Productname: "Cadbury 5 Star 18g", Productstock: 0},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1135, 0, 1170, 7085)
if err != nil {
@@ -502,7 +502,7 @@ func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
{Productid: 6994, Productname: "Pineapple", Productstock: 50},
{Productid: 6989, Productname: "Jammu Apple", Productstock: 0},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1087, 36, 1097, 7014)
if err != nil {
@@ -526,7 +526,7 @@ func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
// still does not. The two endpoints deliberately differ.
func TestBrowseStillHidesWhatTheShopDoesNotHave(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
CategoryID: 2, TenantID: 1135, LocationID: 1170,
@@ -565,7 +565,7 @@ func TestImportPublishesWhatItPriced(t *testing.T) {
ID: 1, Brand: "kohinoor", ProductName: "Kohinoor Charminar Rice 5kg",
ImageID: "kohinoor_kohinoor_charminar_rice_5kg",
},
})
}, nil)
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1147, Locationid: 1185, Brand: "kohinoor", Catalogueid: 1,
@@ -592,7 +592,7 @@ func TestImportPublishesAfterTheRowsExist(t *testing.T) {
repo := newFakeRepo()
service := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 1, Brand: "colin", ProductName: "Colin Glass Cleaner 500ml"},
})
}, nil)
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1147, Locationid: 1185, Brand: "colin", Catalogueid: 1,
@@ -789,7 +789,7 @@ func TestPricingFilterDoesNotDisturbTheCallersSlice(t *testing.T) {
func TestCreateProductReturnsTheIdTheDatabaseAssigned(t *testing.T) {
repo := &fakeProductRepo{}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
created, err := svc.CreateProduct(models.Products{
Tenantid: 9001,
@@ -818,7 +818,7 @@ func TestCreateProductGoesThroughTheOneCreatePath(t *testing.T) {
// discarded the id. Only one remains, and this is what pins that: a second
// path would have to be added here to be used at all.
repo := &fakeProductRepo{}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
if _, err := svc.CreateProduct(models.Products{Tenantid: 9001}); err != nil {
t.Fatalf("CreateProduct: %v", err)
@@ -834,7 +834,7 @@ func TestAFailedCreateReturnsNoProduct(t *testing.T) {
// product with a zero id would be worse than an error — it would send a
// price and a stock movement to product 0.
repo := &fakeProductRepo{createErr: errors.New("duplicate key")}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
created, err := svc.CreateProduct(models.Products{Tenantid: 9001, Productsku: "DUP"})
if err == nil {