File size: 2,184 Bytes
89d9642 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | """Additive concern lookup, backed by OFF's EFSA/ANSES-sourced taxonomy.
Replaces the substring matching in the old `nutriweb/risk_levels.py`. Lookups
are exact on OFF's canonical additive tags (`en:e250`), so "sugar-free" can no
longer be flagged as containing sugar.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
DATA_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "additives.json"
# How much each concern level costs in the health score, in points out of 100.
CONCERN_PENALTY = {"high": 6.0, "moderate": 3.0, "watch": 1.5, "none": 0.0}
# Cap the total additive penalty so a long ingredient list cannot dominate the
# nutrition signal outright.
MAX_ADDITIVE_PENALTY = 18.0
@lru_cache(maxsize=1)
def _table() -> dict[str, dict]:
if not DATA_PATH.exists():
raise FileNotFoundError(
f"{DATA_PATH} missing. Run: python pipeline/additives_taxonomy.py"
)
return json.loads(DATA_PATH.read_text())
def lookup(tag: str) -> dict | None:
"""Return the concern record for an OFF additive tag, or None if unflagged."""
return _table().get(tag)
def concerns(additives_tags: list[str] | None) -> list[dict]:
"""Flagged additives in a product, worst first, for display."""
order = {"high": 0, "moderate": 1, "watch": 2}
found = [
{"tag": tag, **meta}
for tag in (additives_tags or ())
if (meta := lookup(tag)) and meta["concern"] != "none"
]
return sorted(found, key=lambda a: order.get(a["concern"], 9))
def penalty(additives_tags: list[str] | None) -> float:
"""Total health-score penalty from a product's additives, capped."""
total = sum(CONCERN_PENALTY.get(a["concern"], 0.0) for a in concerns(additives_tags))
return min(total, MAX_ADDITIVE_PENALTY)
def has_non_nutritive_sweetener(additives_tags: list[str] | None) -> bool:
"""Whether any additive is a non-nutritive sweetener.
Drives the Nutri-Score 2023 beverage penalty of 4 negative points.
"""
return any(
(meta := lookup(tag)) and meta.get("non_nutritive_sweetener")
for tag in (additives_tags or ())
)
|