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
#: 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.