The 'fancy data fun' Alice wanted: pre-compute structured metadata for
every recipe so the plan generator can match preferences to actual
recipe characteristics, not just match keywords on names.
Sonnet returns per recipe:
- tags[]: curated descriptors (high-protein, weeknight, one-pan,
leftovers-good, kid-friendly, etc — picks 3-8 that genuinely apply)
- cuisine, complexity (easy/medium/involved), estimated_minutes
- meal_type (breakfast/lunch/dinner/snack/dessert/side/sauce/drink)
- primary_protein (chicken/beef/pork/fish/seafood/tofu/...)
- primary_carb (rice/pasta/bread/potato/tortilla/quinoa/...)
- veg_forward (veg-forward/mixed/meat-forward)
- comfort_tier (weeknight-easy/hearty-comfort/fancy-occasion/...)
- season_fit[] + summary one-liner + best_for short phrase
Schema:
- Migration 024: cauldron_recipe_meta keyed by (household_id, recipe_slug),
meta_json + enrich_version (bumping the version invalidates the cache
and forces re-walk). One row per Mealie recipe Alice owns.
- Migration 025: cauldron_enrich_jobs — job runner state. No
proposals/review needed since metadata is purely additive.
Forge:
- enrich_recipe(recipe) builds a compact prompt with name + description
+ ingredients + steps (capped at 2000 chars total) + yields, asks
Sonnet for the structured blob. _extract_recipe_meta validates and
coerces types.
Module enrich_recipes.py:
- Daemon thread runner, walks all household recipes, skips already-
enriched at current ENRICH_VERSION (idempotent), respects external
cancel + stuck-job recovery. Skips cross-household recipes (Lake
another household stuff visible but not enrichable).
Plan generator hookup:
- /api/plan/generate + regenerate now pulls cauldron_recipe_meta and
splices it into the recipe pool prompt. Each pool line goes from:
- chicken-stir-fry: Chicken Stir Fry [asian]
to:
- chicken-stir-fry: Chicken Stir Fry [asian · easy · 30min ·
protein:chicken · carb:rice · high-protein/weeknight/one-pan]
quick weeknight stir-fry with leftover-friendly portions
Sonnet now has rich attributes to actually match a 'high protein
week' or 'comfort food' or 'quick' preference against, instead of
guessing from titles.
Endpoints:
- /enrich-recipes UI page (progress bar + start + force re-enrich +
cancel; no review/approve since meta is additive)
- /api/recipes/enrich-{start,status,cancel} session-authed
- /api/admin/recipes/enrich-start bearer-authed for the operator kick-off
Cost (one-time): ~5s/recipe × 226 = ~20 min walk. Subsequent runs
only process new/changed recipes.
179 lines
5.7 KiB
Python
179 lines
5.7 KiB
Python
"""Recipe metadata enrichment — once per recipe, persist forever.
|
|
|
|
Walks the user's household recipes, calls forge.enrich_recipe(recipe)
|
|
on each one, persists the structured metadata to cauldron_recipe_meta
|
|
keyed by (household_id, recipe_slug).
|
|
|
|
No review/apply step — the metadata is purely additive. The plan
|
|
generator reads it next time it runs.
|
|
|
|
Idempotent: skips recipes already enriched at the current
|
|
db.DB.ENRICH_VERSION. Bumping the version (when the prompt or schema
|
|
changes) forces a re-walk.
|
|
|
|
Same daemon-thread + cancel + stuck-recovery pattern as the rest.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
|
|
from .db import DB
|
|
from .forge import Forge, ForgeError
|
|
from .mealie import Mealie, MealieError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _household_id_for(mealie: Mealie) -> str | None:
|
|
me = mealie.who_am_i()
|
|
hid = me.get("householdId") or me.get("household_id")
|
|
if not hid:
|
|
h = me.get("household")
|
|
if isinstance(h, dict):
|
|
hid = h.get("id")
|
|
return hid
|
|
|
|
|
|
def _recipe_household_id(recipe: dict) -> str | None:
|
|
hid = recipe.get("householdId") or recipe.get("household_id")
|
|
if hid:
|
|
return hid
|
|
h = recipe.get("household")
|
|
if isinstance(h, dict):
|
|
return h.get("id")
|
|
return None
|
|
|
|
|
|
def run_enrich(
|
|
*,
|
|
db: DB,
|
|
job_id: int,
|
|
household_id: int,
|
|
mealie: Mealie,
|
|
forge: Forge,
|
|
force: bool = False,
|
|
) -> None:
|
|
"""Walk all recipes in the user's household, enrich each via Sonnet,
|
|
persist. Runs in a daemon thread; respects external cancel."""
|
|
log.info("[enrich:%s] start (force=%s)", job_id, force)
|
|
|
|
def _cancelled() -> bool:
|
|
s = db.get_enrich_job_state(job_id)
|
|
return s in ("cancelled", "failed", "done")
|
|
|
|
try:
|
|
user_household = _household_id_for(mealie)
|
|
|
|
# Pull every recipe slug from Mealie (paginated)
|
|
slugs: list[tuple[str, str]] = []
|
|
page = 1
|
|
while page <= 50:
|
|
resp = mealie.list_recipes(page=page, per_page=100)
|
|
items = resp.get("items") or []
|
|
for r in items:
|
|
slug = r.get("slug")
|
|
name = r.get("name") or slug or ""
|
|
if slug:
|
|
slugs.append((slug, name))
|
|
tp = resp.get("total_pages") or resp.get("totalPages") or 1
|
|
if not items or page >= tp:
|
|
break
|
|
page += 1
|
|
|
|
with db.conn() as c, c.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE cauldron_enrich_jobs SET total_recipes=%s WHERE id=%s",
|
|
(len(slugs), job_id),
|
|
)
|
|
|
|
for slug, name in slugs:
|
|
if _cancelled():
|
|
log.info("[enrich:%s] aborted (state changed)", job_id)
|
|
return
|
|
|
|
# Skip cross-household — only enrich what the user owns
|
|
try:
|
|
recipe = mealie.get_recipe(slug)
|
|
except MealieError as e:
|
|
msg = str(e)[:500]
|
|
log.warning("[enrich:%s] get_recipe(%s): %s", job_id, slug, msg)
|
|
db.update_enrich_job_progress(
|
|
job_id, error_delta=1, current_slug=slug, last_error=msg
|
|
)
|
|
continue
|
|
|
|
if user_household:
|
|
rec_hh = _recipe_household_id(recipe)
|
|
if rec_hh and rec_hh != user_household:
|
|
db.update_enrich_job_progress(
|
|
job_id, skipped_delta=1, current_slug=slug
|
|
)
|
|
continue
|
|
|
|
# Skip if already enriched at the current version (unless forced)
|
|
if not force:
|
|
existing = db.get_recipe_meta(household_id, slug)
|
|
if existing and existing.get("enrich_version") == db.ENRICH_VERSION:
|
|
db.update_enrich_job_progress(
|
|
job_id, skipped_delta=1, current_slug=slug
|
|
)
|
|
continue
|
|
|
|
db.update_enrich_job_progress(job_id, current_slug=slug)
|
|
try:
|
|
meta = forge.enrich_recipe(recipe)
|
|
except (ForgeError, RuntimeError) as e:
|
|
msg = str(e)[:500]
|
|
log.warning("[enrich:%s] enrich_recipe(%s): %s", job_id, slug, msg)
|
|
db.update_enrich_job_progress(
|
|
job_id, error_delta=1, current_slug=slug, last_error=msg
|
|
)
|
|
continue
|
|
|
|
try:
|
|
db.upsert_recipe_meta(
|
|
household_id=household_id,
|
|
recipe_slug=slug,
|
|
meta_json=json.dumps(meta, ensure_ascii=False),
|
|
version=db.ENRICH_VERSION,
|
|
)
|
|
db.update_enrich_job_progress(job_id, enriched_delta=1)
|
|
except Exception as e:
|
|
msg = str(e)[:500]
|
|
log.warning("[enrich:%s] persist(%s): %s", job_id, slug, msg)
|
|
db.update_enrich_job_progress(
|
|
job_id, error_delta=1, current_slug=slug, last_error=msg
|
|
)
|
|
|
|
db.finalize_enrich_job(job_id, state="done")
|
|
log.info("[enrich:%s] done", job_id)
|
|
except Exception:
|
|
log.exception("[enrich:%s] crashed", job_id)
|
|
try:
|
|
db.finalize_enrich_job(job_id, state="failed")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def spawn_thread(
|
|
*,
|
|
db: DB,
|
|
job_id: int,
|
|
household_id: int,
|
|
mealie: Mealie,
|
|
forge: Forge,
|
|
force: bool = False,
|
|
) -> threading.Thread:
|
|
t = threading.Thread(
|
|
target=run_enrich,
|
|
kwargs={
|
|
"db": db, "job_id": job_id, "household_id": household_id,
|
|
"mealie": mealie, "forge": forge, "force": force,
|
|
},
|
|
name=f"enrich-recipes-{job_id}",
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
return t
|