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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user