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:
2026-07-31 07:42:13 +02:00
committed by fredamn76
parent f2d233b430
commit ddc0fb1a5e
3 changed files with 269 additions and 26 deletions
+86 -23
View File
@@ -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 -----------------------------------------------------------------