diff --git a/backend/app/services/open_food_facts.py b/backend/app/services/open_food_facts.py index 0124cf7..4e1bb95 100644 --- a/backend/app/services/open_food_facts.py +++ b/backend/app/services/open_food_facts.py @@ -222,6 +222,12 @@ 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) @@ -229,12 +235,24 @@ def _normalize_products(payload: dict[str, Any]) -> list[FoodSearchResult]: 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 @@ -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), ) ) diff --git a/backend/tests/test_food_nutrition_basis.py b/backend/tests/test_food_nutrition_basis.py new file mode 100644 index 0000000..5bfca3a --- /dev/null +++ b/backend/tests/test_food_nutrition_basis.py @@ -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 diff --git a/docs/public/data-safety.md b/docs/public/data-safety.md index 26288c4..3a90415 100644 --- a/docs/public/data-safety.md +++ b/docs/public/data-safety.md @@ -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