Commit ·
89d9642
0
Parent(s):
Deploy: Pin Streamlit to 1.61.1; add deployment parity tests
Browse files- .gitattributes +2 -0
- .gitignore +19 -0
- .streamlit/config.toml +13 -0
- README.md +140 -0
- app.py +61 -0
- components/__init__.py +1 -0
- components/badges.py +134 -0
- components/cards.py +119 -0
- data/additives.json +1327 -0
- data/nutriscore_categories.json +547 -0
- nutriweb/__init__.py +0 -0
- nutriweb/data/__init__.py +1 -0
- nutriweb/data/catalog.py +235 -0
- nutriweb/profile/__init__.py +1 -0
- nutriweb/profile/auth.py +184 -0
- nutriweb/profile/model.py +93 -0
- nutriweb/reco/__init__.py +1 -0
- nutriweb/reco/engine.py +281 -0
- nutriweb/reco/filters.py +153 -0
- nutriweb/reco/similarity.py +94 -0
- nutriweb/scoring/__init__.py +1 -0
- nutriweb/scoring/additives.py +63 -0
- nutriweb/scoring/health_score.py +132 -0
- nutriweb/scoring/nutriscore.py +311 -0
- nutriweb/util.py +47 -0
- pipeline/01_download.py +38 -0
- pipeline/02_curate.py +208 -0
- pipeline/03_score.py +265 -0
- pipeline/04_audit.py +142 -0
- pipeline/05_publish.py +108 -0
- pipeline/__init__.py +0 -0
- pipeline/additives_taxonomy.py +91 -0
- pipeline/config.py +78 -0
- pipeline/taxonomy.py +124 -0
- pyproject.toml +32 -0
- requirements.txt +20 -0
- styles.css +143 -0
- views/__init__.py +1 -0
- views/compare.py +55 -0
- views/insights.py +167 -0
- views/product.py +154 -0
- views/profile_page.py +147 -0
- views/recommend.py +107 -0
- views/search.py +85 -0
- views/state.py +71 -0
.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 " "}</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 " "}</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 " "}</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 " "}</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
|