diff --git a/mealie_mcp/normalize.py b/mealie_mcp/normalize.py index 9bcfe82..7219027 100644 --- a/mealie_mcp/normalize.py +++ b/mealie_mcp/normalize.py @@ -6,6 +6,7 @@ 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 = ( @@ -93,6 +94,63 @@ def ingredient_display_lines(recipe: dict) -> list[str]: 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{_AMOUNT})(?:\s*[-–]\s*(?P{_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. @@ -101,6 +159,8 @@ def looks_mangled(line: str) -> bool: """ if any(glyph in line for glyph in ("⁄", "½", "¼", "¾", "⅓", "⅔")): return True - if re.search(r"\b(\w+)\s+\1\b", line, flags=re.IGNORECASE): + # 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 diff --git a/mealie_mcp/server.py b/mealie_mcp/server.py index 3b80519..099cf27 100644 --- a/mealie_mcp/server.py +++ b/mealie_mcp/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import os +import re from decimal import Decimal, ROUND_HALF_UP from pathlib import Path from typing import Any @@ -16,6 +17,7 @@ from .normalize import ( ingredient_display_lines, looks_mangled, normalize_for_parser, + scale_line, ) from .verify import verify_recipe as run_verification @@ -106,12 +108,56 @@ def search_recipes(query: str, limit: int = 10) -> list[dict[str, Any]]: @mcp.tool() -def suggest_recipes(foods: list[str], limit: int = 10, max_missing_foods: int = 0) -> Any: - """Find recipes matching ingredients on hand.""" - return _request("GET", "/api/recipes/suggestions", params={ - "foods": ",".join(foods), "limit": limit, - "maxMissingFoods": max_missing_foods, "includeFoodsOnHand": "true", - }) +def resolve_foods(names: list[str]) -> dict[str, Any]: + """Map food names to the Mealie food records they refer to. + + ``/api/recipes/suggestions`` takes food UUIDs, not names, so free-text has to + be resolved first. Returns the best match per name plus the alternatives, so + the caller can tell that `kyckling` resolved to `kycklingbröst`. + """ + resolved: dict[str, Any] = {} + unresolved: list[str] = [] + for name in names: + data = _request("GET", "/api/foods", params={"search": name, "perPage": 5}) + items = data.get("items", data if isinstance(data, list) else []) + if not items: + unresolved.append(name) + continue + exact = next((i for i in items if (i.get("name") or "").lower() == name.lower()), None) + best = exact or items[0] + resolved[name] = { + "id": best.get("id"), + "name": best.get("name"), + "exact": exact is not None, + "alternatives": [i.get("name") for i in items if i.get("id") != best.get("id")], + } + return {"resolved": resolved, "unresolved": unresolved} + + +@mcp.tool() +def suggest_recipes(foods: list[str], limit: int = 10, max_missing_foods: int = 5) -> dict[str, Any]: + """Find recipes matching ingredients on hand, given plain food names. + + The endpoint expects food UUIDs as repeated query parameters; passing names or + a comma-joined string returns 422. Names are resolved first, and anything that + could not be resolved is reported rather than silently dropped. + """ + lookup = resolve_foods(foods) + ids = [entry["id"] for entry in lookup["resolved"].values()] + if not ids: + return {"items": [], "matched_foods": {}, "unresolved_foods": lookup["unresolved"]} + data = _request("GET", "/api/recipes/suggestions", params=[ + *[("foods", food_id) for food_id in ids], + ("limit", limit), + ("maxMissingFoods", max_missing_foods), + ("includeFoodsOnHand", "true"), + ]) + items = data.get("items", data if isinstance(data, list) else []) + return { + "items": items, + "matched_foods": {name: e["name"] for name, e in lookup["resolved"].items()}, + "unresolved_foods": lookup["unresolved"], + } @mcp.tool() @@ -152,25 +198,42 @@ def find_by_source_url(url: str) -> list[dict[str, Any]]: @mcp.tool() def scale_ingredients(slug_or_id: str, servings: int) -> dict[str, Any]: - """Return readable ingredient lines scaled to a requested serving count.""" + """Return readable ingredient lines scaled to a requested serving count. + + Amounts are read from the display text, not the structured ``quantity`` field: + unparsed recipes store ``quantity: 0.0`` while the real amount lives in the + line. Lines with no leading amount (`salt`, `smör (att steka i)`) are returned + untouched and listed in ``unscaled`` — never silently presented as scaled. + """ recipe = get_recipe(slug_or_id) - original = recipe.get("recipeYield") or recipe.get("recipeServings") + original = recipe.get("recipeServings") or recipe.get("recipeYieldQuantity") if not original: - raise ValueError("Recipe has no serving/yield metadata; scaling is not exact") - try: - base = Decimal(str(original).split()[0]) - factor = Decimal(servings) / base - except Exception as exc: - raise ValueError("Recipe serving metadata is not numeric") from exc - lines = [] - for item in recipe.get("recipeIngredient", []): - text = item.get("display") or item.get("note") or item.get("food", {}).get("name", "") - quantity = item.get("quantity") - if quantity is not None: - scaled = (Decimal(str(quantity)) * factor).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) - text = text.replace(str(quantity), format(scaled, "f"), 1) - lines.append(text) - return {"recipe": recipe.get("name"), "servings": servings, "ingredients": lines} + # recipeYield is free text ("4 portioner"); take a leading number if present. + match = re.match(r"\s*(\d+(?:[.,]\d+)?)", str(recipe.get("recipeYield") or "")) + original = match.group(1).replace(",", ".") if match else None + if not original or Decimal(str(original)) == 0: + raise ValueError( + "Recipe has no serving metadata (recipeServings, recipeYieldQuantity, " + "or a numeric recipeYield); scaling would be a guess" + ) + + factor = Decimal(servings) / Decimal(str(original)) + scaled_lines: list[str] = [] + unscaled: list[str] = [] + for line in ingredient_display_lines(recipe): + text, was_scaled = scale_line(line, factor) + scaled_lines.append(text) + if not was_scaled and line.strip(): + unscaled.append(line) + + return { + "recipe": recipe.get("name"), + "original_servings": float(Decimal(str(original))), + "servings": servings, + "factor": float(round(factor, 3)), + "ingredients": scaled_lines, + "unscaled": unscaled, + } # --- Import ----------------------------------------------------------------- diff --git a/tests/test_scaling.py b/tests/test_scaling.py index aaf0875..3604599 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -1,5 +1,125 @@ +"""Scaling must never present an unscaled line as scaled. + +The original implementation read the structured `quantity` field and string-replaced +`str(quantity)` into the display text. Against the live library that silently did +nothing twice over: unparsed recipes store `quantity: 0.0` with the real amount only +in the text, and `str(800.0)` == "800.0" never matches "800 g kycklinglårfilé". +The result was a confident wrong answer — the worst kind for a shopping list. +""" +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from mealie_mcp.normalize import format_amount, scale_line from mealie_mcp.server import scale_ingredients -def test_module_imports(): - assert callable(scale_ingredients) +class TestFormatAmount: + def test_whole_numbers_lose_the_decimal(self): + assert format_amount(Decimal("1200")) == "1200" + + def test_fractions_use_the_swedish_decimal_comma(self): + assert format_amount(Decimal("1.5")) == "1,5" + + def test_rounds_to_one_decimal(self): + assert format_amount(Decimal("0.66")) == "0,7" + + +class TestScaleLine: + def test_scales_a_leading_gram_amount(self): + assert scale_line("800 g kycklinglårfilé", Decimal("1.5")) == ("1200 g kycklinglårfilé", True) + + def test_scales_a_swedish_decimal_amount(self): + assert scale_line("4,7 dl basmatiris", Decimal("2")) == ("9,4 dl basmatiris", True) + + def test_scales_a_discrete_item(self): + assert scale_line("1 klyfta vitlök", Decimal("3")) == ("3 klyfta vitlök", True) + + def test_line_without_an_amount_is_reported_as_unscaled(self): + assert scale_line("salt", Decimal("2")) == ("salt", False) + + def test_parenthetical_line_without_amount_is_untouched(self): + assert scale_line("smör (att steka i)", Decimal("2")) == ("smör (att steka i)", False) + + def test_does_not_touch_a_number_that_is_not_leading(self): + # "Cheddar 40%" must not become "Cheddar 80%". + assert scale_line("cheddar 40% fetthalt", Decimal("2")) == ("cheddar 40% fetthalt", False) + + +class TestScaleIngredients: + def _recipe(self, **overrides): + recipe = { + "name": "Provencalsk kycklinggryta", + "recipeYield": "", + "recipeServings": 4.0, + "recipeYieldQuantity": 0.0, + "recipeIngredient": [ + # Verified live shape: unparsed rows carry quantity 0.0. + {"display": "800 g kycklinglårfilé", "quantity": 0.0}, + {"display": "salt", "quantity": 0.0}, + {"display": "1 klyfta vitlök", "quantity": 0.0}, + ], + } + recipe.update(overrides) + return recipe + + def test_scales_from_recipe_servings_when_yield_is_empty(self, monkeypatch): + monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: self._recipe()) + result = scale_ingredients("x", 6) + assert result["factor"] == 1.5 + assert result["ingredients"][0] == "1200 g kycklinglårfilé" + assert result["ingredients"][2] == "1,5 klyfta vitlök" + + def test_lines_without_amounts_are_listed_not_hidden(self, monkeypatch): + monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: self._recipe()) + assert scale_ingredients("x", 6)["unscaled"] == ["salt"] + + def test_falls_back_to_a_numeric_leading_recipe_yield(self, monkeypatch): + recipe = self._recipe(recipeServings=0.0, recipeYield="4 portioner") + monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: recipe) + assert scale_ingredients("x", 8)["factor"] == 2.0 + + def test_refuses_to_guess_without_serving_metadata(self, monkeypatch): + recipe = self._recipe(recipeServings=0.0, recipeYield="", recipeYieldQuantity=0.0) + monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: recipe) + with pytest.raises(ValueError, match="serving metadata"): + scale_ingredients("x", 6) + + +class TestRanges: + """Live data: 'Provencalsk kycklinggryta' stores '1-1,5 msk koncentrerad kycklingfond'.""" + + def test_both_ends_of_a_range_are_scaled(self): + assert scale_line("1-1,5 msk koncentrerad kycklingfond", Decimal("1.5")) == ( + "1,5-2,3 msk koncentrerad kycklingfond", + True, + ) + + def test_integer_range_stays_readable(self): + assert scale_line("2-3 dl vatten", Decimal("2")) == ("4-6 dl vatten", True) + + def test_en_dash_range_is_handled(self): + assert scale_line("1–2 st lök", Decimal("2")) == ("2-4 st lök", True) + + +class TestMixedFractions: + """Live data: 'tortillakebab-pa-grillen' stores '1 1/2 msk tomatpuré'.""" + + def test_mixed_number_scales_as_one_value(self): + # Naively scaling the leading "1" would yield "2 1/2 msk" instead of "3". + assert scale_line("1 1/2 msk tomatpuré", Decimal("2")) == ("3 msk tomatpuré", True) + + def test_bare_fraction_scales(self): + assert scale_line("1/2 tsk salt", Decimal("3")) == ("1,5 tsk salt", True) + + def test_mixed_number_is_readable_not_mangled(self): + from mealie_mcp.normalize import looks_mangled + + assert not looks_mangled("1 1/2 msk tomatpuré") + + def test_duplicated_unit_is_still_mangled(self): + from mealie_mcp.normalize import looks_mangled + + assert looks_mangled("2 dl dl grädde")