Files
mealie-mcp/mealie_mcp/normalize.py
2026-07-31 08:38:11 +02:00

247 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Text normalization for Swedish recipe content.
Pure functions, no I/O. These encode pitfalls verified against Fredrik's live
Mealie instance — see resources/import-pitfalls.md.
"""
from __future__ import annotations
import re
from decimal import ROUND_HALF_UP, Decimal
#: Campaign/navigation suffixes seen appended to imported titles, notably from Köket.
TITLE_JUNK_SUFFIXES = (
"- se & gör",
"- se och gör",
"| Köket.se",
"| Köket",
"- Recept",
"- recept",
)
#: Placeholder text Mealie writes when a scrape fails to find real content.
EXTRACTION_FAILURE_MARKERS = (
"Could not detect ingredients",
"Could not detect instructions",
)
#: English units/labels that must not survive into a finished Swedish recipe.
ENGLISH_LEFTOVERS = (
"cup", "cups", "tbsp", "tsp", "tablespoon", "teaspoon",
"oz", "ounce", "lb", "pound", "clove", "cloves",
"servings", "ingredients", "instructions",
)
_DECIMAL_COMMA = re.compile(r"(?<=\d),(?=\d)")
_WHITESPACE = re.compile(r"\s+")
def normalize_for_parser(line: str) -> str:
"""Return a variant of a Swedish ingredient line the Mealie parser handles better.
Only converts decimal comma to decimal point (``4,7 dl`` -> ``4.7 dl``).
The result is for the parser only; the original line stays canonical for
``display``/``note``, because the parser mangles Swedish text.
"""
return _WHITESPACE.sub(" ", _DECIMAL_COMMA.sub(".", line)).strip()
def clean_title(title: str) -> str:
"""Strip campaign/navigation junk that import scrapers append to recipe titles."""
cleaned = title.strip()
changed = True
while changed:
changed = False
for suffix in TITLE_JUNK_SUFFIXES:
if cleaned.lower().endswith(suffix.lower()):
cleaned = cleaned[: -len(suffix)].strip(" -|")
changed = True
return _WHITESPACE.sub(" ", cleaned).strip()
def slugify(title: str) -> str:
"""Build a Mealie-style slug from a Swedish title, preserving å/ä/ö transliteration."""
text = title.lower()
for source, target in (("å", "a"), ("ä", "a"), ("ö", "o"), ("é", "e"), ("&", "och")):
text = text.replace(source, target)
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
def find_english_leftovers(text: str) -> list[str]:
"""Return English unit/label words still present in supposedly Swedish content."""
lowered = text.lower()
return [word for word in ENGLISH_LEFTOVERS if re.search(rf"\b{re.escape(word)}\b", lowered)]
def has_extraction_failure(recipe: dict) -> bool:
"""Detect Mealie's 'could not detect' placeholders anywhere in a recipe."""
haystack = [recipe.get("description") or ""]
haystack += [i.get("display") or i.get("note") or "" for i in recipe.get("recipeIngredient") or []]
haystack += [s.get("text") or "" for s in recipe.get("recipeInstructions") or []]
blob = " ".join(haystack)
return any(marker in blob for marker in EXTRACTION_FAILURE_MARKERS)
def ingredient_display_lines(recipe: dict) -> list[str]:
"""Extract the human-facing ingredient lines, preferring display over note."""
lines = []
for item in recipe.get("recipeIngredient") or []:
text = item.get("display") or item.get("note") or ""
if not text:
food = item.get("food") or {}
text = food.get("name") or ""
lines.append(text)
return lines
#: One amount: a decimal ("4,7"), a fraction ("1/2"), or a mixed number ("1 1/2").
_AMOUNT = r"\d+(?:[.,]\d+)?(?:\s+\d+/\d+)?|\d+/\d+"
#: A leading amount in a Swedish ingredient line, optionally a range:
#: "800 g ...", "4,7 dl ...", "1 klyfta ...", "1-1,5 msk ...", "1 1/2 msk ...".
_LEADING_AMOUNT = re.compile(
rf"^\s*(?P<low>{_AMOUNT})(?:\s*[-]\s*(?P<high>{_AMOUNT}))?(?=\s|[^\d.,/-]|$)"
)
def parse_amount(raw: str) -> Decimal:
"""Read a Swedish/ASCII amount: ``4,7``, ``1/2``, or the mixed number ``1 1/2``."""
text = raw.strip().replace(",", ".")
whole = Decimal(0)
if " " in text:
head, text = text.split(None, 1)
whole = Decimal(head)
if "/" in text:
numerator, denominator = text.split("/", 1)
return whole + Decimal(numerator) / Decimal(denominator)
return whole + Decimal(text)
def format_amount(value: Decimal) -> str:
"""Render a scaled amount the way the recipe library writes them.
Whole numbers lose the decimal part (``1200``, not ``1200.0``) and fractions
use the Swedish decimal comma (``1,5``) to match the stored ingredient lines.
"""
quantized = value.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
if quantized == quantized.to_integral_value():
return str(int(quantized))
return format(quantized, "f").replace(".", ",")
def scale_line(line: str, factor: Decimal) -> tuple[str, bool]:
"""Scale the leading amount of an ingredient line.
Returns the line and whether anything was scaled. The amount is read from the
display text rather than the structured ``quantity`` field, because unparsed
recipes carry ``quantity: 0.0`` while the real number lives in the text — and
because ``str(800.0)`` never substring-matches ``"800 g ..."``.
"""
match = _LEADING_AMOUNT.match(line)
if not match:
return line, False
def scaled(raw: str) -> str:
return format_amount(parse_amount(raw) * factor)
low = scaled(match.group("low"))
high = match.group("high")
# A range must move at both ends: "1-1,5 msk" doubled is "2-3 msk", not "2-1,5 msk".
replacement = f"{low}-{scaled(high)}" if high else low
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.
Verified failure modes: vulgar/composed fractions (``4710 liter``), duplicated
units, and stray fraction glyphs the Mealie parser emits for Swedish rows.
"""
if any(glyph in line for glyph in ("", "½", "¼", "¾", "", "")):
return True
# Duplicated words, but only alphabetic ones: "2 dl dl grädde" is parser damage,
# while the mixed number in "1 1/2 msk tomatpuré" is perfectly readable.
if re.search(r"\b([^\W\d_]+)\s+\1\b", line, flags=re.IGNORECASE):
return True
return False