diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 57e4760..2c21f28 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -334,6 +334,18 @@ def _text_lines(body: str) -> list[str]: 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() 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). @@ -380,6 +392,9 @@ def import_recipe_text(text: str, source_url: str | None = None) -> dict[str, An } if sections.get("portioner"): patch["recipeYield"] = sections["portioner"] + servings = _servings_number(sections["portioner"]) + if servings: + patch["recipeServings"] = servings if source_url: patch["orgURL"] = source_url @@ -460,6 +475,97 @@ def _resolve_organizers(field: str, entries: Any) -> Any: 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() 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. @@ -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 this instance. Patch ``recipeCategory`` and ``tags`` directly; a bare name 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 - before the PATCH because changing ``name`` also changes Mealie's slug, making - the old slug invalid for read-back. + created if the library has no such name. + + ``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) 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 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) return get_recipe(stable_id) diff --git a/mealie_mcp/verify.py b/mealie_mcp/verify.py index 9313a65..527e702 100644 --- a/mealie_mcp/verify.py +++ b/mealie_mcp/verify.py @@ -179,6 +179,21 @@ def verify_recipe( else: 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") checks.append( _check( diff --git a/tests/test_server.py b/tests/test_server.py index 8fbfbdd..39f3dd4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -179,6 +179,27 @@ class TestTextImport: ] assert patch["recipeYield"] == "4-6" 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): """``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"] +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: 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 diff --git a/tests/test_verify.py b/tests/test_verify.py index 4e31650..2e4cb8f 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -31,6 +31,9 @@ def complete_recipe(**overrides): "recipeCategory": [{"name": "Huvudrätter"}], "tags": [{"name": "Källa: Köket"}], "orgURL": "https://www.koket.se/kycklinggryta", + "recipeServings": 4, + "nutrition": {"calories": "520", "proteinContent": "31", "fatContent": "34", + "carbohydrateContent": "18"}, } recipe.update(overrides) return recipe @@ -130,6 +133,22 @@ class TestTaxonomy: 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: def test_missing_source_warns_but_does_not_block(self): report = verify_recipe(