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>
This commit is contained in:
+61
-1
@@ -6,6 +6,7 @@ Mealie instance — see resources/import-pitfalls.md.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
#: Campaign/navigation suffixes seen appended to imported titles, notably from Köket.
|
#: Campaign/navigation suffixes seen appended to imported titles, notably from Köket.
|
||||||
TITLE_JUNK_SUFFIXES = (
|
TITLE_JUNK_SUFFIXES = (
|
||||||
@@ -93,6 +94,63 @@ def ingredient_display_lines(recipe: dict) -> list[str]:
|
|||||||
return lines
|
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:
|
def looks_mangled(line: str) -> bool:
|
||||||
"""Heuristic for parser-damaged display text.
|
"""Heuristic for parser-damaged display text.
|
||||||
|
|
||||||
@@ -101,6 +159,8 @@ def looks_mangled(line: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
if any(glyph in line for glyph in ("⁄", "½", "¼", "¾", "⅓", "⅔")):
|
if any(glyph in line for glyph in ("⁄", "½", "¼", "¾", "⅓", "⅔")):
|
||||||
return True
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|||||||
+86
-23
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,6 +17,7 @@ from .normalize import (
|
|||||||
ingredient_display_lines,
|
ingredient_display_lines,
|
||||||
looks_mangled,
|
looks_mangled,
|
||||||
normalize_for_parser,
|
normalize_for_parser,
|
||||||
|
scale_line,
|
||||||
)
|
)
|
||||||
from .verify import verify_recipe as run_verification
|
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()
|
@mcp.tool()
|
||||||
def suggest_recipes(foods: list[str], limit: int = 10, max_missing_foods: int = 0) -> Any:
|
def resolve_foods(names: list[str]) -> dict[str, Any]:
|
||||||
"""Find recipes matching ingredients on hand."""
|
"""Map food names to the Mealie food records they refer to.
|
||||||
return _request("GET", "/api/recipes/suggestions", params={
|
|
||||||
"foods": ",".join(foods), "limit": limit,
|
``/api/recipes/suggestions`` takes food UUIDs, not names, so free-text has to
|
||||||
"maxMissingFoods": max_missing_foods, "includeFoodsOnHand": "true",
|
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()
|
@mcp.tool()
|
||||||
@@ -152,25 +198,42 @@ def find_by_source_url(url: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def scale_ingredients(slug_or_id: str, servings: int) -> dict[str, Any]:
|
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)
|
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:
|
if not original:
|
||||||
raise ValueError("Recipe has no serving/yield metadata; scaling is not exact")
|
# recipeYield is free text ("4 portioner"); take a leading number if present.
|
||||||
try:
|
match = re.match(r"\s*(\d+(?:[.,]\d+)?)", str(recipe.get("recipeYield") or ""))
|
||||||
base = Decimal(str(original).split()[0])
|
original = match.group(1).replace(",", ".") if match else None
|
||||||
factor = Decimal(servings) / base
|
if not original or Decimal(str(original)) == 0:
|
||||||
except Exception as exc:
|
raise ValueError(
|
||||||
raise ValueError("Recipe serving metadata is not numeric") from exc
|
"Recipe has no serving metadata (recipeServings, recipeYieldQuantity, "
|
||||||
lines = []
|
"or a numeric recipeYield); scaling would be a guess"
|
||||||
for item in recipe.get("recipeIngredient", []):
|
)
|
||||||
text = item.get("display") or item.get("note") or item.get("food", {}).get("name", "")
|
|
||||||
quantity = item.get("quantity")
|
factor = Decimal(servings) / Decimal(str(original))
|
||||||
if quantity is not None:
|
scaled_lines: list[str] = []
|
||||||
scaled = (Decimal(str(quantity)) * factor).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
|
unscaled: list[str] = []
|
||||||
text = text.replace(str(quantity), format(scaled, "f"), 1)
|
for line in ingredient_display_lines(recipe):
|
||||||
lines.append(text)
|
text, was_scaled = scale_line(line, factor)
|
||||||
return {"recipe": recipe.get("name"), "servings": servings, "ingredients": lines}
|
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 -----------------------------------------------------------------
|
# --- Import -----------------------------------------------------------------
|
||||||
|
|||||||
+122
-2
@@ -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
|
from mealie_mcp.server import scale_ingredients
|
||||||
|
|
||||||
|
|
||||||
def test_module_imports():
|
class TestFormatAmount:
|
||||||
assert callable(scale_ingredients)
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user