2846289df1
Mealie stores the literal string "no image" in the recipe's image field when there is no cover, so bool(recipe["image"]) reported a cover that is not there: the check meant to catch a missing image downgraded to a warning instead of failing. Observed on a recipe created through Mealie's own image import, which returned image: "no image". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
7.8 KiB
Python
232 lines
7.8 KiB
Python
"""The 'definition of a finished import' as executable checks.
|
|
|
|
This exists so an agent cannot report a successful import while the recipe still
|
|
has an English title, a missing cover image, or unparsed ingredients. Pure
|
|
functions over a recipe dict — see resources/import-pitfalls.md for the field
|
|
observations these encode.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal
|
|
|
|
from .normalize import (
|
|
find_english_leftovers,
|
|
has_extraction_failure,
|
|
ingredient_display_lines,
|
|
looks_mangled,
|
|
)
|
|
|
|
#: Mealie stores this sentinel in ``image`` when a recipe has no cover, so a
|
|
#: truthiness test on the field reports a cover that is not there.
|
|
NO_IMAGE_SENTINEL = "no image"
|
|
|
|
Status = Literal["pass", "fail", "warn"]
|
|
|
|
|
|
def _check(check_id: str, status: Status, detail: str) -> dict[str, str]:
|
|
return {"check": check_id, "status": status, "detail": detail}
|
|
|
|
|
|
def has_cover_image(recipe: dict[str, Any]) -> bool:
|
|
"""Whether the recipe carries a real cover image.
|
|
|
|
Mealie writes the literal string ``"no image"`` instead of an empty value
|
|
when a recipe has no cover, so an emptiness test has to reject that
|
|
sentinel too.
|
|
"""
|
|
image = recipe.get("image")
|
|
if not isinstance(image, str):
|
|
return bool(image)
|
|
return image.strip().casefold() not in ("", NO_IMAGE_SENTINEL)
|
|
|
|
|
|
def is_parsed(recipe: dict[str, Any]) -> bool:
|
|
"""Whether Mealie will stop showing 'your ingredients aren't parsed yet'.
|
|
|
|
Mealie treats ingredients as parsed once they carry structured data; an
|
|
ingredient with only free text keeps the recipe in the 'Click Parse' state.
|
|
"""
|
|
ingredients = recipe.get("recipeIngredient") or []
|
|
if not ingredients:
|
|
return False
|
|
return any(
|
|
(item.get("food") or {}).get("id") or (item.get("unit") or {}).get("id")
|
|
for item in ingredients
|
|
)
|
|
|
|
|
|
def verify_recipe(
|
|
recipe: dict[str, Any],
|
|
*,
|
|
image_verified: bool = False,
|
|
taxonomy_skipped_reason: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Run every finished-import check and return a pass/fail report.
|
|
|
|
``image_verified`` should only be set by a caller that actually ran an image
|
|
step, because a non-empty ``image`` field alone does not prove the UI shows a
|
|
cover (verified failure mode on Allrecipes imports).
|
|
|
|
``taxonomy_skipped_reason`` records a deliberate decision to leave a recipe
|
|
uncategorized, which turns that check into a pass instead of a failure.
|
|
"""
|
|
checks: list[dict[str, str]] = []
|
|
|
|
name = (recipe.get("name") or "").strip()
|
|
checks.append(
|
|
_check("title_present", "pass" if name else "fail", name or "Recipe has no title")
|
|
)
|
|
|
|
description = (recipe.get("description") or "").strip()
|
|
checks.append(
|
|
_check(
|
|
"description_present",
|
|
"pass" if description else "fail",
|
|
"Description set" if description else "Description is empty",
|
|
)
|
|
)
|
|
|
|
instructions = [
|
|
(step.get("text") or "").strip() for step in recipe.get("recipeInstructions") or []
|
|
]
|
|
instructions = [step for step in instructions if step]
|
|
checks.append(
|
|
_check(
|
|
"instructions_present",
|
|
"pass" if instructions else "fail",
|
|
f"{len(instructions)} step(s)" if instructions else "No instruction steps",
|
|
)
|
|
)
|
|
|
|
leftovers = find_english_leftovers(" ".join([name, description, *instructions]))
|
|
checks.append(
|
|
_check(
|
|
"swedish_prose",
|
|
"pass" if not leftovers else "fail",
|
|
"No English unit/label words found"
|
|
if not leftovers
|
|
else f"English leftovers in title/description/instructions: {', '.join(leftovers)}",
|
|
)
|
|
)
|
|
|
|
lines = ingredient_display_lines(recipe)
|
|
empty_lines = [index for index, line in enumerate(lines) if not line.strip()]
|
|
checks.append(
|
|
_check(
|
|
"ingredient_lines_present",
|
|
"pass" if lines and not empty_lines else "fail",
|
|
f"{len(lines)} ingredient line(s)"
|
|
if lines and not empty_lines
|
|
else f"Missing display text at index {empty_lines}"
|
|
if lines
|
|
else "Recipe has no ingredients",
|
|
)
|
|
)
|
|
|
|
ingredient_leftovers = find_english_leftovers(" ".join(lines))
|
|
checks.append(
|
|
_check(
|
|
"swedish_ingredients",
|
|
"pass" if not ingredient_leftovers else "fail",
|
|
"Ingredient lines are Swedish"
|
|
if not ingredient_leftovers
|
|
else f"English leftovers in ingredient lines: {', '.join(ingredient_leftovers)}",
|
|
)
|
|
)
|
|
|
|
mangled = [line for line in lines if looks_mangled(line)]
|
|
checks.append(
|
|
_check(
|
|
"ingredient_lines_readable",
|
|
"pass" if not mangled else "fail",
|
|
"Display text is clean"
|
|
if not mangled
|
|
else f"Parser-mangled display text: {mangled}",
|
|
)
|
|
)
|
|
|
|
parsed = is_parsed(recipe)
|
|
checks.append(
|
|
_check(
|
|
"ingredients_parsed",
|
|
"pass" if parsed else "fail",
|
|
"Structured food/unit data present"
|
|
if parsed
|
|
else "Recipe still shows the 'Click Parse' state",
|
|
)
|
|
)
|
|
|
|
checks.append(
|
|
_check(
|
|
"no_extraction_placeholders",
|
|
"fail" if has_extraction_failure(recipe) else "pass",
|
|
"Contains 'Could not detect ...' placeholder text"
|
|
if has_extraction_failure(recipe)
|
|
else "No scraper placeholders",
|
|
)
|
|
)
|
|
|
|
has_image_field = has_cover_image(recipe)
|
|
if image_verified:
|
|
image_status: Status = "pass"
|
|
image_detail = "Image step ran and was verified by the caller"
|
|
elif has_image_field:
|
|
image_status = "warn"
|
|
image_detail = (
|
|
"image field is set but no image step was verified; a non-empty image "
|
|
"does not prove the UI shows a cover"
|
|
)
|
|
else:
|
|
image_status = "fail"
|
|
image_detail = "No cover image"
|
|
checks.append(_check("cover_image", image_status, image_detail))
|
|
|
|
categories = recipe.get("recipeCategory") or []
|
|
tags = recipe.get("tags") or []
|
|
if categories or tags:
|
|
checks.append(
|
|
_check(
|
|
"taxonomy",
|
|
"pass",
|
|
f"{len(categories)} category/categories, {len(tags)} tag(s)",
|
|
)
|
|
)
|
|
elif taxonomy_skipped_reason:
|
|
checks.append(_check("taxonomy", "pass", f"Deliberately skipped: {taxonomy_skipped_reason}"))
|
|
else:
|
|
checks.append(_check("taxonomy", "fail", "No categories or tags, and no skip reason given"))
|
|
|
|
nutrition = {k: v for k, v in (recipe.get("nutrition") or {}).items() if v}
|
|
servings = recipe.get("recipeServings") or 0
|
|
if not servings:
|
|
# Mealie's figures are per serving, so nutrition without a serving count
|
|
# is a number without a denominator.
|
|
nutrition_status: Status = "fail"
|
|
nutrition_detail = "No recipeServings, so per-serving nutrition cannot be read"
|
|
elif not nutrition:
|
|
nutrition_status = "fail"
|
|
nutrition_detail = "No nutrition values"
|
|
else:
|
|
nutrition_status = "pass"
|
|
nutrition_detail = f"{len(nutrition)} value(s) per serving, {servings:g} serving(s)"
|
|
checks.append(_check("nutrition", nutrition_status, nutrition_detail))
|
|
|
|
source = recipe.get("orgURL") or (recipe.get("extras") or {}).get("source")
|
|
checks.append(
|
|
_check(
|
|
"attribution",
|
|
"pass" if source else "warn",
|
|
f"Source recorded: {source}" if source else "No orgURL or extras.source",
|
|
)
|
|
)
|
|
|
|
failed = [c for c in checks if c["status"] == "fail"]
|
|
warned = [c for c in checks if c["status"] == "warn"]
|
|
return {
|
|
"recipe": name or recipe.get("slug"),
|
|
"complete": not failed,
|
|
"failed": [c["check"] for c in failed],
|
|
"warnings": [c["check"] for c in warned],
|
|
"checks": checks,
|
|
}
|