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:
+150
-30
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user