Store nutrition per serving, and check the estimate before sending it
Recipes imported from a video caption have no nutrition data, so the values have to be estimated. Mealie will accept anything: the fields are free-text strings, a misspelt key is dropped silently, and a whole-recipe total looks exactly like a per-serving one. The recipe page then renders whatever landed as fact. patch_recipe now validates a nutrition block first. Keys must be Mealie's own, so a number cannot vanish into "carbs". Values are normalized to bare numbers, matching how the library already stores them. Energy is checked against the macros with the Atwater factors (4/9/4 kcal per gram) and refused if it is more than 25% off, which is what catches an arithmetic slip. Nutrition is per serving, and the text import left recipeServings at 0 --- it set only the free-text recipeYield, so "4-6 personer" gave Mealie no number to divide by or scale with. import_recipe_text now also sets recipeServings, from the lower bound of a range, which is how this library already stores "10-12 personer" (recipeServings 10). patch_recipe refuses nutrition while the count is still missing, and accepts it when the same patch supplies it. verify_recipe fails an import that has no nutrition, or has values without a serving count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+119
-3
@@ -334,6 +334,18 @@ def _text_lines(body: str) -> list[str]:
|
|||||||
return [line.strip() for line in body.splitlines() if line.strip()]
|
return [line.strip() for line in body.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _servings_number(portions: str) -> int | None:
|
||||||
|
"""Read a serving count out of a Swedish yield line.
|
||||||
|
|
||||||
|
``recipeYield`` is free text, but Mealie scales ingredients and nutrition by
|
||||||
|
the numeric ``recipeServings`` — left at 0, per-serving figures have nothing
|
||||||
|
to divide by. A range takes its lower bound, which is how the rest of this
|
||||||
|
library stores "10-12 personer" (recipeServings 10).
|
||||||
|
"""
|
||||||
|
match = re.search(r"\d+", portions)
|
||||||
|
return int(match.group()) if match else None
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, Any]:
|
def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, Any]:
|
||||||
"""Import a normalized recipe text block (OCR, transcript, or hand-built Swedish).
|
"""Import a normalized recipe text block (OCR, transcript, or hand-built Swedish).
|
||||||
@@ -380,6 +392,9 @@ def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, An
|
|||||||
}
|
}
|
||||||
if sections.get("portioner"):
|
if sections.get("portioner"):
|
||||||
patch["recipeYield"] = sections["portioner"]
|
patch["recipeYield"] = sections["portioner"]
|
||||||
|
servings = _servings_number(sections["portioner"])
|
||||||
|
if servings:
|
||||||
|
patch["recipeServings"] = servings
|
||||||
if source_url:
|
if source_url:
|
||||||
patch["orgURL"] = source_url
|
patch["orgURL"] = source_url
|
||||||
|
|
||||||
@@ -460,6 +475,97 @@ def _resolve_organizers(field: str, entries: Any) -> Any:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
#: Mealie's own nutrition field names. Anything else is silently dropped by the
|
||||||
|
#: API, so a misspelt key has to be caught here or the number never lands.
|
||||||
|
_NUTRITION_FIELDS = frozenset({
|
||||||
|
"calories",
|
||||||
|
"carbohydrateContent",
|
||||||
|
"cholesterolContent",
|
||||||
|
"fatContent",
|
||||||
|
"fiberContent",
|
||||||
|
"proteinContent",
|
||||||
|
"saturatedFatContent",
|
||||||
|
"sodiumContent",
|
||||||
|
"sugarContent",
|
||||||
|
"transFatContent",
|
||||||
|
"unsaturatedFatContent",
|
||||||
|
})
|
||||||
|
|
||||||
|
#: Atwater factors: kcal per gram of each macronutrient.
|
||||||
|
_ENERGY_PER_GRAM = {"proteinContent": 4.0, "fatContent": 9.0, "carbohydrateContent": 4.0}
|
||||||
|
|
||||||
|
_NUMBER_RE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*(?:kcal|cal|kj|mg|g|gram|grams)?\s*$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _nutrition_number(field: str, value: Any) -> float:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
match = _NUMBER_RE.match(str(value))
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"nutrition.{field} must be a plain number (Mealie renders the unit "
|
||||||
|
f"itself); got {value!r}"
|
||||||
|
)
|
||||||
|
return float(match.group(1).replace(",", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_nutrition(nutrition: Any, servings: Any) -> dict[str, str]:
|
||||||
|
"""Check a nutrition block before it reaches Mealie, and normalize it.
|
||||||
|
|
||||||
|
Mealie stores these as free-text strings and validates nothing: a misspelt
|
||||||
|
key, a unit suffix, or a whole-recipe total in a per-serving field is all
|
||||||
|
accepted and then displayed as fact. Three things are checked instead:
|
||||||
|
|
||||||
|
* the keys are Mealie's own, so a number cannot vanish into `carbs`;
|
||||||
|
* every value is a bare number, matching how the library already stores them;
|
||||||
|
* energy agrees with the macros (4/9/4 kcal per gram) within 25%, which is
|
||||||
|
what catches an arithmetic slip in an estimate.
|
||||||
|
|
||||||
|
``recipeServings`` must be known, because Mealie's figures are per serving and
|
||||||
|
a recipe written "4-6 personer" carries no number Mealie can use.
|
||||||
|
"""
|
||||||
|
if not isinstance(nutrition, dict):
|
||||||
|
raise ValueError(f"nutrition must be an object; got {nutrition!r}")
|
||||||
|
|
||||||
|
unknown = sorted(set(nutrition) - _NUTRITION_FIELDS)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown nutrition fields {unknown}; Mealie's names are "
|
||||||
|
f"{sorted(_NUTRITION_FIELDS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
portions = float(servings or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
portions = 0.0
|
||||||
|
if portions <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"nutrition is stored per serving, but the recipe has no recipeServings; "
|
||||||
|
"set it in the same patch (a yield string like '4-6 personer' is not a "
|
||||||
|
"number Mealie can divide by)"
|
||||||
|
)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
field: _nutrition_number(field, value)
|
||||||
|
for field, value in nutrition.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
macros = {field: values[field] for field in _ENERGY_PER_GRAM if field in values}
|
||||||
|
if "calories" in values and len(macros) == len(_ENERGY_PER_GRAM):
|
||||||
|
expected = sum(grams * _ENERGY_PER_GRAM[field] for field, grams in macros.items())
|
||||||
|
stated = values["calories"]
|
||||||
|
if expected and abs(stated - expected) / expected > 0.25:
|
||||||
|
raise ValueError(
|
||||||
|
f"calories {stated:.0f} does not match the macros "
|
||||||
|
f"({macros['proteinContent']:.0f} g protein, {macros['fatContent']:.0f} g "
|
||||||
|
f"fat, {macros['carbohydrateContent']:.0f} g carbohydrate imply "
|
||||||
|
f"{expected:.0f} kcal). Recheck the estimate; both must be per serving."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {field: f"{value:.0f}" for field, value in values.items()}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Apply an explicit Mealie recipe patch, then read the recipe back.
|
"""Apply an explicit Mealie recipe patch, then read the recipe back.
|
||||||
@@ -467,9 +573,15 @@ def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
|||||||
Use this for tags and categories too — the bulk-action endpoints return 500 on
|
Use this for tags and categories too — the bulk-action endpoints return 500 on
|
||||||
this instance. Patch ``recipeCategory`` and ``tags`` directly; a bare name or
|
this instance. Patch ``recipeCategory`` and ``tags`` directly; a bare name or
|
||||||
``{"name": ...}`` is accepted and resolved to the existing organizer, or
|
``{"name": ...}`` is accepted and resolved to the existing organizer, or
|
||||||
created if the library has no such name. The stable recipe id is captured
|
created if the library has no such name.
|
||||||
before the PATCH because changing ``name`` also changes Mealie's slug, making
|
|
||||||
the old slug invalid for read-back.
|
``nutrition`` is checked before it is sent: Mealie's own field names, bare
|
||||||
|
numbers, and energy that agrees with the macros. The figures are per serving,
|
||||||
|
so ``recipeServings`` has to be set — patch it here if the import only left a
|
||||||
|
yield string.
|
||||||
|
|
||||||
|
The stable recipe id is captured before the PATCH because changing ``name``
|
||||||
|
also changes Mealie's slug, making the old slug invalid for read-back.
|
||||||
"""
|
"""
|
||||||
before = get_recipe(slug_or_id)
|
before = get_recipe(slug_or_id)
|
||||||
stable_id = before.get("id") or slug_or_id
|
stable_id = before.get("id") or slug_or_id
|
||||||
@@ -477,6 +589,10 @@ def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
|||||||
field: _resolve_organizers(field, value) if field in _ORGANIZER_FIELDS else value
|
field: _resolve_organizers(field, value) if field in _ORGANIZER_FIELDS else value
|
||||||
for field, value in patch.items()
|
for field, value in patch.items()
|
||||||
}
|
}
|
||||||
|
if "nutrition" in patch:
|
||||||
|
patch["nutrition"] = _validated_nutrition(
|
||||||
|
patch["nutrition"], patch.get("recipeServings", before.get("recipeServings"))
|
||||||
|
)
|
||||||
_request("PATCH", f"/api/recipes/{slug_or_id}", json=patch)
|
_request("PATCH", f"/api/recipes/{slug_or_id}", json=patch)
|
||||||
return get_recipe(stable_id)
|
return get_recipe(stable_id)
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,21 @@ def verify_recipe(
|
|||||||
else:
|
else:
|
||||||
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
|
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
|
||||||
|
|
||||||
|
nutrition = {k: v for k, v in (recipe.get("nutrition") or {}).items() if v}
|
||||||
|
servings = recipe.get("recipeServings") or 0
|
||||||
|
if not servings:
|
||||||
|
# Mealie's figures are per serving, so nutrition without a serving count
|
||||||
|
# is a number without a denominator.
|
||||||
|
nutrition_status: Status = "fail"
|
||||||
|
nutrition_detail = "No recipeServings, so per-serving nutrition cannot be read"
|
||||||
|
elif not nutrition:
|
||||||
|
nutrition_status = "fail"
|
||||||
|
nutrition_detail = "No nutrition values"
|
||||||
|
else:
|
||||||
|
nutrition_status = "pass"
|
||||||
|
nutrition_detail = f"{len(nutrition)} value(s) per serving, {servings:g} serving(s)"
|
||||||
|
checks.append(_check("nutrition", nutrition_status, nutrition_detail))
|
||||||
|
|
||||||
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
|
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
|
||||||
checks.append(
|
checks.append(
|
||||||
_check(
|
_check(
|
||||||
|
|||||||
@@ -179,6 +179,27 @@ class TestTextImport:
|
|||||||
]
|
]
|
||||||
assert patch["recipeYield"] == "4-6"
|
assert patch["recipeYield"] == "4-6"
|
||||||
assert patch["orgURL"] == "https://example.test/klipp"
|
assert patch["orgURL"] == "https://example.test/klipp"
|
||||||
|
# A range takes its lower bound; without a number Mealie cannot scale
|
||||||
|
# ingredients or show nutrition per serving.
|
||||||
|
assert patch["recipeServings"] == 4
|
||||||
|
|
||||||
|
def test_a_yield_without_a_number_leaves_servings_alone(self, mock_mealie, monkeypatch):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(201, json="testsoppa")
|
||||||
|
return httpx.Response(200, json={"slug": "testsoppa"})
|
||||||
|
|
||||||
|
recorded = mock_mealie(handler)
|
||||||
|
monkeypatch.setattr(server, "get_recipe", lambda _slug: {"slug": "testsoppa"})
|
||||||
|
|
||||||
|
server.import_recipe_text(
|
||||||
|
"Titel: Testsoppa\n\nPortioner: en stor kastrull\n\nIngredienser:\n1 dl vatten\n\n"
|
||||||
|
"Gör så här:\nBlanda."
|
||||||
|
)
|
||||||
|
|
||||||
|
patch = json.loads(recorded[1].content)
|
||||||
|
assert patch["recipeYield"] == "en stor kastrull"
|
||||||
|
assert "recipeServings" not in patch
|
||||||
|
|
||||||
def test_a_marked_reconstruction_heading_is_still_a_heading(self, mock_mealie, monkeypatch):
|
def test_a_marked_reconstruction_heading_is_still_a_heading(self, mock_mealie, monkeypatch):
|
||||||
"""``Gör så här (REKONSTRUERAD):`` marks the source, not the section name.
|
"""``Gör så här (REKONSTRUERAD):`` marks the source, not the section name.
|
||||||
@@ -402,6 +423,81 @@ class TestOrganizerPatch:
|
|||||||
assert lookups == ["/api/organizers/categories", "/api/organizers/categories"]
|
assert lookups == ["/api/organizers/categories", "/api/organizers/categories"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNutritionPatch:
|
||||||
|
"""Mealie validates nothing here: it stores strings and renders them as fact."""
|
||||||
|
|
||||||
|
def _mealie(self, mock_mealie, recipe):
|
||||||
|
sent: list[dict] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.method == "PATCH":
|
||||||
|
sent.append(json.loads(request.content))
|
||||||
|
return httpx.Response(200, json=recipe)
|
||||||
|
|
||||||
|
mock_mealie(handler)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
def test_a_consistent_estimate_is_normalized_to_bare_numbers(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||||
|
|
||||||
|
server.patch_recipe(
|
||||||
|
"sas",
|
||||||
|
{"nutrition": {
|
||||||
|
"calories": "459 kcal",
|
||||||
|
"proteinContent": 13,
|
||||||
|
"fatContent": "38 g",
|
||||||
|
"carbohydrateContent": 15.4,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sent[0]["nutrition"] == {
|
||||||
|
"calories": "459",
|
||||||
|
"proteinContent": "13",
|
||||||
|
"fatContent": "38",
|
||||||
|
"carbohydrateContent": "15",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_energy_that_contradicts_the_macros_is_refused(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||||
|
|
||||||
|
# 13 g protein + 38 g fat + 15 g carbohydrate is ~455 kcal, not 150.
|
||||||
|
with pytest.raises(ValueError, match="does not match the macros"):
|
||||||
|
server.patch_recipe(
|
||||||
|
"sas",
|
||||||
|
{"nutrition": {
|
||||||
|
"calories": 150,
|
||||||
|
"proteinContent": 13,
|
||||||
|
"fatContent": 38,
|
||||||
|
"carbohydrateContent": 15,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
def test_nutrition_without_a_serving_count_is_refused(self, mock_mealie):
|
||||||
|
# The TikTok import left recipeServings 0 and only a "4-6 personer" string,
|
||||||
|
# so per-serving figures would have had nothing to divide by.
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 0})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="recipeServings"):
|
||||||
|
server.patch_recipe("sas", {"nutrition": {"calories": 459}})
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
def test_servings_patched_in_the_same_call_satisfy_the_requirement(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 0})
|
||||||
|
|
||||||
|
server.patch_recipe("sas", {"recipeServings": 4, "nutrition": {"calories": 459}})
|
||||||
|
|
||||||
|
assert sent[0]["recipeServings"] == 4
|
||||||
|
assert sent[0]["nutrition"] == {"calories": "459"}
|
||||||
|
|
||||||
|
def test_a_misspelt_field_names_the_right_one_instead_of_vanishing(self, mock_mealie):
|
||||||
|
sent = self._mealie(mock_mealie, {"id": "stable-id", "slug": "sas", "recipeServings": 4})
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="carbohydrateContent"):
|
||||||
|
server.patch_recipe("sas", {"nutrition": {"carbs": 15}})
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
class TestCoverImage:
|
class TestCoverImage:
|
||||||
def test_an_id_is_resolved_to_the_slug_before_hitting_the_image_route(self, mock_mealie):
|
def test_an_id_is_resolved_to_the_slug_before_hitting_the_image_route(self, mock_mealie):
|
||||||
# Verified on v3.22.0: /api/recipes/{slug} takes an id, but the sub-routes
|
# Verified on v3.22.0: /api/recipes/{slug} takes an id, but the sub-routes
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ def complete_recipe(**overrides):
|
|||||||
"recipeCategory": [{"name": "Huvudrätter"}],
|
"recipeCategory": [{"name": "Huvudrätter"}],
|
||||||
"tags": [{"name": "Källa: Köket"}],
|
"tags": [{"name": "Källa: Köket"}],
|
||||||
"orgURL": "https://www.koket.se/kycklinggryta",
|
"orgURL": "https://www.koket.se/kycklinggryta",
|
||||||
|
"recipeServings": 4,
|
||||||
|
"nutrition": {"calories": "520", "proteinContent": "31", "fatContent": "34",
|
||||||
|
"carbohydrateContent": "18"},
|
||||||
}
|
}
|
||||||
recipe.update(overrides)
|
recipe.update(overrides)
|
||||||
return recipe
|
return recipe
|
||||||
@@ -130,6 +133,22 @@ class TestTaxonomy:
|
|||||||
assert report["complete"] is True
|
assert report["complete"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNutrition:
|
||||||
|
def test_missing_nutrition_fails(self):
|
||||||
|
report = verify_recipe(complete_recipe(nutrition={}), image_verified=True)
|
||||||
|
assert status_of(report, "nutrition") == "fail"
|
||||||
|
assert report["complete"] is False
|
||||||
|
|
||||||
|
def test_values_without_a_serving_count_fail(self):
|
||||||
|
# Mealie shows nutrition per serving, so figures with recipeServings 0
|
||||||
|
# are a numerator without a denominator — the state the TikTok import left.
|
||||||
|
report = verify_recipe(complete_recipe(recipeServings=0), image_verified=True)
|
||||||
|
assert status_of(report, "nutrition") == "fail"
|
||||||
|
assert "recipeServings" in next(
|
||||||
|
c["detail"] for c in report["checks"] if c["check"] == "nutrition"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAttribution:
|
class TestAttribution:
|
||||||
def test_missing_source_warns_but_does_not_block(self):
|
def test_missing_source_warns_but_does_not_block(self):
|
||||||
report = verify_recipe(
|
report = verify_recipe(
|
||||||
|
|||||||
Reference in New Issue
Block a user