Files
mealie-mcp/mealie_mcp/normalize.py
T
fredamn76 812d817296 Fix three bugs found by running against the live instance
suggest_recipes returned 422 for every call. The endpoint takes food UUIDs as
repeated query parameters, not a comma-joined string of names — the rule
inherited from the skill was never actually verified. Added resolve_foods to
map names to food records first, and report anything unresolved instead of
dropping it.

scale_ingredients silently returned unscaled lines. It read the structured
quantity field and string-replaced str(quantity) into the display text, which
failed twice over: unparsed recipes store quantity 0.0 with the real amount only
in the text, and str(800.0) never matches "800 g kycklinglårfilé". Amounts are
now read from the display text, ranges scale at both ends ("1-1,5 msk"), mixed
numbers scale as one value ("1 1/2 msk"), and lines with no amount are reported
in `unscaled` rather than presented as if they had been scaled.

looks_mangled flagged the readable "1 1/2 msk tomatpuré" because its duplicate-word
rule matched the repeated digit. Restricted to alphabetic words, so it still
catches "2 dl dl grädde".

Verified against all 238 recipes in the live library: no false positives remain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 07:42:13 +02:00

167 lines
6.2 KiB
Python
Raw 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
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