Make ingredient parsing safe against live Mealie rewrites

Co-authored-by: Fizz <fizz@agents.famfallman.com>
Co-authored-by: fredamn76 <fredrik.fallman@gmail.com>
Signed-off-by: fredamn76 <fredrik.fallman@gmail.com>
This commit is contained in:
2026-07-31 08:38:11 +02:00
committed by fredamn76
parent ddc0fb1a5e
commit 1f10bd321c
4 changed files with 300 additions and 47 deletions
+80
View File
@@ -151,6 +151,86 @@ def scale_line(line: str, factor: Decimal) -> tuple[str, bool]:
return line[: match.start("low")] + replacement + line[match.end() :], True 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<token>[^\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"(?<![^\W\d_]){re.escape(name)}", line.lower()) is not None
def line_remainder(line: str, written_unit: str | None, food_name: str | None) -> 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"(?<![^\W\d_]){re.escape(food_name.strip().lower())}[^\W\d_]*", rest.lower()
)
if match:
rest = rest[: match.start()] + rest[match.end() :]
return _WHITESPACE.sub(" ", rest).strip()
def same_line(left: str, right: str) -> 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: def looks_mangled(line: str) -> bool:
"""Heuristic for parser-damaged display text. """Heuristic for parser-damaged display text.
+150 -30
View File
@@ -13,11 +13,16 @@ from mcp.server.mcpserver import MCPServer
from .normalize import ( from .normalize import (
clean_title, clean_title,
food_matches_line,
has_extraction_failure, has_extraction_failure,
ingredient_display_lines, ingredient_display_lines,
line_remainder,
looks_mangled, looks_mangled,
match_unit,
normalize_for_parser, normalize_for_parser,
same_line,
scale_line, scale_line,
unit_token,
) )
from .verify import verify_recipe as run_verification 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) 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() @mcp.tool()
def parse_ingredients(slug_or_id: str) -> dict[str, Any]: 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 ``display`` is computed by Mealie from ``quantity unit food note`` and cannot be
Swedish rows (``4,7 dl basmatiris`` came back as ``4710 liter ...``), so the set through the API — writing it is silently ignored. Structuring a row
canonical human line is preserved and restored after parsing. Rows the parser therefore rewrites how it reads, and Mealie's rendering is not always the
still mangles keep readable text with structure cleared — legibility wins. 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) recipe = get_recipe(slug_or_id)
canonical = ingredient_display_lines(recipe) canonical = ingredient_display_lines(recipe)
@@ -338,43 +424,77 @@ def parse_ingredients(slug_or_id: str) -> dict[str, Any]:
"/api/parser/ingredients", "/api/parser/ingredients",
json={"ingredients": [normalize_for_parser(line) for line in canonical], "parser": "nlp"}, json={"ingredients": [normalize_for_parser(line) for line in canonical], "parser": "nlp"},
) )
units = _unit_records()
ingredients = list(recipe.get("recipeIngredient") or []) base = list(recipe.get("recipeIngredient") or [])
degraded: list[str] = [] candidates = [_unstructured_item(item, line) for item, line in zip(base, canonical)]
corrections: list[dict[str, Any]] = []
for index, result in enumerate(parsed or []): for index, result in enumerate(parsed or []):
if index >= len(ingredients): if index >= len(base):
break break
item = dict(ingredients[index]) line = canonical[index]
confidence = (result or {}).get("ingredient") or {} quantity, unit, food, notes = _vet_structure(
item["quantity"] = confidence.get("quantity") line, (result or {}).get("ingredient") or {}, units
item["unit"] = confidence.get("unit") )
item["food"] = confidence.get("food") if notes:
# The canonical Swedish line always wins over parser-generated display text. corrections.append({"line": line, "notes": notes})
item["display"] = canonical[index] if unit or food:
item["note"] = canonical[index] candidates[index] = _structured_item(base[index], line, quantity, unit, food)
if looks_mangled(canonical[index]):
item["quantity"] = None
item["unit"] = None
item["food"] = None
degraded.append(canonical[index])
ingredients[index] = item
try: rejected: list[int] = []
_request("PATCH", f"/api/recipes/{slug_or_id}", json={"recipeIngredient": ingredients})
except httpx.HTTPStatusError as exc: def apply(items: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
# A full recipeIngredient patch with nested parser objects can 500 here. try:
# Preserve the readable import rather than corrupting the recipe. _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 { return {
"parsed": False, "parsed": False,
"reason": f"Structured patch rejected by Mealie ({exc.response.status_code}); " "reason": f"Structured patch rejected by Mealie ({rejected[0]}); the recipe "
"readable Swedish lines were left intact", "was not modified",
"canonical_lines": canonical, "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 { return {
"parsed": True, "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), "recipe": get_recipe(slug_or_id),
"readability_fallback_rows": degraded,
} }
+35
View File
@@ -6,11 +6,16 @@ import pytest
from mealie_mcp.normalize import ( from mealie_mcp.normalize import (
clean_title, clean_title,
find_english_leftovers, find_english_leftovers,
food_matches_line,
has_extraction_failure, has_extraction_failure,
ingredient_display_lines, ingredient_display_lines,
line_remainder,
looks_mangled, looks_mangled,
match_unit,
normalize_for_parser, normalize_for_parser,
same_line,
slugify, slugify,
unit_token,
) )
@@ -97,3 +102,33 @@ class TestIngredientDisplayLines:
] ]
} }
assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"] 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")
+35 -17
View File
@@ -97,45 +97,63 @@ class TestImportReport:
class TestParseIngredients: 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" canonical = "4,7 dl basmatiris"
recipe = { recipe = {
"slug": "ris", "slug": "ris",
"recipeIngredient": [{"display": canonical, "note": canonical}], "recipeIngredient": [{"display": canonical, "note": canonical}],
} }
seen: dict[str, object] = {} patches: list[list[dict]] = []
reads = 0
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
import json as _json import json as _json
nonlocal reads
if request.url.path == "/api/parser/ingredients": 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=[{ return httpx.Response(200, json=[{
"ingredient": { "ingredient": {
"quantity": 4.7, "quantity": 4.7,
"unit": {"id": "u", "name": "dl"}, "unit": {"id": "liter", "name": "liter"},
"food": {"id": "f", "name": "basmatiris"}, "food": {"id": "f", "name": "basmatiris"},
"display": "4710 liter basmatiris",
} }
}]) }])
if request.url.path == "/api/units":
return httpx.Response(
200,
json={"items": [{"id": "dl", "name": "dl", "abbreviation": "dl"}]},
)
if request.method == "PATCH": if request.method == "PATCH":
seen["patched"] = _json.loads(request.content)["recipeIngredient"] patches.append(_json.loads(request.content)["recipeIngredient"])
return httpx.Response(200, json={}) 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": "4710 dl basmatiris",
}],
})
return httpx.Response(200, json=recipe) return httpx.Response(200, json=recipe)
mock_mealie(handler) mock_mealie(handler)
server.parse_ingredients("ris") result = server.parse_ingredients("ris")
# Decimal comma is normalized for the parser only... assert len(patches) == 2
assert seen["sent"] == ["4.7 dl basmatiris"] assert patches[0][0]["unit"]["id"] == "dl"
patched = seen["patched"][0] assert patches[1][0]["quantity"] == 0
# ...while the stored human-facing line stays the original Swedish text. assert patches[1][0]["unit"] is None
assert patched["display"] == canonical assert patches[1][0]["food"] is None
assert patched["note"] == canonical assert patches[1][0]["note"] == canonical
# Structured data from the parser is still applied. assert result["lines_unchanged"] is True
assert patched["quantity"] == 4.7 assert result["left_unstructured"] == [{
assert patched["food"]["name"] == "basmatiris" "line": canonical,
"mealie_would_show": "4710 dl basmatiris",
}]
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie): def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]} recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}