Krish264 commited on
Commit
89d9642
·
0 Parent(s):

Deploy: Pin Streamlit to 1.61.1; add deployment parity tests

Browse files
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #data/*.csv filter=lfs diff=lfs merge=lfs -text
2
+ #embeddings/*.npy filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Built catalog artifacts — rebuild with pipeline/, or pull from the Hub
2
+ data/*.duckdb
3
+ data/*.duckdb.wal
4
+ data/*.parquet
5
+
6
+ # Secrets — never commit. Set via HF Space "Repository secrets".
7
+ .streamlit/secrets.toml
8
+ .env
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ .venv/
14
+ env/
15
+ .pytest_cache/
16
+ *.egg-info/
17
+
18
+ # macOS
19
+ .DS_Store
.streamlit/config.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [server]
2
+ headless = true
3
+
4
+ [browser]
5
+ gatherUsageStats = false
6
+
7
+ [theme]
8
+ base = "light"
9
+ primaryColor = "#0F8A5F"
10
+ backgroundColor = "#FBFBF9"
11
+ secondaryBackgroundColor = "#FFFFFF"
12
+ textColor = "#1A1F1C"
13
+ font = "sans serif"
README.md ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NutriWeb
3
+ emoji: 🥗
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: streamlit
7
+ sdk_version: 1.61.1
8
+ app_file: app.py
9
+ pinned: false
10
+ license: agpl-3.0
11
+ ---
12
+
13
+ # NutriWeb
14
+
15
+ Find a packaged food, and get a **healthier alternative of the same kind** that
16
+ respects your allergens, your diet and your health profile.
17
+
18
+ Built on the full [Open Food Facts product
19
+ database](https://huggingface.co/datasets/openfoodfacts/product-database)
20
+ (4.66M products), filtered to **796,542 US products**.
21
+
22
+ ---
23
+
24
+ ## Why this was rebuilt
25
+
26
+ The previous version ran on a hand-cleaned CSV subset of unknown provenance,
27
+ downloaded from Google Drive at startup. Its recommendations weren't
28
+ trustworthy, and the data turned out to be only one of three causes:
29
+
30
+ | Problem | Old approach | Now |
31
+ |---|---|---|
32
+ | **Coverage** | A static CSV subset, no lineage | 796,542 US products from the live upstream dataset, rebuildable in one command |
33
+ | **Ingredient / allergen logic** | ~90 hardcoded substrings matched with `if key in name`, so `"sugar"` fired on *"sugar-free"* | Exact matching on Open Food Facts' canonical `allergens_tags`, `additives_tags`, `ingredients_analysis_tags` |
34
+ | **Additive risk** | A hand-written opinion list | EFSA overexposure ratings and the ANSES watch list, via the OFF additives taxonomy |
35
+ | **Retrieval** | FAISS index rebuilt **on every query**, with a positional slice applied to a filtered frame that misaligned scores against products | Category-constrained SQL candidate generation, ranked in NumPy |
36
+ | **Relevance** | No category constraint — a soda could return a candy bar | Candidates must share a category tag; regression-tested |
37
+ | **Blood-pressure rule** | `sodium_100g > 5.0` — five *grams* per 100 g, a level no food reaches, so it never fired | FDA-derived per-100g ceilings for salt and saturated fat |
38
+ | **Passwords** | Stored and compared in plaintext | bcrypt via passlib |
39
+ | **Dependencies** | torch + transformers + faiss ≈ 2 GB | None of them; ~40 MB of pure Python and DuckDB |
40
+
41
+ ## What it does
42
+
43
+ **Health score (0–100)** — the single quantity used for ranking:
44
+ 70% Nutri-Score 2023 + 30% NOVA processing group, minus a capped penalty for
45
+ additives flagged by EFSA or ANSES.
46
+
47
+ **Nutri-Score, computed where Open Food Facts has none.** Only 44% of US
48
+ products carry an OFF grade, because Nutri-Score is a European scheme and OFF
49
+ usually cannot assign one without a category. NutriWeb ports the official 2023
50
+ algorithm from OFF's own reference implementation and computes the rest:
51
+
52
+ | | products | share |
53
+ |---|---|---|
54
+ | Published by Open Food Facts | 352,295 | 44.2% |
55
+ | **Computed by NutriWeb** | **290,908** | **36.5%** |
56
+ | **Total graded** | **643,203** | **80.7%** |
57
+
58
+ Validated against OFF's own grades on **317,265 products where both exist**:
59
+ **98.37% exact agreement**, 99.73% within one letter, 95.99% exact numeric score.
60
+
61
+ Grades are never silently merged — the UI labels every grade with its source,
62
+ and grades computed for products with no category are marked *low confidence*.
63
+
64
+ **Recommendations** — three stages:
65
+
66
+ 1. **Candidates** — products sharing a category tag, choosing the most specific
67
+ tag whose pool clears a minimum size. Falls back to ingredient-tag overlap
68
+ for the 49% of US products with no category.
69
+ 2. **Hard filters** — allergens (including traces), diet, and health-condition
70
+ ceilings, applied in SQL *before* the limit so the pool is filtered, not truncated.
71
+ These exclude; they are never traded off against a good score.
72
+ 3. **Ranking** — 45% health gain, 30% macro similarity, 20% ingredient overlap,
73
+ 5% popularity. Every component is shown to the user.
74
+
75
+ ## Running it
76
+
77
+ ```bash
78
+ python -m venv .venv && source .venv/bin/activate
79
+ pip install -r requirements.txt
80
+
81
+ # Build the catalog (one-off; ~7.2 GB download, then about a minute)
82
+ python pipeline/01_download.py # fetch food.parquet from the Hub
83
+ python pipeline/02_curate.py # filter, flatten, pivot -> DuckDB
84
+ python pipeline/03_score.py # Nutri-Score + health score + validation
85
+ python pipeline/04_audit.py # coverage report — read this
86
+
87
+ streamlit run app.py
88
+ ```
89
+
90
+ `01_download.py` needs a Hugging Face token (`hf auth login`, or `HF_TOKEN`):
91
+ anonymous requests get rate-limited on a file that size.
92
+
93
+ ### Deploying to Spaces
94
+
95
+ Publish the built catalog so the Space doesn't rebuild from 7.2 GB on cold start:
96
+
97
+ ```bash
98
+ python pipeline/05_publish.py <your-username>/nutriweb-us-catalog
99
+ ```
100
+
101
+ Then set on the Space:
102
+ - `NUTRIWEB_CATALOG_REPO` = `<your-username>/nutriweb-us-catalog`
103
+ - `MONGODB_URI` *(optional)* — as a **repository secret**, never in a file.
104
+ Without it the app runs with session-only profiles.
105
+
106
+ ## Layout
107
+
108
+ ```
109
+ app.py entry point; st.navigation
110
+ views/ search · product · recommend · compare · profile · insights
111
+ components/ badges, cards, nutrient tables
112
+ nutriweb/
113
+ data/catalog.py DuckDB access, FTS search
114
+ scoring/ nutriscore.py (2023 algorithm) · health_score.py · additives.py
115
+ reco/ engine.py · filters.py · similarity.py
116
+ profile/ model.py · auth.py
117
+ pipeline/ 01_download → 02_curate → 03_score → 04_audit → 05_publish
118
+ tests/ 64 tests: algorithm, filters, engine
119
+ ```
120
+
121
+ ## Tests
122
+
123
+ ```bash
124
+ pytest
125
+ ```
126
+
127
+ Covers Nutri-Score variant selection and grade boundaries, the safety filters
128
+ (an allergen must never reach a result), and engine guarantees — every
129
+ recommendation is strictly healthier, shares the source category, and a soda
130
+ never returns confectionery.
131
+
132
+ ## Data and licence
133
+
134
+ Product data © Open Food Facts contributors, licensed
135
+ [ODbL](https://opendatacommons.org/licenses/odbl/). The Nutri-Score
136
+ implementation is ported from
137
+ [openfoodfacts-server](https://github.com/openfoodfacts/openfoodfacts-server)
138
+ (`lib/ProductOpener/Nutriscore.pm`), AGPL-3.0. This project is AGPL-3.0.
139
+
140
+ NutriWeb is an informational tool, not medical or dietary advice.
app.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NutriWeb — entry point.
2
+
3
+ Replaces the previous single 450-line script that routed pages by mutating
4
+ `st.session_state.page` and calling `st.stop()` at the end of each branch. This
5
+ uses Streamlit's own navigation, so the browser URL, the back button and the
6
+ sidebar all behave normally.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import streamlit as st
15
+
16
+ REPO_ROOT = Path(__file__).resolve().parent
17
+ if str(REPO_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(REPO_ROOT))
19
+
20
+ st.set_page_config(
21
+ page_title="NutriWeb",
22
+ page_icon="🥗",
23
+ layout="wide",
24
+ initial_sidebar_state="expanded",
25
+ )
26
+
27
+
28
+ @st.cache_resource
29
+ def _styles() -> str:
30
+ return (REPO_ROOT / "styles.css").read_text()
31
+
32
+
33
+ st.markdown(f"<style>{_styles()}</style>", unsafe_allow_html=True)
34
+
35
+ from nutriweb.profile.model import UserProfile # noqa: E402
36
+ from views import state # noqa: E402
37
+
38
+ state.init()
39
+
40
+ pages = [
41
+ st.Page("views/search.py", title="Search", icon=":material/search:", default=True),
42
+ st.Page("views/product.py", title="Product", icon=":material/nutrition:"),
43
+ st.Page("views/recommend.py", title="Healthier swaps", icon=":material/swap_horiz:"),
44
+ st.Page("views/compare.py", title="Compare", icon=":material/compare_arrows:"),
45
+ st.Page("views/profile_page.py", title="My profile", icon=":material/person:"),
46
+ st.Page("views/insights.py", title="Data insights", icon=":material/insights:"),
47
+ ]
48
+
49
+ with st.sidebar:
50
+ st.markdown("### 🥗 NutriWeb")
51
+ st.caption("Personalised food choices, from the full Open Food Facts US catalog.")
52
+ profile: UserProfile = st.session_state.profile
53
+ if profile.user_id:
54
+ st.success(f"Signed in as **{profile.user_id}**")
55
+ if st.button("Sign out", use_container_width=True):
56
+ state.sign_out()
57
+ st.rerun()
58
+ else:
59
+ st.info("Browsing as a guest. Set a profile to get personalised results.")
60
+
61
+ st.navigation(pages).run()
components/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Reusable UI components."""
components/badges.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score badges, chips and meters — the small visual vocabulary of the app.
2
+
3
+ Everything returns an HTML string rather than calling st.* directly, so the
4
+ pieces compose inside cards and columns.
5
+
6
+ A recurring rule here: whenever a Nutri-Score grade is shown, its *source* is
7
+ shown with it. Roughly 45% of the grades in the catalog were computed by
8
+ NutriWeb rather than published by Open Food Facts, and presenting the two
9
+ identically would overstate what we know.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import html
15
+
16
+ from nutriweb.util import num, tag_set
17
+
18
+ GRADE_LABEL = {
19
+ "a": "A", "b": "B", "c": "C", "d": "D", "e": "E",
20
+ }
21
+ NOVA_LABEL = {
22
+ 1: "Unprocessed",
23
+ 2: "Culinary ingredient",
24
+ 3: "Processed",
25
+ 4: "Ultra-processed",
26
+ }
27
+
28
+
29
+ def esc(value) -> str:
30
+ return html.escape(str(value)) if value is not None else ""
31
+
32
+
33
+ def nutriscore_badge(grade: str | None, source: str | None = None) -> str:
34
+ """Nutri-Score pill. `source` is 'off' or 'nutriweb'."""
35
+ if not grade or str(grade).lower() not in GRADE_LABEL:
36
+ return '<span class="nw-badge nw-badge-neutral">Nutri-Score n/a</span>'
37
+ g = str(grade).lower()
38
+ chip = ""
39
+ if source == "nutriweb":
40
+ chip = '<span class="nw-source" title="Computed by NutriWeb from the nutrition facts, because Open Food Facts has no grade for this product">est.</span>'
41
+ elif source == "off":
42
+ chip = '<span class="nw-source" title="Published by Open Food Facts">OFF</span>'
43
+ return (
44
+ f'<span class="nw-badge ns-{g}"><span class="k">Nutri-Score</span> '
45
+ f"{GRADE_LABEL[g]}</span>{chip}"
46
+ )
47
+
48
+
49
+ def nova_badge(nova) -> str:
50
+ value = num(nova)
51
+ if value is None or int(value) not in NOVA_LABEL:
52
+ return ""
53
+ n = int(value)
54
+ tone = {1: "chip-good", 2: "chip-good", 3: "chip-warn", 4: "chip-danger"}[n]
55
+ return f'<span class="nw-chip {tone}" title="NOVA {n}">{NOVA_LABEL[n]}</span>'
56
+
57
+
58
+ def eco_badge(grade) -> str:
59
+ if not grade or str(grade).lower() not in GRADE_LABEL:
60
+ return ""
61
+ g = str(grade).lower()
62
+ return f'<span class="nw-badge ns-{g}"><span class="k">Eco</span> {GRADE_LABEL[g]}</span>'
63
+
64
+
65
+ def badge_row(product: dict) -> str:
66
+ parts = [
67
+ nutriscore_badge(product.get("nutriscore_grade"), product.get("nutriscore_source")),
68
+ eco_badge(product.get("environmental_score_grade")),
69
+ nova_badge(product.get("nova_group")),
70
+ ]
71
+ return '<div class="nw-badges">' + "".join(p for p in parts if p) + "</div>"
72
+
73
+
74
+ def health_meter(score, confidence: str | None = None) -> str:
75
+ """The 0-100 NutriWeb health score with a coloured bar."""
76
+ value = num(score)
77
+ if value is None:
78
+ return '<div class="nw-health"><span class="m">Not enough data to score</span></div>'
79
+
80
+ colour = (
81
+ "var(--ns-a)" if value >= 75
82
+ else "var(--ns-b)" if value >= 55
83
+ else "var(--ns-c)" if value >= 40
84
+ else "var(--ns-d)" if value >= 25
85
+ else "var(--ns-e)"
86
+ )
87
+ note = f" · {esc(confidence)} confidence" if confidence else ""
88
+ return (
89
+ '<div class="nw-health">'
90
+ f'<span class="v">{value:.0f}</span><span class="m">/100 health score{note}</span>'
91
+ "</div>"
92
+ f'<div class="nw-meter"><span style="width:{max(2, min(100, value)):.0f}%;'
93
+ f'background:{colour}"></span></div>'
94
+ )
95
+
96
+
97
+ def thumb(product: dict) -> str:
98
+ url = product.get("image_url")
99
+ if url and not isinstance(url, float):
100
+ return f'<img class="nw-thumb" src="{esc(url)}" loading="lazy" alt="">'
101
+ return '<div class="nw-thumb nw-thumb-empty">🥫</div>'
102
+
103
+
104
+ def chips(tags, tone: str = "", limit: int = 12, strip_prefix: bool = True) -> str:
105
+ """Render OFF tags as chips: 'en:peanuts' -> 'peanuts'."""
106
+ items = sorted(tag_set(tags)) if not isinstance(tags, list) else list(tags)
107
+ if not items:
108
+ return ""
109
+ out = []
110
+ for tag in items[:limit]:
111
+ label = tag.split(":", 1)[-1].replace("-", " ") if strip_prefix else tag
112
+ out.append(f'<span class="nw-chip {tone}">{esc(label)}</span>')
113
+ if len(items) > limit:
114
+ out.append(f'<span class="nw-chip">+{len(items) - limit} more</span>')
115
+ return '<div class="nw-chips">' + "".join(out) + "</div>"
116
+
117
+
118
+ def title_of(product: dict) -> str:
119
+ name = product.get("product_name")
120
+ return esc(name) if name and not isinstance(name, float) else "Unnamed product"
121
+
122
+
123
+ def brand_of(product: dict) -> str:
124
+ brands = product.get("brands")
125
+ if not brands or isinstance(brands, float):
126
+ return ""
127
+ # OFF stores brands as a comma-separated string, often with duplicates.
128
+ seen, out = set(), []
129
+ for b in str(brands).split(","):
130
+ b = b.strip()
131
+ if b and b.lower() not in seen:
132
+ seen.add(b.lower())
133
+ out.append(b)
134
+ return esc(", ".join(out[:2]))
components/cards.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Product cards and the verdict panel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from components import badges
8
+ from nutriweb.reco.engine import Recommendation
9
+ from nutriweb.reco.filters import Verdict
10
+
11
+
12
+ def product_card(product: dict, *, key: str, on_open, cta: str = "View details") -> None:
13
+ """A search-result card with an action button beneath it."""
14
+ st.markdown(
15
+ f"""<div class="nw-card">
16
+ {badges.thumb(product)}
17
+ <h4>{badges.title_of(product)}</h4>
18
+ <div class="nw-brand">{badges.brand_of(product) or "&nbsp;"}</div>
19
+ {badges.badge_row(product)}
20
+ {badges.health_meter(product.get("health_score"))}
21
+ </div>""",
22
+ unsafe_allow_html=True,
23
+ )
24
+ if st.button(cta, key=key, width='stretch'):
25
+ on_open(product["code"])
26
+
27
+
28
+ def recommendation_card(rec: Recommendation, *, key: str, on_open) -> None:
29
+ """A recommendation card. Always states why this specific swap was chosen."""
30
+ product = rec.product
31
+ warning = ""
32
+ if rec.warnings:
33
+ warning = (
34
+ '<div class="nw-why" style="color:#8A6D00">⚠︎ '
35
+ + badges.esc("; ".join(rec.warnings))
36
+ + "</div>"
37
+ )
38
+
39
+ st.markdown(
40
+ f"""<div class="nw-card">
41
+ {badges.thumb(product)}
42
+ <h4>{badges.title_of(product)}</h4>
43
+ <div class="nw-brand">{badges.brand_of(product) or "&nbsp;"}</div>
44
+ <span class="nw-gain">+{rec.health_gain:.0f} health</span>
45
+ {badges.badge_row(product)}
46
+ <div class="nw-why">{badges.esc(rec.why)}</div>
47
+ {warning}
48
+ </div>""",
49
+ unsafe_allow_html=True,
50
+ )
51
+ if st.button("View details", key=key, width='stretch'):
52
+ on_open(product["code"])
53
+
54
+
55
+ def verdict_panel(verdict: Verdict, profile_is_empty: bool = False) -> None:
56
+ """The "is this for you" panel.
57
+
58
+ Driven by the same `filters.evaluate` the engine uses, so what the user is
59
+ told here always matches what the recommender actually did.
60
+ """
61
+ if profile_is_empty:
62
+ st.markdown(
63
+ '<div class="nw-verdict v-warn"><h5>No profile set</h5>'
64
+ "<div>Add your allergens and dietary preferences to see whether this "
65
+ "product suits you.</div></div>",
66
+ unsafe_allow_html=True,
67
+ )
68
+ return
69
+
70
+ tone = "v-block" if verdict.blockers else ("v-warn" if verdict.warnings else "v-ok")
71
+ icon = "✕" if verdict.blockers else ("!" if verdict.warnings else "✓")
72
+ items = "".join(
73
+ f"<li>{badges.esc(m)}</li>" for m in (*verdict.blockers, *verdict.warnings)
74
+ )
75
+ body = f"<ul>{items}</ul>" if items else "<div>Nothing in your profile conflicts with this product.</div>"
76
+
77
+ st.markdown(
78
+ f'<div class="nw-verdict {tone}"><h5>{icon} {badges.esc(verdict.summary)}</h5>{body}</div>',
79
+ unsafe_allow_html=True,
80
+ )
81
+
82
+
83
+ def nutrient_table(product: dict, reference: dict | None = None) -> str:
84
+ """Per-100g nutrient table with bars scaled to a reference intake.
85
+
86
+ Bars are relative to a rough per-100g share of an adult reference intake,
87
+ so they answer "is this a lot?" rather than just restating the number.
88
+ """
89
+ rows = [
90
+ ("Energy", "energy_kcal_100g", "kcal", 200.0),
91
+ ("Fat", "fat_100g", "g", 17.5),
92
+ (" of which saturates", "saturated_fat_100g", "g", 5.0),
93
+ ("Carbohydrates", "carbohydrates_100g", "g", 45.0),
94
+ (" of which sugars", "sugars_100g", "g", 22.5),
95
+ ("Fibre", "fiber_100g", "g", 7.5),
96
+ ("Protein", "proteins_100g", "g", 25.0),
97
+ ("Salt", "salt_derived", "g", 1.5),
98
+ ]
99
+ from nutriweb.util import num
100
+
101
+ out = ['<table class="nw-nutri">']
102
+ for label, col, unit, high in rows:
103
+ value = num(product.get(col))
104
+ if value is None:
105
+ out.append(
106
+ f'<tr><td class="n">{badges.esc(label)}</td>'
107
+ f'<td class="v nw-sub">—</td><td></td></tr>'
108
+ )
109
+ continue
110
+ pct = max(2.0, min(100.0, value / high * 100.0))
111
+ over = " over" if value > high else ""
112
+ out.append(
113
+ f'<tr><td class="n">{badges.esc(label)}</td>'
114
+ f'<td class="v">{value:.1f} {unit}</td>'
115
+ f'<td><div class="nw-bar"><span class="{over.strip()}" '
116
+ f'style="width:{pct:.0f}%"></span></div></td></tr>'
117
+ )
118
+ out.append("</table>")
119
+ return "".join(out)
data/additives.json ADDED
@@ -0,0 +1,1327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "en:e100": {
3
+ "concern": "watch",
4
+ "e_number": "100",
5
+ "name": "E100 - Curcumin",
6
+ "non_nutritive_sweetener": false,
7
+ "reasons": [
8
+ "On ANSES' list of additives of interest"
9
+ ]
10
+ },
11
+ "en:e104": {
12
+ "concern": "watch",
13
+ "e_number": "104",
14
+ "name": "E104 - Quinoline yellow",
15
+ "non_nutritive_sweetener": false,
16
+ "reasons": [
17
+ "On ANSES' list of additives of interest"
18
+ ]
19
+ },
20
+ "en:e110": {
21
+ "concern": "watch",
22
+ "e_number": "110",
23
+ "name": "E110 - Sunset yellow FCF",
24
+ "non_nutritive_sweetener": false,
25
+ "reasons": [
26
+ "On ANSES' list of additives of interest"
27
+ ]
28
+ },
29
+ "en:e124": {
30
+ "concern": "watch",
31
+ "e_number": "124",
32
+ "name": "E124 - Ponceau 4r",
33
+ "non_nutritive_sweetener": false,
34
+ "reasons": [
35
+ "On ANSES' list of additives of interest"
36
+ ]
37
+ },
38
+ "en:e129": {
39
+ "concern": "watch",
40
+ "e_number": "129",
41
+ "name": "E129 - Allura red",
42
+ "non_nutritive_sweetener": false,
43
+ "reasons": [
44
+ "On ANSES' list of additives of interest"
45
+ ]
46
+ },
47
+ "en:e131": {
48
+ "concern": "moderate",
49
+ "e_number": "131",
50
+ "name": "E131 - Patent blue v",
51
+ "non_nutritive_sweetener": false,
52
+ "reasons": [
53
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
54
+ ]
55
+ },
56
+ "en:e133": {
57
+ "concern": "moderate",
58
+ "e_number": "133",
59
+ "name": "E133 - Brilliant blue FCF",
60
+ "non_nutritive_sweetener": false,
61
+ "reasons": [
62
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
63
+ ]
64
+ },
65
+ "en:e141": {
66
+ "concern": "watch",
67
+ "e_number": "141",
68
+ "name": "E141 - Copper complexes of chlorophylls and chlorophyllins",
69
+ "non_nutritive_sweetener": false,
70
+ "reasons": [
71
+ "On ANSES' list of additives of interest"
72
+ ]
73
+ },
74
+ "en:e141i": {
75
+ "concern": "watch",
76
+ "e_number": "141",
77
+ "name": "E141i - Copper complexes of chlorophylls",
78
+ "non_nutritive_sweetener": false,
79
+ "reasons": [
80
+ "On ANSES' list of additives of interest"
81
+ ]
82
+ },
83
+ "en:e141ii": {
84
+ "concern": "watch",
85
+ "e_number": "141",
86
+ "name": "E141ii - Copper complexes of chlorophyllins",
87
+ "non_nutritive_sweetener": false,
88
+ "reasons": [
89
+ "On ANSES' list of additives of interest"
90
+ ]
91
+ },
92
+ "en:e142": {
93
+ "concern": "moderate",
94
+ "e_number": "142",
95
+ "name": "E142 - Green s",
96
+ "non_nutritive_sweetener": false,
97
+ "reasons": [
98
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
99
+ ]
100
+ },
101
+ "en:e150a": {
102
+ "concern": "watch",
103
+ "e_number": "150a",
104
+ "name": "E150a - Plain caramel",
105
+ "non_nutritive_sweetener": false,
106
+ "reasons": [
107
+ "On ANSES' list of additives of interest"
108
+ ]
109
+ },
110
+ "en:e150b": {
111
+ "concern": "watch",
112
+ "e_number": "150b",
113
+ "name": "E150b - Caustic sulphite caramel",
114
+ "non_nutritive_sweetener": false,
115
+ "reasons": [
116
+ "On ANSES' list of additives of interest"
117
+ ]
118
+ },
119
+ "en:e150c": {
120
+ "concern": "moderate",
121
+ "e_number": "150c",
122
+ "name": "E150c - Ammonia caramel",
123
+ "non_nutritive_sweetener": false,
124
+ "reasons": [
125
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
126
+ "On ANSES' list of additives of interest"
127
+ ]
128
+ },
129
+ "en:e150d": {
130
+ "concern": "watch",
131
+ "e_number": "150d",
132
+ "name": "E150d - Sulphite ammonia caramel",
133
+ "non_nutritive_sweetener": false,
134
+ "reasons": [
135
+ "On ANSES' list of additives of interest"
136
+ ]
137
+ },
138
+ "en:e155": {
139
+ "concern": "high",
140
+ "e_number": "155",
141
+ "name": "E155 - Brown ht",
142
+ "non_nutritive_sweetener": false,
143
+ "reasons": [
144
+ "EFSA: high risk of exceeding the acceptable daily intake"
145
+ ]
146
+ },
147
+ "en:e160ai": {
148
+ "concern": "watch",
149
+ "e_number": "160a",
150
+ "name": "E160ai - Beta-carotene",
151
+ "non_nutritive_sweetener": false,
152
+ "reasons": [
153
+ "On ANSES' list of additives of interest"
154
+ ]
155
+ },
156
+ "en:e160aii": {
157
+ "concern": "watch",
158
+ "e_number": "160a",
159
+ "name": "E160aii - Plant carotenes",
160
+ "non_nutritive_sweetener": false,
161
+ "reasons": [
162
+ "On ANSES' list of additives of interest"
163
+ ]
164
+ },
165
+ "en:e160b": {
166
+ "concern": "watch",
167
+ "e_number": "160b",
168
+ "name": "E160b - Annatto",
169
+ "non_nutritive_sweetener": false,
170
+ "reasons": [
171
+ "On ANSES' list of additives of interest"
172
+ ]
173
+ },
174
+ "en:e160e": {
175
+ "concern": "watch",
176
+ "e_number": "160e",
177
+ "name": "E160e - Beta-apo-8\u2032-carotenal (c30)",
178
+ "non_nutritive_sweetener": false,
179
+ "reasons": [
180
+ "On ANSES' list of additives of interest"
181
+ ]
182
+ },
183
+ "en:e160f": {
184
+ "concern": "watch",
185
+ "e_number": "160f",
186
+ "name": "E160f - Ethyl ester of beta-apo-8'-carotenic acid (C 30)",
187
+ "non_nutritive_sweetener": false,
188
+ "reasons": [
189
+ "On ANSES' list of additives of interest"
190
+ ]
191
+ },
192
+ "en:e161b": {
193
+ "concern": "watch",
194
+ "e_number": "161b",
195
+ "name": "E161b - Lutein",
196
+ "non_nutritive_sweetener": false,
197
+ "reasons": [
198
+ "On ANSES' list of additives of interest"
199
+ ]
200
+ },
201
+ "en:e170": {
202
+ "concern": "watch",
203
+ "e_number": null,
204
+ "name": "E170 - Calcium carbonates",
205
+ "non_nutritive_sweetener": false,
206
+ "reasons": [
207
+ "On ANSES' list of additives of interest"
208
+ ]
209
+ },
210
+ "en:e171": {
211
+ "concern": "watch",
212
+ "e_number": "171",
213
+ "name": "E171 - Titanium dioxide",
214
+ "non_nutritive_sweetener": false,
215
+ "reasons": [
216
+ "On ANSES' list of additives of interest"
217
+ ]
218
+ },
219
+ "en:e172": {
220
+ "concern": "watch",
221
+ "e_number": "172",
222
+ "name": "E172 - Iron oxides and iron hydroxides",
223
+ "non_nutritive_sweetener": false,
224
+ "reasons": [
225
+ "On ANSES' list of additives of interest"
226
+ ]
227
+ },
228
+ "en:e174": {
229
+ "concern": "watch",
230
+ "e_number": "174",
231
+ "name": "E174 - Silver",
232
+ "non_nutritive_sweetener": false,
233
+ "reasons": [
234
+ "On ANSES' list of additives of interest"
235
+ ]
236
+ },
237
+ "en:e180": {
238
+ "concern": "watch",
239
+ "e_number": "180",
240
+ "name": "E180 - Litholrubine bk",
241
+ "non_nutritive_sweetener": false,
242
+ "reasons": [
243
+ "On ANSES' list of additives of interest"
244
+ ]
245
+ },
246
+ "en:e200": {
247
+ "concern": "high",
248
+ "e_number": "200",
249
+ "name": "E200 - Sorbic acid",
250
+ "non_nutritive_sweetener": false,
251
+ "reasons": [
252
+ "EFSA: high risk of exceeding the acceptable daily intake"
253
+ ]
254
+ },
255
+ "en:e202": {
256
+ "concern": "high",
257
+ "e_number": "202",
258
+ "name": "E202 - Potassium sorbate",
259
+ "non_nutritive_sweetener": false,
260
+ "reasons": [
261
+ "EFSA: high risk of exceeding the acceptable daily intake"
262
+ ]
263
+ },
264
+ "en:e210": {
265
+ "concern": "high",
266
+ "e_number": "210",
267
+ "name": "E210 - Benzoic acid",
268
+ "non_nutritive_sweetener": false,
269
+ "reasons": [
270
+ "EFSA: high risk of exceeding the acceptable daily intake",
271
+ "On ANSES' list of additives of interest"
272
+ ]
273
+ },
274
+ "en:e211": {
275
+ "concern": "high",
276
+ "e_number": "211",
277
+ "name": "E211 - Sodium benzoate",
278
+ "non_nutritive_sweetener": false,
279
+ "reasons": [
280
+ "EFSA: high risk of exceeding the acceptable daily intake",
281
+ "On ANSES' list of additives of interest"
282
+ ]
283
+ },
284
+ "en:e212": {
285
+ "concern": "high",
286
+ "e_number": "212",
287
+ "name": "E212 - Potassium benzoate",
288
+ "non_nutritive_sweetener": false,
289
+ "reasons": [
290
+ "EFSA: high risk of exceeding the acceptable daily intake",
291
+ "On ANSES' list of additives of interest"
292
+ ]
293
+ },
294
+ "en:e213": {
295
+ "concern": "high",
296
+ "e_number": "213",
297
+ "name": "E213 - Calcium benzoate",
298
+ "non_nutritive_sweetener": false,
299
+ "reasons": [
300
+ "EFSA: high risk of exceeding the acceptable daily intake",
301
+ "On ANSES' list of additives of interest"
302
+ ]
303
+ },
304
+ "en:e220": {
305
+ "concern": "high",
306
+ "e_number": "220",
307
+ "name": "E220 - Sulphur dioxide",
308
+ "non_nutritive_sweetener": false,
309
+ "reasons": [
310
+ "EFSA: high risk of exceeding the acceptable daily intake",
311
+ "On ANSES' list of additives of interest"
312
+ ]
313
+ },
314
+ "en:e221": {
315
+ "concern": "high",
316
+ "e_number": "221",
317
+ "name": "E221 - Sodium sulphite",
318
+ "non_nutritive_sweetener": false,
319
+ "reasons": [
320
+ "EFSA: high risk of exceeding the acceptable daily intake",
321
+ "On ANSES' list of additives of interest"
322
+ ]
323
+ },
324
+ "en:e222": {
325
+ "concern": "high",
326
+ "e_number": "222",
327
+ "name": "E222 - Sodium bisulphite",
328
+ "non_nutritive_sweetener": false,
329
+ "reasons": [
330
+ "EFSA: high risk of exceeding the acceptable daily intake",
331
+ "On ANSES' list of additives of interest"
332
+ ]
333
+ },
334
+ "en:e223": {
335
+ "concern": "high",
336
+ "e_number": "223",
337
+ "name": "E223 - Sodium metabisulphite",
338
+ "non_nutritive_sweetener": false,
339
+ "reasons": [
340
+ "EFSA: high risk of exceeding the acceptable daily intake",
341
+ "On ANSES' list of additives of interest"
342
+ ]
343
+ },
344
+ "en:e224": {
345
+ "concern": "high",
346
+ "e_number": "224",
347
+ "name": "E224 - Potassium metabisulphite",
348
+ "non_nutritive_sweetener": false,
349
+ "reasons": [
350
+ "EFSA: high risk of exceeding the acceptable daily intake",
351
+ "On ANSES' list of additives of interest"
352
+ ]
353
+ },
354
+ "en:e225": {
355
+ "concern": "watch",
356
+ "e_number": "225",
357
+ "name": "E225 - Calcium Disulphite",
358
+ "non_nutritive_sweetener": false,
359
+ "reasons": [
360
+ "On ANSES' list of additives of interest"
361
+ ]
362
+ },
363
+ "en:e226": {
364
+ "concern": "high",
365
+ "e_number": "226",
366
+ "name": "E226 - Calcium sulphite",
367
+ "non_nutritive_sweetener": false,
368
+ "reasons": [
369
+ "EFSA: high risk of exceeding the acceptable daily intake",
370
+ "On ANSES' list of additives of interest"
371
+ ]
372
+ },
373
+ "en:e227": {
374
+ "concern": "high",
375
+ "e_number": "227",
376
+ "name": "E227 - Calcium bisulphite",
377
+ "non_nutritive_sweetener": false,
378
+ "reasons": [
379
+ "EFSA: high risk of exceeding the acceptable daily intake",
380
+ "On ANSES' list of additives of interest"
381
+ ]
382
+ },
383
+ "en:e228": {
384
+ "concern": "high",
385
+ "e_number": "228",
386
+ "name": "E228 - Potassium bisulphite",
387
+ "non_nutritive_sweetener": false,
388
+ "reasons": [
389
+ "EFSA: high risk of exceeding the acceptable daily intake",
390
+ "On ANSES' list of additives of interest"
391
+ ]
392
+ },
393
+ "en:e243": {
394
+ "concern": "watch",
395
+ "e_number": "243",
396
+ "name": "E243 - Ethyl lauroyl arginate",
397
+ "non_nutritive_sweetener": false,
398
+ "reasons": [
399
+ "On ANSES' list of additives of interest"
400
+ ]
401
+ },
402
+ "en:e249": {
403
+ "concern": "watch",
404
+ "e_number": "249",
405
+ "name": "E249 - Potassium nitrite",
406
+ "non_nutritive_sweetener": false,
407
+ "reasons": [
408
+ "On ANSES' list of additives of interest"
409
+ ]
410
+ },
411
+ "en:e250": {
412
+ "concern": "high",
413
+ "e_number": "250",
414
+ "name": "E250 - Sodium nitrite",
415
+ "non_nutritive_sweetener": false,
416
+ "reasons": [
417
+ "EFSA: high risk of exceeding the acceptable daily intake",
418
+ "On ANSES' list of additives of interest"
419
+ ]
420
+ },
421
+ "en:e251": {
422
+ "concern": "high",
423
+ "e_number": "251",
424
+ "name": "E251 - Sodium nitrate",
425
+ "non_nutritive_sweetener": false,
426
+ "reasons": [
427
+ "EFSA: high risk of exceeding the acceptable daily intake"
428
+ ]
429
+ },
430
+ "en:e252": {
431
+ "concern": "high",
432
+ "e_number": "252",
433
+ "name": "E252 - Potassium nitrate",
434
+ "non_nutritive_sweetener": false,
435
+ "reasons": [
436
+ "EFSA: high risk of exceeding the acceptable daily intake"
437
+ ]
438
+ },
439
+ "en:e321": {
440
+ "concern": "watch",
441
+ "e_number": "321",
442
+ "name": "E321 - Butylated hydroxytoluene",
443
+ "non_nutritive_sweetener": false,
444
+ "reasons": [
445
+ "On ANSES' list of additives of interest"
446
+ ]
447
+ },
448
+ "en:e334": {
449
+ "concern": "watch",
450
+ "e_number": "334",
451
+ "name": "E334 - L(+)-tartaric acid",
452
+ "non_nutritive_sweetener": false,
453
+ "reasons": [
454
+ "On ANSES' list of additives of interest"
455
+ ]
456
+ },
457
+ "en:e335": {
458
+ "concern": "watch",
459
+ "e_number": "335",
460
+ "name": "E335 - Sodium tartrates",
461
+ "non_nutritive_sweetener": false,
462
+ "reasons": [
463
+ "On ANSES' list of additives of interest"
464
+ ]
465
+ },
466
+ "en:e335i": {
467
+ "concern": "watch",
468
+ "e_number": "335",
469
+ "name": "E335i - Monosodium tartrate",
470
+ "non_nutritive_sweetener": false,
471
+ "reasons": [
472
+ "On ANSES' list of additives of interest"
473
+ ]
474
+ },
475
+ "en:e335ii": {
476
+ "concern": "watch",
477
+ "e_number": "335",
478
+ "name": "E335ii - Disodium tartrate",
479
+ "non_nutritive_sweetener": false,
480
+ "reasons": [
481
+ "On ANSES' list of additives of interest"
482
+ ]
483
+ },
484
+ "en:e336": {
485
+ "concern": "watch",
486
+ "e_number": "336",
487
+ "name": "E336 - Potassium tartrates",
488
+ "non_nutritive_sweetener": false,
489
+ "reasons": [
490
+ "On ANSES' list of additives of interest"
491
+ ]
492
+ },
493
+ "en:e336i": {
494
+ "concern": "watch",
495
+ "e_number": "336",
496
+ "name": "E336i - Monopotassium tartrate",
497
+ "non_nutritive_sweetener": false,
498
+ "reasons": [
499
+ "On ANSES' list of additives of interest"
500
+ ]
501
+ },
502
+ "en:e336ii": {
503
+ "concern": "watch",
504
+ "e_number": "336",
505
+ "name": "E336ii - Dipotassium tartrate",
506
+ "non_nutritive_sweetener": false,
507
+ "reasons": [
508
+ "On ANSES' list of additives of interest"
509
+ ]
510
+ },
511
+ "en:e337": {
512
+ "concern": "watch",
513
+ "e_number": "337",
514
+ "name": "E337 - Potassium sodium tartrate",
515
+ "non_nutritive_sweetener": false,
516
+ "reasons": [
517
+ "On ANSES' list of additives of interest"
518
+ ]
519
+ },
520
+ "en:e338": {
521
+ "concern": "high",
522
+ "e_number": "338",
523
+ "name": "E338 - Phosphoric acid",
524
+ "non_nutritive_sweetener": false,
525
+ "reasons": [
526
+ "EFSA: high risk of exceeding the acceptable daily intake",
527
+ "On ANSES' list of additives of interest"
528
+ ]
529
+ },
530
+ "en:e339": {
531
+ "concern": "high",
532
+ "e_number": "339",
533
+ "name": "E339 - Sodium phosphates",
534
+ "non_nutritive_sweetener": false,
535
+ "reasons": [
536
+ "EFSA: high risk of exceeding the acceptable daily intake",
537
+ "On ANSES' list of additives of interest"
538
+ ]
539
+ },
540
+ "en:e339i": {
541
+ "concern": "watch",
542
+ "e_number": "339",
543
+ "name": "E339i - Monosodium phosphate",
544
+ "non_nutritive_sweetener": false,
545
+ "reasons": [
546
+ "On ANSES' list of additives of interest"
547
+ ]
548
+ },
549
+ "en:e339ii": {
550
+ "concern": "watch",
551
+ "e_number": "339",
552
+ "name": "E339ii - Disodium phosphate",
553
+ "non_nutritive_sweetener": false,
554
+ "reasons": [
555
+ "On ANSES' list of additives of interest"
556
+ ]
557
+ },
558
+ "en:e339iii": {
559
+ "concern": "watch",
560
+ "e_number": "339",
561
+ "name": "E339iii - Trisodium phosphate",
562
+ "non_nutritive_sweetener": false,
563
+ "reasons": [
564
+ "On ANSES' list of additives of interest"
565
+ ]
566
+ },
567
+ "en:e340": {
568
+ "concern": "high",
569
+ "e_number": "340",
570
+ "name": "E340 - Potassium phosphates",
571
+ "non_nutritive_sweetener": false,
572
+ "reasons": [
573
+ "EFSA: high risk of exceeding the acceptable daily intake",
574
+ "On ANSES' list of additives of interest"
575
+ ]
576
+ },
577
+ "en:e340i": {
578
+ "concern": "watch",
579
+ "e_number": "340",
580
+ "name": "E340i - Monopotassium phosphate",
581
+ "non_nutritive_sweetener": false,
582
+ "reasons": [
583
+ "On ANSES' list of additives of interest"
584
+ ]
585
+ },
586
+ "en:e340ii": {
587
+ "concern": "watch",
588
+ "e_number": "340",
589
+ "name": "E340ii - Dipotassium phosphate",
590
+ "non_nutritive_sweetener": false,
591
+ "reasons": [
592
+ "On ANSES' list of additives of interest"
593
+ ]
594
+ },
595
+ "en:e340iii": {
596
+ "concern": "watch",
597
+ "e_number": "340",
598
+ "name": "E340iii - Tripotassium phosphate",
599
+ "non_nutritive_sweetener": false,
600
+ "reasons": [
601
+ "On ANSES' list of additives of interest"
602
+ ]
603
+ },
604
+ "en:e341": {
605
+ "concern": "high",
606
+ "e_number": "341",
607
+ "name": "E341 - Calcium phosphates",
608
+ "non_nutritive_sweetener": false,
609
+ "reasons": [
610
+ "EFSA: high risk of exceeding the acceptable daily intake",
611
+ "On ANSES' list of additives of interest"
612
+ ]
613
+ },
614
+ "en:e341i": {
615
+ "concern": "watch",
616
+ "e_number": "341",
617
+ "name": "E341i - Monocalcium phosphate",
618
+ "non_nutritive_sweetener": false,
619
+ "reasons": [
620
+ "On ANSES' list of additives of interest"
621
+ ]
622
+ },
623
+ "en:e341ii": {
624
+ "concern": "watch",
625
+ "e_number": "341",
626
+ "name": "E341ii - Dicalcium phosphate",
627
+ "non_nutritive_sweetener": false,
628
+ "reasons": [
629
+ "On ANSES' list of additives of interest"
630
+ ]
631
+ },
632
+ "en:e341iii": {
633
+ "concern": "watch",
634
+ "e_number": "341",
635
+ "name": "E341iii - Tricalcium phosphate",
636
+ "non_nutritive_sweetener": false,
637
+ "reasons": [
638
+ "On ANSES' list of additives of interest"
639
+ ]
640
+ },
641
+ "en:e343": {
642
+ "concern": "high",
643
+ "e_number": "343",
644
+ "name": "E343 - Magnesium phosphates",
645
+ "non_nutritive_sweetener": false,
646
+ "reasons": [
647
+ "EFSA: high risk of exceeding the acceptable daily intake",
648
+ "On ANSES' list of additives of interest"
649
+ ]
650
+ },
651
+ "en:e343i": {
652
+ "concern": "watch",
653
+ "e_number": "343",
654
+ "name": "E343i - Monomagnesium phosphate",
655
+ "non_nutritive_sweetener": false,
656
+ "reasons": [
657
+ "On ANSES' list of additives of interest"
658
+ ]
659
+ },
660
+ "en:e343ii": {
661
+ "concern": "watch",
662
+ "e_number": "343",
663
+ "name": "E343ii - Dimagnesium phosphate",
664
+ "non_nutritive_sweetener": false,
665
+ "reasons": [
666
+ "On ANSES' list of additives of interest"
667
+ ]
668
+ },
669
+ "en:e354": {
670
+ "concern": "watch",
671
+ "e_number": "354",
672
+ "name": "E354 - Calcium tartrate",
673
+ "non_nutritive_sweetener": false,
674
+ "reasons": [
675
+ "On ANSES' list of additives of interest"
676
+ ]
677
+ },
678
+ "en:e407": {
679
+ "concern": "high",
680
+ "e_number": "407",
681
+ "name": "E407 - Carrageenan",
682
+ "non_nutritive_sweetener": false,
683
+ "reasons": [
684
+ "EFSA: high risk of exceeding the acceptable daily intake",
685
+ "On ANSES' list of additives of interest"
686
+ ]
687
+ },
688
+ "en:e407a": {
689
+ "concern": "high",
690
+ "e_number": "407a",
691
+ "name": "E407a - Processed eucheuma seaweed",
692
+ "non_nutritive_sweetener": false,
693
+ "reasons": [
694
+ "EFSA: high risk of exceeding the acceptable daily intake"
695
+ ]
696
+ },
697
+ "en:e432": {
698
+ "concern": "moderate",
699
+ "e_number": "432",
700
+ "name": "E432 - Polyoxyethylene sorbitan monolaurate",
701
+ "non_nutritive_sweetener": false,
702
+ "reasons": [
703
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
704
+ "On ANSES' list of additives of interest"
705
+ ]
706
+ },
707
+ "en:e433": {
708
+ "concern": "moderate",
709
+ "e_number": "433",
710
+ "name": "E433 - Polyoxyethylene sorbitan monooleate",
711
+ "non_nutritive_sweetener": false,
712
+ "reasons": [
713
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
714
+ "On ANSES' list of additives of interest"
715
+ ]
716
+ },
717
+ "en:e434": {
718
+ "concern": "moderate",
719
+ "e_number": "434",
720
+ "name": "E434 - Polyoxyethylene sorbitan monopalmitate",
721
+ "non_nutritive_sweetener": false,
722
+ "reasons": [
723
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
724
+ "On ANSES' list of additives of interest"
725
+ ]
726
+ },
727
+ "en:e435": {
728
+ "concern": "moderate",
729
+ "e_number": "435",
730
+ "name": "E435 - Polyoxyethylene sorbitan monostearate",
731
+ "non_nutritive_sweetener": false,
732
+ "reasons": [
733
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
734
+ "On ANSES' list of additives of interest"
735
+ ]
736
+ },
737
+ "en:e436": {
738
+ "concern": "moderate",
739
+ "e_number": "436",
740
+ "name": "E436 - Polyoxyethylene sorbitan tristearate",
741
+ "non_nutritive_sweetener": false,
742
+ "reasons": [
743
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
744
+ "On ANSES' list of additives of interest"
745
+ ]
746
+ },
747
+ "en:e450": {
748
+ "concern": "high",
749
+ "e_number": "450",
750
+ "name": "E450 - Diphosphates",
751
+ "non_nutritive_sweetener": false,
752
+ "reasons": [
753
+ "EFSA: high risk of exceeding the acceptable daily intake",
754
+ "On ANSES' list of additives of interest"
755
+ ]
756
+ },
757
+ "en:e450i": {
758
+ "concern": "watch",
759
+ "e_number": "450",
760
+ "name": "E450i - Disodium diphosphate",
761
+ "non_nutritive_sweetener": false,
762
+ "reasons": [
763
+ "On ANSES' list of additives of interest"
764
+ ]
765
+ },
766
+ "en:e450ii": {
767
+ "concern": "watch",
768
+ "e_number": "450",
769
+ "name": "E450ii - Trisodium diphosphate",
770
+ "non_nutritive_sweetener": false,
771
+ "reasons": [
772
+ "On ANSES' list of additives of interest"
773
+ ]
774
+ },
775
+ "en:e450iii": {
776
+ "concern": "watch",
777
+ "e_number": "450",
778
+ "name": "E450iii - Tetrasodium diphosphate",
779
+ "non_nutritive_sweetener": false,
780
+ "reasons": [
781
+ "On ANSES' list of additives of interest"
782
+ ]
783
+ },
784
+ "en:e450v": {
785
+ "concern": "watch",
786
+ "e_number": "450",
787
+ "name": "E450v - Tetrapotassium diphosphate",
788
+ "non_nutritive_sweetener": false,
789
+ "reasons": [
790
+ "On ANSES' list of additives of interest"
791
+ ]
792
+ },
793
+ "en:e450vi": {
794
+ "concern": "watch",
795
+ "e_number": "450",
796
+ "name": "E450vi - Dicalcium diphosphate",
797
+ "non_nutritive_sweetener": false,
798
+ "reasons": [
799
+ "On ANSES' list of additives of interest"
800
+ ]
801
+ },
802
+ "en:e450vii": {
803
+ "concern": "watch",
804
+ "e_number": "450",
805
+ "name": "E450vii - Calcium dihydrogen diphosphate",
806
+ "non_nutritive_sweetener": false,
807
+ "reasons": [
808
+ "On ANSES' list of additives of interest"
809
+ ]
810
+ },
811
+ "en:e451": {
812
+ "concern": "high",
813
+ "e_number": "451",
814
+ "name": "E451 - Triphosphates",
815
+ "non_nutritive_sweetener": false,
816
+ "reasons": [
817
+ "EFSA: high risk of exceeding the acceptable daily intake",
818
+ "On ANSES' list of additives of interest"
819
+ ]
820
+ },
821
+ "en:e451i": {
822
+ "concern": "watch",
823
+ "e_number": "451",
824
+ "name": "E451i - Pentasodium triphosphate",
825
+ "non_nutritive_sweetener": false,
826
+ "reasons": [
827
+ "On ANSES' list of additives of interest"
828
+ ]
829
+ },
830
+ "en:e451ii": {
831
+ "concern": "watch",
832
+ "e_number": "451",
833
+ "name": "E451ii - Pentapotassium triphosphate",
834
+ "non_nutritive_sweetener": false,
835
+ "reasons": [
836
+ "On ANSES' list of additives of interest"
837
+ ]
838
+ },
839
+ "en:e452": {
840
+ "concern": "high",
841
+ "e_number": "452",
842
+ "name": "E452 - Polyphosphates",
843
+ "non_nutritive_sweetener": false,
844
+ "reasons": [
845
+ "EFSA: high risk of exceeding the acceptable daily intake",
846
+ "On ANSES' list of additives of interest"
847
+ ]
848
+ },
849
+ "en:e452i": {
850
+ "concern": "watch",
851
+ "e_number": "452",
852
+ "name": "E452i - Sodium polyphosphate",
853
+ "non_nutritive_sweetener": false,
854
+ "reasons": [
855
+ "On ANSES' list of additives of interest"
856
+ ]
857
+ },
858
+ "en:e452ii": {
859
+ "concern": "watch",
860
+ "e_number": "452",
861
+ "name": "E452ii - Potassium polyphosphate",
862
+ "non_nutritive_sweetener": false,
863
+ "reasons": [
864
+ "On ANSES' list of additives of interest"
865
+ ]
866
+ },
867
+ "en:e452iii": {
868
+ "concern": "watch",
869
+ "e_number": "452",
870
+ "name": "E452iii - Sodium calcium polyphosphate",
871
+ "non_nutritive_sweetener": false,
872
+ "reasons": [
873
+ "On ANSES' list of additives of interest"
874
+ ]
875
+ },
876
+ "en:e452iv": {
877
+ "concern": "watch",
878
+ "e_number": "452",
879
+ "name": "E452iv - Calcium polyphosphate",
880
+ "non_nutritive_sweetener": false,
881
+ "reasons": [
882
+ "On ANSES' list of additives of interest"
883
+ ]
884
+ },
885
+ "en:e459": {
886
+ "concern": "high",
887
+ "e_number": "459",
888
+ "name": "E459 - Beta-cyclodextrine",
889
+ "non_nutritive_sweetener": false,
890
+ "reasons": [
891
+ "EFSA: high risk of exceeding the acceptable daily intake"
892
+ ]
893
+ },
894
+ "en:e460i": {
895
+ "concern": "watch",
896
+ "e_number": "460",
897
+ "name": "E460i - Microcrystalline cellulose",
898
+ "non_nutritive_sweetener": false,
899
+ "reasons": [
900
+ "On ANSES' list of additives of interest"
901
+ ]
902
+ },
903
+ "en:e472e": {
904
+ "concern": "watch",
905
+ "e_number": "472e",
906
+ "name": "E472e - Mono- and diacetyltartaric acid esters of mono- and diglycerides of fatty acids",
907
+ "non_nutritive_sweetener": false,
908
+ "reasons": [
909
+ "On ANSES' list of additives of interest"
910
+ ]
911
+ },
912
+ "en:e473": {
913
+ "concern": "high",
914
+ "e_number": "473",
915
+ "name": "E473 - Sucrose esters of fatty acids",
916
+ "non_nutritive_sweetener": false,
917
+ "reasons": [
918
+ "EFSA: high risk of exceeding the acceptable daily intake",
919
+ "On ANSES' list of additives of interest"
920
+ ]
921
+ },
922
+ "en:e474": {
923
+ "concern": "watch",
924
+ "e_number": "474",
925
+ "name": "E474 - Sucroglycerides",
926
+ "non_nutritive_sweetener": false,
927
+ "reasons": [
928
+ "On ANSES' list of additives of interest"
929
+ ]
930
+ },
931
+ "en:e475": {
932
+ "concern": "watch",
933
+ "e_number": "475",
934
+ "name": "E475 - Polyglycerol esters of fatty acids",
935
+ "non_nutritive_sweetener": false,
936
+ "reasons": [
937
+ "On ANSES' list of additives of interest"
938
+ ]
939
+ },
940
+ "en:e481": {
941
+ "concern": "high",
942
+ "e_number": "481",
943
+ "name": "E481 - Sodium stearoyl-2-lactylate",
944
+ "non_nutritive_sweetener": false,
945
+ "reasons": [
946
+ "EFSA: high risk of exceeding the acceptable daily intake",
947
+ "On ANSES' list of additives of interest"
948
+ ]
949
+ },
950
+ "en:e482": {
951
+ "concern": "high",
952
+ "e_number": "482",
953
+ "name": "E482 - Calcium stearoyl-2-lactylate",
954
+ "non_nutritive_sweetener": false,
955
+ "reasons": [
956
+ "EFSA: high risk of exceeding the acceptable daily intake",
957
+ "On ANSES' list of additives of interest"
958
+ ]
959
+ },
960
+ "en:e483": {
961
+ "concern": "watch",
962
+ "e_number": "483",
963
+ "name": "E483 - Stearyl tartrate",
964
+ "non_nutritive_sweetener": false,
965
+ "reasons": [
966
+ "On ANSES' list of additives of interest"
967
+ ]
968
+ },
969
+ "en:e491": {
970
+ "concern": "high",
971
+ "e_number": "491",
972
+ "name": "E491 - Sorbitan monostearate",
973
+ "non_nutritive_sweetener": false,
974
+ "reasons": [
975
+ "EFSA: high risk of exceeding the acceptable daily intake",
976
+ "On ANSES' list of additives of interest"
977
+ ]
978
+ },
979
+ "en:e492": {
980
+ "concern": "high",
981
+ "e_number": "492",
982
+ "name": "E492 - Sorbitan tristearate",
983
+ "non_nutritive_sweetener": false,
984
+ "reasons": [
985
+ "EFSA: high risk of exceeding the acceptable daily intake",
986
+ "On ANSES' list of additives of interest"
987
+ ]
988
+ },
989
+ "en:e493": {
990
+ "concern": "high",
991
+ "e_number": "493",
992
+ "name": "E493 - Sorbitan monolaurate",
993
+ "non_nutritive_sweetener": false,
994
+ "reasons": [
995
+ "EFSA: high risk of exceeding the acceptable daily intake",
996
+ "On ANSES' list of additives of interest"
997
+ ]
998
+ },
999
+ "en:e494": {
1000
+ "concern": "high",
1001
+ "e_number": "494",
1002
+ "name": "E494 - Sorbitan monooleate",
1003
+ "non_nutritive_sweetener": false,
1004
+ "reasons": [
1005
+ "EFSA: high risk of exceeding the acceptable daily intake",
1006
+ "On ANSES' list of additives of interest"
1007
+ ]
1008
+ },
1009
+ "en:e495": {
1010
+ "concern": "high",
1011
+ "e_number": "495",
1012
+ "name": "E495 - Sorbitan monopalmitate",
1013
+ "non_nutritive_sweetener": false,
1014
+ "reasons": [
1015
+ "EFSA: high risk of exceeding the acceptable daily intake",
1016
+ "On ANSES' list of additives of interest"
1017
+ ]
1018
+ },
1019
+ "en:e507": {
1020
+ "concern": "moderate",
1021
+ "e_number": "507",
1022
+ "name": "E507 - Hydrochloric acid",
1023
+ "non_nutritive_sweetener": false,
1024
+ "reasons": [
1025
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
1026
+ ]
1027
+ },
1028
+ "en:e508": {
1029
+ "concern": "moderate",
1030
+ "e_number": "508",
1031
+ "name": "E508 - Potassium chloride",
1032
+ "non_nutritive_sweetener": false,
1033
+ "reasons": [
1034
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
1035
+ ]
1036
+ },
1037
+ "en:e509": {
1038
+ "concern": "moderate",
1039
+ "e_number": "509",
1040
+ "name": "E509 - Calcium chloride",
1041
+ "non_nutritive_sweetener": false,
1042
+ "reasons": [
1043
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
1044
+ ]
1045
+ },
1046
+ "en:e511": {
1047
+ "concern": "moderate",
1048
+ "e_number": "511",
1049
+ "name": "E511 - Magnesium chloride",
1050
+ "non_nutritive_sweetener": false,
1051
+ "reasons": [
1052
+ "EFSA: moderate risk of exceeding the acceptable daily intake"
1053
+ ]
1054
+ },
1055
+ "en:e520": {
1056
+ "concern": "watch",
1057
+ "e_number": "520",
1058
+ "name": "E520 - Aluminium sulphate",
1059
+ "non_nutritive_sweetener": false,
1060
+ "reasons": [
1061
+ "On ANSES' list of additives of interest"
1062
+ ]
1063
+ },
1064
+ "en:e521": {
1065
+ "concern": "watch",
1066
+ "e_number": "521",
1067
+ "name": "E521 - Aluminium sodium sulphate",
1068
+ "non_nutritive_sweetener": false,
1069
+ "reasons": [
1070
+ "On ANSES' list of additives of interest"
1071
+ ]
1072
+ },
1073
+ "en:e522": {
1074
+ "concern": "watch",
1075
+ "e_number": "522",
1076
+ "name": "E522 - Aluminium potassium sulphate",
1077
+ "non_nutritive_sweetener": false,
1078
+ "reasons": [
1079
+ "On ANSES' list of additives of interest"
1080
+ ]
1081
+ },
1082
+ "en:e523": {
1083
+ "concern": "watch",
1084
+ "e_number": "523",
1085
+ "name": "E523 - Aluminium ammonium sulphate",
1086
+ "non_nutritive_sweetener": false,
1087
+ "reasons": [
1088
+ "On ANSES' list of additives of interest"
1089
+ ]
1090
+ },
1091
+ "en:e535": {
1092
+ "concern": "watch",
1093
+ "e_number": "535",
1094
+ "name": "E535 - Sodium ferrocyanide",
1095
+ "non_nutritive_sweetener": false,
1096
+ "reasons": [
1097
+ "On ANSES' list of additives of interest"
1098
+ ]
1099
+ },
1100
+ "en:e536": {
1101
+ "concern": "watch",
1102
+ "e_number": "536",
1103
+ "name": "E536 - Potassium ferrocyanide",
1104
+ "non_nutritive_sweetener": false,
1105
+ "reasons": [
1106
+ "On ANSES' list of additives of interest"
1107
+ ]
1108
+ },
1109
+ "en:e538": {
1110
+ "concern": "watch",
1111
+ "e_number": "538",
1112
+ "name": "E538 - Calcium ferrocyanide",
1113
+ "non_nutritive_sweetener": false,
1114
+ "reasons": [
1115
+ "On ANSES' list of additives of interest"
1116
+ ]
1117
+ },
1118
+ "en:e541": {
1119
+ "concern": "watch",
1120
+ "e_number": "541",
1121
+ "name": "E541 - Sodium aluminium phosphate",
1122
+ "non_nutritive_sweetener": false,
1123
+ "reasons": [
1124
+ "On ANSES' list of additives of interest"
1125
+ ]
1126
+ },
1127
+ "en:e551": {
1128
+ "concern": "watch",
1129
+ "e_number": "551",
1130
+ "name": "E551 - Silicon dioxide",
1131
+ "non_nutritive_sweetener": false,
1132
+ "reasons": [
1133
+ "On ANSES' list of additives of interest"
1134
+ ]
1135
+ },
1136
+ "en:e554": {
1137
+ "concern": "watch",
1138
+ "e_number": "554",
1139
+ "name": "E554 - Sodium aluminium silicate",
1140
+ "non_nutritive_sweetener": false,
1141
+ "reasons": [
1142
+ "On ANSES' list of additives of interest"
1143
+ ]
1144
+ },
1145
+ "en:e555": {
1146
+ "concern": "watch",
1147
+ "e_number": "555",
1148
+ "name": "E555 - Potassium aluminium silicate",
1149
+ "non_nutritive_sweetener": false,
1150
+ "reasons": [
1151
+ "On ANSES' list of additives of interest"
1152
+ ]
1153
+ },
1154
+ "en:e556": {
1155
+ "concern": "watch",
1156
+ "e_number": "556",
1157
+ "name": "E556 - Calcium aluminium silicate",
1158
+ "non_nutritive_sweetener": false,
1159
+ "reasons": [
1160
+ "On ANSES' list of additives of interest"
1161
+ ]
1162
+ },
1163
+ "en:e558": {
1164
+ "concern": "watch",
1165
+ "e_number": "558",
1166
+ "name": "E558 - Bentonite",
1167
+ "non_nutritive_sweetener": false,
1168
+ "reasons": [
1169
+ "On ANSES' list of additives of interest"
1170
+ ]
1171
+ },
1172
+ "en:e559": {
1173
+ "concern": "watch",
1174
+ "e_number": "559",
1175
+ "name": "E559 - Aluminium silicate",
1176
+ "non_nutritive_sweetener": false,
1177
+ "reasons": [
1178
+ "On ANSES' list of additives of interest"
1179
+ ]
1180
+ },
1181
+ "en:e620": {
1182
+ "concern": "watch",
1183
+ "e_number": "620",
1184
+ "name": "E620 - Glutamic acid",
1185
+ "non_nutritive_sweetener": false,
1186
+ "reasons": [
1187
+ "On ANSES' list of additives of interest"
1188
+ ]
1189
+ },
1190
+ "en:e621": {
1191
+ "concern": "high",
1192
+ "e_number": "621",
1193
+ "name": "E621 - Monosodium glutamate",
1194
+ "non_nutritive_sweetener": false,
1195
+ "reasons": [
1196
+ "EFSA: high risk of exceeding the acceptable daily intake",
1197
+ "On ANSES' list of additives of interest"
1198
+ ]
1199
+ },
1200
+ "en:e622": {
1201
+ "concern": "watch",
1202
+ "e_number": "622",
1203
+ "name": "E622 - Monopotassium glutamate",
1204
+ "non_nutritive_sweetener": false,
1205
+ "reasons": [
1206
+ "On ANSES' list of additives of interest"
1207
+ ]
1208
+ },
1209
+ "en:e623": {
1210
+ "concern": "watch",
1211
+ "e_number": "623",
1212
+ "name": "E623 - Calcium diglutamate",
1213
+ "non_nutritive_sweetener": false,
1214
+ "reasons": [
1215
+ "On ANSES' list of additives of interest"
1216
+ ]
1217
+ },
1218
+ "en:e624": {
1219
+ "concern": "watch",
1220
+ "e_number": "624",
1221
+ "name": "E624 - Monoammonium glutamate",
1222
+ "non_nutritive_sweetener": false,
1223
+ "reasons": [
1224
+ "On ANSES' list of additives of interest"
1225
+ ]
1226
+ },
1227
+ "en:e625": {
1228
+ "concern": "watch",
1229
+ "e_number": "625",
1230
+ "name": "E625 - Magnesium diglutamate",
1231
+ "non_nutritive_sweetener": false,
1232
+ "reasons": [
1233
+ "On ANSES' list of additives of interest"
1234
+ ]
1235
+ },
1236
+ "en:e950": {
1237
+ "concern": "watch",
1238
+ "e_number": "950",
1239
+ "name": "E950 - Acesulfame k",
1240
+ "non_nutritive_sweetener": true,
1241
+ "reasons": [
1242
+ "On ANSES' list of additives of interest"
1243
+ ]
1244
+ },
1245
+ "en:e951": {
1246
+ "concern": "none",
1247
+ "e_number": "951",
1248
+ "name": "E951 - Aspartame",
1249
+ "non_nutritive_sweetener": true,
1250
+ "reasons": []
1251
+ },
1252
+ "en:e952": {
1253
+ "concern": "none",
1254
+ "e_number": "952",
1255
+ "name": "E952 - Cyclamic acid and its Na and Ca salts",
1256
+ "non_nutritive_sweetener": true,
1257
+ "reasons": []
1258
+ },
1259
+ "en:e954": {
1260
+ "concern": "none",
1261
+ "e_number": "954",
1262
+ "name": "E954 - saccharin and its salts",
1263
+ "non_nutritive_sweetener": true,
1264
+ "reasons": []
1265
+ },
1266
+ "en:e955": {
1267
+ "concern": "watch",
1268
+ "e_number": "955",
1269
+ "name": "E955 - Sucralose",
1270
+ "non_nutritive_sweetener": true,
1271
+ "reasons": [
1272
+ "On ANSES' list of additives of interest"
1273
+ ]
1274
+ },
1275
+ "en:e957": {
1276
+ "concern": "none",
1277
+ "e_number": "957",
1278
+ "name": "E957 - Thaumatin",
1279
+ "non_nutritive_sweetener": true,
1280
+ "reasons": []
1281
+ },
1282
+ "en:e959": {
1283
+ "concern": "none",
1284
+ "e_number": "959",
1285
+ "name": "E959 - Neohesperidine dihydrochalcone",
1286
+ "non_nutritive_sweetener": true,
1287
+ "reasons": []
1288
+ },
1289
+ "en:e960": {
1290
+ "concern": "moderate",
1291
+ "e_number": "960",
1292
+ "name": "E960 - Steviol glycosides",
1293
+ "non_nutritive_sweetener": true,
1294
+ "reasons": [
1295
+ "EFSA: moderate risk of exceeding the acceptable daily intake",
1296
+ "On ANSES' list of additives of interest"
1297
+ ]
1298
+ },
1299
+ "en:e960a": {
1300
+ "concern": "none",
1301
+ "e_number": "960a",
1302
+ "name": "E960a - Steviol glycosides from Stevia",
1303
+ "non_nutritive_sweetener": true,
1304
+ "reasons": []
1305
+ },
1306
+ "en:e961": {
1307
+ "concern": "none",
1308
+ "e_number": "961",
1309
+ "name": "E961 - Neotame",
1310
+ "non_nutritive_sweetener": true,
1311
+ "reasons": []
1312
+ },
1313
+ "en:e962": {
1314
+ "concern": "none",
1315
+ "e_number": "962",
1316
+ "name": "E962 - Salt of aspartame-acesulfame",
1317
+ "non_nutritive_sweetener": true,
1318
+ "reasons": []
1319
+ },
1320
+ "en:e969": {
1321
+ "concern": "none",
1322
+ "e_number": "969",
1323
+ "name": "E969 - Advantame",
1324
+ "non_nutritive_sweetener": true,
1325
+ "reasons": []
1326
+ }
1327
+ }
data/nutriscore_categories.json ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fat_oil_nuts_seeds": [
3
+ "en:almond-butters",
4
+ "en:almond-flakes",
5
+ "en:almond-peeled-unpeeled-or-blanched",
6
+ "en:almonds",
7
+ "en:almonds-roasted-and-unsalted",
8
+ "en:areca-nuts",
9
+ "en:black-sesame",
10
+ "en:black-walnuts",
11
+ "en:blanched-almonds",
12
+ "en:blanched-roasted-hazelnuts",
13
+ "en:blanched-shelled-hazelnuts",
14
+ "en:boiled-chestnuts",
15
+ "en:brazil-nuts",
16
+ "en:brown-flax-seeds",
17
+ "en:canned-chestnut",
18
+ "en:cashew-butters",
19
+ "en:cashew-nuts",
20
+ "en:cedar-nuts",
21
+ "en:chia",
22
+ "en:coated-peanuts",
23
+ "en:coconut-butters",
24
+ "en:coconut-kernel-in-syrup",
25
+ "en:coconuts",
26
+ "en:cotton-seeds",
27
+ "en:creamy-peanut-butters",
28
+ "en:creamy-pure-peanut-butters",
29
+ "en:crunchy-peanut-butters",
30
+ "en:crunchy-pure-peanut-butters",
31
+ "en:cucurbitacea-seeds",
32
+ "en:dried-coconut-kernel",
33
+ "en:european-pine",
34
+ "en:flax-seeds",
35
+ "en:fresh-chestnuts",
36
+ "en:fresh-immature-coconut-kernel",
37
+ "en:fresh-ripe-coconut-kernel",
38
+ "en:fresh-walnuts",
39
+ "en:gold-flax-seeds",
40
+ "en:grated-coconut",
41
+ "en:grilled-and-salted-macadamia-nut",
42
+ "en:grilled-chestnut",
43
+ "en:ground-almonds",
44
+ "en:ground-brown-flax-seeds",
45
+ "en:ground-flax-seeds",
46
+ "en:ground-gold-flax-seeds",
47
+ "en:hacked-pistachios",
48
+ "en:hazelnut-butters",
49
+ "en:hazelnut-grilled-salted",
50
+ "en:hazelnut-powders",
51
+ "en:hazelnuts",
52
+ "en:hazelnuts-from-cervione",
53
+ "en:hazelnuts-from-reus",
54
+ "en:hazelnuts-whole",
55
+ "en:husked-sesame-seed",
56
+ "en:kola-nuts",
57
+ "en:korean-pine",
58
+ "en:macadamia-nut-butters",
59
+ "en:macadamia-nuts",
60
+ "en:marcona-almonds",
61
+ "en:melon-seeds",
62
+ "en:mixed-nut-butters",
63
+ "en:mustard-seeds",
64
+ "en:non-blanched-almonds",
65
+ "en:non-roasted-cashew-nuts",
66
+ "en:non-roasted-pumpkin-seeds",
67
+ "en:non-roasted-unsalted-shelled-pumpkin-seeds",
68
+ "en:nonpareil-almonds",
69
+ "en:nut-butters",
70
+ "en:papaver-seeds",
71
+ "en:peanut-butters",
72
+ "en:peanut-grilled-salted",
73
+ "en:peanuts",
74
+ "en:peanuts-cooked-in-water-with-salt",
75
+ "en:pecan-nuts",
76
+ "en:pine-nuts",
77
+ "en:pine-nuts-plain",
78
+ "en:pine-nuts-roasted",
79
+ "en:pistachio-butters",
80
+ "en:pistachio-butters-100",
81
+ "en:pistachio-kernels",
82
+ "en:pistachios",
83
+ "en:pistachios-from-bronte",
84
+ "en:pistachios-from-raffadali",
85
+ "en:pistachios-roasted-and-salted",
86
+ "en:plain-pecan-nuts",
87
+ "en:plain-pistachios",
88
+ "en:pumpkin-seeds",
89
+ "en:pure-peanut-butters",
90
+ "en:raw-almonds",
91
+ "en:raw-cashew-nuts",
92
+ "en:raw-hazelnuts",
93
+ "en:roasted-almonds",
94
+ "en:roasted-and-salted-almonds",
95
+ "en:roasted-and-salted-pecan-nuts",
96
+ "en:roasted-cashew-nuts",
97
+ "en:roasted-hazelnuts",
98
+ "en:roasted-hazelnuts-with-skin",
99
+ "en:roasted-nuts",
100
+ "en:roasted-peanuts",
101
+ "en:roasted-pecan-nuts",
102
+ "en:roasted-pistachios",
103
+ "en:roasted-pumpkin-seeds",
104
+ "en:roasted-shelled-pumpkin-seeds",
105
+ "en:roasted-shelled-sunflower-seeds",
106
+ "en:roasted-sunflower-seeds",
107
+ "en:roasted-unshelled-pumpkin-seeds",
108
+ "en:roasted-unshelled-sunflower-seeds",
109
+ "en:safflower-seeds",
110
+ "en:salted-almonds",
111
+ "en:salted-brazil-nuts",
112
+ "en:salted-cashews",
113
+ "en:salted-peanuts",
114
+ "en:salted-pecan-nuts",
115
+ "en:salted-pistachios",
116
+ "en:sesame",
117
+ "en:shelled-almonds",
118
+ "en:shelled-brazil-nuts",
119
+ "en:shelled-chestnuts",
120
+ "en:shelled-hazelnuts",
121
+ "en:shelled-hazelnuts-with-skin",
122
+ "en:shelled-macadamia-nuts",
123
+ "en:shelled-pine-nuts",
124
+ "en:shelled-pumpkin-seeds",
125
+ "en:shelled-sunflower-seeds",
126
+ "en:siberian-pine",
127
+ "en:slivered-almonds",
128
+ "en:sunflower-seed-grilled-and-salted",
129
+ "en:sunflower-seeds",
130
+ "en:toasted-sesame",
131
+ "en:unroasted-almonds",
132
+ "en:unsalted-almonds",
133
+ "en:unsalted-brazil-nuts",
134
+ "en:unsalted-cashews",
135
+ "en:unsalted-hazelnuts",
136
+ "en:unsalted-pistachios",
137
+ "en:unshelled-almonds",
138
+ "en:unshelled-brazil-nuts",
139
+ "en:unshelled-hazelnuts",
140
+ "en:unshelled-peanuts",
141
+ "en:unshelled-pistachios",
142
+ "en:unshelled-pumpkin-seeds",
143
+ "en:unshelled-sunflower-seeds",
144
+ "en:walnut-butters",
145
+ "en:walnut-kernels",
146
+ "en:walnuts",
147
+ "en:walnuts-from-grenoble",
148
+ "en:walnuts-from-the-perigord",
149
+ "en:wasabi-coated-peanuts",
150
+ "en:white-almond-butters",
151
+ "en:whole-almond-purees",
152
+ "en:whole-almonds",
153
+ "es:almendra-de-mallorca",
154
+ "fr:amandes-hachees",
155
+ "fr:chataigne-d-ardeche",
156
+ "it:marrone-del-mugello",
157
+ "it:marrone-della-valle-di-susa",
158
+ "it:marrone-di-caprese-michelangelo",
159
+ "it:marrone-di-castel-del-rio",
160
+ "it:marrone-di-combai",
161
+ "it:marrone-di-roccadaspide",
162
+ "it:marrone-di-san-zeno",
163
+ "it:marrone-di-serino",
164
+ "it:pinoli-del-tirreno",
165
+ "nl:katjang-pedis",
166
+ "nl:witte-chiazaad"
167
+ ],
168
+ "red_meat": [
169
+ "de:bayerisches-rindfleisch",
170
+ "en:arrotolata-pancetta",
171
+ "en:back-bacon",
172
+ "en:bacon",
173
+ "en:bacon-covered-with-cornmeal",
174
+ "en:bacon-rashers",
175
+ "en:beef",
176
+ "en:beef-bacon",
177
+ "en:beef-blood",
178
+ "en:beef-bolar-blade",
179
+ "en:beef-cheek",
180
+ "en:beef-chuck",
181
+ "en:beef-flank-steak",
182
+ "en:beef-from-bazas",
183
+ "en:beef-from-chalosse",
184
+ "en:beef-from-charolles",
185
+ "en:beef-from-maine",
186
+ "en:beef-from-vendee",
187
+ "en:beef-front-quarter",
188
+ "en:beef-hanger-steak",
189
+ "en:beef-heart",
190
+ "en:beef-kidney",
191
+ "en:beef-knuckle",
192
+ "en:beef-livers",
193
+ "en:beef-marrowbone",
194
+ "en:beef-offals",
195
+ "en:beef-oxtail",
196
+ "en:beef-rib-steak",
197
+ "en:beef-ribs",
198
+ "en:beef-round",
199
+ "en:beef-rump-steak",
200
+ "en:beef-short-ribs",
201
+ "en:beef-shoulder",
202
+ "en:beef-sirloin-steak",
203
+ "en:beef-sirloin-steak-red-label",
204
+ "en:beef-steaks",
205
+ "en:beef-stew-meat",
206
+ "en:beef-thin-flank",
207
+ "en:beef-tongues",
208
+ "en:beef-topside",
209
+ "en:boiled-beef-knuckle",
210
+ "en:boiled-beef-oxtail",
211
+ "en:braised-beef",
212
+ "en:braised-beef-cheek",
213
+ "en:braised-beef-chuck",
214
+ "en:braised-beef-neck",
215
+ "en:braised-beef-short-ribs",
216
+ "en:braised-lamb-kidneys",
217
+ "en:braised-lamb-leg",
218
+ "en:braised-lamb-neck",
219
+ "en:braised-pork-brain",
220
+ "en:braised-pork-spare-ribs",
221
+ "en:braised-veal-shoulder",
222
+ "en:calf-brain",
223
+ "en:calf-foot",
224
+ "en:calf-kidney",
225
+ "en:calf-liver",
226
+ "en:calf-sweetbread",
227
+ "en:calf-tongue",
228
+ "en:cambrian-mountains-lamb",
229
+ "en:canned-sheep-meat",
230
+ "en:chop-grilled-lamb-rib",
231
+ "en:comeragh-mountain-lamb",
232
+ "en:connemara-hill-lamb",
233
+ "en:cooked-beef-heart",
234
+ "en:cooked-beef-kidney",
235
+ "en:cooked-beef-meat",
236
+ "en:cooked-beef-round",
237
+ "en:cooked-beef-tongue",
238
+ "en:cooked-calf-brain",
239
+ "en:cooked-calf-liver",
240
+ "en:cooked-calf-tongue",
241
+ "en:cooked-ground-beef-steaks",
242
+ "en:cooked-lamb-brains",
243
+ "en:cooked-lamb-chops",
244
+ "en:cooked-lamb-hearts",
245
+ "en:cooked-lamb-liver",
246
+ "en:cooked-lamb-meat",
247
+ "en:cooked-lamb-sweetbread",
248
+ "en:cooked-minced-beef-steak-with-10-fat",
249
+ "en:cooked-minced-beef-steak-with-15-fat",
250
+ "en:cooked-minced-beef-steak-with-20-fat",
251
+ "en:cooked-minced-beef-steak-with-5-fat",
252
+ "en:cooked-pork-filet-mignon",
253
+ "en:cooked-pork-ham-escalope",
254
+ "en:cooked-pork-kidneys",
255
+ "en:cooked-pork-liver",
256
+ "en:cooked-pork-loin",
257
+ "en:cooked-pork-meat",
258
+ "en:cooked-pork-racks",
259
+ "en:cooked-pork-roast",
260
+ "en:cooked-pork-round-steak",
261
+ "en:cooked-pork-shoulder",
262
+ "en:cooked-pork-shoulder-choice",
263
+ "en:cooked-pork-tenderloin-roast",
264
+ "en:cooked-sheep-meat",
265
+ "en:cooked-veal-escalopes",
266
+ "en:cooked-young-cow-liver",
267
+ "en:cooked-young-goat",
268
+ "en:coppata-pancetta",
269
+ "en:cured-pork-loin",
270
+ "en:cvarci-dry-bacon",
271
+ "en:devilled-pork-shoulder-in-mustard-sauce",
272
+ "en:dry-bacon",
273
+ "en:dry-cured-ham-with-fat-and-rind-removed",
274
+ "en:fleica-dry-bacon",
275
+ "en:french-pork",
276
+ "en:fresh-ground-beef-steaks",
277
+ "en:fried-pork-rind",
278
+ "en:frozen-beef-cheek",
279
+ "en:frozen-beef-flank-steak",
280
+ "en:frozen-beef-front-quarter",
281
+ "en:frozen-beef-knuckle",
282
+ "en:frozen-beef-marrowbone",
283
+ "en:frozen-beef-offals",
284
+ "en:frozen-beef-rib-steak",
285
+ "en:frozen-beef-ribs",
286
+ "en:frozen-beef-round",
287
+ "en:frozen-beef-rump-steak",
288
+ "en:frozen-beef-shoulder",
289
+ "en:frozen-beef-sirloin-steak",
290
+ "en:frozen-ground-beef-meats",
291
+ "en:frozen-ground-beef-steaks",
292
+ "en:frozen-ground-pork-meat",
293
+ "en:frozen-ground-sheep-meat",
294
+ "en:frozen-lamb-breast",
295
+ "en:frozen-lamb-fat",
296
+ "en:frozen-lamb-leg",
297
+ "en:frozen-lamb-loin",
298
+ "en:frozen-lamb-meat",
299
+ "en:frozen-lamb-offals",
300
+ "en:frozen-lamb-rack",
301
+ "en:frozen-lamb-shank",
302
+ "en:frozen-marbled-beef-ribs",
303
+ "en:frozen-mutton-shoulder",
304
+ "en:frozen-pork-belly",
305
+ "en:frozen-pork-jowl",
306
+ "en:frozen-pork-knuckle",
307
+ "en:frozen-pork-loin",
308
+ "en:frozen-pork-meat",
309
+ "en:frozen-pork-offals",
310
+ "en:frozen-pork-shoulders",
311
+ "en:frozen-pork-tenderloin-lean",
312
+ "en:frozen-pork-way-leg-without-rind-fat-and-bone",
313
+ "en:frozen-sheep-meat",
314
+ "en:frozen-veal-chop",
315
+ "en:frozen-veal-fillets",
316
+ "en:frozen-veal-knuckle",
317
+ "en:frozen-veal-neck",
318
+ "en:frozen-veal-offals",
319
+ "en:frozen-veal-rump-steak",
320
+ "en:frozen-veal-shin",
321
+ "en:frozen-veal-shoulder",
322
+ "en:goat-meat",
323
+ "en:gower-salt-marsh-lamb",
324
+ "en:grilled-beef-bolar-blade",
325
+ "en:grilled-beef-flank-steak",
326
+ "en:grilled-beef-hanger-steak",
327
+ "en:grilled-beef-rump-steak",
328
+ "en:grilled-beef-sirloin-steak",
329
+ "en:grilled-beef-steaks",
330
+ "en:grilled-beef-thin-flank",
331
+ "en:grilled-beef-topside",
332
+ "en:grilled-lamb-chop-fillet",
333
+ "en:grilled-lamb-chops",
334
+ "en:grilled-lamb-cutlet",
335
+ "en:grilled-lamb-leg",
336
+ "en:grilled-lamb-saddle",
337
+ "en:grilled-lean-beef-rib-steak",
338
+ "en:grilled-pork-chops",
339
+ "en:grilled-veal-chop",
340
+ "en:grilled-veal-shoulder",
341
+ "en:grilled-veal-tenderloin",
342
+ "en:ground-beef-meats",
343
+ "en:ground-beef-steaks",
344
+ "en:ground-pork-meat",
345
+ "en:ground-sheep-meat",
346
+ "en:horse-fresh-meat",
347
+ "en:horse-rib-steak",
348
+ "en:horse-sirloin-steak",
349
+ "en:horse-steak",
350
+ "en:horse-topside",
351
+ "en:isle-of-man-manx-loaghtan-lamb",
352
+ "en:lamb-bone",
353
+ "en:lamb-brain",
354
+ "en:lamb-breast",
355
+ "en:lamb-carcass",
356
+ "en:lamb-chop",
357
+ "en:lamb-chop-fillet",
358
+ "en:lamb-cutlet",
359
+ "en:lamb-fat",
360
+ "en:lamb-heart",
361
+ "en:lamb-kidneys",
362
+ "en:lamb-leg",
363
+ "en:lamb-liver",
364
+ "en:lamb-loin",
365
+ "en:lamb-meat",
366
+ "en:lamb-neck",
367
+ "en:lamb-offals",
368
+ "en:lamb-on-skewer",
369
+ "en:lamb-rack",
370
+ "en:lamb-rib-chop",
371
+ "en:lamb-saddle",
372
+ "en:lamb-shank",
373
+ "en:lamb-shoulder",
374
+ "en:lamb-sweetbread",
375
+ "en:lean-lamb-shoulder",
376
+ "en:lomo-embuchado",
377
+ "en:lomo-iberico",
378
+ "en:lomo-iberico-bellota",
379
+ "en:marbled-beef-ribs",
380
+ "en:minced-beef-steak-with-10-fat",
381
+ "en:minced-beef-steak-with-15-fat",
382
+ "en:minced-beef-steak-with-20-fat",
383
+ "en:minced-beef-steak-with-5-fat",
384
+ "en:mutton-leg",
385
+ "en:mutton-shoulder",
386
+ "en:njeguska-dry-bacon",
387
+ "en:olive-veal",
388
+ "en:orkney-beef",
389
+ "en:orkney-lamb",
390
+ "en:pan-fried-calf-sweetbread",
391
+ "en:pancetta",
392
+ "en:pig-head",
393
+ "en:pig-heart",
394
+ "en:pigs-in-a-blanket",
395
+ "en:pigs-in-blankets",
396
+ "en:pork",
397
+ "en:pork-80-20-trimming",
398
+ "en:pork-90-10-trimming",
399
+ "en:pork-belly",
400
+ "en:pork-belly-with-flank-removed",
401
+ "en:pork-brain",
402
+ "en:pork-eye-of-shortloin",
403
+ "en:pork-fat",
404
+ "en:pork-filet-mignon",
405
+ "en:pork-ham-escalope",
406
+ "en:pork-jowl",
407
+ "en:pork-kidney",
408
+ "en:pork-knuckle",
409
+ "en:pork-liver",
410
+ "en:pork-liver-mousse",
411
+ "en:pork-liver-mousse-superior-quality",
412
+ "en:pork-loin",
413
+ "en:pork-offals",
414
+ "en:pork-racks",
415
+ "en:pork-ribs",
416
+ "en:pork-rind",
417
+ "en:pork-roasts",
418
+ "en:pork-round-steak",
419
+ "en:pork-salami",
420
+ "en:pork-shoulder-lower-half-without-rind-fat-and-bone-raw",
421
+ "en:pork-shoulder-upper-half-without-rind-fat-and-bone",
422
+ "en:pork-shoulders",
423
+ "en:pork-spare-ribs",
424
+ "en:pork-tenderloin",
425
+ "en:pork-tenderloin-lean",
426
+ "en:pork-tongue",
427
+ "en:pork-way-leg-without-rind-fat-and-bone",
428
+ "en:prepared-veal-meat",
429
+ "en:preserved-pork-liver",
430
+ "en:refrigerated-ground-beef-meats",
431
+ "en:rindless-pork-back-fat",
432
+ "en:rindless-pork-jowl",
433
+ "en:rindless-short-cut-bacon",
434
+ "en:roast-beef",
435
+ "en:roasted-beef-bolar-blade",
436
+ "en:roasted-beef-sirloin-steak",
437
+ "en:roasted-beef-topside",
438
+ "en:roasted-lamb-leg",
439
+ "en:roasted-lamb-shoulders",
440
+ "en:roasted-lean-lamb-saddle",
441
+ "en:roasted-lean-lamb-shoulders",
442
+ "en:roasted-veal-fillet",
443
+ "en:roasted-veal-tenderloin",
444
+ "en:rolled-escalope-of-pork-with-pistachios",
445
+ "en:salami-ungherese",
446
+ "en:salt-cured-pork-belly",
447
+ "en:salt-cured-pork-ear",
448
+ "en:salt-cured-pork-trotters",
449
+ "en:sauteed-veal-kidney",
450
+ "en:sauteed-veal-loin",
451
+ "en:saxe-fillet",
452
+ "en:scotch-beef",
453
+ "en:scotch-lamb",
454
+ "en:sheep-carcass",
455
+ "en:sheep-foot",
456
+ "en:sheep-head",
457
+ "en:sheep-meat",
458
+ "en:shetland-lamb",
459
+ "en:slanina-dry-bacon",
460
+ "en:sliced-bacon",
461
+ "en:smoked-back-bacon",
462
+ "en:smoked-bacon",
463
+ "en:smoked-bacon-covered-with-cornmeal",
464
+ "en:smoked-bacon-lardons",
465
+ "en:smoked-cooked-beef-meat",
466
+ "en:smoked-cooked-pork-meat",
467
+ "en:smoked-pork-belly",
468
+ "en:smoked-rindless-back-bacon-rashers",
469
+ "en:spianata",
470
+ "en:standard-rindless-and-fatless-cooked-pork-shoulder",
471
+ "en:steccata-pancetta",
472
+ "en:stufata-pancetta",
473
+ "en:sunculita-dry-bacon",
474
+ "en:tesa-pancetta",
475
+ "en:traditionally-farmed-gloucestershire-old-spots-pork",
476
+ "en:traditionally-reared-pedigree-welsh-pork",
477
+ "en:unsmoked-back-bacon",
478
+ "en:unsmoked-bacon",
479
+ "en:unsmoked-rindless-back-bacon-rashers",
480
+ "en:veal-breast",
481
+ "en:veal-chop",
482
+ "en:veal-escalopes",
483
+ "en:veal-fillets",
484
+ "en:veal-heart",
485
+ "en:veal-knuckle",
486
+ "en:veal-loin",
487
+ "en:veal-meat",
488
+ "en:veal-medallion",
489
+ "en:veal-minced-steak-with-15-fat",
490
+ "en:veal-minced-steak-with-20-fat",
491
+ "en:veal-neck",
492
+ "en:veal-offals",
493
+ "en:veal-orloff",
494
+ "en:veal-paupiette-cooked-in-oven",
495
+ "en:veal-rump-steak",
496
+ "en:veal-shin",
497
+ "en:veal-shoulder",
498
+ "en:veal-tenderloins",
499
+ "en:ventricina",
500
+ "en:welsh-beef",
501
+ "en:welsh-lamb",
502
+ "en:west-country-beef",
503
+ "en:west-country-lamb",
504
+ "en:young-cow-liver",
505
+ "en:young-goat",
506
+ "fr:agneau-de-l-aveyron",
507
+ "fr:agneau-de-lait-des-pyrenees",
508
+ "fr:agneau-de-lozere",
509
+ "fr:agneau-de-pauillac",
510
+ "fr:agneau-de-sisteron",
511
+ "fr:agneau-du-bourbonnais",
512
+ "fr:agneau-du-limousin",
513
+ "fr:agneau-du-perigord",
514
+ "fr:agneau-du-poitou-charentes",
515
+ "fr:agneau-du-quercy",
516
+ "fr:boeuf-charolais-du-bourbonnais",
517
+ "fr:echine-de-porc",
518
+ "fr:echine-de-porc-rotie",
519
+ "fr:filets-mignons-de-porc",
520
+ "fr:genisse-fleur-d-aubrac",
521
+ "fr:maine-anjou",
522
+ "fr:palette-de-porc-fumee",
523
+ "fr:porc-d-auvergne",
524
+ "fr:porc-de-franche-comte",
525
+ "fr:porc-de-l-aveyron",
526
+ "fr:porc-de-la-sarthe",
527
+ "fr:porc-de-normandie",
528
+ "fr:porc-de-vendee",
529
+ "fr:porc-du-limousin",
530
+ "fr:porc-du-sud-ouest",
531
+ "fr:porc-noir-de-bigorre",
532
+ "fr:pres-sales-de-la-baie-de-somme",
533
+ "fr:pres-sales-du-mont-saint-michel",
534
+ "fr:ribs-de-boeuf-sauce-barbecue",
535
+ "fr:souris-d-agneau",
536
+ "fr:steak-hache-de-porc",
537
+ "fr:taureau-de-camargue",
538
+ "fr:veau-d-aveyron-et-du-segala",
539
+ "fr:veau-du-limousin",
540
+ "it:pancetta-di-calabria",
541
+ "it:pancetta-piacentina",
542
+ "sv:h\u00e5nnlamb",
543
+ "tr:uykuluk-\u015fi\u015f",
544
+ "xx:kakuni",
545
+ "xx:yakibuta"
546
+ ]
547
+ }
nutriweb/__init__.py ADDED
File without changes
nutriweb/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """DuckDB catalog access."""
nutriweb/data/catalog.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read access to the curated DuckDB catalog.
2
+
3
+ The catalog is built offline by `pipeline/` and downloaded from the Hugging
4
+ Face Hub at app startup. Everything here is read-only and query-time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import threading
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ import duckdb
15
+
16
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
17
+ DEFAULT_CATALOG = REPO_ROOT / "data" / "nutriweb-us.duckdb"
18
+
19
+ # Set to a HF dataset repo id to fetch the catalog at startup instead of
20
+ # expecting it on local disk (this is how the Space runs).
21
+ CATALOG_REPO = os.environ.get("NUTRIWEB_CATALOG_REPO", "")
22
+ CATALOG_FILENAME = "nutriweb-us.duckdb"
23
+
24
+ # Columns the UI needs. Selecting explicitly keeps result frames small --
25
+ # `catalog` carries the full tag arrays, which are heavy to materialise.
26
+ PRODUCT_COLUMNS = """
27
+ code, product_name, generic_name, brands, quantity, serving_size,
28
+ image_url, ingredients_text, primary_category, categories_tags,
29
+ ingredients_tags, allergens_tags, traces_tags, additives_tags,
30
+ labels_tags, ingredients_analysis_tags, nutrient_levels_tags,
31
+ energy_kcal_100g, energy_kj_derived, fat_100g, saturated_fat_100g,
32
+ carbohydrates_100g, sugars_100g, fiber_100g, proteins_100g,
33
+ salt_derived, sodium_100g, fruits_veg_derived,
34
+ nova_group, environmental_score_grade,
35
+ nutriscore_grade, nutriscore_grade_off, nutriscore_grade_computed,
36
+ nutriscore_score_computed, nutriscore_source,
37
+ health_score, health_confidence, additive_penalty, n_flagged_additives,
38
+ is_beverage, unique_scans_n
39
+ """
40
+
41
+
42
+ def catalog_path() -> Path:
43
+ """Local path to the catalog, downloading it from the Hub if configured."""
44
+ if CATALOG_REPO:
45
+ from huggingface_hub import hf_hub_download
46
+
47
+ return Path(
48
+ hf_hub_download(
49
+ repo_id=CATALOG_REPO, filename=CATALOG_FILENAME, repo_type="dataset"
50
+ )
51
+ )
52
+ return DEFAULT_CATALOG
53
+
54
+
55
+ @lru_cache(maxsize=1)
56
+ def _database() -> duckdb.DuckDBPyConnection:
57
+ """Open the catalog read-only, once per process."""
58
+ path = catalog_path()
59
+ if not path.exists():
60
+ raise FileNotFoundError(
61
+ f"Catalog not found at {path}. Build it with:\n"
62
+ " python pipeline/01_download.py\n"
63
+ " python pipeline/02_curate.py\n"
64
+ " python pipeline/03_score.py"
65
+ )
66
+ con = duckdb.connect(str(path), read_only=True)
67
+ _try_load_fts(con)
68
+ return con
69
+
70
+
71
+ def _try_load_fts(con: duckdb.DuckDBPyConnection) -> bool:
72
+ """Best-effort load of the full-text-search extension.
73
+
74
+ Must never raise. The extension is present on the machine that builds the
75
+ catalog but not in a fresh container, and an unconditional `LOAD fts` there
76
+ takes the whole app down on startup.
77
+
78
+ In practice the BM25 macros are persisted inside the catalog file and work
79
+ without the extension, so this is belt-and-braces: if it cannot be loaded
80
+ we fall back to a LIKE search rather than failing.
81
+ """
82
+ for statement in ("LOAD fts", "INSTALL fts; LOAD fts"):
83
+ try:
84
+ con.execute(statement)
85
+ return True
86
+ except duckdb.Error:
87
+ continue
88
+ return False
89
+
90
+
91
+ # Streamlit runs every user session on its own thread, and a DuckDB connection
92
+ # is not safe to share across them: two threads interleaving execute() and
93
+ # fetchdf() will consume each other's result sets, which surfaces as sporadic
94
+ # None results rather than a clean error. Each thread therefore gets its own
95
+ # cursor -- a lightweight handle onto the same open database, with its own
96
+ # result set.
97
+ _local = threading.local()
98
+
99
+
100
+ def connect() -> duckdb.DuckDBPyConnection:
101
+ """Return this thread's cursor onto the catalog."""
102
+ con = getattr(_local, "con", None)
103
+ if con is None:
104
+ con = _database().cursor()
105
+ _try_load_fts(con)
106
+ _local.con = con
107
+ return con
108
+
109
+
110
+ def get_product(code: str) -> dict | None:
111
+ """Fetch one product by barcode, tolerating leading-zero variants.
112
+
113
+ OFF stores EAN-13, but a scanner or a user may supply the UPC-A form
114
+ without the leading zero, so we try both.
115
+ """
116
+ code = str(code).strip()
117
+ con = connect()
118
+ row = con.execute(
119
+ f"SELECT {PRODUCT_COLUMNS} FROM catalog WHERE code = ?", [code]
120
+ ).fetchdf()
121
+ if row.empty and code.isdigit():
122
+ variants = [code.lstrip("0"), code.zfill(13), code.zfill(12)]
123
+ row = con.execute(
124
+ f"SELECT {PRODUCT_COLUMNS} FROM catalog WHERE code IN "
125
+ f"({','.join('?' * len(variants))}) LIMIT 1",
126
+ variants,
127
+ ).fetchdf()
128
+ return None if row.empty else row.iloc[0].to_dict()
129
+
130
+
131
+ def search(query: str, limit: int = 30) -> list[dict]:
132
+ """Full-text search over product name and brand.
133
+
134
+ Uses the BM25 index built in the pipeline. The app this replaces scanned
135
+ every row with `str.contains`, which does not rank and cannot use an index.
136
+ Results are biased toward products we can actually score and toward
137
+ popular items, so the first page is useful rather than merely matching.
138
+ """
139
+ query = (query or "").strip()
140
+ if not query:
141
+ return []
142
+
143
+ con = connect()
144
+ # A bare digit string is a barcode, not a search term.
145
+ if query.isdigit() and len(query) >= 8:
146
+ product = get_product(query)
147
+ return [product] if product else []
148
+
149
+ try:
150
+ df = con.execute(
151
+ f"""
152
+ WITH scored AS (
153
+ SELECT code, fts_main_products.match_bm25(code, ?) AS relevance
154
+ FROM products
155
+ )
156
+ SELECT {PRODUCT_COLUMNS}, relevance
157
+ FROM scored JOIN catalog USING (code)
158
+ WHERE relevance IS NOT NULL
159
+ ORDER BY
160
+ relevance * (CASE WHEN health_score IS NOT NULL THEN 1.0 ELSE 0.4 END)
161
+ * (1 + ln(1 + COALESCE(unique_scans_n, 0)) / 10) DESC
162
+ LIMIT ?
163
+ """,
164
+ [query, limit],
165
+ ).fetchdf()
166
+ return df.to_dict("records")
167
+ except duckdb.Error:
168
+ # The BM25 macros live in the catalog file and normally work without
169
+ # the extension, but if anything about full-text search is unavailable
170
+ # a degraded search beats a broken page.
171
+ return _search_without_fts(query, limit)
172
+
173
+
174
+ def _search_without_fts(query: str, limit: int) -> list[dict]:
175
+ """Substring search fallback, used only when BM25 is unavailable.
176
+
177
+ Ranks a name match above a brand match and a prefix above a mid-word hit,
178
+ then favours popular, scoreable products -- roughly what BM25 gives us,
179
+ without needing the extension.
180
+ """
181
+ pattern = f"%{query.lower()}%"
182
+ prefix = f"{query.lower()}%"
183
+ df = connect().execute(
184
+ f"""
185
+ SELECT {PRODUCT_COLUMNS}
186
+ FROM catalog
187
+ WHERE lower(product_name) LIKE ? OR lower(COALESCE(brands, '')) LIKE ?
188
+ ORDER BY
189
+ (CASE WHEN lower(product_name) LIKE ? THEN 2
190
+ WHEN lower(product_name) LIKE ? THEN 1
191
+ ELSE 0 END) DESC,
192
+ (CASE WHEN health_score IS NOT NULL THEN 1 ELSE 0 END) DESC,
193
+ COALESCE(unique_scans_n, 0) DESC
194
+ LIMIT ?
195
+ """,
196
+ [pattern, pattern, prefix, pattern, limit],
197
+ ).fetchdf()
198
+ return df.to_dict("records")
199
+
200
+
201
+ @lru_cache(maxsize=1)
202
+ def macro_stats() -> dict[str, tuple[float, float]]:
203
+ """Per-nutrient (mean, std) used to z-score the macro vector."""
204
+ from pipeline.config import MACRO_COLUMNS
205
+
206
+ row = connect().execute("SELECT * FROM macro_stats").fetchdf().iloc[0]
207
+ return {
208
+ col: (float(row[f"{col}_mean"]), float(row[f"{col}_std"]) or 1.0)
209
+ for col in MACRO_COLUMNS
210
+ }
211
+
212
+
213
+ def category_sizes(tags: list[str]) -> dict[str, int]:
214
+ """How many scored products sit under each of these category tags."""
215
+ if not tags:
216
+ return {}
217
+ placeholders = ",".join("?" * len(tags))
218
+ rows = connect().execute(
219
+ f"SELECT tag, n FROM category_sizes WHERE tag IN ({placeholders})", list(tags)
220
+ ).fetchall()
221
+ return dict(rows)
222
+
223
+
224
+ def stats() -> dict:
225
+ """Headline catalog numbers, shown on the Insights page."""
226
+ return connect().execute("""
227
+ SELECT
228
+ count(*) AS products,
229
+ count(health_score) AS scored,
230
+ count(*) FILTER (WHERE nutriscore_source = 'off') AS graded_by_off,
231
+ count(*) FILTER (WHERE nutriscore_source = 'nutriweb') AS graded_by_nutriweb,
232
+ count(*) FILTER (WHERE image_url IS NOT NULL) AS with_image,
233
+ count(DISTINCT primary_category) AS categories
234
+ FROM catalog
235
+ """).fetchdf().iloc[0].to_dict()
nutriweb/profile/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """User profiles and authentication."""
nutriweb/profile/auth.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accounts and profile persistence.
2
+
3
+ Two changes from the app this replaces:
4
+
5
+ * Passwords are hashed with bcrypt. The previous version stored them in
6
+ plaintext and compared with `user["password"] == login_pw`.
7
+ * MongoDB is optional. If no connection string is configured the app runs in
8
+ a session-only demo mode instead of crashing on import, so it can be
9
+ developed and reviewed without a database.
10
+
11
+ Set MONGODB_URI as a Hugging Face Space repository secret -- never in a file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass
18
+
19
+ import bcrypt
20
+ import streamlit as st
21
+
22
+ from nutriweb.profile.model import UserProfile
23
+
24
+ # bcrypt hashes at most 72 bytes and raises on anything longer (it used to
25
+ # truncate silently). Truncating here is the standard handling and keeps long
26
+ # passphrases usable.
27
+ BCRYPT_MAX_BYTES = 72
28
+
29
+
30
+ def _encode(password: str) -> bytes:
31
+ return (password or "").encode("utf-8")[:BCRYPT_MAX_BYTES]
32
+
33
+
34
+ def _hash(password: str) -> str:
35
+ return bcrypt.hashpw(_encode(password), bcrypt.gensalt()).decode()
36
+
37
+
38
+ @dataclass
39
+ class AuthResult:
40
+ ok: bool
41
+ message: str = ""
42
+ profile: UserProfile | None = None
43
+
44
+
45
+ def _uri() -> str:
46
+ """Read the connection string, environment first.
47
+
48
+ Order matters. Hugging Face Space secrets arrive as environment variables,
49
+ and a stale `.streamlit/secrets.toml` left in the working tree must never
50
+ be able to override an explicitly-set variable -- reading the file first
51
+ silently redirected connections to an old cluster, which is exactly the
52
+ kind of failure that is invisible until it matters.
53
+ """
54
+ from_env = os.environ.get("MONGODB_URI", "").strip()
55
+ if from_env:
56
+ return from_env
57
+ try:
58
+ if "MONGODB_URI" in st.secrets:
59
+ return str(st.secrets["MONGODB_URI"])
60
+ except Exception:
61
+ pass # no secrets.toml at all is the normal local state
62
+ return ""
63
+
64
+
65
+ @st.cache_resource(show_spinner=False)
66
+ def _collections():
67
+ """Return (users, history) collections, or (None, None) in demo mode."""
68
+ uri = _uri()
69
+ if not uri:
70
+ return None, None
71
+ try:
72
+ from pymongo import MongoClient
73
+
74
+ client = MongoClient(uri, serverSelectionTimeoutMS=5000)
75
+ client.admin.command("ping")
76
+ db_name = os.environ.get("MONGO_DB_NAME", "nutriweb_db")
77
+ try:
78
+ db_name = str(st.secrets.get("MONGO_DB_NAME", db_name))
79
+ except Exception:
80
+ pass
81
+ db = client[db_name]
82
+ return db["users"], db["history"]
83
+ except Exception as exc: # unreachable database must not break the app
84
+ st.warning(f"Could not reach the account database ({exc}). Running in demo mode.")
85
+ return None, None
86
+
87
+
88
+ def _verify(password: str, hashed: str) -> bool:
89
+ """Check a password without raising on a missing or malformed hash.
90
+
91
+ bcrypt raises on an empty or unrecognised hash string, which would turn a
92
+ corrupted record into a crash rather than a failed login.
93
+ """
94
+ if not hashed:
95
+ return False
96
+ try:
97
+ return bcrypt.checkpw(_encode(password), hashed.encode("utf-8"))
98
+ except (ValueError, TypeError):
99
+ return False
100
+
101
+
102
+ def demo_mode() -> bool:
103
+ users, _ = _collections()
104
+ return users is None
105
+
106
+
107
+ def _demo_store() -> dict:
108
+ return st.session_state.setdefault("_demo_users", {})
109
+
110
+
111
+ def register(user_id: str, password: str, profile: UserProfile) -> AuthResult:
112
+ user_id = (user_id or "").strip()
113
+ if not user_id or not password:
114
+ return AuthResult(False, "Pick a username and a password.")
115
+ if len(password) < 8:
116
+ return AuthResult(False, "Use at least 8 characters for the password.")
117
+
118
+ record = {"_id": user_id, "password_hash": _hash(password), **profile.to_dict()}
119
+ users, _ = _collections()
120
+
121
+ if users is None:
122
+ store = _demo_store()
123
+ if user_id in store:
124
+ return AuthResult(False, "That username is taken.")
125
+ store[user_id] = record
126
+ else:
127
+ from pymongo.errors import DuplicateKeyError
128
+
129
+ try:
130
+ users.insert_one(record)
131
+ except DuplicateKeyError:
132
+ return AuthResult(False, "That username is taken.")
133
+
134
+ profile.user_id = user_id
135
+ return AuthResult(True, "Account created.", profile)
136
+
137
+
138
+ def login(user_id: str, password: str) -> AuthResult:
139
+ users, _ = _collections()
140
+ record = _demo_store().get(user_id) if users is None else users.find_one({"_id": user_id})
141
+
142
+ if not record or not _verify(password, record.get("password_hash", "")):
143
+ # Deliberately vague: distinguishing the two leaks which accounts exist.
144
+ return AuthResult(False, "Incorrect username or password.")
145
+
146
+ return AuthResult(True, "", UserProfile.from_dict(record))
147
+
148
+
149
+ def save_profile(profile: UserProfile) -> None:
150
+ users, _ = _collections()
151
+ if users is None:
152
+ store = _demo_store()
153
+ if profile.user_id in store:
154
+ store[profile.user_id].update(profile.to_dict())
155
+ else:
156
+ users.update_one({"_id": profile.user_id}, {"$set": profile.to_dict()})
157
+
158
+
159
+ def log_view(user_id: str, product: dict, verdict_summary: str) -> None:
160
+ """Record that a user looked at a product, for the history panel."""
161
+ _, history = _collections()
162
+ entry = {
163
+ "user_id": user_id,
164
+ "code": product.get("code"),
165
+ "product_name": product.get("product_name"),
166
+ "brands": product.get("brands"),
167
+ "health_score": product.get("health_score"),
168
+ "verdict": verdict_summary,
169
+ }
170
+ if history is None:
171
+ st.session_state.setdefault("_demo_history", []).insert(0, entry)
172
+ else:
173
+ from datetime import datetime, timezone
174
+
175
+ history.insert_one({**entry, "timestamp": datetime.now(timezone.utc)})
176
+
177
+
178
+ def recent_views(user_id: str, limit: int = 25) -> list[dict]:
179
+ _, history = _collections()
180
+ if history is None:
181
+ return st.session_state.get("_demo_history", [])[:limit]
182
+ return list(
183
+ history.find({"user_id": user_id}).sort("timestamp", -1).limit(limit)
184
+ )
nutriweb/profile/model.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The user profile that drives filtering and ranking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ # Allergen options, mapped to OFF's canonical allergen tags. Using OFF's tags
8
+ # means we match on the curated `allergens_tags`/`traces_tags` arrays rather
9
+ # than guessing from ingredient text with regexes.
10
+ ALLERGEN_CHOICES: dict[str, str] = {
11
+ "Gluten": "en:gluten",
12
+ "Milk / Dairy": "en:milk",
13
+ "Eggs": "en:eggs",
14
+ "Peanuts": "en:peanuts",
15
+ "Tree nuts": "en:nuts",
16
+ "Soybeans": "en:soybeans",
17
+ "Fish": "en:fish",
18
+ "Crustaceans": "en:crustaceans",
19
+ "Molluscs": "en:molluscs",
20
+ "Sesame seeds": "en:sesame-seeds",
21
+ "Celery": "en:celery",
22
+ "Mustard": "en:mustard",
23
+ "Sulphites": "en:sulphur-dioxide-and-sulphites",
24
+ "Lupin": "en:lupin",
25
+ }
26
+
27
+ DIET_CHOICES = ["Vegetarian", "Vegan", "Palm-oil free"]
28
+
29
+ # Per-100g ceilings for health conditions.
30
+ #
31
+ # The app this replaces tested `sodium_100g > 5.0` -- five *grams* of sodium per
32
+ # 100 g, a level essentially no food reaches, so the rule never fired. These
33
+ # thresholds come from the FDA "high in" labelling guidance: a food is high in a
34
+ # nutrient at 20% of the Daily Value per serving. Expressed per 100 g against
35
+ # DVs of 2300 mg sodium and 20 g saturated fat.
36
+ SODIUM_CEILING_G_100G = 0.46 # 20% of the 2300 mg DV
37
+ SALT_CEILING_G_100G = SODIUM_CEILING_G_100G * 2.5
38
+ SATURATED_FAT_CEILING_G_100G = 4.0 # 20% of the 20 g DV
39
+
40
+
41
+ @dataclass
42
+ class UserProfile:
43
+ """Everything the engine needs to know about a person.
44
+
45
+ Allergens are stored as OFF tags, not display labels, so the filters are
46
+ exact set operations.
47
+ """
48
+
49
+ user_id: str = ""
50
+ age: int = 30
51
+ gender: str = "Prefer not to say"
52
+ allergens: list[str] = field(default_factory=list) # OFF tags, e.g. 'en:peanuts'
53
+ diets: list[str] = field(default_factory=list) # from DIET_CHOICES
54
+ high_blood_pressure: bool = False
55
+ high_cholesterol: bool = False
56
+ avoid_flagged_additives: bool = False
57
+
58
+ @property
59
+ def wants_vegan(self) -> bool:
60
+ return "Vegan" in self.diets
61
+
62
+ @property
63
+ def wants_vegetarian(self) -> bool:
64
+ # Vegan is stricter than vegetarian and implies it.
65
+ return "Vegetarian" in self.diets or self.wants_vegan
66
+
67
+ @property
68
+ def wants_palm_oil_free(self) -> bool:
69
+ return "Palm-oil free" in self.diets
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "age": self.age,
74
+ "gender": self.gender,
75
+ "allergens": self.allergens,
76
+ "diets": self.diets,
77
+ "high_blood_pressure": self.high_blood_pressure,
78
+ "high_cholesterol": self.high_cholesterol,
79
+ "avoid_flagged_additives": self.avoid_flagged_additives,
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> UserProfile:
84
+ return cls(
85
+ user_id=str(data.get("_id", data.get("user_id", ""))),
86
+ age=int(data.get("age", 30)),
87
+ gender=data.get("gender", "Prefer not to say"),
88
+ allergens=list(data.get("allergens", [])),
89
+ diets=list(data.get("diets", [])),
90
+ high_blood_pressure=bool(data.get("high_blood_pressure", False)),
91
+ high_cholesterol=bool(data.get("high_cholesterol", False)),
92
+ avoid_flagged_additives=bool(data.get("avoid_flagged_additives", False)),
93
+ )
nutriweb/reco/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Recommendation engine: candidate generation, profile filters, composite ranking."""
nutriweb/reco/engine.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The recommendation engine: healthier, similar, and safe for this person.
2
+
3
+ Three stages, in order:
4
+
5
+ 1. Candidate generation — products of the same *kind*. Constrained to a
6
+ shared category tag, which is what stops a soda from returning a candy
7
+ bar. Falls back to ingredient overlap for the ~49% of US products that
8
+ carry no category at all.
9
+
10
+ 2. Hard filters — allergens, diet, and health-condition ceilings, applied in
11
+ SQL before the LIMIT so the pool is filtered rather than truncated.
12
+
13
+ 3. Ranking — a weighted composite of health gain, macro similarity and
14
+ ingredient overlap, with every component returned so the UI can explain
15
+ each result rather than assert it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from nutriweb.data import catalog
26
+ from nutriweb.profile.model import UserProfile
27
+ from nutriweb.reco import filters, similarity
28
+
29
+ # Ranking weights. They sum to 1.0 and live here so they can be justified and
30
+ # tuned in one place rather than being scattered through the query.
31
+ W_HEALTH_GAIN = 0.45
32
+ W_MACRO = 0.30
33
+ W_INGREDIENT = 0.20
34
+ W_POPULARITY = 0.05
35
+
36
+ # A category tag must cover at least this many scored products to be a usable
37
+ # candidate pool; otherwise we widen to a more general tag.
38
+ MIN_POOL = 12
39
+
40
+ # Ceiling on how many candidates we pull into Python for ranking. Candidates
41
+ # are ordered by popularity so the cap keeps the well-known products.
42
+ MAX_CANDIDATES = 1500
43
+
44
+
45
+ @dataclass
46
+ class Recommendation:
47
+ product: dict
48
+ health_gain: float
49
+ macro_similarity: float
50
+ ingredient_similarity: float
51
+ score: float
52
+ shared_macros: list[str] = field(default_factory=list)
53
+ warnings: list[str] = field(default_factory=list)
54
+
55
+ @property
56
+ def why(self) -> str:
57
+ """One line explaining this specific swap."""
58
+ parts = [f"+{self.health_gain:.0f} health points"]
59
+ if self.shared_macros:
60
+ parts.append("similar " + ", ".join(self.shared_macros[:3]))
61
+ if self.ingredient_similarity >= 0.3:
62
+ parts.append(f"{self.ingredient_similarity * 100:.0f}% shared ingredients")
63
+ return " · ".join(parts)
64
+
65
+
66
+ from nutriweb.util import tag_set as _tag_set # noqa: E402
67
+
68
+
69
+ def choose_category(product: dict) -> str | None:
70
+ """Pick the most specific category tag with a large enough candidate pool.
71
+
72
+ `categories_tags` is not reliably ordered general-to-specific, so we rank
73
+ the product's tags by how many products sit under each and take the
74
+ smallest that still clears MIN_POOL. Smallest pool == most specific.
75
+ """
76
+ tags = [
77
+ t for t in _tag_set(product.get("categories_tags"))
78
+ if t not in ("en:null", "en:undefined")
79
+ ]
80
+ if not tags:
81
+ return None
82
+
83
+ sizes = catalog.category_sizes(tags)
84
+ usable = [(n, t) for t, n in sizes.items() if n >= MIN_POOL]
85
+ if usable:
86
+ return min(usable)[1] # smallest qualifying pool
87
+ # Nothing clears the bar; use the broadest tag we have rather than nothing.
88
+ return max(((n, t) for t, n in sizes.items()), default=(0, None))[1]
89
+
90
+
91
+ def _candidates_by_category(
92
+ category: str, code: str, min_health: float, where: str, params: list
93
+ ) -> pd.DataFrame:
94
+ return catalog.connect().execute(
95
+ f"""
96
+ SELECT {catalog.PRODUCT_COLUMNS}
97
+ FROM catalog
98
+ WHERE list_contains(categories_tags, ?)
99
+ AND code <> ?
100
+ AND health_score IS NOT NULL
101
+ AND health_score > ?
102
+ AND {where}
103
+ ORDER BY COALESCE(unique_scans_n, 0) DESC
104
+ LIMIT {MAX_CANDIDATES}
105
+ """,
106
+ [category, code, min_health, *params],
107
+ ).fetchdf()
108
+
109
+
110
+ def _candidates_by_ingredients(
111
+ ingredient_tags: list[str], code: str, min_health: float, where: str, params: list
112
+ ) -> pd.DataFrame:
113
+ """Fallback for products with no category: nearest by ingredient overlap."""
114
+ if not ingredient_tags:
115
+ return pd.DataFrame()
116
+ return catalog.connect().execute(
117
+ f"""
118
+ SELECT {catalog.PRODUCT_COLUMNS},
119
+ len(list_intersect(ingredients_tags, ?::VARCHAR[])) AS overlap
120
+ FROM catalog
121
+ WHERE list_has_any(COALESCE(ingredients_tags, []::VARCHAR[]), ?::VARCHAR[])
122
+ AND code <> ?
123
+ AND health_score IS NOT NULL
124
+ AND health_score > ?
125
+ AND {where}
126
+ ORDER BY overlap DESC, COALESCE(unique_scans_n, 0) DESC
127
+ LIMIT {MAX_CANDIDATES}
128
+ """,
129
+ [ingredient_tags, ingredient_tags, code, min_health, *params],
130
+ ).fetchdf()
131
+
132
+
133
+ def recommend(
134
+ product: dict, profile: UserProfile, top_n: int = 8
135
+ ) -> tuple[list[Recommendation], dict]:
136
+ """Return ranked healthier alternatives plus a note on how they were found.
137
+
138
+ The second element describes the search (which category was used, how many
139
+ candidates survived filtering) so the UI can be honest when results are
140
+ thin rather than silently showing three items.
141
+ """
142
+ source_health = product.get("health_score")
143
+ if source_health is None or pd.isna(source_health):
144
+ return [], {"reason": "unscored", "message": "This product has no health score, so we cannot compare it."}
145
+
146
+ source_health = float(source_health)
147
+ where, params = filters.sql_exclusions(profile)
148
+
149
+ category = choose_category(product)
150
+ if category:
151
+ frame = _candidates_by_category(
152
+ category, product["code"], source_health, where, params
153
+ )
154
+ basis = {"mode": "category", "category": category}
155
+ else:
156
+ frame = _candidates_by_ingredients(
157
+ sorted(_tag_set(product.get("ingredients_tags"))),
158
+ product["code"], source_health, where, params,
159
+ )
160
+ basis = {"mode": "ingredients", "category": None}
161
+
162
+ basis["pool"] = len(frame)
163
+ if frame.empty:
164
+ basis.update(_diagnose_empty(product, source_health, category))
165
+ return [], basis
166
+
167
+ ranked = _rank(product, frame, source_health, profile, top_n)
168
+ basis["returned"] = len(ranked)
169
+ return ranked, basis
170
+
171
+
172
+ def _diagnose_empty(product: dict, source_health: float, category: str | None) -> dict:
173
+ """Work out *why* there are no results, so the UI can say something true.
174
+
175
+ An empty pool has three quite different causes, and telling a user their
176
+ filters were too strict when the real answer is "this is already the best
177
+ product in its category" is simply wrong.
178
+ """
179
+ if category is None:
180
+ return {
181
+ "reason": "no_candidates",
182
+ "message": "We couldn't find comparable products to rank this against.",
183
+ }
184
+
185
+ # Is anything in this category healthier at all, ignoring the profile?
186
+ unfiltered = catalog.connect().execute(
187
+ """SELECT count(*) FROM catalog
188
+ WHERE list_contains(categories_tags, ?) AND code <> ?
189
+ AND health_score IS NOT NULL AND health_score > ?""",
190
+ [category, product["code"], source_health],
191
+ ).fetchone()[0]
192
+
193
+ if unfiltered == 0:
194
+ label = str(category).split(":", 1)[-1].replace("-", " ")
195
+ return {
196
+ "reason": "already_best",
197
+ "message": (
198
+ f"Nothing in {label} scores higher — this is already among the "
199
+ f"healthiest options we have ({source_health:.0f}/100)."
200
+ ),
201
+ }
202
+
203
+ return {
204
+ "reason": "filtered_out",
205
+ "filtered_out": unfiltered,
206
+ "message": (
207
+ f"{unfiltered} healthier option exists, but it did not pass your "
208
+ f"allergen and diet filters."
209
+ if unfiltered == 1
210
+ else f"{unfiltered} healthier options exist, but none passed your "
211
+ f"allergen and diet filters."
212
+ ),
213
+ }
214
+
215
+
216
+ def _rank(
217
+ product: dict,
218
+ frame: pd.DataFrame,
219
+ source_health: float,
220
+ profile: UserProfile,
221
+ top_n: int,
222
+ ) -> list[Recommendation]:
223
+ stats = catalog.macro_stats()
224
+
225
+ source_macros = similarity.macro_vector(product, stats)
226
+ candidate_macros = similarity.macro_matrix(frame, stats)
227
+ macro_sim = similarity.macro_similarity(source_macros, candidate_macros)
228
+
229
+ source_ingredients = _tag_set(product.get("ingredients_tags"))
230
+ ingredient_sim = similarity.jaccard(
231
+ source_ingredients, [_tag_set(t) for t in frame["ingredients_tags"]]
232
+ )
233
+
234
+ health = frame["health_score"].astype(float).to_numpy()
235
+ gain = health - source_health
236
+ # Normalise gain against the best available so weights stay comparable.
237
+ gain_norm = gain / gain.max() if gain.max() > 0 else np.zeros_like(gain)
238
+
239
+ scans = frame["unique_scans_n"].fillna(0).astype(float).to_numpy()
240
+ popularity = np.log1p(scans)
241
+ popularity = popularity / popularity.max() if popularity.max() > 0 else popularity
242
+
243
+ total = (
244
+ W_HEALTH_GAIN * gain_norm
245
+ + W_MACRO * macro_sim
246
+ + W_INGREDIENT * ingredient_sim
247
+ + W_POPULARITY * popularity
248
+ )
249
+
250
+ order = np.argsort(-total)[: top_n * 3]
251
+ results: list[Recommendation] = []
252
+ seen_names: set[str] = set()
253
+
254
+ for i in order:
255
+ row = frame.iloc[int(i)].to_dict()
256
+ # OFF holds many near-identical listings of the same item; collapsing on
257
+ # name keeps the results a real choice rather than one product repeated.
258
+ key = (str(row.get("product_name") or "").strip().lower(), str(row.get("brands") or "").lower())
259
+ if key in seen_names:
260
+ continue
261
+ seen_names.add(key)
262
+
263
+ verdict = filters.evaluate(row, profile)
264
+ if not verdict.passed: # belt and braces; SQL should have excluded these
265
+ continue
266
+
267
+ results.append(
268
+ Recommendation(
269
+ product=row,
270
+ health_gain=float(gain[i]),
271
+ macro_similarity=float(macro_sim[i]),
272
+ ingredient_similarity=float(ingredient_sim[i]),
273
+ score=float(total[i]),
274
+ shared_macros=similarity.shared_macros(product, row),
275
+ warnings=verdict.warnings,
276
+ )
277
+ )
278
+ if len(results) >= top_n:
279
+ break
280
+
281
+ return results
nutriweb/reco/filters.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hard safety filters — the rules a recommendation must never violate.
2
+
3
+ These are deliberately separate from ranking. A product that fails any of these
4
+ is removed, never merely demoted, so an allergen can't be outweighed by a good
5
+ health score.
6
+
7
+ The same functions power the "why this is / isn't for you" panel on the product
8
+ page, so what the user is told always matches what the engine actually did.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from nutriweb.profile.model import (
16
+ SALT_CEILING_G_100G,
17
+ SATURATED_FAT_CEILING_G_100G,
18
+ UserProfile,
19
+ )
20
+ from nutriweb.scoring import additives as additives_mod
21
+ from nutriweb.util import num, tag_set
22
+
23
+
24
+ @dataclass
25
+ class Verdict:
26
+ """Why a product passed or failed, in the user's terms."""
27
+
28
+ passed: bool
29
+ blockers: list[str]
30
+ warnings: list[str]
31
+
32
+ @property
33
+ def summary(self) -> str:
34
+ if self.blockers:
35
+ return "Not suitable for you"
36
+ if self.warnings:
37
+ return "Check before buying"
38
+ return "Fits your profile"
39
+
40
+
41
+ def _tags(product: dict, key: str) -> set[str]:
42
+ return tag_set(product.get(key))
43
+
44
+
45
+ def _num(product: dict, key: str) -> float | None:
46
+ return num(product.get(key))
47
+
48
+
49
+ def evaluate(product: dict, profile: UserProfile) -> Verdict:
50
+ """Check a product against a profile.
51
+
52
+ Blockers exclude a product outright. Warnings are surfaced but do not
53
+ exclude -- traces and health-condition ceilings are advisory, because a
54
+ "may contain" label and a genuine ingredient are different risks.
55
+ """
56
+ blockers: list[str] = []
57
+ warnings: list[str] = []
58
+
59
+ allergens = set(profile.allergens)
60
+ if allergens:
61
+ contains = allergens & _tags(product, "allergens_tags")
62
+ if contains:
63
+ blockers.append(f"Contains {_label(contains)}")
64
+
65
+ traces = allergens & _tags(product, "traces_tags")
66
+ if traces:
67
+ warnings.append(f"May contain traces of {_label(traces)}")
68
+
69
+ analysis = _tags(product, "ingredients_analysis_tags")
70
+ if profile.wants_vegan:
71
+ if "en:non-vegan" in analysis:
72
+ blockers.append("Not vegan")
73
+ elif "en:vegan" not in analysis:
74
+ warnings.append("Vegan status unconfirmed")
75
+ elif profile.wants_vegetarian:
76
+ if "en:non-vegetarian" in analysis:
77
+ blockers.append("Not vegetarian")
78
+ elif "en:vegetarian" not in analysis:
79
+ warnings.append("Vegetarian status unconfirmed")
80
+
81
+ if profile.wants_palm_oil_free and "en:palm-oil" in analysis:
82
+ blockers.append("Contains palm oil")
83
+
84
+ if profile.high_blood_pressure:
85
+ salt = _num(product, "salt_derived")
86
+ if salt is not None and salt > SALT_CEILING_G_100G:
87
+ warnings.append(f"High in salt ({salt:.1f} g/100 g) — you flagged high blood pressure")
88
+
89
+ if profile.high_cholesterol:
90
+ satfat = _num(product, "saturated_fat_100g")
91
+ if satfat is not None and satfat > SATURATED_FAT_CEILING_G_100G:
92
+ warnings.append(
93
+ f"High in saturated fat ({satfat:.1f} g/100 g) — you flagged high cholesterol"
94
+ )
95
+
96
+ if profile.avoid_flagged_additives:
97
+ flagged = additives_mod.concerns(list(_tags(product, "additives_tags")))
98
+ high = [a for a in flagged if a["concern"] == "high"]
99
+ if high:
100
+ blockers.append(
101
+ "Contains additives flagged by EFSA: "
102
+ + ", ".join(a["name"] for a in high[:3])
103
+ )
104
+
105
+ return Verdict(passed=not blockers, blockers=blockers, warnings=warnings)
106
+
107
+
108
+ def _label(tags: set[str]) -> str:
109
+ """Turn OFF tags into readable text: {'en:peanuts'} -> 'peanuts'."""
110
+ return ", ".join(sorted(t.split(":", 1)[-1].replace("-", " ") for t in tags))
111
+
112
+
113
+ def sql_exclusions(profile: UserProfile) -> tuple[str, list]:
114
+ """Push the blocking filters into SQL so candidates are excluded up front.
115
+
116
+ Mirrors the blockers in `evaluate`. Doing this in the database avoids
117
+ pulling thousands of rows into Python only to discard them, and keeps the
118
+ candidate pool honest -- filtering after a LIMIT would silently shrink it.
119
+ """
120
+ clauses: list[str] = []
121
+ params: list = []
122
+
123
+ if profile.allergens:
124
+ clauses.append(
125
+ "NOT list_has_any(COALESCE(allergens_tags, []::VARCHAR[]), ?::VARCHAR[])"
126
+ )
127
+ params.append(list(profile.allergens))
128
+
129
+ if profile.wants_vegan:
130
+ clauses.append(
131
+ "NOT list_contains(COALESCE(ingredients_analysis_tags, []::VARCHAR[]), 'en:non-vegan')"
132
+ )
133
+ elif profile.wants_vegetarian:
134
+ clauses.append(
135
+ "NOT list_contains(COALESCE(ingredients_analysis_tags, []::VARCHAR[]), "
136
+ "'en:non-vegetarian')"
137
+ )
138
+
139
+ if profile.wants_palm_oil_free:
140
+ clauses.append(
141
+ "NOT list_contains(COALESCE(ingredients_analysis_tags, []::VARCHAR[]), 'en:palm-oil')"
142
+ )
143
+
144
+ if profile.high_blood_pressure:
145
+ clauses.append(f"(salt_derived IS NULL OR salt_derived <= {SALT_CEILING_G_100G})")
146
+
147
+ if profile.high_cholesterol:
148
+ clauses.append(
149
+ f"(saturated_fat_100g IS NULL OR "
150
+ f"saturated_fat_100g <= {SATURATED_FAT_CEILING_G_100G})"
151
+ )
152
+
153
+ return (" AND ".join(clauses) if clauses else "TRUE"), params
nutriweb/reco/similarity.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Similarity between products, on two axes the user cares about.
2
+
3
+ The app this replaces used sentence-transformer embeddings over ingredient
4
+ text, searched with FAISS. That approach had three problems: the index was
5
+ rebuilt on every query, the embeddings had to be downloaded from Google Drive,
6
+ and the similarity it measured was over free text that OFF has *already*
7
+ canonicalised.
8
+
9
+ Open Food Facts resolves every ingredient to a taxonomy tag, so set overlap on
10
+ `ingredients_tags` measures the same thing exactly, in microseconds, with no
11
+ model. Macro similarity is a plain distance over the nutrient vector. Both are
12
+ explainable to the user, which embeddings never were.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ from nutriweb.util import num
20
+ from pipeline.config import MACRO_COLUMNS
21
+
22
+
23
+ def macro_vector(product: dict, stats: dict[str, tuple[float, float]]) -> np.ndarray:
24
+ """Z-scored nutrient vector. Missing nutrients sit at the mean (z = 0).
25
+
26
+ Standardising matters because the raw units are wildly different: energy is
27
+ hundreds of kcal while salt is under a gram, so an unscaled distance would
28
+ be energy and nothing else.
29
+ """
30
+ out = np.zeros(len(MACRO_COLUMNS), dtype=np.float32)
31
+ for i, col in enumerate(MACRO_COLUMNS):
32
+ value = num(product.get(col))
33
+ if value is None:
34
+ continue
35
+ mean, std = stats.get(col, (0.0, 1.0))
36
+ out[i] = (value - mean) / (std or 1.0)
37
+ return out
38
+
39
+
40
+ def macro_matrix(frame, stats: dict[str, tuple[float, float]]) -> np.ndarray:
41
+ """Vectorised `macro_vector` over a DataFrame of candidates."""
42
+ cols = []
43
+ for col in MACRO_COLUMNS:
44
+ mean, std = stats.get(col, (0.0, 1.0))
45
+ values = frame[col].astype("float32").to_numpy()
46
+ cols.append(np.nan_to_num((values - mean) / (std or 1.0), nan=0.0))
47
+ return np.column_stack(cols).astype(np.float32)
48
+
49
+
50
+ def macro_similarity(source: np.ndarray, candidates: np.ndarray) -> np.ndarray:
51
+ """Similarity in [0, 1] from Euclidean distance in z-space.
52
+
53
+ Distance rather than cosine: for nutrition, *magnitude* matters. Cosine
54
+ would call a 500 kcal bar and a 50 kcal snack identical if their nutrient
55
+ ratios matched, which is exactly the swap a user would reject.
56
+ """
57
+ distance = np.linalg.norm(candidates - source[None, :], axis=1)
58
+ # Scaled so a distance of one standard deviation lands near 0.5.
59
+ return 1.0 / (1.0 + distance / np.sqrt(len(MACRO_COLUMNS)))
60
+
61
+
62
+ def jaccard(source_tags: set[str], candidate_tags: list[set[str]]) -> np.ndarray:
63
+ """Ingredient-set overlap: |A ∩ B| / |A ∪ B| for each candidate."""
64
+ if not source_tags:
65
+ return np.zeros(len(candidate_tags), dtype=np.float32)
66
+ out = np.zeros(len(candidate_tags), dtype=np.float32)
67
+ for i, tags in enumerate(candidate_tags):
68
+ if not tags:
69
+ continue
70
+ union = len(source_tags | tags)
71
+ if union:
72
+ out[i] = len(source_tags & tags) / union
73
+ return out
74
+
75
+
76
+ def shared_macros(source: dict, candidate: dict, tolerance: float = 0.25) -> list[str]:
77
+ """Which macros are within `tolerance` relative difference — for the "why" line."""
78
+ labels = {
79
+ "energy_kcal_100g": "calories",
80
+ "proteins_100g": "protein",
81
+ "carbohydrates_100g": "carbs",
82
+ "fat_100g": "fat",
83
+ "sugars_100g": "sugar",
84
+ "fiber_100g": "fibre",
85
+ }
86
+ matches = []
87
+ for col, label in labels.items():
88
+ a, b = num(source.get(col)), num(candidate.get(col))
89
+ if a is None or b is None:
90
+ continue
91
+ scale = max(abs(a), abs(b), 1e-6)
92
+ if abs(a - b) / scale <= tolerance:
93
+ matches.append(label)
94
+ return matches
nutriweb/scoring/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Scoring: Nutri-Score 2023, additive concern levels, and the NutriWeb health score."""
nutriweb/scoring/additives.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Additive concern lookup, backed by OFF's EFSA/ANSES-sourced taxonomy.
2
+
3
+ Replaces the substring matching in the old `nutriweb/risk_levels.py`. Lookups
4
+ are exact on OFF's canonical additive tags (`en:e250`), so "sugar-free" can no
5
+ longer be flagged as containing sugar.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ DATA_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "additives.json"
15
+
16
+ # How much each concern level costs in the health score, in points out of 100.
17
+ CONCERN_PENALTY = {"high": 6.0, "moderate": 3.0, "watch": 1.5, "none": 0.0}
18
+
19
+ # Cap the total additive penalty so a long ingredient list cannot dominate the
20
+ # nutrition signal outright.
21
+ MAX_ADDITIVE_PENALTY = 18.0
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def _table() -> dict[str, dict]:
26
+ if not DATA_PATH.exists():
27
+ raise FileNotFoundError(
28
+ f"{DATA_PATH} missing. Run: python pipeline/additives_taxonomy.py"
29
+ )
30
+ return json.loads(DATA_PATH.read_text())
31
+
32
+
33
+ def lookup(tag: str) -> dict | None:
34
+ """Return the concern record for an OFF additive tag, or None if unflagged."""
35
+ return _table().get(tag)
36
+
37
+
38
+ def concerns(additives_tags: list[str] | None) -> list[dict]:
39
+ """Flagged additives in a product, worst first, for display."""
40
+ order = {"high": 0, "moderate": 1, "watch": 2}
41
+ found = [
42
+ {"tag": tag, **meta}
43
+ for tag in (additives_tags or ())
44
+ if (meta := lookup(tag)) and meta["concern"] != "none"
45
+ ]
46
+ return sorted(found, key=lambda a: order.get(a["concern"], 9))
47
+
48
+
49
+ def penalty(additives_tags: list[str] | None) -> float:
50
+ """Total health-score penalty from a product's additives, capped."""
51
+ total = sum(CONCERN_PENALTY.get(a["concern"], 0.0) for a in concerns(additives_tags))
52
+ return min(total, MAX_ADDITIVE_PENALTY)
53
+
54
+
55
+ def has_non_nutritive_sweetener(additives_tags: list[str] | None) -> bool:
56
+ """Whether any additive is a non-nutritive sweetener.
57
+
58
+ Drives the Nutri-Score 2023 beverage penalty of 4 negative points.
59
+ """
60
+ return any(
61
+ (meta := lookup(tag)) and meta.get("non_nutritive_sweetener")
62
+ for tag in (additives_tags or ())
63
+ )
nutriweb/scoring/health_score.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The NutriWeb health score — one 0-100 number used to rank alternatives.
2
+
3
+ Why a composite rather than the Nutri-Score alone
4
+ -------------------------------------------------
5
+ Nutri-Score grades nutrition but says nothing about processing or additives:
6
+ a diet cola and a sparkling water can land in the same band. NOVA grades
7
+ processing but ignores nutrition entirely. Ranking "healthier" on either one
8
+ alone produces swaps users don't accept.
9
+
10
+ So the score blends three sourced signals, each weighted and each individually
11
+ explainable in the UI:
12
+
13
+ * Nutri-Score 2023 (70) — nutritional quality, computed by us when OFF has
14
+ no grade, which covers 1.7x more of the US catalog.
15
+ * NOVA group (30) — degree of industrial processing.
16
+ * Additives (-18 max) — EFSA/ANSES-flagged additives, subtracted.
17
+
18
+ Higher is better. The weights are deliberately blunt and declared in one place
19
+ so they can be tuned and justified rather than buried in the ranking code.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ from nutriweb.scoring import additives as additives_mod
27
+
28
+ NUTRISCORE_WEIGHT = 70.0
29
+ NOVA_WEIGHT = 30.0
30
+
31
+ # Nutri-Score letters map onto the nutrition component linearly.
32
+ GRADE_POINTS = {"a": 1.0, "b": 0.75, "c": 0.5, "d": 0.25, "e": 0.0}
33
+
34
+ # NOVA 1 (unprocessed) is best, NOVA 4 (ultra-processed) worst.
35
+ NOVA_POINTS = {1: 1.0, 2: 0.75, 3: 0.4, 4: 0.0}
36
+
37
+ # Neutral prior for products with no NOVA group.
38
+ #
39
+ # The obvious alternative -- rescaling the Nutri-Score component to the full
40
+ # 0-100 range when NOVA is absent -- breaks comparability, which is fatal here
41
+ # because these scores are used to *rank*. It gave a grade-B diet soda with no
42
+ # NOVA data a 75 while a grade-A product known to be NOVA 4 scored 70, so
43
+ # missing metadata beat measured quality. A neutral prior keeps every product
44
+ # on one scale; the cost is visible in `confidence`, not hidden in the number.
45
+ NOVA_PRIOR = 0.5
46
+
47
+
48
+ @dataclass
49
+ class HealthScore:
50
+ value: float
51
+ nutriscore_component: float | None
52
+ nova_component: float | None
53
+ additive_penalty: float
54
+ confidence: str # 'high' | 'medium' | 'low'
55
+
56
+ def __post_init__(self) -> None:
57
+ self.value = round(self.value, 1)
58
+
59
+
60
+ def compute(
61
+ *,
62
+ grade: str | None,
63
+ nova_group: int | None,
64
+ additives_tags: list[str] | None,
65
+ grade_is_computed: bool = False,
66
+ category_known: bool = True,
67
+ ) -> HealthScore | None:
68
+ """Blend the available signals into a 0-100 score.
69
+
70
+ Returns None when neither a Nutri-Score grade nor a NOVA group is known —
71
+ we would be inventing a number, and an invented number would rank.
72
+
73
+ When only one of the two is present its weight is rescaled to the full
74
+ range, so a product with a grade but no NOVA is not penalised for OFF's
75
+ missing metadata.
76
+ """
77
+ ns_component = (
78
+ GRADE_POINTS.get(grade.lower()) * NUTRISCORE_WEIGHT
79
+ if grade and grade.lower() in GRADE_POINTS
80
+ else None
81
+ )
82
+ nova_component = (
83
+ NOVA_POINTS.get(int(nova_group)) * NOVA_WEIGHT
84
+ if nova_group is not None and int(nova_group) in NOVA_POINTS
85
+ else None
86
+ )
87
+
88
+ if ns_component is None and nova_component is None:
89
+ return None
90
+
91
+ # Substitute neutral priors for whichever signal is missing so every
92
+ # product is scored on the same 0-100 scale and remains comparable.
93
+ base = (
94
+ (ns_component if ns_component is not None else 0.5 * NUTRISCORE_WEIGHT)
95
+ + (nova_component if nova_component is not None else NOVA_PRIOR * NOVA_WEIGHT)
96
+ )
97
+
98
+ penalty = additives_mod.penalty(additives_tags)
99
+ value = max(0.0, min(100.0, base - penalty))
100
+
101
+ return HealthScore(
102
+ value=value,
103
+ nutriscore_component=ns_component,
104
+ nova_component=nova_component,
105
+ additive_penalty=penalty,
106
+ confidence=_confidence(
107
+ has_nutriscore=ns_component is not None,
108
+ has_nova=nova_component is not None,
109
+ grade_is_computed=grade_is_computed,
110
+ category_known=category_known,
111
+ ),
112
+ )
113
+
114
+
115
+ def _confidence(
116
+ *,
117
+ has_nutriscore: bool,
118
+ has_nova: bool,
119
+ grade_is_computed: bool,
120
+ category_known: bool,
121
+ ) -> str:
122
+ """How much to trust this score, surfaced in the UI rather than hidden.
123
+
124
+ A grade we computed for a product with no category is the weakest case: we
125
+ had to assume the general-food variant, and a mis-assumed beverage or fat
126
+ is graded on the wrong thresholds entirely.
127
+ """
128
+ if grade_is_computed and not category_known:
129
+ return "low"
130
+ if has_nutriscore and has_nova:
131
+ return "high"
132
+ return "medium"
nutriweb/scoring/nutriscore.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nutri-Score 2023 (v2) — a faithful port of Open Food Facts' reference implementation.
2
+
3
+ Why this exists
4
+ ---------------
5
+ 58% of US products in Open Food Facts carry `nutriscore_grade = 'unknown'`. Nutri-Score
6
+ is a European scheme and OFF often cannot assign it to US items, usually because the
7
+ category needed to pick the algorithm variant is missing. Ranking "healthier alternatives"
8
+ on OFF's grade alone would therefore silently discard most of the US catalog.
9
+
10
+ So NutriWeb computes the grade itself wherever the input nutrients exist. We keep OFF's
11
+ own grade alongside ours and never merge the two — the UI always states which is which.
12
+
13
+ Provenance
14
+ ----------
15
+ Ported from `lib/ProductOpener/Nutriscore.pm` (`compute_nutriscore_score_2023`) and the
16
+ category predicates in `lib/ProductOpener/Food.pm`, from openfoodfacts-server @ main.
17
+ Threshold tables are reproduced verbatim; the irregular steps (e.g. beverage energy
18
+ jumping 30/90/150/210 then by 30) are intentional and match upstream.
19
+
20
+ Reference: Nutri-Score 2023 main algorithm update, Santé publique France.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+
27
+ # --------------------------------------------------------------------------
28
+ # Threshold tables — verbatim from Nutriscore.pm %points_thresholds_2023
29
+ # --------------------------------------------------------------------------
30
+ # A value scores one point per threshold it exceeds (strictly greater than),
31
+ # except saturated_fat_ratio which uses >=.
32
+
33
+ THRESHOLDS: dict[str, list[float]] = {
34
+ # negative
35
+ "energy": [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350], # kJ/100g
36
+ "energy_beverages": [30, 90, 150, 210, 240, 270, 300, 330, 360, 390],
37
+ "sugars": [3.4, 6.8, 10, 14, 17, 20, 24, 27, 31, 34, 37, 41, 44, 48, 51], # g/100g
38
+ "sugars_beverages": [0.5, 2, 3.5, 5, 6, 7, 8, 9, 10, 11],
39
+ "saturated_fat": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], # g/100g
40
+ "salt": [0.2, 0.4, 0.6, 0.8, 1, 1.2, 1.4, 1.6, 1.8, 2,
41
+ 2.2, 2.4, 2.6, 2.8, 3, 3.2, 3.4, 3.6, 3.8, 4], # g/100g
42
+ # fats/oils/nuts/seeds variant
43
+ "energy_from_saturated_fat": [120, 240, 360, 480, 600, 720, 840, 960, 1080, 1200],
44
+ "saturated_fat_ratio": [10, 16, 22, 28, 34, 40, 46, 52, 58, 64], # %
45
+ # positive
46
+ # 80 repeats so that >80% earns the full 5 points; this is upstream's encoding.
47
+ "fruits_vegetables_legumes": [40, 60, 80, 80, 80], # %
48
+ "fruits_vegetables_legumes_beverages": [40, 40, 60, 60, 80, 80], # yields 0/2/4/6
49
+ "fiber": [3.0, 4.1, 5.2, 6.3, 7.4], # g/100g, AOAC
50
+ "proteins": [2.4, 4.8, 7.2, 9.6, 12, 14, 17], # g/100g
51
+ "proteins_beverages": [1.2, 1.5, 1.8, 2.1, 2.4, 2.7, 3.0],
52
+ }
53
+
54
+ # Energy density of fat, used to derive energy-from-saturates for the fats variant.
55
+ KJ_PER_G_FAT = 37.0
56
+
57
+ # --------------------------------------------------------------------------
58
+ # Category predicates — from Food.pm
59
+ # --------------------------------------------------------------------------
60
+ # OFF's `categories_tags` is the *expanded* tag list: it already contains every
61
+ # ancestor category, so a plain membership test is equivalent to upstream's
62
+ # has_tag() walk up the taxonomy.
63
+
64
+ BEVERAGE_ROOTS = {"en:beverages", "en:beverage-preparations"}
65
+ NOT_BEVERAGES_2023 = {"en:meal-replacement", "en:soups"}
66
+ ALSO_BEVERAGES_2023 = {
67
+ "en:milks", "en:plant-based-milk-alternatives", "en:dairy-drinks",
68
+ "en:plant-based-beverages", "en:tea-based-beverages", "en:iced-teas",
69
+ "en:herbal-tea-beverages", "en:coffee-beverages", "en:coffee-drinks",
70
+ "en:coffees", "en:herbal-teas", "en:teas",
71
+ }
72
+ FAT_OIL_NUTS_SEEDS_ROOTS = {"en:fats", "en:creams", "en:seeds"}
73
+
74
+ # Products OFF exempts from Nutri-Score entirely. We honour the same list so we
75
+ # don't invent grades for salt, spices, or alcohol.
76
+ EXEMPTED = {
77
+ "en:alcoholic-beverages", "en:baby-foods", "en:baby-milks", "en:chewing-gum",
78
+ "en:food-additives", "en:dietary-supplements", "en:meal-replacements",
79
+ "en:salts", "en:spices", "en:sugar-substitutes", "en:vinegars",
80
+ "en:non-food-products",
81
+ }
82
+ NOT_EXEMPTED = {
83
+ "en:tea-based-beverages", "en:iced-teas", "en:herbal-tea-beverages",
84
+ "en:coffee-beverages", "en:coffee-drinks",
85
+ }
86
+
87
+
88
+ @dataclass
89
+ class CategoryFlags:
90
+ """Which algorithm variant applies, derived from `categories_tags`."""
91
+
92
+ is_beverage: bool = False
93
+ is_water: bool = False
94
+ is_cheese: bool = False
95
+ is_fat_oil_nuts_seeds: bool = False
96
+ is_red_meat_product: bool = False
97
+
98
+
99
+ def category_flags(
100
+ categories_tags: list[str] | None,
101
+ fat_oil_nuts_seeds_ids: frozenset[str] = frozenset(),
102
+ red_meat_ids: frozenset[str] = frozenset(),
103
+ ) -> CategoryFlags:
104
+ """Derive the Nutri-Score variant flags from a product's category tags.
105
+
106
+ `fat_oil_nuts_seeds_ids` and `red_meat_ids` come from
107
+ `pipeline/taxonomy.py`, which resolves the WCO Harmonized-System properties
108
+ that upstream reads via taxonomy inheritance. Passing empty sets falls back
109
+ to the explicit root categories only.
110
+ """
111
+ tags = set(categories_tags or ())
112
+
113
+ is_beverage = bool(tags & BEVERAGE_ROOTS) and not (tags & NOT_BEVERAGES_2023)
114
+ if tags & ALSO_BEVERAGES_2023:
115
+ is_beverage = True
116
+
117
+ is_water = "en:spring-waters" in tags and not (
118
+ tags & {"en:flavored-waters", "en:flavoured-waters"}
119
+ )
120
+ is_cheese = "en:cheeses" in tags and "fr:fromages-blancs" not in tags
121
+
122
+ if "en:chestnuts" in tags:
123
+ is_fat = False
124
+ else:
125
+ is_fat = bool(tags & FAT_OIL_NUTS_SEEDS_ROOTS) or bool(tags & fat_oil_nuts_seeds_ids)
126
+
127
+ return CategoryFlags(
128
+ is_beverage=is_beverage,
129
+ is_water=is_water,
130
+ is_cheese=is_cheese,
131
+ is_fat_oil_nuts_seeds=is_fat,
132
+ is_red_meat_product=bool(tags & red_meat_ids),
133
+ )
134
+
135
+
136
+ def is_exempt(categories_tags: list[str] | None) -> bool:
137
+ """True if OFF excludes this product from Nutri-Score (salt, spices, alcohol...)."""
138
+ tags = set(categories_tags or ())
139
+ return bool(tags & EXEMPTED) and not (tags & NOT_EXEMPTED)
140
+
141
+
142
+ # --------------------------------------------------------------------------
143
+ # Point assignment
144
+ # --------------------------------------------------------------------------
145
+
146
+
147
+ def _points(value: float | None, table_key: str) -> int:
148
+ """One point per threshold exceeded. Missing values score 0, per upstream."""
149
+ if value is None:
150
+ return 0
151
+ table = THRESHOLDS[table_key]
152
+ # The saturated fat ratio table is the one that uses >= rather than >.
153
+ if table_key == "saturated_fat_ratio":
154
+ return sum(1 for t in table if value >= t)
155
+ return sum(1 for t in table if value > t)
156
+
157
+
158
+ def _table(nutrient: str, is_beverage: bool) -> str:
159
+ """Beverages use their own table where one exists."""
160
+ key = f"{nutrient}_beverages"
161
+ return key if is_beverage and key in THRESHOLDS else nutrient
162
+
163
+
164
+ @dataclass
165
+ class NutriScoreResult:
166
+ score: int
167
+ grade: str
168
+ negative_points: int
169
+ positive_points: int
170
+ counted_proteins: bool
171
+ detail: dict[str, int] = field(default_factory=dict)
172
+
173
+
174
+ def compute(
175
+ *,
176
+ energy_kj: float | None,
177
+ sugars: float | None,
178
+ saturated_fat: float | None,
179
+ salt: float | None,
180
+ fiber: float | None,
181
+ proteins: float | None,
182
+ fruits_vegetables_legumes: float | None,
183
+ fat: float | None = None,
184
+ has_non_nutritive_sweeteners: bool = False,
185
+ flags: CategoryFlags | None = None,
186
+ ) -> NutriScoreResult | None:
187
+ """Compute the 2023 Nutri-Score. All nutrients are per 100 g / 100 ml.
188
+
189
+ Returns None when there is too little data to score honestly — see
190
+ `has_sufficient_data`. Absent *optional* nutrients (fiber, fruit content)
191
+ score 0 points, matching upstream, which is a conservative penalty.
192
+ """
193
+ flags = flags or CategoryFlags()
194
+
195
+ if not has_sufficient_data(
196
+ energy_kj=energy_kj, sugars=sugars, saturated_fat=saturated_fat,
197
+ salt=salt, proteins=proteins,
198
+ ):
199
+ return None
200
+
201
+ # In the fats/oils/nuts/seeds variant, energy is replaced by energy-from-saturates
202
+ # and saturated fat by the saturates-to-fat ratio.
203
+ if flags.is_fat_oil_nuts_seeds:
204
+ energy_value = (saturated_fat or 0.0) * KJ_PER_G_FAT
205
+ energy_key = "energy_from_saturated_fat"
206
+ satfat_value = (
207
+ (saturated_fat / fat * 100.0) if fat not in (None, 0) and saturated_fat is not None
208
+ else None
209
+ )
210
+ satfat_key = "saturated_fat_ratio"
211
+ else:
212
+ energy_value, energy_key = energy_kj, "energy"
213
+ satfat_value, satfat_key = saturated_fat, "saturated_fat"
214
+
215
+ bev = flags.is_beverage
216
+ detail = {
217
+ "energy": _points(energy_value, _table(energy_key, bev)),
218
+ "sugars": _points(sugars, _table("sugars", bev)),
219
+ "saturated_fat": _points(satfat_value, satfat_key),
220
+ "salt": _points(salt, "salt"),
221
+ "fiber": _points(fiber, "fiber"),
222
+ "proteins": _points(proteins, _table("proteins", bev)),
223
+ "fruits_vegetables_legumes": _points(
224
+ fruits_vegetables_legumes, _table("fruits_vegetables_legumes", bev)
225
+ ),
226
+ }
227
+
228
+ # Red meat products cap protein at 2 points, so a fatty sausage cannot
229
+ # protein its way to a good grade.
230
+ if flags.is_red_meat_product:
231
+ detail["proteins"] = min(detail["proteins"], 2)
232
+
233
+ negative = detail["energy"] + detail["sugars"] + detail["saturated_fat"] + detail["salt"]
234
+ # Beverages carrying non-nutritive sweeteners take 4 extra negative points.
235
+ if bev:
236
+ detail["non_nutritive_sweeteners"] = 4 if has_non_nutritive_sweeteners else 0
237
+ negative += detail["non_nutritive_sweeteners"]
238
+
239
+ # Protein only counts when the product isn't already heavily penalised —
240
+ # otherwise processed meats and cheeses would score as health foods.
241
+ if bev or flags.is_cheese:
242
+ count_proteins = True
243
+ elif flags.is_fat_oil_nuts_seeds:
244
+ count_proteins = negative < 7
245
+ else:
246
+ count_proteins = negative < 11
247
+
248
+ positive = detail["fiber"] + detail["fruits_vegetables_legumes"]
249
+ if count_proteins:
250
+ positive += detail["proteins"]
251
+
252
+ score = negative - positive
253
+ return NutriScoreResult(
254
+ score=score,
255
+ grade=grade_for(score, flags),
256
+ negative_points=negative,
257
+ positive_points=positive,
258
+ counted_proteins=count_proteins,
259
+ detail=detail,
260
+ )
261
+
262
+
263
+ def grade_for(score: int, flags: CategoryFlags) -> str:
264
+ """Map a numeric score to a letter. Cutoffs differ by variant."""
265
+ if flags.is_beverage:
266
+ if flags.is_water:
267
+ return "a" # unflavoured spring water is graded A by definition
268
+ if score <= 2:
269
+ return "b"
270
+ if score <= 6:
271
+ return "c"
272
+ if score <= 9:
273
+ return "d"
274
+ return "e"
275
+ if flags.is_fat_oil_nuts_seeds:
276
+ if score <= -6:
277
+ return "a"
278
+ if score <= 2:
279
+ return "b"
280
+ if score <= 10:
281
+ return "c"
282
+ if score <= 18:
283
+ return "d"
284
+ return "e"
285
+ if score <= 0:
286
+ return "a"
287
+ if score <= 2:
288
+ return "b"
289
+ if score <= 10:
290
+ return "c"
291
+ if score <= 18:
292
+ return "d"
293
+ return "e"
294
+
295
+
296
+ def has_sufficient_data(
297
+ *,
298
+ energy_kj: float | None,
299
+ sugars: float | None,
300
+ saturated_fat: float | None,
301
+ salt: float | None,
302
+ proteins: float | None,
303
+ ) -> bool:
304
+ """Whether we have enough to compute a grade we're willing to show.
305
+
306
+ Upstream treats every missing nutrient as 0 points, which would hand a
307
+ product with no nutrition data whatsoever a clean 'A'. We require the four
308
+ negative-side drivers plus protein to be present, so a sparse record is
309
+ reported as unscored rather than as healthy.
310
+ """
311
+ return all(v is not None for v in (energy_kj, sugars, saturated_fat, salt, proteins))
nutriweb/util.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Coercion helpers for values coming out of DuckDB via pandas.
2
+
3
+ DuckDB list and numeric columns surface as several different "missing" sentinels
4
+ depending on the column's type and whether the frame went through Arrow: None,
5
+ float('nan'), and pandas.NA all appear. `pd.NA` in particular raises on both
6
+ `bool()` and `iter()`, so a plain `if value is not None` guard is not enough.
7
+ These two helpers are the single place that knows about it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import pandas as pd
15
+
16
+
17
+ def is_missing(value: Any) -> bool:
18
+ """True for None, NaN, and pandas.NA — without raising on arrays."""
19
+ if value is None:
20
+ return True
21
+ try:
22
+ result = pd.isna(value)
23
+ except (TypeError, ValueError):
24
+ return False
25
+ # pd.isna returns an array for list-likes; a present list is not missing.
26
+ return bool(result) if isinstance(result, bool) else False
27
+
28
+
29
+ def tag_set(value: Any) -> set[str]:
30
+ """Coerce a DuckDB list column to a set of strings."""
31
+ if is_missing(value):
32
+ return set()
33
+ try:
34
+ return {str(v) for v in value}
35
+ except TypeError:
36
+ return set()
37
+
38
+
39
+ def num(value: Any) -> float | None:
40
+ """Coerce a numeric column to float, mapping every missing form to None."""
41
+ if is_missing(value):
42
+ return None
43
+ try:
44
+ result = float(value)
45
+ except (TypeError, ValueError):
46
+ return None
47
+ return None if pd.isna(result) else result
pipeline/01_download.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 1 — fetch the Open Food Facts parquet from the Hugging Face Hub.
2
+
3
+ The file is ~7.8 GB. It lands in the shared HF cache rather than the repo, so
4
+ re-runs are free and the working tree stays clean. Anonymous access gets
5
+ rate-limited (HTTP 429) on a file this size, so we authenticate: `hf auth login`
6
+ once, or export HF_TOKEN.
7
+
8
+ Usage:
9
+ python pipeline/01_download.py
10
+ """
11
+
12
+ import os
13
+ import sys
14
+
15
+ from huggingface_hub import hf_hub_download
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+ from pipeline.config import SOURCE_FILE, SOURCE_REPO # noqa: E402
19
+
20
+
21
+ def download() -> str:
22
+ """Download food.parquet and return its local path."""
23
+ # hf_transfer gives a large speedup on multi-GB files; harmless if absent.
24
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
25
+
26
+ print(f"Downloading {SOURCE_REPO}/{SOURCE_FILE} (~7.8 GB)...")
27
+ path = hf_hub_download(
28
+ repo_id=SOURCE_REPO,
29
+ filename=SOURCE_FILE,
30
+ repo_type="dataset",
31
+ )
32
+ size_gb = os.path.getsize(path) / 1024**3
33
+ print(f"Done: {path} ({size_gb:.2f} GB)")
34
+ return path
35
+
36
+
37
+ if __name__ == "__main__":
38
+ download()
pipeline/02_curate.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 2 — curate the raw OFF parquet into a compact US catalog.
2
+
3
+ One DuckDB pass over the 7.2 GB source that:
4
+ * filters to non-obsolete US products with a usable name,
5
+ * collapses the multilingual list<struct{lang,text}> fields to one language,
6
+ * pivots the long-format `nutriments` list into per-100g columns,
7
+ * derives the primary category and the front-image URL,
8
+ * keeps OFF's curated tag arrays for set operations downstream.
9
+
10
+ DuckDB streams the parquet, so peak memory stays well under the file size.
11
+
12
+ Usage:
13
+ python pipeline/02_curate.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import time
21
+
22
+ import duckdb
23
+
24
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
25
+ from pipeline.config import ( # noqa: E402
26
+ CATALOG_PATH,
27
+ COUNTRY_TAG,
28
+ DATA_DIR,
29
+ DISQUALIFYING_QUALITY_ERRORS,
30
+ KJ_PER_KCAL,
31
+ NUTRIENT_MAP,
32
+ SALT_PER_SODIUM,
33
+ SOURCE_FILE,
34
+ SOURCE_REPO,
35
+ )
36
+
37
+
38
+ def source_path() -> str:
39
+ """Locate the downloaded parquet in the HF cache."""
40
+ from huggingface_hub import hf_hub_download
41
+
42
+ return hf_hub_download(
43
+ repo_id=SOURCE_REPO, filename=SOURCE_FILE, repo_type="dataset",
44
+ local_files_only=True,
45
+ )
46
+
47
+
48
+ def _pick_lang(column: str) -> str:
49
+ """SQL to collapse a list<struct{lang,text}> to a single string.
50
+
51
+ Prefers English, then OFF's 'main' pseudo-language, then whatever is first.
52
+ """
53
+ def first_where(cond: str) -> str:
54
+ return (
55
+ f"list_extract(list_transform(list_filter({column}, x -> {cond}), "
56
+ f"x -> x.text), 1)"
57
+ )
58
+
59
+ en = first_where("x.lang = 'en'")
60
+ main = first_where("x.lang = 'main'")
61
+ any_lang = f"list_extract(list_transform({column}, x -> x.text), 1)"
62
+ return f"COALESCE({en}, {main}, {any_lang})"
63
+
64
+
65
+ def _pivot_nutrient(off_name: str, alias: str) -> str:
66
+ """SQL to pull one nutrient's per-100g value out of the `nutriments` list."""
67
+ return (
68
+ "list_extract(list_transform("
69
+ f"list_filter(nutriments, x -> x.name = '{off_name}'), x -> x.\"100g\"), 1"
70
+ f") AS {alias}"
71
+ )
72
+
73
+
74
+ def build_sql(parquet: str) -> str:
75
+ nutrient_cols = ",\n ".join(
76
+ _pivot_nutrient(off, alias) for off, alias in NUTRIENT_MAP.items()
77
+ )
78
+ bad_quality = ", ".join(f"'{t}'" for t in DISQUALIFYING_QUALITY_ERRORS)
79
+
80
+ return f"""
81
+ CREATE OR REPLACE TABLE products AS
82
+ WITH src AS (
83
+ SELECT
84
+ code,
85
+ {_pick_lang('product_name')} AS product_name,
86
+ {_pick_lang('generic_name')} AS generic_name,
87
+ {_pick_lang('ingredients_text')} AS ingredients_text,
88
+ brands,
89
+ quantity,
90
+ serving_size,
91
+ categories_tags,
92
+ -- The last English tag is usually the most specific one. 'en:null'
93
+ -- and 'en:undefined' are data-entry artifacts in the source and
94
+ -- must not be treated as categories.
95
+ list_extract(
96
+ list_filter(
97
+ categories_tags,
98
+ x -> starts_with(x, 'en:')
99
+ AND x NOT IN ('en:null', 'en:undefined')
100
+ ), -1
101
+ ) AS primary_category,
102
+ ingredients_tags,
103
+ allergens_tags,
104
+ traces_tags,
105
+ additives_tags,
106
+ labels_tags,
107
+ ingredients_analysis_tags,
108
+ nutrient_levels_tags,
109
+ additives_n,
110
+ nova_group,
111
+ nutriscore_grade AS nutriscore_grade_off,
112
+ nutriscore_score AS nutriscore_score_off,
113
+ environmental_score_grade,
114
+ environmental_score_score,
115
+ with_non_nutritive_sweeteners,
116
+ unique_scans_n,
117
+ popularity_key,
118
+ completeness,
119
+ last_modified_t,
120
+ {nutrient_cols},
121
+ -- Front image URL. OFF splits barcodes longer than 8 digits into
122
+ -- 3/3/3/rest path segments on its image CDN.
123
+ CASE
124
+ WHEN length(code) > 8
125
+ THEN regexp_replace(code, '^(...)(...)(...)(.*)$', '\\1/\\2/\\3/\\4')
126
+ ELSE code
127
+ END AS code_path,
128
+ -- Prefer the English front photo; fall back to any front photo
129
+ -- (front_fr, front_es, ...) which lifts image coverage materially.
130
+ COALESCE(
131
+ list_extract(list_filter(images, i -> i.key = 'front_en'), 1),
132
+ list_extract(list_filter(images, i -> starts_with(i.key, 'front')), 1)
133
+ ) AS front_image
134
+ FROM read_parquet('{parquet}')
135
+ WHERE NOT obsolete
136
+ AND list_contains(countries_tags, '{COUNTRY_TAG}')
137
+ AND NOT list_has_any(
138
+ COALESCE(data_quality_errors_tags, []::VARCHAR[]),
139
+ [{bad_quality}]
140
+ )
141
+ )
142
+ SELECT
143
+ * EXCLUDE (code_path, front_image),
144
+ CASE
145
+ WHEN front_image IS NOT NULL AND front_image.rev IS NOT NULL
146
+ THEN 'https://images.openfoodfacts.org/images/products/'
147
+ || code_path || '/' || front_image.key || '.'
148
+ || CAST(front_image.rev AS VARCHAR) || '.400.jpg'
149
+ END AS image_url,
150
+ -- Nutri-Score works in kJ but US labels report kcal, so derive it.
151
+ COALESCE(energy_kj_100g, energy_kcal_100g * {KJ_PER_KCAL}) AS energy_kj_derived,
152
+ -- Salt and sodium are interconvertible; fill whichever is missing.
153
+ COALESCE(salt_100g, sodium_100g * {SALT_PER_SODIUM}) AS salt_derived,
154
+ COALESCE(fruits_veg_legumes_100g, fruits_veg_nuts_100g) AS fruits_veg_derived
155
+ FROM src
156
+ WHERE product_name IS NOT NULL
157
+ AND trim(product_name) <> ''
158
+ -- A product needs ingredients, a category, or nutrition facts to be
159
+ -- useful. Records with none of the three are empty barcode stubs --
160
+ -- roughly 209k of the US rows -- and are pure noise in search results.
161
+ AND (len(COALESCE(ingredients_tags, []::VARCHAR[])) > 0
162
+ OR len(COALESCE(categories_tags, []::VARCHAR[])) > 0
163
+ OR energy_kcal_100g IS NOT NULL
164
+ OR proteins_100g IS NOT NULL)
165
+ -- The source carries a handful of genuinely duplicated barcodes; keep the
166
+ -- most recently edited record so the choice is deterministic.
167
+ QUALIFY row_number() OVER (
168
+ PARTITION BY code ORDER BY last_modified_t DESC, completeness DESC
169
+ ) = 1
170
+ """
171
+
172
+
173
+ def main() -> None:
174
+ parquet = source_path()
175
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
176
+ if CATALOG_PATH.exists():
177
+ CATALOG_PATH.unlink()
178
+
179
+ con = duckdb.connect(str(CATALOG_PATH))
180
+ con.execute("SET preserve_insertion_order = false")
181
+ con.execute("SET memory_limit = '6GB'")
182
+
183
+ print(f"Curating {parquet}\n -> {CATALOG_PATH}")
184
+ start = time.time()
185
+ con.execute(build_sql(parquet))
186
+ elapsed = time.time() - start
187
+
188
+ n = con.execute("SELECT count(*) FROM products").fetchone()[0]
189
+ print(f"products: {n:,} rows in {elapsed:.0f}s")
190
+
191
+ print("Building indexes...")
192
+ con.execute("CREATE UNIQUE INDEX idx_products_code ON products(code)")
193
+ con.execute("CREATE INDEX idx_products_category ON products(primary_category)")
194
+
195
+ # Full-text search over name + brand, replacing the old str.contains scan.
196
+ con.execute("INSTALL fts; LOAD fts;")
197
+ con.execute(
198
+ "PRAGMA create_fts_index('products', 'code', 'product_name', 'brands', "
199
+ "overwrite=1)"
200
+ )
201
+
202
+ con.close()
203
+ size_mb = CATALOG_PATH.stat().st_size / 1024**2
204
+ print(f"Done. Catalog is {size_mb:.0f} MB")
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
pipeline/03_score.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 3 — compute Nutri-Score and the NutriWeb health score for every product.
2
+
3
+ Runs after 02_curate. Reads the catalog in batches, scores each product in
4
+ Python (the algorithm is branchy enough that SQL would obscure it), and writes
5
+ a `scores` table back into the same DuckDB file.
6
+
7
+ Also validates our Nutri-Score implementation against OFF's own grades on the
8
+ ~347k products where OFF published one. That agreement rate is the single most
9
+ important correctness signal in the pipeline, so it is printed every run.
10
+
11
+ Usage:
12
+ python pipeline/03_score.py
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ import time
20
+
21
+ import duckdb
22
+ import pandas as pd
23
+
24
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
25
+
26
+ from nutriweb.scoring import additives as additives_mod # noqa: E402
27
+ from nutriweb.scoring import health_score, nutriscore # noqa: E402
28
+ from pipeline import taxonomy # noqa: E402
29
+ from pipeline.config import CATALOG_PATH # noqa: E402
30
+
31
+ BATCH = 100_000
32
+
33
+ SELECT_COLS = """
34
+ code, categories_tags, additives_tags, nova_group,
35
+ nutriscore_grade_off, nutriscore_score_off,
36
+ energy_kj_derived, sugars_100g, saturated_fat_100g, salt_derived,
37
+ fiber_100g, proteins_100g, fruits_veg_derived, fat_100g,
38
+ with_non_nutritive_sweeteners
39
+ """
40
+
41
+
42
+ def _f(value) -> float | None:
43
+ """Coerce a DuckDB/pandas value to float, mapping NaN and NULL to None."""
44
+ if value is None or pd.isna(value):
45
+ return None
46
+ return float(value)
47
+
48
+
49
+ def _list(value) -> list[str]:
50
+ """Coerce a DuckDB list column to a plain list.
51
+
52
+ Missing lists arrive as None, pd.NA, or a NumPy array depending on the
53
+ column, so `is not None` alone is not enough.
54
+ """
55
+ if value is None:
56
+ return []
57
+ if isinstance(value, float) or value is pd.NA:
58
+ return []
59
+ return list(value)
60
+
61
+
62
+ def score_row(row, fat_ids: frozenset[str], red_meat_ids: frozenset[str]) -> dict:
63
+ tags = _list(row.categories_tags)
64
+ additive_tags = _list(row.additives_tags)
65
+ category_known = len(tags) > 0
66
+
67
+ flags = nutriscore.category_flags(tags, fat_ids, red_meat_ids)
68
+
69
+ # OFF's own boolean is authoritative where present; fall back to reading the
70
+ # additive list, which is what matters for the beverage sweetener penalty.
71
+ sweeteners = row.with_non_nutritive_sweeteners
72
+ if sweeteners is None or pd.isna(sweeteners):
73
+ has_sweetener = additives_mod.has_non_nutritive_sweetener(additive_tags)
74
+ else:
75
+ has_sweetener = int(sweeteners) > 0
76
+
77
+ result = None
78
+ if not nutriscore.is_exempt(tags):
79
+ result = nutriscore.compute(
80
+ energy_kj=_f(row.energy_kj_derived),
81
+ sugars=_f(row.sugars_100g),
82
+ saturated_fat=_f(row.saturated_fat_100g),
83
+ salt=_f(row.salt_derived),
84
+ fiber=_f(row.fiber_100g),
85
+ proteins=_f(row.proteins_100g),
86
+ fruits_vegetables_legumes=_f(row.fruits_veg_derived),
87
+ fat=_f(row.fat_100g),
88
+ has_non_nutritive_sweeteners=has_sweetener,
89
+ flags=flags,
90
+ )
91
+
92
+ computed_grade = result.grade if result else None
93
+ off_grade = row.nutriscore_grade_off
94
+ if off_grade is None or pd.isna(off_grade):
95
+ off_grade = None
96
+ elif str(off_grade) in ("unknown", "not-applicable", ""):
97
+ off_grade = None
98
+
99
+ # Prefer OFF's published grade; ours fills the gap it leaves.
100
+ effective_grade = off_grade or computed_grade
101
+ grade_is_computed = off_grade is None and computed_grade is not None
102
+
103
+ nova = None if row.nova_group is None or pd.isna(row.nova_group) else int(row.nova_group)
104
+ hs = health_score.compute(
105
+ grade=effective_grade,
106
+ nova_group=nova,
107
+ additives_tags=additive_tags,
108
+ grade_is_computed=grade_is_computed,
109
+ category_known=category_known,
110
+ )
111
+
112
+ return {
113
+ "code": row.code,
114
+ "nutriscore_grade_computed": computed_grade,
115
+ "nutriscore_score_computed": result.score if result else None,
116
+ "nutriscore_grade": effective_grade,
117
+ "nutriscore_source": (
118
+ "off" if off_grade else ("nutriweb" if computed_grade else None)
119
+ ),
120
+ "is_beverage": flags.is_beverage,
121
+ "is_fat_oil_nuts_seeds": flags.is_fat_oil_nuts_seeds,
122
+ "is_red_meat_product": flags.is_red_meat_product,
123
+ "health_score": hs.value if hs else None,
124
+ "health_confidence": hs.confidence if hs else None,
125
+ "additive_penalty": hs.additive_penalty if hs else None,
126
+ "n_flagged_additives": len(additives_mod.concerns(additive_tags)),
127
+ }
128
+
129
+
130
+ def main() -> None:
131
+ if not CATALOG_PATH.exists():
132
+ raise SystemExit(f"{CATALOG_PATH} missing. Run pipeline/02_curate.py first.")
133
+
134
+ fat_ids, red_meat_ids = taxonomy.load()
135
+ additives_mod._table() # fail fast if the additives cache is missing
136
+
137
+ con = duckdb.connect(str(CATALOG_PATH))
138
+ total = con.execute("SELECT count(*) FROM products").fetchone()[0]
139
+ print(f"Scoring {total:,} products...")
140
+
141
+ start = time.time()
142
+ frames: list[pd.DataFrame] = []
143
+ for offset in range(0, total, BATCH):
144
+ batch = con.execute(
145
+ f"SELECT {SELECT_COLS} FROM products LIMIT {BATCH} OFFSET {offset}"
146
+ ).fetchdf()
147
+ frames.append(
148
+ pd.DataFrame(
149
+ [score_row(r, fat_ids, red_meat_ids) for r in batch.itertuples()]
150
+ )
151
+ )
152
+ print(f" {min(offset + BATCH, total):,}/{total:,}", end="\r")
153
+
154
+ scores = pd.concat(frames, ignore_index=True)
155
+ print(f"\nScored in {time.time() - start:.0f}s")
156
+
157
+ con.execute("DROP TABLE IF EXISTS scores")
158
+ con.register("scores_df", scores)
159
+ con.execute("CREATE TABLE scores AS SELECT * FROM scores_df")
160
+ con.execute("CREATE UNIQUE INDEX idx_scores_code ON scores(code)")
161
+
162
+ build_derived_tables(con)
163
+ report(con)
164
+ con.close()
165
+
166
+
167
+ def build_derived_tables(con: duckdb.DuckDBPyConnection) -> None:
168
+ """Derived structures the app queries at request time.
169
+
170
+ `catalog` — the single joined view the app reads from.
171
+ `category_sizes` — how many scored products sit under each category tag.
172
+ Candidate generation picks the *smallest* pool that is
173
+ still big enough, which is a robust proxy for "most
174
+ specific category" -- `categories_tags` is not reliably
175
+ ordered general-to-specific, so position cannot be used.
176
+ `macro_stats` — per-nutrient mean and standard deviation, used to
177
+ z-score the macro vector before comparing products.
178
+ """
179
+ print("\nBuilding derived tables...")
180
+
181
+ con.execute("""
182
+ CREATE OR REPLACE VIEW catalog AS
183
+ SELECT p.*, s.* EXCLUDE (code)
184
+ FROM products p JOIN scores s USING (code)
185
+ """)
186
+
187
+ con.execute("""
188
+ CREATE OR REPLACE TABLE category_sizes AS
189
+ SELECT tag, count(*) AS n
190
+ FROM (
191
+ SELECT unnest(categories_tags) AS tag
192
+ FROM catalog
193
+ WHERE health_score IS NOT NULL
194
+ )
195
+ WHERE tag NOT IN ('en:null', 'en:undefined')
196
+ GROUP BY tag
197
+ """)
198
+ con.execute("CREATE UNIQUE INDEX idx_category_sizes_tag ON category_sizes(tag)")
199
+
200
+ from pipeline.config import MACRO_COLUMNS
201
+
202
+ stats = ", ".join(
203
+ f"avg({c}) AS {c}_mean, coalesce(stddev_samp({c}), 1.0) AS {c}_std"
204
+ for c in MACRO_COLUMNS
205
+ )
206
+ con.execute(f"CREATE OR REPLACE TABLE macro_stats AS SELECT {stats} FROM catalog")
207
+
208
+ n_cat = con.execute("SELECT count(*) FROM category_sizes").fetchone()[0]
209
+ print(f" category_sizes: {n_cat:,} tags")
210
+
211
+
212
+ def report(con: duckdb.DuckDBPyConnection) -> None:
213
+ """Print coverage and, critically, agreement with OFF's published grades."""
214
+ print("\n" + "=" * 62)
215
+ print("NUTRI-SCORE COVERAGE")
216
+ print("=" * 62)
217
+ print(
218
+ con.execute("""
219
+ SELECT
220
+ count(*) AS products,
221
+ count(nutriscore_grade) AS graded,
222
+ count(*) FILTER (WHERE nutriscore_source = 'off') AS from_off,
223
+ count(*) FILTER (WHERE nutriscore_source = 'nutriweb') AS from_nutriweb,
224
+ count(health_score) AS with_health_score
225
+ FROM scores
226
+ """).fetchdf().T.to_string(header=False)
227
+ )
228
+
229
+ print("\n" + "=" * 62)
230
+ print("VALIDATION vs OFF's own grades (ground truth)")
231
+ print("=" * 62)
232
+ agree = con.execute("""
233
+ WITH v AS (
234
+ SELECT p.nutriscore_grade_off AS off, s.nutriscore_grade_computed AS ours,
235
+ p.nutriscore_score_off AS off_score, s.nutriscore_score_computed AS our_score
236
+ FROM products p JOIN scores s USING (code)
237
+ WHERE p.nutriscore_grade_off NOT IN ('unknown','not-applicable')
238
+ AND p.nutriscore_grade_off IS NOT NULL
239
+ AND s.nutriscore_grade_computed IS NOT NULL
240
+ )
241
+ SELECT count(*) AS compared,
242
+ round(100.0 * count(*) FILTER (WHERE off = ours) / count(*), 2) AS exact_grade_pct,
243
+ round(100.0 * count(*) FILTER (
244
+ WHERE abs(ascii(off) - ascii(ours)) <= 1) / count(*), 2) AS within_one_letter_pct,
245
+ round(100.0 * count(*) FILTER (WHERE off_score = our_score) / count(*), 2) AS exact_score_pct
246
+ FROM v
247
+ """).fetchdf()
248
+ print(agree.T.to_string(header=False))
249
+
250
+ print("\nDisagreements by OFF grade:")
251
+ print(
252
+ con.execute("""
253
+ SELECT p.nutriscore_grade_off AS off_grade, s.nutriscore_grade_computed AS our_grade,
254
+ count(*) AS n
255
+ FROM products p JOIN scores s USING (code)
256
+ WHERE p.nutriscore_grade_off NOT IN ('unknown','not-applicable')
257
+ AND s.nutriscore_grade_computed IS NOT NULL
258
+ AND p.nutriscore_grade_off <> s.nutriscore_grade_computed
259
+ GROUP BY 1,2 ORDER BY n DESC LIMIT 10
260
+ """).fetchdf().to_string(index=False)
261
+ )
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()
pipeline/04_audit.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4 — measure what is actually in the catalog.
2
+
3
+ The point of this script is to replace assumptions with numbers. The app makes
4
+ claims ("healthier alternative", "safe for your allergies") that are only as
5
+ good as the coverage underneath them, so coverage is measured rather than
6
+ hoped for. Run it after any pipeline change and read the output before
7
+ trusting a recommendation.
8
+
9
+ Usage:
10
+ python pipeline/04_audit.py
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+
18
+ import duckdb
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+ from pipeline.config import CATALOG_PATH # noqa: E402
22
+
23
+
24
+ def rule(title: str) -> None:
25
+ print(f"\n{'=' * 66}\n{title}\n{'=' * 66}")
26
+
27
+
28
+ def pct(part: int, whole: int) -> str:
29
+ return f"{part:>9,} ({part / whole * 100:5.1f}%)" if whole else f"{part:>9,}"
30
+
31
+
32
+ def main() -> None:
33
+ if not CATALOG_PATH.exists():
34
+ raise SystemExit(f"{CATALOG_PATH} missing. Run steps 01-03 first.")
35
+
36
+ con = duckdb.connect(str(CATALOG_PATH), read_only=True)
37
+ total = con.execute("SELECT count(*) FROM catalog").fetchone()[0]
38
+
39
+ rule("CATALOG")
40
+ print(f"{'products':<42}{total:>9,}")
41
+ size = CATALOG_PATH.stat().st_size / 1024**2
42
+ print(f"{'catalog size':<42}{size:>8.0f} MB")
43
+
44
+ rule("FIELD COVERAGE")
45
+ fields = {
46
+ "product name": "product_name IS NOT NULL",
47
+ "brand": "brands IS NOT NULL",
48
+ "front image": "image_url IS NOT NULL",
49
+ "ingredients text": "ingredients_text IS NOT NULL",
50
+ "ingredient tags (used for similarity)": "len(COALESCE(ingredients_tags,[]::VARCHAR[])) > 0",
51
+ "category (used for candidate generation)": "primary_category IS NOT NULL",
52
+ "allergen tags": "len(COALESCE(allergens_tags,[]::VARCHAR[])) > 0",
53
+ "energy": "energy_kcal_100g IS NOT NULL",
54
+ "sugars": "sugars_100g IS NOT NULL",
55
+ "saturated fat": "saturated_fat_100g IS NOT NULL",
56
+ "salt or sodium": "salt_derived IS NOT NULL",
57
+ "protein": "proteins_100g IS NOT NULL",
58
+ "fibre": "fiber_100g IS NOT NULL",
59
+ "fruit/veg estimate": "fruits_veg_derived IS NOT NULL",
60
+ "NOVA group": "nova_group IS NOT NULL",
61
+ "Eco-Score": "environmental_score_grade IS NOT NULL",
62
+ }
63
+ for label, condition in fields.items():
64
+ n = con.execute(f"SELECT count(*) FROM catalog WHERE {condition}").fetchone()[0]
65
+ print(f"{label:<42}{pct(n, total)}")
66
+
67
+ rule("SCORING — the reason this pipeline exists")
68
+ off = con.execute(
69
+ "SELECT count(*) FROM catalog WHERE nutriscore_source = 'off'"
70
+ ).fetchone()[0]
71
+ ours = con.execute(
72
+ "SELECT count(*) FROM catalog WHERE nutriscore_source = 'nutriweb'"
73
+ ).fetchone()[0]
74
+ health = con.execute("SELECT count(health_score) FROM catalog").fetchone()[0]
75
+
76
+ print(f"{'Nutri-Score published by Open Food Facts':<42}{pct(off, total)}")
77
+ print(f"{'Nutri-Score computed by NutriWeb':<42}{pct(ours, total)}")
78
+ print(f"{'total graded':<42}{pct(off + ours, total)}")
79
+ print(f"{'with a health score':<42}{pct(health, total)}")
80
+ if off:
81
+ print(f"\n Computing grades ourselves increased scored coverage {(off + ours) / off:.2f}x.")
82
+
83
+ rule("VALIDATION — computed grades vs Open Food Facts' own")
84
+ print(
85
+ con.execute("""
86
+ WITH v AS (
87
+ SELECT nutriscore_grade_off AS off, nutriscore_grade_computed AS ours
88
+ FROM catalog
89
+ WHERE nutriscore_grade_off NOT IN ('unknown','not-applicable')
90
+ AND nutriscore_grade_off IS NOT NULL
91
+ AND nutriscore_grade_computed IS NOT NULL
92
+ )
93
+ SELECT count(*) AS compared,
94
+ round(100.0*count(*) FILTER (WHERE off=ours)/count(*), 2) AS exact_pct,
95
+ round(100.0*count(*) FILTER (WHERE abs(ascii(off)-ascii(ours))<=1)/count(*), 2)
96
+ AS within_one_pct
97
+ FROM v
98
+ """).fetchdf().T.to_string(header=False)
99
+ )
100
+
101
+ rule("CONFIDENCE — how much to trust each health score")
102
+ print(
103
+ con.execute("""
104
+ SELECT COALESCE(health_confidence,'unscored') AS confidence, count(*) AS n
105
+ FROM catalog GROUP BY 1 ORDER BY n DESC
106
+ """).fetchdf().to_string(index=False)
107
+ )
108
+ print(
109
+ "\n 'low' means the Nutri-Score was computed for a product with no category,\n"
110
+ " so the general-food thresholds were assumed. Surfaced in the UI."
111
+ )
112
+
113
+ rule("RECOMMENDABILITY — can we actually serve a swap?")
114
+ print(
115
+ con.execute("""
116
+ SELECT
117
+ count(*) FILTER (WHERE health_score IS NOT NULL
118
+ AND primary_category IS NOT NULL) AS category_path,
119
+ count(*) FILTER (WHERE health_score IS NOT NULL
120
+ AND primary_category IS NULL
121
+ AND len(COALESCE(ingredients_tags,[]::VARCHAR[]))>0)
122
+ AS ingredient_fallback_path,
123
+ count(*) FILTER (WHERE health_score IS NULL) AS cannot_recommend_from
124
+ FROM catalog
125
+ """).fetchdf().T.to_string(header=False)
126
+ )
127
+
128
+ rule("TOP CATEGORIES")
129
+ print(
130
+ con.execute("""
131
+ SELECT replace(split_part(primary_category,':',2),'-',' ') AS category,
132
+ count(*) AS products, round(avg(health_score),1) AS avg_health
133
+ FROM catalog WHERE primary_category IS NOT NULL AND health_score IS NOT NULL
134
+ GROUP BY 1 ORDER BY products DESC LIMIT 12
135
+ """).fetchdf().to_string(index=False)
136
+ )
137
+
138
+ con.close()
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
pipeline/05_publish.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 5 — publish the curated catalog to the Hugging Face Hub.
2
+
3
+ The Space must not rebuild from the 7.2 GB source on every cold start, so the
4
+ finished ~400 MB catalog is pushed to a dataset repo and downloaded at startup
5
+ by `nutriweb/data/catalog.py`. That download is in-network on Spaces and takes
6
+ seconds.
7
+
8
+ This replaces the Google Drive + gdown + zip arrangement the previous version
9
+ used, which had no versioning and no checksum.
10
+
11
+ Usage:
12
+ python pipeline/05_publish.py <your-hf-username>/nutriweb-us-catalog
13
+ python pipeline/05_publish.py <your-hf-username>/nutriweb-us-catalog --private
14
+
15
+ Repos are created public by default, which is what a Space on the free tier
16
+ needs in order to download the catalog without a token. Pass --private to keep
17
+ it unlisted; the Space will then need an HF_TOKEN secret to read it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+ from pipeline.config import CATALOG_PATH # noqa: E402
27
+
28
+ CARD = """---
29
+ license: odbl
30
+ tags:
31
+ - food
32
+ - nutrition
33
+ - open-food-facts
34
+ ---
35
+
36
+ # NutriWeb US catalog
37
+
38
+ A curated, scored subset of the [Open Food Facts product
39
+ database](https://huggingface.co/datasets/openfoodfacts/product-database),
40
+ built by the NutriWeb pipeline. One DuckDB file.
41
+
42
+ * US products only, non-obsolete, with a resolvable English name and at least
43
+ ingredients, a category, or nutrition facts.
44
+ * Multilingual fields collapsed to English; `nutriments` pivoted to per-100g columns.
45
+ * Nutri-Score computed with the official 2023 algorithm where Open Food Facts
46
+ publishes none, which raises scored coverage roughly 1.8x.
47
+ * Tables: `products`, `scores`, `category_sizes`, `macro_stats`; view `catalog`.
48
+
49
+ Product data (c) Open Food Facts contributors, licensed ODbL. This derived
50
+ catalog carries the same licence.
51
+
52
+ Rebuild with `pipeline/01_download.py` .. `pipeline/05_publish.py`.
53
+ """
54
+
55
+
56
+ def main() -> None:
57
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
58
+ private = "--private" in sys.argv
59
+ if not args:
60
+ raise SystemExit(
61
+ "Usage: python pipeline/05_publish.py <hf-username>/nutriweb-us-catalog [--private]"
62
+ )
63
+ repo_id = args[0]
64
+
65
+ if not CATALOG_PATH.exists():
66
+ raise SystemExit(f"{CATALOG_PATH} missing. Run steps 01-04 first.")
67
+
68
+ from huggingface_hub import HfApi
69
+
70
+ api = HfApi()
71
+
72
+ # Fail early with a clear message rather than a 403 midway through a
73
+ # 400 MB upload.
74
+ user = api.whoami().get("name")
75
+ owner = repo_id.split("/")[0]
76
+ if owner != user and owner not in {o.get("name") for o in api.whoami().get("orgs", [])}:
77
+ raise SystemExit(
78
+ f"You are logged in as '{user}', but the repo id starts with '{owner}'.\n"
79
+ f"Use '{user}/{repo_id.split('/')[-1]}' instead."
80
+ )
81
+
82
+ size_mb = CATALOG_PATH.stat().st_size / 1024**2
83
+ visibility = "private" if private else "public"
84
+ print(f"Publishing {CATALOG_PATH.name} ({size_mb:.0f} MB) to {repo_id} [{visibility}] ...")
85
+
86
+ api.create_repo(repo_id, repo_type="dataset", exist_ok=True, private=private)
87
+ api.upload_file(
88
+ path_or_fileobj=str(CATALOG_PATH),
89
+ path_in_repo=CATALOG_PATH.name,
90
+ repo_id=repo_id,
91
+ repo_type="dataset",
92
+ )
93
+ api.upload_file(
94
+ path_or_fileobj=CARD.encode(),
95
+ path_in_repo="README.md",
96
+ repo_id=repo_id,
97
+ repo_type="dataset",
98
+ )
99
+
100
+ print(
101
+ f"Done: https://huggingface.co/datasets/{repo_id}\n\n"
102
+ f"Point the Space at it by setting this variable:\n"
103
+ f" NUTRIWEB_CATALOG_REPO={repo_id}"
104
+ )
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
pipeline/__init__.py ADDED
File without changes
pipeline/additives_taxonomy.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Derive additive concern levels from Open Food Facts' additives taxonomy.
2
+
3
+ The app this replaces classified additives with a hand-written dictionary of
4
+ ~90 substrings (`nutriweb/risk_levels.py`), matched with `if key in name`. That
5
+ produced both false positives ("sugar" matching "sugar-free") and unsourced
6
+ verdicts.
7
+
8
+ OFF's additives taxonomy carries properties backed by real regulatory
9
+ assessments, which we use instead:
10
+
11
+ * `efsa_evaluation_overexposure_risk` — EFSA's finding on whether normal
12
+ consumption can exceed the acceptable daily intake (high / moderate / no).
13
+ * `anses_additives_of_interest` — ANSES' watch list.
14
+ * `non_nutritive_sweetener` — required by the Nutri-Score 2023 beverage
15
+ penalty, which adds 4 negative points when one is present.
16
+
17
+ Every verdict the app shows is therefore attributable to a named authority.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import urllib.request
24
+ from pathlib import Path
25
+
26
+ TAXONOMY_URL = "https://static.openfoodfacts.org/data/taxonomies/additives.json"
27
+ CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "additives.json"
28
+
29
+
30
+ def _prop(entry: dict, name: str) -> str | None:
31
+ block = entry.get(name)
32
+ if isinstance(block, dict):
33
+ value = block.get("en")
34
+ return str(value) if value is not None else None
35
+ return str(block) if block is not None else None
36
+
37
+
38
+ def build(force: bool = False) -> dict:
39
+ """Emit {tag: {name, e_number, concern, reasons[], non_nutritive_sweetener}}."""
40
+ if CACHE_PATH.exists() and not force:
41
+ return json.loads(CACHE_PATH.read_text())
42
+
43
+ print(f"Fetching additives taxonomy from {TAXONOMY_URL} ...")
44
+ with urllib.request.urlopen(TAXONOMY_URL, timeout=120) as resp:
45
+ taxonomy = json.loads(resp.read().decode("utf-8"))
46
+
47
+ out: dict[str, dict] = {}
48
+ for tag, entry in taxonomy.items():
49
+ overexposure = (_prop(entry, "efsa_evaluation_overexposure_risk") or "").lower()
50
+ anses = (_prop(entry, "anses_additives_of_interest") or "").lower() == "yes"
51
+ sweetener = (_prop(entry, "non_nutritive_sweetener") or "").lower() == "yes"
52
+
53
+ reasons: list[str] = []
54
+ if overexposure.endswith("high"):
55
+ concern = "high"
56
+ reasons.append("EFSA: high risk of exceeding the acceptable daily intake")
57
+ elif overexposure.endswith("moderate"):
58
+ concern = "moderate"
59
+ reasons.append("EFSA: moderate risk of exceeding the acceptable daily intake")
60
+ else:
61
+ concern = "none"
62
+
63
+ if anses:
64
+ reasons.append("On ANSES' list of additives of interest")
65
+ if concern == "none":
66
+ concern = "watch"
67
+
68
+ # Only keep entries that carry a signal worth surfacing.
69
+ if concern == "none" and not sweetener:
70
+ continue
71
+
72
+ out[tag] = {
73
+ "name": _prop(entry, "name") or tag,
74
+ "e_number": _prop(entry, "e_number"),
75
+ "concern": concern,
76
+ "reasons": reasons,
77
+ "non_nutritive_sweetener": sweetener,
78
+ }
79
+
80
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
81
+ CACHE_PATH.write_text(json.dumps(out, indent=1, sort_keys=True))
82
+
83
+ counts: dict[str, int] = {}
84
+ for meta in out.values():
85
+ counts[meta["concern"]] = counts.get(meta["concern"], 0) + 1
86
+ print(f"Wrote {len(out)} additives to {CACHE_PATH}: {counts}")
87
+ return out
88
+
89
+
90
+ if __name__ == "__main__":
91
+ build(force=True)
pipeline/config.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared paths and constants for the NutriWeb data pipeline.
2
+
3
+ The pipeline runs offline on a workstation. It reads the full Open Food Facts
4
+ parquet (~7.8 GB) and emits a compact DuckDB catalog that the Streamlit app on
5
+ Hugging Face Spaces downloads at startup.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ # Upstream source ---------------------------------------------------------
11
+ SOURCE_REPO = "openfoodfacts/product-database"
12
+ SOURCE_FILE = "food.parquet"
13
+
14
+ # Local artifacts ---------------------------------------------------------
15
+ REPO_ROOT = Path(__file__).resolve().parent.parent
16
+ DATA_DIR = REPO_ROOT / "data"
17
+ CATALOG_PATH = DATA_DIR / "nutriweb-us.duckdb"
18
+
19
+ # Scope -------------------------------------------------------------------
20
+ # US + English focus. OFF tags countries canonically, so this is an exact match
21
+ # rather than a string search.
22
+ COUNTRY_TAG = "en:united-states"
23
+
24
+ # Nutriments we pivot out of the long-format `nutriments` list<struct>.
25
+ # Keys are OFF's own nutrient names; values are the column names we emit.
26
+ NUTRIENT_MAP = {
27
+ "energy-kcal": "energy_kcal_100g",
28
+ # Nutri-Score works in kJ; we keep kcal too because that is what US labels show.
29
+ "energy-kj": "energy_kj_100g",
30
+ "fat": "fat_100g",
31
+ "saturated-fat": "saturated_fat_100g",
32
+ "carbohydrates": "carbohydrates_100g",
33
+ "sugars": "sugars_100g",
34
+ "fiber": "fiber_100g",
35
+ "proteins": "proteins_100g",
36
+ "salt": "salt_100g",
37
+ "sodium": "sodium_100g",
38
+ # Required by the Nutri-Score algorithm's positive-points side. The 2023
39
+ # algorithm uses the "legumes" variant; the older "nuts" field is kept as a
40
+ # fallback because some records carry only that one.
41
+ "fruits-vegetables-legumes-estimate-from-ingredients": "fruits_veg_legumes_100g",
42
+ "fruits-vegetables-nuts-estimate-from-ingredients": "fruits_veg_nuts_100g",
43
+ }
44
+
45
+ # US labels report kcal; Nutri-Score needs kJ. Only ~7% of US records carry an
46
+ # explicit energy-kj, so we derive it from kcal with the Codex conversion factor.
47
+ KJ_PER_KCAL = 4.184
48
+
49
+ # Salt and sodium are interconvertible; OFF uses this factor internally.
50
+ SALT_PER_SODIUM = 2.5
51
+
52
+ # The macro vector used for similarity ranking, in a fixed order.
53
+ # Uses salt_derived rather than salt_100g so products that report only sodium
54
+ # still contribute a salt value instead of silently sitting at the mean.
55
+ MACRO_COLUMNS = [
56
+ "energy_kcal_100g",
57
+ "fat_100g",
58
+ "saturated_fat_100g",
59
+ "carbohydrates_100g",
60
+ "sugars_100g",
61
+ "fiber_100g",
62
+ "proteins_100g",
63
+ "salt_derived",
64
+ ]
65
+
66
+ # Data-quality errors that make a record unusable for recommendation.
67
+ # OFF publishes many quality tags; these are the ones that indicate the
68
+ # nutrition facts themselves are wrong, not merely incomplete.
69
+ DISQUALIFYING_QUALITY_ERRORS = [
70
+ "en:nutrition-value-total-over-105",
71
+ "en:nutrition-value-negative-energy",
72
+ "en:nutrition-value-negative-fat",
73
+ "en:nutrition-value-negative-carbohydrates",
74
+ "en:nutrition-value-negative-proteins",
75
+ "en:nutrition-value-negative-sugars",
76
+ "en:nutrition-value-negative-salt",
77
+ "en:energy-value-in-kcal-does-not-match-value-computed-from-other-nutrients",
78
+ ]
pipeline/taxonomy.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve OFF category-taxonomy properties needed by the Nutri-Score variants.
2
+
3
+ Two Nutri-Score flags cannot be read off `categories_tags` directly:
4
+
5
+ * `is_fat_oil_nuts_seeds` — nuts and seeds are identified by WCO Harmonized
6
+ System headings (08.01, 08.02, 12.02, 12.04, 12.06, 12.07) and codes
7
+ (2008.11, 2008.19), not by a single parent category.
8
+ * `is_red_meat_product` — identified by HS headings 02.01–02.06.
9
+
10
+ Upstream reads these via taxonomy *inheritance*: a category inherits
11
+ `wco_hs_heading` from its ancestors. We reproduce that by downloading OFF's
12
+ categories taxonomy once and propagating the property down the parent graph.
13
+
14
+ The result is two flat sets of category ids, cached to JSON so the curate step
15
+ stays offline and deterministic.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import urllib.request
22
+ from pathlib import Path
23
+
24
+ TAXONOMY_URL = "https://static.openfoodfacts.org/data/taxonomies/categories.json"
25
+
26
+ FAT_OIL_NUTS_SEEDS_HEADINGS = {"08.01", "08.02", "12.02", "12.04", "12.06", "12.07"}
27
+ FAT_OIL_NUTS_SEEDS_CODES = {"2008.11", "2008.19"}
28
+ RED_MEAT_HEADINGS = {"02.01", "02.02", "02.03", "02.04", "02.05", "02.06"}
29
+
30
+ CACHE_PATH = Path(__file__).resolve().parent.parent / "data" / "nutriscore_categories.json"
31
+
32
+
33
+ def _download() -> dict:
34
+ print(f"Fetching category taxonomy from {TAXONOMY_URL} ...")
35
+ with urllib.request.urlopen(TAXONOMY_URL, timeout=300) as resp:
36
+ return json.loads(resp.read().decode("utf-8"))
37
+
38
+
39
+ def _direct_property(entry: dict, prop: str) -> str | None:
40
+ """Read a taxonomy property, which OFF stores as {prop: {lang: value}}."""
41
+ block = entry.get(prop)
42
+ if isinstance(block, dict):
43
+ value = block.get("en")
44
+ return str(value) if value is not None else None
45
+ return str(block) if block is not None else None
46
+
47
+
48
+ def _resolve_inherited(taxonomy: dict, prop: str) -> dict[str, str]:
49
+ """Resolve `prop` for every category, walking up parents when unset.
50
+
51
+ OFF's taxonomy is a DAG (a category may have several parents), so we memoise
52
+ and guard against cycles. First parent with a value wins, matching upstream's
53
+ depth-first get_inherited_property_from_categories_tags.
54
+ """
55
+ resolved: dict[str, str] = {}
56
+ in_progress: set[str] = set()
57
+
58
+ def visit(cid: str) -> str | None:
59
+ if cid in resolved:
60
+ return resolved[cid]
61
+ if cid in in_progress: # cycle guard
62
+ return None
63
+ entry = taxonomy.get(cid)
64
+ if entry is None:
65
+ return None
66
+
67
+ in_progress.add(cid)
68
+ value = _direct_property(entry, prop)
69
+ if value is None:
70
+ for parent in entry.get("parents", []) or []:
71
+ value = visit(parent)
72
+ if value is not None:
73
+ break
74
+ in_progress.discard(cid)
75
+
76
+ if value is not None:
77
+ resolved[cid] = value
78
+ return value
79
+
80
+ for cid in taxonomy:
81
+ visit(cid)
82
+ return resolved
83
+
84
+
85
+ def build(force: bool = False) -> dict[str, list[str]]:
86
+ """Return {'fat_oil_nuts_seeds': [...], 'red_meat': [...]} category ids."""
87
+ if CACHE_PATH.exists() and not force:
88
+ return json.loads(CACHE_PATH.read_text())
89
+
90
+ taxonomy = _download()
91
+ headings = _resolve_inherited(taxonomy, "wco_hs_heading")
92
+ codes = _resolve_inherited(taxonomy, "wco_hs_code")
93
+
94
+ fat_ids = {
95
+ cid for cid, h in headings.items() if h in FAT_OIL_NUTS_SEEDS_HEADINGS
96
+ } | {
97
+ cid for cid, c in codes.items() if c in FAT_OIL_NUTS_SEEDS_CODES
98
+ }
99
+ # Chestnuts are explicitly excluded from the fats/nuts/seeds category.
100
+ fat_ids.discard("en:chestnuts")
101
+
102
+ red_meat_ids = {cid for cid, h in headings.items() if h in RED_MEAT_HEADINGS}
103
+
104
+ result = {
105
+ "fat_oil_nuts_seeds": sorted(fat_ids),
106
+ "red_meat": sorted(red_meat_ids),
107
+ }
108
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
109
+ CACHE_PATH.write_text(json.dumps(result, indent=1))
110
+ print(
111
+ f"Resolved {len(fat_ids)} fat/oil/nuts/seeds and "
112
+ f"{len(red_meat_ids)} red-meat categories -> {CACHE_PATH}"
113
+ )
114
+ return result
115
+
116
+
117
+ def load() -> tuple[frozenset[str], frozenset[str]]:
118
+ """Convenience accessor returning the two sets."""
119
+ data = build()
120
+ return frozenset(data["fat_oil_nuts_seeds"]), frozenset(data["red_meat"])
121
+
122
+
123
+ if __name__ == "__main__":
124
+ build(force=True)
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "nutriweb"
3
+ version = "2.0.0"
4
+ description = "Personalised, evidence-backed food recommendations from the Open Food Facts US catalog."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "AGPL-3.0" }
8
+
9
+ dependencies = [
10
+ "streamlit>=1.40",
11
+ "duckdb>=1.1",
12
+ "pandas>=2.2",
13
+ "numpy>=2.0",
14
+ "plotly>=6.0",
15
+ "huggingface_hub>=0.30",
16
+ "pymongo>=4.12",
17
+ "bcrypt>=4.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8.0", "hf_transfer"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ filterwarnings = ["ignore::DeprecationWarning"]
26
+
27
+ [build-system]
28
+ requires = ["setuptools>=68"]
29
+ build-backend = "setuptools.build_meta"
30
+
31
+ [tool.setuptools]
32
+ packages = ["nutriweb", "pipeline", "views", "components"]
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime dependencies for the Hugging Face Space.
2
+ #
3
+ # The previous version pulled sentence-transformers, transformers, tokenizers,
4
+ # torch and faiss-cpu -- roughly 2 GB -- to compute ingredient similarity.
5
+ # Open Food Facts already canonicalises ingredients into tags, so set overlap
6
+ # replaces that entirely. See nutriweb/reco/similarity.py.
7
+
8
+ # Pinned, and kept identical to `sdk_version` in README.md -- Hugging Face
9
+ # Spaces installs the version declared there, not this one. When the two
10
+ # drifted (1.40 on the Space, 1.61 locally) the app crashed on st.button(width=),
11
+ # an argument that only exists in the newer release. tests/test_deploy.py
12
+ # fails if they diverge again.
13
+ streamlit==1.61.1
14
+ duckdb>=1.1
15
+ pandas>=2.2
16
+ numpy>=2.0
17
+ plotly>=6.0
18
+ huggingface_hub>=0.30
19
+ pymongo>=4.12 # optional: account storage, app runs without it
20
+ bcrypt>=4.0 # password hashing (passlib is unmaintained and breaks on bcrypt 5)
styles.css ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* NutriWeb — injected once by app.py.
2
+ Kept in a real .css file rather than scattered st.markdown calls so the
3
+ design is editable in one place. */
4
+
5
+ :root {
6
+ --nw-green: #0F8A5F;
7
+ --nw-green-dark: #0A6244;
8
+ --nw-ink: #1A1F1C;
9
+ --nw-muted: #5F6B64;
10
+ --nw-line: #E3E8E4;
11
+ --nw-surface: #FFFFFF;
12
+ --nw-bg: #FBFBF9;
13
+
14
+ /* Official Nutri-Score palette */
15
+ --ns-a: #038141;
16
+ --ns-b: #85BB2F;
17
+ --ns-c: #FECB02;
18
+ --ns-d: #EE8100;
19
+ --ns-e: #E63E11;
20
+
21
+ --nw-radius: 14px;
22
+ --nw-shadow: 0 1px 2px rgba(16, 32, 24, .06), 0 4px 16px rgba(16, 32, 24, .05);
23
+ }
24
+
25
+ /* ---------- layout ---------- */
26
+ .block-container { padding-top: 2.2rem; max-width: 1180px; }
27
+
28
+ /* ---------- hero ---------- */
29
+ .nw-hero {
30
+ background: linear-gradient(135deg, var(--nw-green-dark), var(--nw-green) 55%, #3DAF7D);
31
+ color: #fff;
32
+ border-radius: var(--nw-radius);
33
+ padding: 1.6rem 1.8rem;
34
+ margin-bottom: 1.4rem;
35
+ }
36
+ .nw-hero h1 { margin: 0 0 .3rem; font-size: 1.7rem; font-weight: 700; letter-spacing: -.02em; }
37
+ .nw-hero p { margin: 0; opacity: .92; font-size: .95rem; }
38
+
39
+ /* ---------- cards ---------- */
40
+ .nw-card {
41
+ background: var(--nw-surface);
42
+ border: 1px solid var(--nw-line);
43
+ border-radius: var(--nw-radius);
44
+ padding: 1rem 1.1rem;
45
+ box-shadow: var(--nw-shadow);
46
+ height: 100%;
47
+ }
48
+ .nw-card h4 { margin: 0 0 .2rem; font-size: 1rem; line-height: 1.32; color: var(--nw-ink); }
49
+ .nw-brand { color: var(--nw-muted); font-size: .8rem; margin-bottom: .55rem; }
50
+
51
+ .nw-thumb {
52
+ width: 100%; height: 132px; object-fit: contain;
53
+ background: #F4F6F4; border-radius: 10px; margin-bottom: .7rem;
54
+ }
55
+ .nw-thumb-empty {
56
+ display: flex; align-items: center; justify-content: center;
57
+ color: #B6C0B9; font-size: 1.9rem;
58
+ }
59
+
60
+ /* ---------- score badges ---------- */
61
+ .nw-badges { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; }
62
+ .nw-badge {
63
+ display: inline-flex; align-items: center; gap: .35rem;
64
+ border-radius: 999px; padding: .2rem .6rem;
65
+ font-size: .74rem; font-weight: 650; letter-spacing: .01em;
66
+ border: 1px solid transparent; white-space: nowrap;
67
+ }
68
+ .nw-badge .k { opacity: .75; font-weight: 500; }
69
+ .ns-a { background: var(--ns-a); color: #fff; }
70
+ .ns-b { background: var(--ns-b); color: #14310A; }
71
+ .ns-c { background: var(--ns-c); color: #3A2E00; }
72
+ .ns-d { background: var(--ns-d); color: #fff; }
73
+ .ns-e { background: var(--ns-e); color: #fff; }
74
+ .nw-badge-neutral { background: #EEF2EF; color: var(--nw-muted); border-color: var(--nw-line); }
75
+
76
+ /* Provenance chip: whether a grade came from OFF or was computed here. */
77
+ .nw-source {
78
+ font-size: .68rem; color: var(--nw-muted);
79
+ background: #F2F5F3; border: 1px solid var(--nw-line);
80
+ border-radius: 6px; padding: .1rem .4rem; margin-left: .3rem;
81
+ }
82
+
83
+ /* ---------- health score dial ---------- */
84
+ .nw-health { display: flex; align-items: baseline; gap: .4rem; margin: .55rem 0 .3rem; }
85
+ .nw-health .v { font-size: 1.5rem; font-weight: 700; color: var(--nw-ink); line-height: 1; }
86
+ .nw-health .m { font-size: .74rem; color: var(--nw-muted); }
87
+ .nw-meter { height: 6px; background: #EDF1EE; border-radius: 999px; overflow: hidden; }
88
+ .nw-meter > span { display: block; height: 100%; border-radius: 999px; }
89
+
90
+ /* ---------- gain pill on recommendations ---------- */
91
+ .nw-gain {
92
+ display: inline-block; background: #E7F5EE; color: var(--nw-green-dark);
93
+ border: 1px solid #BFE5D3; border-radius: 999px;
94
+ padding: .16rem .55rem; font-size: .76rem; font-weight: 650;
95
+ }
96
+ .nw-why { color: var(--nw-muted); font-size: .78rem; margin-top: .45rem; line-height: 1.45; }
97
+
98
+ /* ---------- verdict panel ---------- */
99
+ .nw-verdict { border-radius: var(--nw-radius); padding: .85rem 1rem; margin: .2rem 0 1rem; border: 1px solid; }
100
+ .nw-verdict h5 { margin: 0 0 .3rem; font-size: .95rem; }
101
+ .nw-verdict ul { margin: .3rem 0 0; padding-left: 1.1rem; font-size: .85rem; }
102
+ .v-ok { background: #EAF7F0; border-color: #BFE5D3; color: #0A5A3C; }
103
+ .v-warn { background: #FFF7E6; border-color: #FBE0A2; color: #6B4C00; }
104
+ .v-block { background: #FDECEA; border-color: #F6C7C0; color: #8A1F12; }
105
+
106
+ /* ---------- chips ---------- */
107
+ .nw-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
108
+ .nw-chip {
109
+ font-size: .75rem; padding: .18rem .55rem; border-radius: 999px;
110
+ background: #F2F5F3; border: 1px solid var(--nw-line); color: var(--nw-ink);
111
+ }
112
+ .chip-danger { background: #FDECEA; border-color: #F6C7C0; color: #8A1F12; }
113
+ .chip-warn { background: #FFF7E6; border-color: #FBE0A2; color: #6B4C00; }
114
+ .chip-good { background: #EAF7F0; border-color: #BFE5D3; color: #0A5A3C; }
115
+
116
+ /* ---------- nutrient table ---------- */
117
+ .nw-nutri { width: 100%; border-collapse: collapse; font-size: .86rem; }
118
+ .nw-nutri td { padding: .42rem .2rem; border-bottom: 1px solid var(--nw-line); vertical-align: middle; }
119
+ .nw-nutri td.n { color: var(--nw-muted); width: 34%; }
120
+ .nw-nutri td.v { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; width: 18%; }
121
+ .nw-bar { background: #EDF1EE; border-radius: 999px; height: 6px; }
122
+ .nw-bar > span { display: block; height: 100%; border-radius: 999px; background: var(--nw-green); }
123
+ .nw-bar > span.over { background: var(--ns-d); }
124
+
125
+ .nw-sub { color: var(--nw-muted); font-size: .82rem; }
126
+
127
+ /* ---------- dark mode ---------- */
128
+ @media (prefers-color-scheme: dark) {
129
+ :root {
130
+ --nw-ink: #E9EFEB; --nw-muted: #9FAFA6;
131
+ --nw-line: #2A332E; --nw-surface: #161B18; --nw-bg: #0F1411;
132
+ }
133
+ .nw-thumb { background: #1D2320; }
134
+ .nw-meter, .nw-bar { background: #262E29; }
135
+ .nw-chip, .nw-source { background: #1D2320; }
136
+ .v-ok { background: #10241B; border-color: #1F4634; color: #8FD9B6; }
137
+ .v-warn { background: #2A2312; border-color: #4A3D1A; color: #E8C874; }
138
+ .v-block { background: #2B1512; border-color: #55251E; color: #F0A79B; }
139
+ .nw-gain { background: #10241B; border-color: #1F4634; color: #8FD9B6; }
140
+ .chip-danger { background: #2B1512; border-color: #55251E; color: #F0A79B; }
141
+ .chip-warn { background: #2A2312; border-color: #4A3D1A; color: #E8C874; }
142
+ .chip-good { background: #10241B; border-color: #1F4634; color: #8FD9B6; }
143
+ }
views/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Streamlit views."""
views/compare.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Side-by-side comparison of up to three products."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from components import badges, cards
8
+ from nutriweb.data import catalog
9
+ from nutriweb.reco import filters
10
+ from views import state
11
+
12
+ st.markdown(
13
+ """<div class="nw-hero">
14
+ <h1>Compare</h1>
15
+ <p>Put up to three products side by side, scored the same way.</p>
16
+ </div>""",
17
+ unsafe_allow_html=True,
18
+ )
19
+
20
+ codes: list[str] = st.session_state.compare_codes
21
+
22
+ if not codes:
23
+ st.info("Nothing to compare yet. Open a product and choose **Add to compare**.")
24
+ if st.button("Go to Search"):
25
+ st.switch_page(state.SEARCH)
26
+ st.stop()
27
+
28
+ products = [p for code in codes if (p := catalog.get_product(code))]
29
+ profile = state.profile()
30
+
31
+ columns = st.columns(len(products), gap="large")
32
+ for column, product in zip(columns, products):
33
+ with column:
34
+ st.markdown(
35
+ f'<div class="nw-card">{badges.thumb(product)}'
36
+ f"<h4>{badges.title_of(product)}</h4>"
37
+ f'<div class="nw-brand">{badges.brand_of(product) or "&nbsp;"}</div>'
38
+ f"{badges.badge_row(product)}"
39
+ f'{badges.health_meter(product.get("health_score"))}</div>',
40
+ unsafe_allow_html=True,
41
+ )
42
+ st.markdown(cards.nutrient_table(product), unsafe_allow_html=True)
43
+
44
+ verdict = filters.evaluate(product, profile)
45
+ if not state.profile_is_empty():
46
+ cards.verdict_panel(verdict)
47
+
48
+ if st.button("Remove", key=f"rm_{product['code']}", width='stretch'):
49
+ st.session_state.compare_codes.remove(product["code"])
50
+ st.rerun()
51
+
52
+ st.divider()
53
+ if st.button("Clear all"):
54
+ st.session_state.compare_codes = []
55
+ st.rerun()
views/insights.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data insights — what is actually in the catalog, and how it was built.
2
+
3
+ This page exists because the honest answer to "how good are these
4
+ recommendations?" is "as good as the coverage underneath them". Rather than
5
+ hide that, it is measured and shown.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import plotly.express as px
11
+ import plotly.graph_objects as go
12
+ import streamlit as st
13
+
14
+ from nutriweb.data import catalog
15
+
16
+ st.markdown(
17
+ """<div class="nw-hero">
18
+ <h1>What's in the catalog</h1>
19
+ <p>Built from the Open Food Facts product database on Hugging Face,
20
+ filtered to US products and scored with the official Nutri-Score 2023 algorithm.</p>
21
+ </div>""",
22
+ unsafe_allow_html=True,
23
+ )
24
+
25
+
26
+ @st.cache_data(show_spinner=False)
27
+ def _stats() -> dict:
28
+ return catalog.stats()
29
+
30
+
31
+ @st.cache_data(show_spinner=False)
32
+ def _grade_distribution():
33
+ return catalog.connect().execute("""
34
+ SELECT nutriscore_grade AS grade, nutriscore_source AS source, count(*) AS n
35
+ FROM catalog WHERE nutriscore_grade IS NOT NULL
36
+ GROUP BY 1, 2 ORDER BY 1
37
+ """).fetchdf()
38
+
39
+
40
+ @st.cache_data(show_spinner=False)
41
+ def _top_categories(limit: int = 12):
42
+ return catalog.connect().execute(f"""
43
+ SELECT replace(split_part(primary_category, ':', 2), '-', ' ') AS category,
44
+ count(*) AS products, round(avg(health_score), 1) AS avg_health
45
+ FROM catalog
46
+ WHERE primary_category IS NOT NULL AND health_score IS NOT NULL
47
+ GROUP BY 1 HAVING count(*) > 200
48
+ ORDER BY products DESC LIMIT {limit}
49
+ """).fetchdf()
50
+
51
+
52
+ @st.cache_data(show_spinner=False)
53
+ def _nutrient_profile(limit: int = 8):
54
+ return catalog.connect().execute(f"""
55
+ WITH top AS (
56
+ SELECT primary_category FROM catalog
57
+ WHERE primary_category IS NOT NULL AND health_score IS NOT NULL
58
+ GROUP BY 1 ORDER BY count(*) DESC LIMIT {limit}
59
+ )
60
+ SELECT replace(split_part(c.primary_category, ':', 2), '-', ' ') AS category,
61
+ median(c.proteins_100g) AS protein,
62
+ median(c.carbohydrates_100g) AS carbs,
63
+ median(c.sugars_100g) AS sugars,
64
+ median(c.fat_100g) AS fat,
65
+ median(c.fiber_100g) AS fibre,
66
+ median(c.salt_derived) AS salt
67
+ FROM catalog c JOIN top USING (primary_category)
68
+ GROUP BY 1 ORDER BY 1
69
+ """).fetchdf()
70
+
71
+
72
+ stats = _stats()
73
+ cols = st.columns(4)
74
+ cols[0].metric("US products", f"{stats['products']:,}")
75
+ cols[1].metric("With a health score", f"{stats['scored']:,}")
76
+ cols[2].metric("Graded by NutriWeb", f"{stats['graded_by_nutriweb']:,}",
77
+ help="Products where Open Food Facts publishes no Nutri-Score and we computed one.")
78
+ cols[3].metric("With a photo", f"{stats['with_image']:,}")
79
+
80
+ st.divider()
81
+
82
+ left, right = st.columns(2, gap="large")
83
+
84
+ with left:
85
+ st.markdown("#### Nutri-Score distribution, by source")
86
+ grades = _grade_distribution()
87
+ grades["source"] = grades["source"].map(
88
+ {"off": "Published by Open Food Facts", "nutriweb": "Computed by NutriWeb"}
89
+ )
90
+ figure = px.bar(
91
+ grades, x="grade", y="n", color="source",
92
+ category_orders={"grade": ["a", "b", "c", "d", "e"]},
93
+ color_discrete_sequence=["#0F8A5F", "#9CC5B4"],
94
+ labels={"grade": "Nutri-Score", "n": "Products", "source": ""},
95
+ )
96
+ figure.update_layout(
97
+ legend=dict(orientation="h", y=-0.22), margin=dict(l=0, r=0, t=6, b=0), height=340,
98
+ )
99
+ st.plotly_chart(figure, width='stretch')
100
+ st.caption(
101
+ "Computing grades ourselves lifts scored coverage well beyond what Open "
102
+ "Food Facts publishes for the US market."
103
+ )
104
+
105
+ with right:
106
+ st.markdown("#### Largest categories")
107
+ categories = _top_categories()
108
+ figure = px.bar(
109
+ categories.sort_values("products"), x="products", y="category",
110
+ orientation="h", color="avg_health",
111
+ color_continuous_scale=["#E63E11", "#FECB02", "#038141"],
112
+ labels={"products": "Products", "category": "", "avg_health": "Avg health"},
113
+ )
114
+ figure.update_layout(margin=dict(l=0, r=0, t=6, b=0), height=340)
115
+ st.plotly_chart(figure, width='stretch')
116
+ st.caption("Colour shows the average NutriWeb health score for the category.")
117
+
118
+ st.divider()
119
+ st.markdown("#### Nutrient profile of the largest categories")
120
+ st.caption(
121
+ "Median grams per 100 g. Axes are clipped at the 95th percentile so a few "
122
+ "extreme products cannot flatten the shape."
123
+ )
124
+
125
+ profile_data = _nutrient_profile()
126
+ nutrients = ["protein", "carbs", "sugars", "fat", "fibre", "salt"]
127
+ maxima = {n: max(profile_data[n].max(), 1e-6) for n in nutrients}
128
+
129
+ radar = go.Figure()
130
+ for _, row in profile_data.iterrows():
131
+ radar.add_trace(
132
+ go.Scatterpolar(
133
+ r=[row[n] / maxima[n] for n in nutrients],
134
+ theta=[n.capitalize() for n in nutrients],
135
+ fill="toself",
136
+ name=str(row["category"])[:28],
137
+ )
138
+ )
139
+ radar.update_layout(
140
+ polar=dict(radialaxis=dict(visible=True, range=[0, 1], showticklabels=False)),
141
+ height=470, margin=dict(l=40, r=40, t=20, b=20),
142
+ legend=dict(orientation="h", y=-0.12),
143
+ )
144
+ st.plotly_chart(radar, width='stretch')
145
+
146
+ st.divider()
147
+ with st.expander("How the health score is built"):
148
+ st.markdown(
149
+ """
150
+ **Health score (0–100)** = 70% Nutri-Score 2023 + 30% NOVA processing group,
151
+ minus a capped penalty for additives flagged by EFSA or ANSES.
152
+
153
+ **Nutri-Score** is computed with the official 2023 algorithm, ported from Open
154
+ Food Facts' reference implementation. Where Open Food Facts already publishes a
155
+ grade we use theirs; where it does not, we compute one and label it. Validated
156
+ against Open Food Facts' own grades on 317,265 products: **98.4% exact agreement**,
157
+ 99.7% within one letter.
158
+
159
+ **Where a signal is missing** a neutral prior is substituted rather than
160
+ rescaling the others, so every product stays on the same scale and remains
161
+ comparable. Products where we had to assume the category are marked *low
162
+ confidence* on the product page.
163
+
164
+ Data: [openfoodfacts/product-database](https://huggingface.co/datasets/openfoodfacts/product-database),
165
+ licensed ODbL. Product data © Open Food Facts contributors.
166
+ """
167
+ )
views/product.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Product detail — scores, nutrients, and whether it suits this person."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from components import badges, cards
8
+ from nutriweb.data import catalog
9
+ from nutriweb.reco import filters
10
+ from nutriweb.scoring import additives as additives_mod
11
+ from nutriweb.util import num, tag_set
12
+ from views import state
13
+
14
+ code = state.require_selection()
15
+ if not code:
16
+ st.stop()
17
+
18
+
19
+ @st.cache_data(show_spinner=False, ttl=600)
20
+ def _load(code: str) -> dict | None:
21
+ return catalog.get_product(code)
22
+
23
+
24
+ product = _load(code)
25
+ if product is None:
26
+ st.error(f"No product found for barcode {code}.")
27
+ st.stop()
28
+
29
+ profile = state.profile()
30
+ verdict = filters.evaluate(product, profile)
31
+
32
+ left, right = st.columns([1, 1.55], gap="large")
33
+
34
+ with left:
35
+ st.markdown(
36
+ f'<div class="nw-card">{badges.thumb(product)}'
37
+ f"{badges.badge_row(product)}"
38
+ f'{badges.health_meter(product.get("health_score"), product.get("health_confidence"))}'
39
+ "</div>",
40
+ unsafe_allow_html=True,
41
+ )
42
+ st.write("")
43
+ if st.button("🔄 Find healthier swaps", type="primary", width='stretch'):
44
+ state.open_recommendations(code)
45
+ if st.button("➕ Add to compare", width='stretch'):
46
+ outcome = state.add_to_compare(code)
47
+ st.toast(
48
+ {"added": "Added to compare.",
49
+ "already": "Already in the compare list.",
50
+ "full": "Compare holds three products; remove one first."}[outcome]
51
+ )
52
+
53
+ with right:
54
+ st.markdown(f"## {badges.title_of(product)}")
55
+ brand = badges.brand_of(product)
56
+ subtitle = " · ".join(
57
+ x for x in (brand, product.get("quantity") if isinstance(product.get("quantity"), str) else None) if x
58
+ )
59
+ if subtitle:
60
+ st.markdown(f'<div class="nw-sub">{badges.esc(subtitle)}</div>', unsafe_allow_html=True)
61
+ st.caption(f"Barcode {product['code']}")
62
+
63
+ cards.verdict_panel(verdict, profile_is_empty=state.profile_is_empty())
64
+
65
+ # Be explicit when the grade is ours rather than Open Food Facts'.
66
+ if product.get("nutriscore_source") == "nutriweb":
67
+ confidence = product.get("health_confidence")
68
+ note = (
69
+ " Its category is unknown, so the general-food thresholds were assumed —"
70
+ " treat this grade as indicative."
71
+ if confidence == "low" else ""
72
+ )
73
+ st.info(
74
+ "Open Food Facts has no Nutri-Score for this product. NutriWeb computed "
75
+ f"**{str(product.get('nutriscore_grade', '')).upper()}** from the nutrition "
76
+ f"facts using the official 2023 algorithm.{note}"
77
+ )
78
+
79
+ st.markdown("#### Nutrition per 100 g")
80
+ st.markdown(cards.nutrient_table(product), unsafe_allow_html=True)
81
+ st.caption("Bars show the share of an adult reference intake for a 100 g portion.")
82
+
83
+ st.divider()
84
+
85
+ col_a, col_b = st.columns(2, gap="large")
86
+
87
+ with col_a:
88
+ st.markdown("#### Allergens")
89
+ allergens = tag_set(product.get("allergens_tags"))
90
+ traces = tag_set(product.get("traces_tags"))
91
+ if allergens:
92
+ st.markdown(badges.chips(sorted(allergens), "chip-danger"), unsafe_allow_html=True)
93
+ if traces:
94
+ st.caption("May contain traces of:")
95
+ st.markdown(badges.chips(sorted(traces), "chip-warn"), unsafe_allow_html=True)
96
+ if not allergens and not traces:
97
+ st.caption("No allergens declared by Open Food Facts for this product.")
98
+
99
+ st.markdown("#### Diet")
100
+ analysis = tag_set(product.get("ingredients_analysis_tags"))
101
+ diet_labels = {
102
+ "en:vegan": ("Vegan", "chip-good"),
103
+ "en:non-vegan": ("Not vegan", "chip-danger"),
104
+ "en:maybe-vegan": ("Vegan status unclear", "chip-warn"),
105
+ "en:vegetarian": ("Vegetarian", "chip-good"),
106
+ "en:non-vegetarian": ("Not vegetarian", "chip-danger"),
107
+ "en:maybe-vegetarian": ("Vegetarian status unclear", "chip-warn"),
108
+ "en:palm-oil-free": ("Palm-oil free", "chip-good"),
109
+ "en:palm-oil": ("Contains palm oil", "chip-danger"),
110
+ }
111
+ shown = [
112
+ f'<span class="nw-chip {tone}">{label}</span>'
113
+ for tag, (label, tone) in diet_labels.items()
114
+ if tag in analysis
115
+ ]
116
+ st.markdown(
117
+ '<div class="nw-chips">' + "".join(shown) + "</div>" if shown
118
+ else "<span class='nw-sub'>Not analysed.</span>",
119
+ unsafe_allow_html=True,
120
+ )
121
+
122
+ with col_b:
123
+ st.markdown("#### Additives")
124
+ flagged = additives_mod.concerns(sorted(tag_set(product.get("additives_tags"))))
125
+ if flagged:
126
+ for item in flagged:
127
+ tone = {"high": "chip-danger", "moderate": "chip-warn", "watch": ""}[item["concern"]]
128
+ st.markdown(
129
+ f'<div style="margin-bottom:.45rem"><span class="nw-chip {tone}">'
130
+ f'{badges.esc(item["name"])}</span>'
131
+ f'<div class="nw-sub" style="margin-top:.2rem">'
132
+ f'{badges.esc("; ".join(item["reasons"]))}</div></div>',
133
+ unsafe_allow_html=True,
134
+ )
135
+ st.caption("Assessments from EFSA and ANSES, via the Open Food Facts additives taxonomy.")
136
+ else:
137
+ total = num(product.get("n_flagged_additives"))
138
+ st.caption(
139
+ "No additives on the EFSA or ANSES watch lists."
140
+ if total is not None else "No additive data for this product."
141
+ )
142
+
143
+ st.markdown("#### Ingredients")
144
+ text = product.get("ingredients_text")
145
+ if text and isinstance(text, str) and text.strip():
146
+ st.markdown(f'<div class="nw-sub">{badges.esc(text)}</div>', unsafe_allow_html=True)
147
+ else:
148
+ st.caption("No ingredient list available.")
149
+
150
+ # Record the view for the profile history.
151
+ if profile.user_id:
152
+ from nutriweb.profile import auth
153
+
154
+ auth.log_view(profile.user_id, product, verdict.summary)
views/profile_page.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Profile — sign in, and set the preferences that drive every filter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from nutriweb.profile import auth
8
+ from nutriweb.profile.model import ALLERGEN_CHOICES, DIET_CHOICES, UserProfile
9
+ from views import state
10
+
11
+ st.markdown(
12
+ """<div class="nw-hero">
13
+ <h1>My profile</h1>
14
+ <p>Everything here is used as a hard filter: allergens and diet exclude
15
+ products outright rather than merely ranking them lower.</p>
16
+ </div>""",
17
+ unsafe_allow_html=True,
18
+ )
19
+
20
+ profile: UserProfile = state.profile()
21
+
22
+ if auth.demo_mode():
23
+ st.caption(
24
+ "No account database is configured, so profiles last for this session only. "
25
+ "Set `MONGODB_URI` as a Space secret to persist them."
26
+ )
27
+
28
+
29
+ def _preferences_form(current: UserProfile, key_prefix: str) -> UserProfile:
30
+ """The shared preference widgets. Returns a profile built from the inputs."""
31
+ label_for_tag = {v: k for k, v in ALLERGEN_CHOICES.items()}
32
+ selected_labels = st.multiselect(
33
+ "Allergens to avoid",
34
+ list(ALLERGEN_CHOICES),
35
+ default=[label_for_tag[t] for t in current.allergens if t in label_for_tag],
36
+ key=f"{key_prefix}_allergens",
37
+ help="Matched against Open Food Facts' curated allergen and traces tags.",
38
+ )
39
+ diets = st.multiselect(
40
+ "Dietary preferences",
41
+ DIET_CHOICES,
42
+ default=current.diets,
43
+ key=f"{key_prefix}_diets",
44
+ )
45
+ col1, col2 = st.columns(2)
46
+ with col1:
47
+ age = st.number_input("Age", 1, 120, current.age, key=f"{key_prefix}_age")
48
+ high_bp = st.checkbox(
49
+ "High blood pressure", current.high_blood_pressure, key=f"{key_prefix}_bp",
50
+ help="Flags and filters products above 1.15 g salt per 100 g.",
51
+ )
52
+ with col2:
53
+ gender = st.selectbox(
54
+ "Gender",
55
+ ["Prefer not to say", "Female", "Male", "Other"],
56
+ index=["Prefer not to say", "Female", "Male", "Other"].index(current.gender)
57
+ if current.gender in ["Prefer not to say", "Female", "Male", "Other"] else 0,
58
+ key=f"{key_prefix}_gender",
59
+ )
60
+ high_chol = st.checkbox(
61
+ "High cholesterol", current.high_cholesterol, key=f"{key_prefix}_chol",
62
+ help="Flags and filters products above 4 g saturated fat per 100 g.",
63
+ )
64
+ avoid_additives = st.checkbox(
65
+ "Exclude products with additives EFSA flags as high-risk",
66
+ current.avoid_flagged_additives,
67
+ key=f"{key_prefix}_additives",
68
+ )
69
+
70
+ return UserProfile(
71
+ user_id=current.user_id,
72
+ age=int(age),
73
+ gender=gender,
74
+ allergens=[ALLERGEN_CHOICES[label] for label in selected_labels],
75
+ diets=diets,
76
+ high_blood_pressure=high_bp,
77
+ high_cholesterol=high_chol,
78
+ avoid_flagged_additives=avoid_additives,
79
+ )
80
+
81
+
82
+ if profile.user_id:
83
+ st.success(f"Signed in as **{profile.user_id}**")
84
+ with st.form("prefs"):
85
+ updated = _preferences_form(profile, "signed")
86
+ if st.form_submit_button("Save preferences", type="primary"):
87
+ updated.user_id = profile.user_id
88
+ st.session_state.profile = updated
89
+ auth.save_profile(updated)
90
+ st.toast("Preferences saved.")
91
+
92
+ st.divider()
93
+ st.markdown("### Recently viewed")
94
+ history = auth.recent_views(profile.user_id)
95
+ if not history:
96
+ st.caption("Nothing viewed yet.")
97
+ else:
98
+ for entry in history[:15]:
99
+ st.markdown(
100
+ f"- **{entry.get('product_name') or 'Unnamed'}** "
101
+ f"<span class='nw-sub'>{entry.get('brands') or ''} · "
102
+ f"{entry.get('verdict') or ''}</span>",
103
+ unsafe_allow_html=True,
104
+ )
105
+ st.stop()
106
+
107
+
108
+ tab_guest, tab_login, tab_register = st.tabs(
109
+ ["Use without an account", "Sign in", "Create account"]
110
+ )
111
+
112
+ with tab_guest:
113
+ st.caption(
114
+ "Set preferences for this session only. Nothing is stored and no account is needed."
115
+ )
116
+ with st.form("guest"):
117
+ guest_profile = _preferences_form(profile, "guest")
118
+ if st.form_submit_button("Apply", type="primary"):
119
+ st.session_state.profile = guest_profile
120
+ st.toast("Preferences applied for this session.")
121
+ st.rerun()
122
+
123
+ with tab_login:
124
+ with st.form("login"):
125
+ user_id = st.text_input("Username")
126
+ password = st.text_input("Password", type="password")
127
+ if st.form_submit_button("Sign in", type="primary"):
128
+ result = auth.login(user_id, password)
129
+ if result.ok:
130
+ st.session_state.profile = result.profile
131
+ st.rerun()
132
+ else:
133
+ st.error(result.message)
134
+
135
+ with tab_register:
136
+ with st.form("register"):
137
+ new_id = st.text_input("Choose a username")
138
+ new_pw = st.text_input("Choose a password", type="password", help="At least 8 characters.")
139
+ new_profile = _preferences_form(UserProfile(), "reg")
140
+ if st.form_submit_button("Create account", type="primary"):
141
+ result = auth.register(new_id, new_pw, new_profile)
142
+ if result.ok:
143
+ st.session_state.profile = result.profile
144
+ st.success(result.message)
145
+ st.rerun()
146
+ else:
147
+ st.error(result.message)
views/recommend.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Healthier swaps for the selected product."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from components import badges, cards
8
+ from nutriweb.data import catalog
9
+ from nutriweb.reco import engine
10
+ from views import state
11
+
12
+ code = state.require_selection()
13
+ if not code:
14
+ st.stop()
15
+
16
+ product = catalog.get_product(code)
17
+ if product is None:
18
+ st.error(f"No product found for barcode {code}.")
19
+ st.stop()
20
+
21
+ profile = state.profile()
22
+
23
+ st.markdown(
24
+ f"""<div class="nw-hero">
25
+ <h1>Healthier than {badges.title_of(product)}</h1>
26
+ <p>Same kind of product, better health score, filtered against your profile.</p>
27
+ </div>""",
28
+ unsafe_allow_html=True,
29
+ )
30
+
31
+ col_left, col_right = st.columns([1, 3], gap="large")
32
+ with col_left:
33
+ st.markdown(
34
+ f'<div class="nw-card">{badges.thumb(product)}'
35
+ f"<h4>{badges.title_of(product)}</h4>"
36
+ f'<div class="nw-brand">{badges.brand_of(product) or "&nbsp;"}</div>'
37
+ f"{badges.badge_row(product)}"
38
+ f'{badges.health_meter(product.get("health_score"))}</div>',
39
+ unsafe_allow_html=True,
40
+ )
41
+ if st.button("← Back to product", width='stretch'):
42
+ state.open_product(code)
43
+
44
+ with col_right:
45
+ top_n = st.slider("How many alternatives", 3, 12, 6, key="rec_n")
46
+
47
+ with st.spinner("Finding alternatives..."):
48
+ recommendations, basis = engine.recommend(product, profile, top_n=top_n)
49
+
50
+ if state.profile_is_empty():
51
+ st.info(
52
+ "You have no profile set, so these results are not filtered for allergens "
53
+ "or diet. Add them under **My profile** for personalised swaps."
54
+ )
55
+
56
+ if not recommendations:
57
+ message = basis.get("message", "No healthier alternatives found.")
58
+ # "Already the best option" is good news, not a failure.
59
+ if basis.get("reason") == "already_best":
60
+ st.success(f"✓ {message}")
61
+ else:
62
+ st.warning(message)
63
+ if basis.get("reason") == "filtered_out":
64
+ st.caption("Relaxing a dietary preference would surface these.")
65
+ st.stop()
66
+
67
+ # Be explicit about how the candidate set was chosen, rather than
68
+ # presenting results as if they came from nowhere.
69
+ if basis["mode"] == "category":
70
+ label = str(basis["category"]).split(":", 1)[-1].replace("-", " ")
71
+ st.caption(
72
+ f"Compared against **{basis['pool']}** products in **{label}** that pass your filters."
73
+ )
74
+ else:
75
+ st.caption(
76
+ f"This product has no category in Open Food Facts, so alternatives were "
77
+ f"matched by shared ingredients across **{basis['pool']}** candidates."
78
+ )
79
+
80
+ for start in range(0, len(recommendations), 3):
81
+ row = recommendations[start : start + 3]
82
+ for column, rec in zip(st.columns(3), row):
83
+ with column:
84
+ cards.recommendation_card(
85
+ rec,
86
+ key=f"rec_{rec.product['code']}",
87
+ on_open=state.open_product,
88
+ )
89
+
90
+ with st.expander("How these were ranked"):
91
+ st.markdown(
92
+ f"""
93
+ Candidates must be **healthier** than the original and must pass every hard
94
+ filter from your profile — allergens and diet are exclusions, never trade-offs.
95
+ Survivors are then ranked on a weighted blend:
96
+
97
+ | Signal | Weight | What it measures |
98
+ |---|---|---|
99
+ | Health gain | {engine.W_HEALTH_GAIN:.0%} | Improvement in the 0–100 NutriWeb health score |
100
+ | Macro similarity | {engine.W_MACRO:.0%} | Distance across energy, fat, carbs, sugar, fibre, protein and salt |
101
+ | Ingredient overlap | {engine.W_INGREDIENT:.0%} | Jaccard overlap of Open Food Facts' canonical ingredient tags |
102
+ | Popularity | {engine.W_POPULARITY:.0%} | Scan count, used only to break ties |
103
+
104
+ The health score itself is 70% Nutri-Score 2023, 30% NOVA processing group,
105
+ minus a penalty for additives flagged by EFSA or ANSES.
106
+ """
107
+ )
views/search.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Search — the landing page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from components import cards
8
+ from nutriweb.data import catalog
9
+ from views import state
10
+
11
+ st.markdown(
12
+ """<div class="nw-hero">
13
+ <h1>Find a product, get a healthier swap</h1>
14
+ <p>Search by name or scan a barcode. Every result is checked against your
15
+ allergens, your diet and your health profile before it is recommended.</p>
16
+ </div>""",
17
+ unsafe_allow_html=True,
18
+ )
19
+
20
+
21
+ @st.cache_data(show_spinner=False, ttl=600)
22
+ def _search(query: str, limit: int) -> list[dict]:
23
+ return catalog.search(query, limit=limit)
24
+
25
+
26
+ @st.cache_data(show_spinner=False)
27
+ def _stats() -> dict:
28
+ return catalog.stats()
29
+
30
+
31
+ def _use_example(example: str) -> None:
32
+ """Fill the search box from an example chip."""
33
+ st.session_state.search_box = example
34
+
35
+
36
+ query = st.text_input(
37
+ "Search",
38
+ placeholder="e.g. peanut butter, greek yogurt, or a barcode like 0049000028911",
39
+ label_visibility="collapsed",
40
+ key="search_box",
41
+ )
42
+
43
+ if not query:
44
+ stats = _stats()
45
+ st.caption(
46
+ f"{stats['products']:,} US products · {stats['scored']:,} with a health score · "
47
+ f"{stats['graded_by_nutriweb']:,} graded by NutriWeb where Open Food Facts had no grade"
48
+ )
49
+ st.markdown("##### Try one of these")
50
+ examples = ["greek yogurt", "peanut butter", "granola bar", "tortilla chips", "sparkling water"]
51
+ columns = st.columns(len(examples))
52
+ for column, example in zip(columns, examples):
53
+ # Seeding must happen in an on_click callback, not in the button's
54
+ # if-branch: Streamlit refuses assignment to a widget's key once that
55
+ # widget has been instantiated this run, and search_box is created
56
+ # above. Callbacks run before the rerun, so the assignment is legal.
57
+ column.button(
58
+ example,
59
+ width="stretch",
60
+ key=f"eg_{example}",
61
+ on_click=_use_example,
62
+ args=(example,),
63
+ )
64
+ st.stop()
65
+
66
+ with st.spinner("Searching..."):
67
+ results = _search(query, 24)
68
+
69
+ if not results:
70
+ st.warning(
71
+ f"Nothing matched **{query}**. Try a brand name, a simpler term, or a full barcode."
72
+ )
73
+ st.stop()
74
+
75
+ st.caption(f"{len(results)} results for **{query}**")
76
+
77
+ for start in range(0, len(results), 4):
78
+ row = results[start : start + 4]
79
+ for column, product in zip(st.columns(4), row):
80
+ with column:
81
+ cards.product_card(
82
+ product,
83
+ key=f"open_{product['code']}",
84
+ on_open=state.open_product,
85
+ )
views/state.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared session state and small navigation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+
7
+ from nutriweb.profile.model import UserProfile
8
+
9
+ # Page paths, so navigation targets are declared in one place.
10
+ SEARCH = "views/search.py"
11
+ PRODUCT = "views/product.py"
12
+ RECOMMEND = "views/recommend.py"
13
+ COMPARE = "views/compare.py"
14
+ PROFILE = "views/profile_page.py"
15
+
16
+
17
+ def init() -> None:
18
+ st.session_state.setdefault("profile", UserProfile())
19
+ st.session_state.setdefault("selected_code", None)
20
+ st.session_state.setdefault("compare_codes", [])
21
+ st.session_state.setdefault("last_query", "")
22
+
23
+
24
+ def profile() -> UserProfile:
25
+ return st.session_state.profile
26
+
27
+
28
+ def profile_is_empty() -> bool:
29
+ """True when the user has told us nothing we could filter on."""
30
+ p = profile()
31
+ return not (
32
+ p.allergens or p.diets or p.high_blood_pressure
33
+ or p.high_cholesterol or p.avoid_flagged_additives
34
+ )
35
+
36
+
37
+ def open_product(code: str) -> None:
38
+ st.session_state.selected_code = code
39
+ st.switch_page(PRODUCT)
40
+
41
+
42
+ def open_recommendations(code: str) -> None:
43
+ st.session_state.selected_code = code
44
+ st.switch_page(RECOMMEND)
45
+
46
+
47
+ def add_to_compare(code: str) -> str:
48
+ codes: list[str] = st.session_state.compare_codes
49
+ if code in codes:
50
+ return "already"
51
+ if len(codes) >= 3:
52
+ return "full"
53
+ codes.append(code)
54
+ return "added"
55
+
56
+
57
+ def sign_out() -> None:
58
+ st.session_state.profile = UserProfile()
59
+ st.session_state.selected_code = None
60
+ st.session_state.compare_codes = []
61
+
62
+
63
+ def require_selection() -> str | None:
64
+ """Guard for pages that need a chosen product."""
65
+ code = st.session_state.get("selected_code")
66
+ if not code:
67
+ st.info("Pick a product from Search first.")
68
+ if st.button("Go to Search"):
69
+ st.switch_page(SEARCH)
70
+ return None
71
+ return code