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:
2026-08-17 14:52:54 +02:00
parent 42d78f4060
commit 7d693ef429
4 changed files with 249 additions and 3 deletions
+119 -3
View File
@@ -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)
+15
View File
@@ -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(
+96
View File
@@ -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
+19
View File
@@ -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(