"""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")) 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, }