Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions backend/app/services/open_food_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,19 +222,37 @@ async def _search_food_products_once(

def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]:
results: list[FoodSearchResult] = []
nutrient_fields = {
"calories": "energy-kcal",
"protein": "proteins",
"fat": "fat",
"carbohydrates": "carbohydrates",
}
for product in payload.get("products", []):
raw_product_name = (product.get("product_name") or "").strip()
product_name = _repair_common_mojibake(raw_product_name)
if not product_name:
continue

nutriments = product.get("nutriments") or {}
serving_size = _to_optional_text(product.get("serving_size"))
nutrition = {
"calories": _to_float(nutriments.get("energy-kcal_100g")),
"protein": _to_float(nutriments.get("proteins_100g")),
"fat": _to_float(nutriments.get("fat_100g")),
"carbohydrates": _to_float(nutriments.get("carbohydrates_100g")),
name: _to_float(nutriments.get(f"{field}_serving"))
for name, field in nutrient_fields.items()
}
if (
not serving_size
or len(serving_size) > 80
or any(value is None for value in nutrition.values())
):
# Never label 100 g/ml values as a whole packaging serving. Keep
# every nutrient on one source-provided basis; do not infer a
# serving weight, volume or density from free-text packaging data.
nutrition = {
name: _to_float(nutriments.get(f"{field}_100g"))
for name, field in nutrient_fields.items()
}
serving_size = "100 g / 100 ml (source reference)"

# A missing value is not the same as a measured zero. Incomplete
# records are excluded from loggable search results so CalorieApp cannot
Expand All @@ -252,7 +270,7 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]:
image_url=_extract_image_url(product),
barcode=_to_optional_text(product.get("code")),
brand=_extract_brand(product),
serving_size=_to_optional_text(product.get("serving_size")),
serving_size=serving_size,
nutri_score=_extract_nutri_score(product),
)
)
Expand Down
102 changes: 102 additions & 0 deletions backend/tests/test_food_nutrition_basis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Keep the displayed reference amount aligned with all four nutrient values."""

import pytest

from app.services.open_food_facts import _normalize_products


REFERENCE_AMOUNT = "100 g / 100 ml (source reference)"


def product_with_two_bases() -> dict:
return {
"product_name": "Sample cocoa drink",
"serving_size": "250 ml",
"nutriments": {
"energy-kcal_100g": 87.2,
"proteins_100g": 3.2,
"fat_100g": 2.7,
"carbohydrates_100g": 12,
"energy-kcal_serving": 218,
"proteins_serving": 8,
"fat_serving": 6.75,
"carbohydrates_serving": 30,
},
}


def test_named_serving_uses_source_serving_values_instead_of_100g_values() -> None:
item, = _normalize_products({"products": [product_with_two_bases()]})
assert item.serving_size == "250 ml"
assert (item.calories, item.protein, item.fat, item.carbohydrates) == (218, 8, 6.75, 30)


def test_complete_source_serving_can_be_used_without_100g_values() -> None:
product = product_with_two_bases()
product["nutriments"] = {
key: value for key, value in product["nutriments"].items() if key.endswith("_serving")
}
item, = _normalize_products({"products": [product]})
assert item.calories == 218
assert item.serving_size == "250 ml"


@pytest.mark.parametrize("missing", ["energy-kcal", "proteins", "fat", "carbohydrates"])
def test_partial_serving_data_falls_back_as_one_complete_reference_set(missing: str) -> None:
product = product_with_two_bases()
del product["nutriments"][f"{missing}_serving"]
item, = _normalize_products({"products": [product]})
assert item.serving_size == REFERENCE_AMOUNT
assert (item.calories, item.protein, item.fat, item.carbohydrates) == (87.2, 3.2, 2.7, 12)


@pytest.mark.parametrize("invalid", [None, -1, float("nan"), float("inf"), "unknown"])
def test_invalid_serving_value_does_not_mix_reference_amounts(invalid: object) -> None:
product = product_with_two_bases()
product["nutriments"]["proteins_serving"] = invalid
item, = _normalize_products({"products": [product]})
assert item.serving_size == REFERENCE_AMOUNT
assert item.calories == 87.2
assert item.protein == 3.2


@pytest.mark.parametrize("label", [None, "", " ", "x" * 81])
def test_unusable_serving_label_keeps_the_explicit_100g_reference(label: object) -> None:
product = product_with_two_bases()
product["serving_size"] = label
item, = _normalize_products({"products": [product]})
assert item.serving_size == REFERENCE_AMOUNT
assert item.calories == 87.2


def test_real_zero_serving_nutrients_are_preserved() -> None:
product = product_with_two_bases()
product["product_name"] = "Sample drink"
product["serving_size"] = "1 can (330 ml)"
product["nutriments"].update({
"energy-kcal_serving": 135.3,
"proteins_serving": 0,
"fat_serving": 0,
"carbohydrates_serving": 10.23,
})
item, = _normalize_products({"products": [product]})
assert item.serving_size == "1 can (330 ml)"
assert (item.calories, item.protein, item.fat, item.carbohydrates) == (135.3, 0, 0, 10.23)


def test_product_is_omitted_if_neither_basis_has_complete_nutrition() -> None:
product = product_with_two_bases()
del product["nutriments"]["proteins_serving"]
del product["nutriments"]["fat_100g"]
assert _normalize_products({"products": [product]}) == []


def test_reference_values_do_not_require_or_infer_a_packaging_serving() -> None:
product = product_with_two_bases()
product["nutriments"] = {
key: value for key, value in product["nutriments"].items() if key.endswith("_100g")
}
product["serving_size"] = "1 large glass"
item, = _normalize_products({"products": [product]})
assert item.serving_size == REFERENCE_AMOUNT
assert item.calories == 87.2
9 changes: 9 additions & 0 deletions docs/public/data-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ An unexpired result can be returned without contacting an unavailable provider.
Provider 429/503 responses pause new upstream searches for Retry-After (30 seconds
when absent); no extra retry or alternate provider is used to bypass that pause.

Search results use the provider's per-serving nutrient values only when all four
values and a usable serving label are present. Otherwise all four values use the
provider's 100 g/ml reference, which is explicitly labelled as the reference amount.
Portion percentages apply to that displayed amount. Values from different bases
are never combined, and no density or serving quantity is inferred from packaging
text. Existing food logs remain as recorded; they are not retrospectively
rescaled when this search normalization changes. This distinction follows the
[Open Food Facts nutrition schema](https://openfoodfacts.github.io/openfoodfacts-server/dev/explain-nutrition-data/).

Personal food history, email addresses, profile details and stable user
identifiers are not intended for public blockchain or public IPFS storage.
Optional encrypted user-controlled exports and non-reversible integrity proofs
Expand Down