From 1f10bd321cb8da7845919483c671104c4bb3d27b Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 31 Jul 2026 08:38:11 +0200 Subject: [PATCH] Make ingredient parsing safe against live Mealie rewrites Co-authored-by: Fizz Co-authored-by: fredamn76 Signed-off-by: fredamn76 --- mealie_mcp/normalize.py | 80 ++++++++++++++++++ mealie_mcp/server.py | 180 +++++++++++++++++++++++++++++++++------- tests/test_normalize.py | 35 ++++++++ tests/test_server.py | 52 ++++++++---- 4 files changed, 300 insertions(+), 47 deletions(-) diff --git a/mealie_mcp/normalize.py b/mealie_mcp/normalize.py index 7219027..d40f762 100644 --- a/mealie_mcp/normalize.py +++ b/mealie_mcp/normalize.py @@ -151,6 +151,86 @@ def scale_line(line: str, factor: Decimal) -> tuple[str, bool]: return line[: match.start("low")] + replacement + line[match.end() :], True +#: The word directly after a leading amount — the unit candidate in a Swedish line. +_UNIT_TOKEN = re.compile( + rf"^\s*(?:{_AMOUNT})(?:\s*[-–]\s*(?:{_AMOUNT}))?\s+(?P[^\W\d_]+)", + flags=re.UNICODE, +) + + +def unit_token(line: str) -> str | None: + """Return the word following the leading amount, or None if the line has no amount. + + This is the unit as *written* (``dl``, ``msk``, ``g``, ``klyfta``). It is the + authority for which unit a row means, because the Mealie NLP parser resolves + ``3 dl`` to the ``liter`` record — a verified 10x error on a live instance. + Not every match is a unit (``0,5 citron`` yields ``citron``); callers confirm + the token against the instance's own unit table. + """ + match = _UNIT_TOKEN.match(line) + return match.group("token") if match else None + + +def match_unit(token: str | None, units: list[dict]) -> dict | None: + """Find the unit record a written token refers to, by name or abbreviation. + + Matches singular and plural forms of both. Returns None when the token is not + a unit in this Mealie instance, which is the signal to store no unit at all + rather than the parser's guess. + """ + if not token: + return None + needle = token.strip().lower() + fields = ("name", "pluralName", "abbreviation", "pluralAbbreviation") + for unit in units: + if any((unit.get(field) or "").strip().lower() == needle for field in fields): + return unit + return None + + +def food_matches_line(food_name: str, line: str) -> bool: + """True when the parser's food is actually the ingredient the line names. + + The parser substitutes near neighbours from the existing food table — + ``800 g kycklinglårfilé`` came back as the food ``kycklingfilé``. A food is + accepted only when its name appears in the line outright (``koncentrerad + kycklingfond``) or begins a word in it, which keeps the singular record + ``körsbärstomat`` for ``körsbärstomater`` while rejecting the lår/filé swap. + """ + name = (food_name or "").strip().lower() + if not name: + return False + # The name must begin a word, so `lök` does not match inside `vitlök`, while a + # trailing plural (`körsbärstomat` in `körsbärstomater`) is still allowed. + return re.search(rf"(? str: + """Return what is left of a line once amount, unit, and food are held as structure. + + Mealie composes the visible row as ``quantity unit food note``, so ``note`` must + hold only the leftover text. Passing the whole line duplicates it + (``3 dl vispgrädde 3 dl vispgrädde``). The food match is widened to the end of + the word it starts, so the record ``körsbärstomat`` consumes + ``körsbärstomater`` and leaves ``(i olika färger)``. + """ + rest = _LEADING_AMOUNT.sub("", line, count=1) + if written_unit: + rest = re.sub(rf"^\s*{re.escape(written_unit)}\b", "", rest, count=1, flags=re.IGNORECASE) + if food_name: + match = re.search( + rf"(? bool: + """Compare two ingredient rows ignoring only whitespace differences.""" + return _WHITESPACE.sub(" ", left or "").strip() == _WHITESPACE.sub(" ", right or "").strip() + + def looks_mangled(line: str) -> bool: """Heuristic for parser-damaged display text. diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 099cf27..66534b0 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -13,11 +13,16 @@ from mcp.server.mcpserver import MCPServer from .normalize import ( clean_title, + food_matches_line, has_extraction_failure, ingredient_display_lines, + line_remainder, looks_mangled, + match_unit, normalize_for_parser, + same_line, scale_line, + unit_token, ) from .verify import verify_recipe as run_verification @@ -319,14 +324,95 @@ def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]: return get_recipe(slug_or_id) +def _unit_records() -> list[dict[str, Any]]: + """The instance's own unit table — the authority for what `dl` and `msk` mean.""" + data = _request("GET", "/api/units", params={"perPage": 200}) + return data.get("items", data if isinstance(data, list) else []) + + +def _vet_structure(line: str, ingredient: dict[str, Any], units: list[dict[str, Any]]) -> tuple[ + Any, dict[str, Any] | None, dict[str, Any] | None, list[str] +]: + """Keep only parser output the Swedish line actually supports. + + Verified against the live instance: the parser resolves `3 dl` to the `liter` + record, swaps `kycklinglårfilé` for the existing food `kycklingfilé`, and + invents foods with ``id: null`` — the last of which makes the recipe PATCH + fail with a 500. Unsupported values are dropped, never guessed at, and every + drop is reported so the caller can see what was not structured. + """ + quantity = ingredient.get("quantity") + unit = ingredient.get("unit") + food = ingredient.get("food") + notes: list[str] = [] + + if looks_mangled(line): + return None, None, None, ["stored line is already parser-damaged; left unstructured"] + + if unit: + written = unit_token(line) + confirmed = match_unit(written, units) + if confirmed and confirmed.get("id") != unit.get("id"): + notes.append( + f"unit {unit.get('name')!r} corrected to {confirmed.get('name')!r} " + f"from the written token {written!r}" + ) + unit = confirmed + elif not confirmed: + notes.append( + f"unit {unit.get('name')!r} is not what the line writes " + f"({written!r}); dropped" + ) + unit = None + + if food and not food.get("id"): + # A food with no id makes Mealie 500 on the recipe patch (ValueError). + notes.append(f"food {food.get('name')!r} does not exist in Mealie; dropped") + food = None + elif food and not food_matches_line(food.get("name") or "", line): + notes.append(f"food {food.get('name')!r} is not the ingredient this line names; dropped") + food = None + + return quantity, unit, food, notes + + +def _unstructured_item(base: dict[str, Any], line: str) -> dict[str, Any]: + """A row carrying only the human line — Mealie renders ``note`` verbatim.""" + item = dict(base) + item["quantity"] = 0 + item["unit"] = None + item["food"] = None + item["note"] = line + return item + + +def _structured_item( + base: dict[str, Any], line: str, quantity: Any, unit: Any, food: Any +) -> dict[str, Any]: + """A row holding structure, with ``note`` reduced to the leftover text.""" + item = dict(base) + item["quantity"] = quantity + item["unit"] = unit + item["food"] = food + item["note"] = line_remainder(line, unit_token(line) if unit else None, + (food or {}).get("name") if food else None) + return item + + @mcp.tool() def parse_ingredients(slug_or_id: str) -> dict[str, Any]: - """Add structured food/unit/quantity data while keeping the Swedish display text. + """Add structured food/unit/quantity data without changing any visible Swedish line. - The parser is a structured-data helper only. Its ``display`` output mangles - Swedish rows (``4,7 dl basmatiris`` came back as ``47⁄10 liter ...``), so the - canonical human line is preserved and restored after parsing. Rows the parser - still mangles keep readable text with structure cleared — legibility wins. + ``display`` is computed by Mealie from ``quantity unit food note`` and cannot be + set through the API — writing it is silently ignored. Structuring a row + therefore rewrites how it reads, and Mealie's rendering is not always the + Swedish the recipe was written in (``800 g`` renders as ``800 gram``, ``0,25`` + as ``¹/₄``, and plural foods render singular). + + So structure is applied, read back, and kept only for rows Mealie renders + identically to the original line. Any row it would have reworded is restored + unstructured, and reported in ``left_unstructured`` with the text Mealie wanted + to show. The visible line is never changed by this tool. """ recipe = get_recipe(slug_or_id) canonical = ingredient_display_lines(recipe) @@ -338,43 +424,77 @@ def parse_ingredients(slug_or_id: str) -> dict[str, Any]: "/api/parser/ingredients", json={"ingredients": [normalize_for_parser(line) for line in canonical], "parser": "nlp"}, ) + units = _unit_records() - ingredients = list(recipe.get("recipeIngredient") or []) - degraded: list[str] = [] + base = list(recipe.get("recipeIngredient") or []) + candidates = [_unstructured_item(item, line) for item, line in zip(base, canonical)] + corrections: list[dict[str, Any]] = [] for index, result in enumerate(parsed or []): - if index >= len(ingredients): + if index >= len(base): break - item = dict(ingredients[index]) - confidence = (result or {}).get("ingredient") or {} - item["quantity"] = confidence.get("quantity") - item["unit"] = confidence.get("unit") - item["food"] = confidence.get("food") - # The canonical Swedish line always wins over parser-generated display text. - item["display"] = canonical[index] - item["note"] = canonical[index] - if looks_mangled(canonical[index]): - item["quantity"] = None - item["unit"] = None - item["food"] = None - degraded.append(canonical[index]) - ingredients[index] = item + line = canonical[index] + quantity, unit, food, notes = _vet_structure( + line, (result or {}).get("ingredient") or {}, units + ) + if notes: + corrections.append({"line": line, "notes": notes}) + if unit or food: + candidates[index] = _structured_item(base[index], line, quantity, unit, food) - try: - _request("PATCH", f"/api/recipes/{slug_or_id}", json={"recipeIngredient": ingredients}) - except httpx.HTTPStatusError as exc: - # A full recipeIngredient patch with nested parser objects can 500 here. - # Preserve the readable import rather than corrupting the recipe. + rejected: list[int] = [] + + def apply(items: list[dict[str, Any]]) -> list[dict[str, Any]] | None: + try: + _request("PATCH", f"/api/recipes/{slug_or_id}", json={"recipeIngredient": items}) + except httpx.HTTPStatusError as exc: + rejected.append(exc.response.status_code) + return None + return get_recipe(slug_or_id).get("recipeIngredient") or [] + + stored = apply(candidates) + if stored is None: + # A rejected patch writes nothing, so the readable import is already intact. return { "parsed": False, - "reason": f"Structured patch rejected by Mealie ({exc.response.status_code}); " - "readable Swedish lines were left intact", + "reason": f"Structured patch rejected by Mealie ({rejected[0]}); the recipe " + "was not modified", "canonical_lines": canonical, } + # Mealie is the only authority on how a row renders, so compare what it stored. + reverted: list[dict[str, Any]] = [] + final = list(candidates) + for index, line in enumerate(canonical): + if index >= len(stored): + break + shown = stored[index].get("display") or "" + if not same_line(shown, line): + final[index] = _unstructured_item(base[index], line) + reverted.append({"line": line, "mealie_would_show": shown}) + + if reverted: + # The first patch succeeded, so these rows are stored reworded right now and + # the revert is a repair, not a precaution — say so plainly if it fails. + stored = apply(final) + if stored is None: + return { + "parsed": False, + "reason": f"Rows were reworded by Mealie and the repair patch failed " + f"({rejected[-1]}); these lines are still reworded in Mealie " + f"and need fixing by hand", + "reworded_rows": reverted, + } + + structured = [line for index, line in enumerate(canonical) + if final[index].get("unit") or final[index].get("food")] return { "parsed": True, + "structured_rows": structured, + "left_unstructured": reverted, + "corrections": corrections, + "lines_unchanged": all(same_line((stored[i].get("display") or ""), line) + for i, line in enumerate(canonical) if i < len(stored)), "recipe": get_recipe(slug_or_id), - "readability_fallback_rows": degraded, } diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 2c5d076..21c081d 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -6,11 +6,16 @@ import pytest from mealie_mcp.normalize import ( clean_title, find_english_leftovers, + food_matches_line, has_extraction_failure, ingredient_display_lines, + line_remainder, looks_mangled, + match_unit, normalize_for_parser, + same_line, slugify, + unit_token, ) @@ -97,3 +102,33 @@ class TestIngredientDisplayLines: ] } assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"] + + +class TestParserStructureSafety: + def test_reads_the_written_unit_token(self): + assert unit_token("4,7 dl basmatiris") == "dl" + assert unit_token("1-1,5 msk fond") == "msk" + assert unit_token("salt") is None + + def test_matches_instance_unit_by_abbreviation(self): + gram = {"id": "g", "name": "gram", "abbreviation": "g"} + assert match_unit("g", [gram]) == gram + assert match_unit("dl", [gram]) is None + + def test_rejects_a_nearby_but_different_food(self): + assert food_matches_line("kycklinglårfilé", "800 g kycklinglårfilé") + assert not food_matches_line("kycklingfilé", "800 g kycklinglårfilé") + + def test_builds_note_from_only_the_unstructured_remainder(self): + assert ( + line_remainder( + "200 g körsbärstomater (i olika färger)", + "g", + "körsbärstomat", + ) + == "(i olika färger)" + ) + + def test_same_line_ignores_only_whitespace(self): + assert same_line("3 dl grädde", " 3 dl grädde ") + assert not same_line("3 dl grädde", "3 liter grädde") diff --git a/tests/test_server.py b/tests/test_server.py index 2b67c02..f75d793 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -97,45 +97,63 @@ class TestImportReport: class TestParseIngredients: - def test_swedish_display_text_survives_the_parser(self, mock_mealie): + def test_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie): canonical = "4,7 dl basmatiris" recipe = { "slug": "ris", "recipeIngredient": [{"display": canonical, "note": canonical}], } - seen: dict[str, object] = {} + patches: list[list[dict]] = [] + reads = 0 def handler(request: httpx.Request) -> httpx.Response: import json as _json + nonlocal reads if request.url.path == "/api/parser/ingredients": - seen["sent"] = _json.loads(request.content)["ingredients"] - # The parser returns mangled display text; it must not be stored. return httpx.Response(200, json=[{ "ingredient": { "quantity": 4.7, - "unit": {"id": "u", "name": "dl"}, + "unit": {"id": "liter", "name": "liter"}, "food": {"id": "f", "name": "basmatiris"}, - "display": "47⁄10 liter basmatiris", } }]) + if request.url.path == "/api/units": + return httpx.Response( + 200, + json={"items": [{"id": "dl", "name": "dl", "abbreviation": "dl"}]}, + ) if request.method == "PATCH": - seen["patched"] = _json.loads(request.content)["recipeIngredient"] + patches.append(_json.loads(request.content)["recipeIngredient"]) return httpx.Response(200, json={}) + reads += 1 + if reads == 1: + return httpx.Response(200, json=recipe) + if len(patches) == 1: + # This is what Mealie rendered from the first structured patch. + return httpx.Response(200, json={ + "slug": "ris", + "recipeIngredient": [{ + **patches[0][0], + "display": "47⁄10 dl basmatiris", + }], + }) return httpx.Response(200, json=recipe) mock_mealie(handler) - server.parse_ingredients("ris") + result = server.parse_ingredients("ris") - # Decimal comma is normalized for the parser only... - assert seen["sent"] == ["4.7 dl basmatiris"] - patched = seen["patched"][0] - # ...while the stored human-facing line stays the original Swedish text. - assert patched["display"] == canonical - assert patched["note"] == canonical - # Structured data from the parser is still applied. - assert patched["quantity"] == 4.7 - assert patched["food"]["name"] == "basmatiris" + assert len(patches) == 2 + assert patches[0][0]["unit"]["id"] == "dl" + assert patches[1][0]["quantity"] == 0 + assert patches[1][0]["unit"] is None + assert patches[1][0]["food"] is None + assert patches[1][0]["note"] == canonical + assert result["lines_unchanged"] is True + assert result["left_unstructured"] == [{ + "line": canonical, + "mealie_would_show": "47⁄10 dl basmatiris", + }] def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie): recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}