Files
mealie-mcp/mealie_mcp/normalize.py
T
Fizz f2d233b430 Initial commit: Mealie MCP server
Ports a working Hermes agent skill to an MCP server. The skill's value was not
its Mealie endpoints but ~25 operational rules found by running imports against
the live instance; those are now code with tests rather than prompt text.

The server owns deterministic mechanics — auth, the non-default User-Agent
Cloudflare requires, the `extension` field on cover uploads, PATCH instead of
the 500-ing bulk-action endpoints, decimal-comma normalization, and restoring
Swedish display text after parsing. Language and taste judgement stay with the
model, fed by the four reference notes shipped as MCP resources.

verify_recipe runs the finished-import definition as code so an agent cannot
report success on a recipe with an English ingredient line, a missing cover, or
ingredients still in the "Click Parse" state.

Home Assistant is optional and isolated; the Obsidian meal plan is out of scope.

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

107 lines
3.8 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
#: 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
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
if re.search(r"\b(\w+)\s+\1\b", line, flags=re.IGNORECASE):
return True
return False