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 ())
    )