200 lines
6.5 KiB
Python
200 lines
6.5 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,
|
||
|
|
)
|
||
|
|
|
||
|
|
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 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 = bool(recipe.get("image"))
|
||
|
|
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"))
|
||
|
|
|
||
|
|
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,
|
||
|
|
}
|