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
+150 -30
View File
@@ -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 ``4710 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,
}