Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""Deterministic provenance and quality audit for the published recipe catalog."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
Ingredient,
|
||||
Recipe,
|
||||
RecipeCatalogStatus,
|
||||
RecipeImportStaging,
|
||||
RecipeImportStatus,
|
||||
)
|
||||
from app.services.wikibooks_import import WIKIBOOKS_SOURCE, WikibooksImportService
|
||||
|
||||
|
||||
OPERATOR_QUARANTINE_FINDING_CODE = "operator_quarantine"
|
||||
|
||||
|
||||
def audit_recipe_catalog(
|
||||
db: Session,
|
||||
*,
|
||||
apply: bool = False,
|
||||
reviewed_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Audit every recipe and optionally apply active/quarantined state changes."""
|
||||
timestamp = reviewed_at or datetime.now(timezone.utc)
|
||||
ingredient_names = {
|
||||
str(ingredient_id): name
|
||||
for ingredient_id, name in db.query(Ingredient.id, Ingredient.name).all()
|
||||
}
|
||||
catalog_query = (
|
||||
db.query(
|
||||
Recipe,
|
||||
RecipeImportStaging.source,
|
||||
RecipeImportStaging.external_id,
|
||||
RecipeImportStaging.source_url,
|
||||
)
|
||||
.outerjoin(
|
||||
RecipeImportStaging,
|
||||
and_(
|
||||
RecipeImportStaging.recipe_id == Recipe.id,
|
||||
RecipeImportStaging.status == RecipeImportStatus.APPROVED,
|
||||
),
|
||||
)
|
||||
.order_by(Recipe.id, RecipeImportStaging.id)
|
||||
)
|
||||
if apply:
|
||||
catalog_query = catalog_query.with_for_update(of=Recipe)
|
||||
recipes: dict[str, Recipe] = {}
|
||||
approved_links: dict[str, set[tuple[str, str, str]]] = {}
|
||||
for recipe, source, external_id, source_url in catalog_query.all():
|
||||
recipe_id = str(recipe.id)
|
||||
recipes[recipe_id] = recipe
|
||||
if source is not None and external_id is not None and source_url is not None:
|
||||
approved_links.setdefault(recipe_id, set()).add(
|
||||
(source, external_id, source_url)
|
||||
)
|
||||
validator = WikibooksImportService("MealPlanner catalog audit")
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
for recipe_id, recipe in recipes.items():
|
||||
prior_findings = (
|
||||
recipe.catalog_findings
|
||||
if isinstance(recipe.catalog_findings, list)
|
||||
else []
|
||||
)
|
||||
operator_quarantine_findings = [
|
||||
finding
|
||||
for finding in prior_findings
|
||||
if isinstance(finding, dict)
|
||||
and finding.get("code") == OPERATOR_QUARANTINE_FINDING_CODE
|
||||
]
|
||||
provenance, findings = _provenance_findings(
|
||||
recipe, approved_links.get(recipe_id, set())
|
||||
)
|
||||
candidate, reference_findings = _validation_candidate(recipe, ingredient_names)
|
||||
findings.extend(reference_findings)
|
||||
validation = validator.validate_recipe(candidate)
|
||||
findings.extend(
|
||||
{**issue, "severity": severity}
|
||||
for severity, key in (("error", "errors"), ("warning", "warnings"))
|
||||
for issue in validation[key]
|
||||
)
|
||||
findings.extend(operator_quarantine_findings)
|
||||
target_status = (
|
||||
RecipeCatalogStatus.QUARANTINED
|
||||
if operator_quarantine_findings
|
||||
or any(item["severity"] == "error" for item in findings)
|
||||
else RecipeCatalogStatus.ACTIVE
|
||||
)
|
||||
if apply:
|
||||
recipe.catalog_status = target_status
|
||||
recipe.catalog_findings = findings
|
||||
recipe.catalog_reviewed_at = timestamp
|
||||
records.append(
|
||||
{
|
||||
"id": str(recipe.id),
|
||||
"name": recipe.name,
|
||||
"provenance": provenance,
|
||||
"catalog_status": target_status.value,
|
||||
"findings": findings,
|
||||
}
|
||||
)
|
||||
|
||||
if apply:
|
||||
db.flush()
|
||||
quarantined = sum(
|
||||
record["catalog_status"] == RecipeCatalogStatus.QUARANTINED.value
|
||||
for record in records
|
||||
)
|
||||
return {
|
||||
"version": 1,
|
||||
"generated_at": timestamp.isoformat(),
|
||||
"applied": apply,
|
||||
"total": len(records),
|
||||
"summary": {
|
||||
"active": len(records) - quarantined,
|
||||
"quarantined": quarantined,
|
||||
},
|
||||
"recipes": records,
|
||||
}
|
||||
|
||||
|
||||
def _provenance_findings(
|
||||
recipe: Recipe, approved_links: set[tuple[str, str, str]]
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
if recipe.is_manually_added and not recipe.external_source:
|
||||
return "manual", []
|
||||
if recipe.external_source == WIKIBOOKS_SOURCE:
|
||||
findings = []
|
||||
expected_link = (recipe.external_source, recipe.external_id, recipe.source_url)
|
||||
if expected_link not in approved_links:
|
||||
findings.append(
|
||||
_finding(
|
||||
"missing_import_provenance",
|
||||
"external_source",
|
||||
"Published Wikibooks recipe is not linked to an approved staged import.",
|
||||
)
|
||||
)
|
||||
if not recipe.source_url:
|
||||
findings.append(
|
||||
_finding("missing_source_url", "source_url", "External recipe has no source URL.")
|
||||
)
|
||||
return "staged_wikibooks", findings
|
||||
if recipe.external_source == "spoonacular":
|
||||
return "spoonacular", [
|
||||
_finding(
|
||||
"unlicensed_external_source",
|
||||
"external_source",
|
||||
"Spoonacular data was persisted without durable catalog rights.",
|
||||
)
|
||||
]
|
||||
if recipe.external_source:
|
||||
return "external_unverified", [
|
||||
_finding(
|
||||
"unverified_external_source",
|
||||
"external_source",
|
||||
"External recipe has no approved staged license record.",
|
||||
)
|
||||
]
|
||||
return "missing", [
|
||||
_finding(
|
||||
"missing_provenance",
|
||||
"external_source",
|
||||
"Non-manual recipe has no auditable source or license record.",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _validation_candidate(
|
||||
recipe: Recipe, ingredient_names: dict[str, str]
|
||||
) -> tuple[dict[str, Any], list[dict[str, str]]]:
|
||||
ingredients: list[dict[str, Any]] = []
|
||||
findings: list[dict[str, str]] = []
|
||||
for index, item in enumerate(recipe.ingredients or []):
|
||||
if not isinstance(item, dict):
|
||||
findings.append(
|
||||
_finding(
|
||||
"invalid_ingredient_reference",
|
||||
f"ingredients[{index}]",
|
||||
"Ingredient reference must be an object.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
ingredient_id = str(item.get("ingredient_id") or "")
|
||||
name = ingredient_names.get(ingredient_id)
|
||||
if not name:
|
||||
findings.append(
|
||||
_finding(
|
||||
"orphan_ingredient_reference",
|
||||
f"ingredients[{index}].ingredient_id",
|
||||
"Ingredient reference does not resolve to the canonical ingredient table.",
|
||||
)
|
||||
)
|
||||
name = str(item.get("name") or "")
|
||||
quantity = item.get("qty")
|
||||
if isinstance(quantity, Decimal):
|
||||
quantity = float(quantity)
|
||||
ingredients.append({"name": name, "quantity": quantity})
|
||||
return (
|
||||
{
|
||||
"name": recipe.name,
|
||||
"description": recipe.description,
|
||||
"servings": recipe.servings,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"baking_time_minutes": None,
|
||||
"ingredients": ingredients,
|
||||
"instructions": list(recipe.instructions or []),
|
||||
},
|
||||
findings,
|
||||
)
|
||||
|
||||
|
||||
def _finding(code: str, field: str, message: str) -> dict[str, str]:
|
||||
return {"code": code, "severity": "error", "field": field, "message": message}
|
||||
Reference in New Issue
Block a user