From 10849e0e95114c2f2faf30ca02d8db6e7d199c6f Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 30 Apr 2026 20:08:20 -0700 Subject: [PATCH] recipe enrichment: per-recipe Sonnet meta for smarter planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'fancy data fun' Cobb 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 Cobb 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 Elsinore 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 kayos kick-off Cost (one-time): ~5s/recipe × 226 = ~20 min walk. Subsequent runs only process new/changed recipes. --- cauldron/db.py | 182 ++++++++++++++++++++++++ cauldron/enrich_recipes.py | 179 +++++++++++++++++++++++ cauldron/forge.py | 189 ++++++++++++++++++++++++- cauldron/server.py | 124 +++++++++++++++- cauldron/templates/enrich_recipes.html | 158 +++++++++++++++++++++ cauldron/templates/me.html | 3 + 6 files changed, 828 insertions(+), 7 deletions(-) create mode 100644 cauldron/enrich_recipes.py create mode 100644 cauldron/templates/enrich_recipes.html diff --git a/cauldron/db.py b/cauldron/db.py index 3a38712..48322ee 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -408,6 +408,52 @@ MIGRATIONS = [ ALTER TABLE cauldron_meal_plans ADD COLUMN IF NOT EXISTS preference_prompt VARCHAR(1000) """, + # 024 — Per-recipe AI-generated metadata. Sonnet looks at the full + # recipe (name, description, ingredients, steps, yields) and returns + # a structured blob: tags, cuisine, complexity, estimated_minutes, + # meal_type, primary_protein, primary_carb, veg_forward, comfort_tier, + # season_fit, summary, best_for. Plan generator uses this so "high + # protein week" becomes a real query, not just a vibe-prompt. + # enrich_version lets us bump the prompt and re-enrich without + # losing the prior data. + """ + CREATE TABLE IF NOT EXISTS cauldron_recipe_meta ( + household_id BIGINT NOT NULL, + recipe_slug VARCHAR(255) NOT NULL, + meta_json JSON, + enrich_version INT NOT NULL DEFAULT 1, + last_enriched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (household_id, recipe_slug), + INDEX idx_household (household_id), + FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + # 025 — Recipe-enrichment bulk job state. Runs through every household + # recipe, calls Sonnet, persists meta. No apply/review step — meta is + # purely additive so we just write it. Same daemon-thread runner + + # cancel + stuck-recovery pattern. + """ + CREATE TABLE IF NOT EXISTS cauldron_enrich_jobs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + household_id BIGINT NOT NULL, + started_by_sub VARCHAR(190) NOT NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_progress_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME, + total_recipes INT NOT NULL DEFAULT 0, + enriched_count INT NOT NULL DEFAULT 0, + skipped_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + current_slug VARCHAR(255), + last_error VARCHAR(500), + state ENUM('running','done','failed','cancelled') + NOT NULL DEFAULT 'running', + INDEX idx_household_state (household_id, state), + FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE, + FOREIGN KEY (started_by_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, ] @@ -1499,6 +1545,142 @@ class DB: (proposal_id,), ) + # --- recipe enrichment ------------------------------------------------ + + ENRICH_VERSION = 1 + + def get_recipe_meta(self, household_id: int, recipe_slug: str) -> dict | None: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT recipe_slug, meta_json, enrich_version, last_enriched_at + FROM cauldron_recipe_meta + WHERE household_id=%s AND recipe_slug=%s""", + (household_id, recipe_slug), + ) + row = cur.fetchone() + return dict(row) if row else None + + def list_recipe_meta_for_household(self, household_id: int) -> list[dict]: + """Used by the plan generator to splice meta into the recipe pool prompt.""" + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT recipe_slug, meta_json, enrich_version + FROM cauldron_recipe_meta + WHERE household_id=%s""", + (household_id,), + ) + return [dict(r) for r in cur.fetchall()] + + def upsert_recipe_meta( + self, + *, + household_id: int, + recipe_slug: str, + meta_json: str, + version: int, + ) -> None: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """INSERT INTO cauldron_recipe_meta + (household_id, recipe_slug, meta_json, enrich_version) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + meta_json = VALUES(meta_json), + enrich_version = VALUES(enrich_version)""", + (household_id, recipe_slug, meta_json, version), + ) + + def create_enrich_job(self, *, household_id: int, started_by_sub: str) -> int: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """INSERT INTO cauldron_enrich_jobs + (household_id, started_by_sub, state) + VALUES (%s, %s, 'running')""", + (household_id, started_by_sub), + ) + return cur.lastrowid + + def get_enrich_job(self, job_id: int) -> dict | None: + with self.conn() as c, c.cursor() as cur: + cur.execute("SELECT * FROM cauldron_enrich_jobs WHERE id=%s", (job_id,)) + return cur.fetchone() + + def get_enrich_job_state(self, job_id: int) -> str | None: + with self.conn() as c, c.cursor() as cur: + cur.execute("SELECT state FROM cauldron_enrich_jobs WHERE id=%s", (job_id,)) + row = cur.fetchone() + return row["state"] if row else None + + def latest_enrich_job_for_household(self, household_id: int) -> dict | None: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT * FROM cauldron_enrich_jobs + WHERE household_id=%s ORDER BY started_at DESC LIMIT 1""", + (household_id,), + ) + return cur.fetchone() + + def running_enrich_job_for_household(self, household_id: int) -> dict | None: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT * FROM cauldron_enrich_jobs + WHERE household_id=%s AND state='running' + ORDER BY started_at DESC LIMIT 1""", + (household_id,), + ) + return cur.fetchone() + + def update_enrich_job_progress( + self, + job_id: int, + *, + enriched_delta: int = 0, + skipped_delta: int = 0, + error_delta: int = 0, + current_slug: str | None = None, + last_error: str | None = None, + ) -> None: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """UPDATE cauldron_enrich_jobs + SET enriched_count = enriched_count + %s, + skipped_count = skipped_count + %s, + error_count = error_count + %s, + current_slug = COALESCE(%s, current_slug), + last_error = COALESCE(%s, last_error), + last_progress_at = NOW() + WHERE id=%s""", + (enriched_delta, skipped_delta, error_delta, + current_slug, last_error, job_id), + ) + + def finalize_enrich_job(self, job_id: int, *, state: str) -> None: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """UPDATE cauldron_enrich_jobs + SET state=%s, + finished_at = CASE WHEN %s IN ('done','failed','cancelled') + THEN NOW() ELSE finished_at END, + last_progress_at = NOW(), + current_slug = NULL + WHERE id=%s + AND state NOT IN ('done','failed','cancelled')""", + (state, state, job_id), + ) + + def fail_stuck_enrich_jobs(self, *, stale_minutes: int = 15) -> int: + with self.conn() as c, c.cursor() as cur: + cur.execute( + """UPDATE cauldron_enrich_jobs + SET state='failed', + finished_at=NOW(), + last_error=COALESCE(last_error, 'recovery: worker exited mid-run') + WHERE state='running' + AND last_progress_at < NOW() - INTERVAL %s MINUTE""", + (stale_minutes,), + ) + return cur.rowcount + def fail_stuck_recipe_dedupe_jobs(self, *, stale_minutes: int = 15) -> int: with self.conn() as c, c.cursor() as cur: cur.execute( diff --git a/cauldron/enrich_recipes.py b/cauldron/enrich_recipes.py new file mode 100644 index 0000000..66f0245 --- /dev/null +++ b/cauldron/enrich_recipes.py @@ -0,0 +1,179 @@ +"""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 diff --git a/cauldron/forge.py b/cauldron/forge.py index c839e4c..aea742a 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -169,9 +169,11 @@ class Forge: slug = r.get("slug") or "" name = r.get("name") or slug tags = r.get("tags") or [] - tag_str = "" + meta = r.get("meta") or {} + + extras: list[str] = [] + # First 3 Mealie tags if tags: - # First 3 tags only — keeps prompt token count under control cleaned = [] for t in tags[:3]: if isinstance(t, dict): @@ -180,8 +182,32 @@ class Forge: cleaned.append(t) cleaned = [c for c in cleaned if c] if cleaned: - tag_str = f" [{', '.join(cleaned)}]" - pool_lines.append(f"- {slug}: {name}{tag_str}") + extras.append(", ".join(cleaned)) + # Sonnet-generated meta — the actual high-signal stuff + if meta: + if meta.get("cuisine") and meta["cuisine"] not in ("unknown", "other"): + extras.append(meta["cuisine"]) + if meta.get("complexity"): + extras.append(meta["complexity"]) + em = meta.get("estimated_minutes") + if isinstance(em, int) and em > 0: + extras.append(f"{em}min") + if meta.get("primary_protein") and meta["primary_protein"] != "none": + extras.append(f"protein:{meta['primary_protein']}") + if meta.get("primary_carb") and meta["primary_carb"] != "none": + extras.append(f"carb:{meta['primary_carb']}") + if meta.get("veg_forward") and meta["veg_forward"] != "mixed": + extras.append(meta["veg_forward"]) + meta_tags = meta.get("tags") or [] + if meta_tags: + extras.append("/".join(meta_tags[:5])) + if meta.get("summary"): + # Inline 1-line summary helps Sonnet match preferences + summary = str(meta["summary"])[:140] + pool_lines.append(f"- {slug}: {name} [{' · '.join(extras)}]\n {summary}") + continue + extra_str = f" [{' · '.join(extras)}]" if extras else "" + pool_lines.append(f"- {slug}: {name}{extra_str}") pick_lines = [] for p in picks: @@ -354,6 +380,113 @@ class Forge: result = self.run(prompt, model=model or "sonnet", timeout_secs=60) return _extract_cluster_decision(result) + def enrich_recipe(self, recipe: dict, *, model: str | None = None) -> dict: + """Generate structured metadata for a recipe so the plan generator + can match preferences to actual recipe characteristics, not just + names. + + Input: a Mealie recipe dict (uses name + description + ingredients + + instructions + yields + recipeYield). + + Output (validated): + { + "tags": [], + # e.g. "high-protein", "weeknight", "one-pan", + # "kid-friendly", "leftovers-good", "freezer-friendly" + "cuisine": "", + "complexity": "easy|medium|involved", + "estimated_minutes": , + "meal_type": "breakfast|lunch|dinner|snack|dessert|side", + "primary_protein": "", + "primary_carb": "", + "veg_forward": "veg-forward|mixed|meat-forward", + "comfort_tier": "", + "season_fit": [], + "summary": "", + "best_for": "" + } + + Cheap call, idempotent — run once per recipe and cache forever + (or until enrich_version bumps).""" + # Build a compact recipe summary for the prompt + ings = recipe.get("recipeIngredient") or [] + ing_lines: list[str] = [] + for i in ings[:30]: + food = (i.get("food") or {}).get("name") if isinstance(i.get("food"), dict) else None + qty = i.get("quantity") + unit = (i.get("unit") or {}).get("name") if isinstance(i.get("unit"), dict) else None + note = i.get("note") or "" + line = "" + if qty not in (None, ""): + line += f"{qty} " + if unit: + line += f"{unit} " + if food: + line += food + elif note: + line += note + if line.strip(): + ing_lines.append(line.strip()) + instructions = recipe.get("recipeInstructions") or [] + steps: list[str] = [] + char_budget = 2000 + for step in instructions: + if not isinstance(step, dict): + continue + text = (step.get("text") or "").strip() + if not text or char_budget <= 0: + continue + if len(text) > char_budget: + text = text[:char_budget] + "…" + steps.append(text) + char_budget -= len(text) + + prompt = ( + "Given the following recipe, return structured metadata to help " + "an AI meal planner pick recipes that match user preferences " + "('high protein week', 'carb load', 'light recovery', etc).\n\n" + f"NAME: {recipe.get('name') or '(unnamed)'}\n" + f"DESCRIPTION: {(recipe.get('description') or '').strip()[:400]}\n" + f"YIELDS: {(recipe.get('recipeYield') or '').strip()[:80]}\n" + f"INGREDIENTS:\n - " + "\n - ".join(ing_lines or ['(none listed)']) + "\n" + f"STEPS:\n - " + "\n - ".join(steps or ['(none listed)']) + "\n\n" + "Output JSON ONLY, no prose:\n" + "{\n" + ' "tags": [],\n' + ' "cuisine": "",\n' + ' "complexity": "",\n' + ' "estimated_minutes": ,\n' + ' "meal_type": "",\n' + ' "primary_protein": "",\n' + ' "primary_carb": "",\n' + ' "veg_forward": "",\n' + ' "comfort_tier": "",\n' + ' "season_fit": [],\n' + ' "summary": "",\n' + ' "best_for": ""\n' + "}\n\n" + "Rules:\n" + "- Return ONLY the JSON object, no markdown fences, no prose.\n" + "- Be concrete: 'high-protein' goes in tags ONLY if the recipe genuinely " + "qualifies (significant meat/eggs/dairy/protein source per serving).\n" + "- estimated_minutes: best guess from prep + cook implied by steps. Dishes " + "needing rise/marinade time count that time.\n" + "- complexity: 'easy' = ≤30 min + ≤7 ingredients + simple technique; " + "'medium' = 30-90 min OR moderate technique; 'involved' = >90 min OR " + "advanced technique (lamination, fermentation, multi-component).\n" + "- summary should describe the vibe / use-case, not just restate the name. " + "e.g. 'quick weeknight stir-fry with leftover-friendly portions' beats " + "'chicken stir fry with rice'.\n" + "- When uncertain on a categorical, use 'unknown' or 'other' rather than guessing." + ) + result = self.run(prompt, model=model or "sonnet", timeout_secs=90) + return _extract_recipe_meta(result) + def fetch_food_info(self, name: str, *, model: str | None = None) -> dict: """Ask Sonnet for density + unit class + common size of a single food. Returns a dict shaped like: @@ -390,6 +523,54 @@ class Forge: return _extract_food_info(result) +def _extract_recipe_meta(forge_result: dict) -> dict: + """Validate the recipe metadata blob from Sonnet. Coerces types, + normalizes enums to lowercase, drops fields not in the schema.""" + if not isinstance(forge_result, dict): + raise ForgeError("forge result not a dict") + inner = forge_result.get("result", forge_result) + if isinstance(inner, str): + inner = _parse_json_blob(inner) + if not isinstance(inner, dict): + raise ForgeError(f"recipe meta not a dict: {str(inner)[:200]}") + + def _str(v, default=""): + return str(v).strip().lower()[:64] if isinstance(v, str) and v.strip() else default + + def _str_long(v, default=""): + return str(v).strip()[:300] if isinstance(v, str) and v.strip() else default + + def _str_list(v) -> list[str]: + if not isinstance(v, list): + return [] + out = [] + for item in v: + if isinstance(item, str) and item.strip(): + out.append(item.strip().lower()[:48]) + return out[:12] + + def _int(v, default=0): + try: + return max(0, int(v)) + except (TypeError, ValueError): + return default + + return { + "tags": _str_list(inner.get("tags")), + "cuisine": _str(inner.get("cuisine"), "unknown"), + "complexity": _str(inner.get("complexity"), "medium"), + "estimated_minutes": _int(inner.get("estimated_minutes")), + "meal_type": _str(inner.get("meal_type"), "dinner"), + "primary_protein": _str(inner.get("primary_protein"), "none"), + "primary_carb": _str(inner.get("primary_carb"), "none"), + "veg_forward": _str(inner.get("veg_forward"), "mixed"), + "comfort_tier": _str(inner.get("comfort_tier"), "weeknight-easy"), + "season_fit": _str_list(inner.get("season_fit")) or ["year-round"], + "summary": _str_long(inner.get("summary")), + "best_for": _str_long(inner.get("best_for")), + } + + def _extract_recipe_dedupe_decision(forge_result: dict) -> dict: if not isinstance(forge_result, dict): raise ForgeError("forge result not a dict") diff --git a/cauldron/server.py b/cauldron/server.py index 964a91a..9787758 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -33,7 +33,7 @@ from .config import load from .crypto import TokenCrypto from .db import DB from .forge import Forge, ForgeError -from . import aggregator, bulk_sterilize, consolidate_foods, dedupe_recipes, foods +from . import aggregator, bulk_sterilize, consolidate_foods, dedupe_recipes, enrich_recipes, foods from .mealie import Mealie, MealieError from .oidc import init_oauth from .recipe_index import flatten_recipe, refresh_household_index, search_index @@ -125,6 +125,13 @@ def create_app() -> Flask: except Exception as e: app.logger.warning("recipe-dedupe stuck-job recovery failed: %s", e) + try: + n_failed = db.fail_stuck_enrich_jobs(stale_minutes=15) + if n_failed: + app.logger.info("failed %d stuck enrich jobs at boot", n_failed) + except Exception as e: + app.logger.warning("enrich stuck-job recovery failed: %s", e) + oauth = init_oauth( app, issuer=cfg.oidc_issuer, @@ -662,9 +669,23 @@ def create_app() -> Flask: db.set_plan_preference(plan["id"], preference) plan["preference_prompt"] = preference[:1000] - # Pull picks (with picker_subs) + recipe pool (slug+name+tags only) + # Pull picks + recipe pool. The pool now splices in cauldron_recipe_meta + # (Sonnet-generated per-recipe attributes — cuisine, complexity, macros, + # meal_type, primary_protein/carb, comfort_tier, summary) so the planner + # can match preferences to actual recipe characteristics, not just names. picks = db.list_household_picks_with_pickers(hid) rows = db.list_indexed_recipes(hid, limit=2000, offset=0) + meta_rows = db.list_recipe_meta_for_household(hid) + meta_by_slug: dict[str, dict] = {} + for mr in meta_rows: + blob = mr.get("meta_json") + if isinstance(blob, str): + try: + meta_by_slug[mr["recipe_slug"]] = _json_loads(blob) + except Exception: + pass + elif isinstance(blob, dict): + meta_by_slug[mr["recipe_slug"]] = blob recipes = [] for r in rows: tags = [] @@ -676,7 +697,11 @@ def create_app() -> Flask: raw = None if isinstance(raw, dict): tags = raw.get("tags") or [] - recipes.append({"slug": r["slug"], "name": r["name"], "tags": tags}) + entry = {"slug": r["slug"], "name": r["name"], "tags": tags} + m = meta_by_slug.get(r["slug"]) + if m: + entry["meta"] = m + recipes.append(entry) if not recipes: return jsonify({"error": "no_recipes_indexed"}), 409 @@ -1049,6 +1074,99 @@ def create_app() -> Flask: db.finalize_sterilize_job(job_id, state="cancelled") return jsonify({"ok": True}) + # ---------- recipe metadata enrichment ----------------------------- + + @app.get("/enrich-recipes") + @require_session + def enrich_recipes_page(): + hid = current_household_id() + if not hid: + return redirect(url_for("connect_mealie_get")) + latest = db.latest_enrich_job_for_household(hid) + existing_count = len(db.list_recipe_meta_for_household(hid)) + return render_template( + "enrich_recipes.html", + active="enrich", + latest_job=latest, + existing_count=existing_count, + ) + + @app.post("/api/recipes/enrich-start") + @require_session + def enrich_recipes_start(): + u = session["user"] + hid = current_household_id() + if not hid: + return jsonify({"error": "no household"}), 409 + active = db.running_enrich_job_for_household(hid) + if active: + return jsonify({"error": "already_running", "job_id": active["id"]}), 409 + client = current_user_mealie() + if client is None: + return redirect(url_for("connect_mealie_get")) + body = request.get_json(silent=True) or {} + force = bool(body.get("force")) + job_id = db.create_enrich_job(household_id=hid, started_by_sub=u["sub"]) + enrich_recipes.spawn_thread( + db=db, job_id=job_id, household_id=hid, + mealie=client, forge=forge, force=force, + ) + return jsonify({"ok": True, "job_id": job_id}) + + @app.get("/api/recipes/enrich-status") + @require_session + def enrich_recipes_status(): + hid = current_household_id() + if not hid: + return jsonify({"error": "no household"}), 409 + job = db.latest_enrich_job_for_household(hid) + if not job: + return jsonify({"job": None}) + return jsonify({"job": _consolidate_job_payload(job)}) + + @app.post("/api/recipes/enrich-cancel/") + @require_session + def enrich_recipes_cancel(job_id: int): + hid = current_household_id() + if not hid: + return jsonify({"error": "no household"}), 409 + job = db.get_enrich_job(job_id) + if not job or job["household_id"] != hid: + return jsonify({"error": "not_found"}), 404 + if job["state"] != "running": + return jsonify({"error": f"bad_state:{job['state']}"}), 409 + db.finalize_enrich_job(job_id, state="cancelled") + return jsonify({"ok": True}) + + @app.post("/api/admin/recipes/enrich-start") + @require_bearer + def admin_enrich_recipes_start(): + body = request.get_json(silent=True) or {} + sub = (body.get("started_by_sub") or "").strip() + if not sub: + return jsonify({"error": "started_by_sub required"}), 400 + hid = db.get_user_household_id(sub) + if not hid: + return jsonify({"error": "user has no household"}), 404 + active = db.running_enrich_job_for_household(hid) + if active: + return jsonify({"error": "already_running", "job_id": active["id"]}), 409 + blob = db.get_user_mealie_token_blob(sub) + if not blob: + return jsonify({"error": "user_not_connected_to_mealie"}), 409 + try: + tok = crypto.decrypt(blob) + except Exception: + return jsonify({"error": "user_token_undecryptable"}), 500 + mealie = Mealie(base_url=cfg.mealie_api_url, api_token=tok) + force = bool(body.get("force")) + job_id = db.create_enrich_job(household_id=hid, started_by_sub=sub) + enrich_recipes.spawn_thread( + db=db, job_id=job_id, household_id=hid, + mealie=mealie, forge=forge, force=force, + ) + return jsonify({"ok": True, "job_id": job_id}) + # ---------- recipe dedupe ------------------------------------------ @app.get("/dedupe-recipes") diff --git a/cauldron/templates/enrich_recipes.html b/cauldron/templates/enrich_recipes.html new file mode 100644 index 0000000..e222ca9 --- /dev/null +++ b/cauldron/templates/enrich_recipes.html @@ -0,0 +1,158 @@ +{% extends "_base.html" %} +{% block title %}Enrich Recipes · Cauldron{% endblock %} +{% block content %} + + + +
+
// enrich · per-recipe metadata for smarter planning
+

recipe enrich

+
+ walk every household recipe and have sonnet generate structured metadata — + cuisine, complexity, macros, meal type, primary protein + carb, + comfort tier, one-line summary. the plan generator uses this so + "high protein week" actually filters the pool, not just biases the vibe. +
+
+ +
+
+

state

+ loading… + +
+ +
+
{{ existing_count }}
+
recipes already enriched in this household
+
+ + + + + + + + +
+ + + +{% endblock %} diff --git a/cauldron/templates/me.html b/cauldron/templates/me.html index 3d16dc6..7a54aed 100644 --- a/cauldron/templates/me.html +++ b/cauldron/templates/me.html @@ -62,6 +62,9 @@

find duplicate recipes by name + ingredient similarity. sonnet picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.

🌀 dedupe recipes →

+ +

have sonnet generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.

+

✨ enrich recipes →

{% endif %}