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:
@@ -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<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:
|
||||
"""Heuristic for parser-damaged display text.
|
||||
|
||||
|
||||
+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