From 89f33f237c13fb9e33c00e77a5054b3dd653a776 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 30 Apr 2026 22:04:46 -0700 Subject: [PATCH] plan agent: per-user fit + pairings + mood + leftover_potential + Hecate's weekly reading + 2nd-pass allergen verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new context dimensions for the planner agent (and one quality fix), bumping ENRICH_VERSION 3→4 so existing meta gets refreshed on the next walk: (A) Per-user fit score. Computed at plan-gen time from picker_profiles × meta — no extra Sonnet calls. _compute_fit_score scores each recipe 1-5 per household member based on cuisine match, protein match, comfort_tier match, and tag overlap with that user's historical picks. Renders in the recipe pool prompt as 'fit:cobb=5,abby=2,leia=3'. Gives Sonnet per-user signal to bias AI-chosen slots toward whoever's home that week, AND makes pick rationale explainable. (B) Pairings per recipe. New meta fields: pairings.serves_well_with: ["crusty bread","green salad",...] pairings.drinks: ["pinot noir","iced tea",...] Sonnet enriches both during the main pass. Foundation for future "auto-suggest matching side" UX in multi-meal slots. (C) Leftover potential 1-5. Per-recipe score: 1=eat-now-only (crispy things, fresh salads), 5=actually BETTER as leftovers (stews, braises, lasagna). Lets the planner thread Sunday's slow-cooked pot roast into Monday's lunch slots intentionally. (D) Mood scores. Per-recipe 1-5 ratings on: cozy / summer_fresh / energizing / comfort Independent dimensions — a recipe can score high on multiple. Foundation for future weather/mood-aware planning ("rainy week → bias cozy>=4"). (E) Hecate's weekly reading. New TEXT column hecate_reading on cauldron_meal_plans (migration 032). The plan-gen prompt asks Hecate to write a 1-paragraph narrative voice description of the week — "this week leans into the brisk turn of the season, three hearty one-pots front-load your weekday energy, salmon Wednesday for Cobb's gym push..." Confident, wise, theatrical voice. Pure flavor, no functional impact, but makes Hecate FEEL like an advisor not an opaque function. Plus the planner system prompt now opens with the Hecate persona ("You are Hecate, Greek- mythology witch goddess of crossroads, herbs, and magic — and the family's meal planner"). Output schema gains a "reading" field alongside "slots". ALLERGEN VERIFICATION (the quality fix Cobb explicitly asked for): - Sonnet sometimes flags pork=true on a sweet potato recipe via the conservative-default rule. Cobb wants CLEAN data. - New forge.verify_allergens — second Sonnet pass after main enrich with a strict prompt: "name the SPECIFIC ingredient triggering each allergen flag, or set FALSE." For non-anaphylaxis exclusions like pork, set FALSE unless an actual pork ingredient is named. For ANAPHYLAXIS allergens, conservative TRUE still applies. - Cost: ~3s/recipe extra. Wired into enrich_recipes.run_enrich after initial enrich; failure is non-fatal (falls through to original). - Eliminates the pork-on-sweet-potatoes class of false positives. Code restructure: forge.generate_plan now returns {slots, reading} instead of just slots. _extract_plan_payload pulls both. Server-side generate + regenerate paths unwrap and persist reading via the new db.set_plan_hecate_reading. Plan template renders the reading in a purple-bordered serif callout above the day grid. Schema: - migration 032 adds hecate_reading TEXT to cauldron_meal_plans. - Cauldron_recipe_meta gets new fields persisted in meta_json (no schema change there — JSON column already accommodates). --- cauldron/db.py | 20 +++- cauldron/enrich_recipes.py | 11 ++ cauldron/forge.py | 212 ++++++++++++++++++++++++++++++++--- cauldron/server.py | 83 ++++++++++++-- cauldron/templates/plan.html | 30 +++++ 5 files changed, 330 insertions(+), 26 deletions(-) diff --git a/cauldron/db.py b/cauldron/db.py index bd1adaa..ae91939 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -497,6 +497,12 @@ MIGRATIONS = [ ALTER TABLE cauldron_meal_plans ADD COLUMN IF NOT EXISTS meal_types_json JSON """, + # 032 — Hecate's narrative weekly reading. Stored alongside the plan + # so it persists with the slots and can be re-rendered. ~2KB ceiling. + """ + ALTER TABLE cauldron_meal_plans + ADD COLUMN IF NOT EXISTS hecate_reading TEXT + """, ] @@ -704,6 +710,16 @@ class DB: (clean, plan_id), ) + def set_plan_hecate_reading(self, plan_id: int, reading: str) -> None: + """Persist Hecate's narrative weekly reading. Called after generate + so a re-render of the plan view shows it.""" + clean = (reading or "").strip()[:2000] or None + with self.conn() as c, c.cursor() as cur: + cur.execute( + "UPDATE cauldron_meal_plans SET hecate_reading=%s WHERE id=%s", + (clean, plan_id), + ) + def set_plan_meal_types(self, plan_id: int, meal_types: list | None) -> None: """Persist which meal types this plan should generate. None / empty list defaults to ['dinner'] at generation time.""" @@ -1696,7 +1712,9 @@ class DB: # v2: added calories, protein_g, carbs_g, fat_g per-serving estimates # v3: added contains.{dairy,gluten,nuts,peanuts,eggs,shellfish,fish,soy, # sesame,pork} allergen booleans - ENRICH_VERSION = 3 + # v4: added pairings, mood scores, leftover_potential, plus second-pass + # allergen verification to clean false-positive contains.* booleans + ENRICH_VERSION = 4 def get_recipe_meta(self, household_id: int, recipe_slug: str) -> dict | None: with self.conn() as c, c.cursor() as cur: diff --git a/cauldron/enrich_recipes.py b/cauldron/enrich_recipes.py index 66f0245..d513323 100644 --- a/cauldron/enrich_recipes.py +++ b/cauldron/enrich_recipes.py @@ -132,6 +132,17 @@ def run_enrich( ) continue + # Verification pass: re-check contains.* booleans with a strict + # prompt. Catches false-positives like "pork=true on sweet potatoes" + # that the conservative-default rule produces. Best-effort — + # falls through to original meta on any failure. + try: + verified = forge.verify_allergens(recipe, meta.get("contains")) + if verified: + meta["contains"] = verified + except Exception as e: + log.warning("[enrich:%s] verify_allergens(%s): %s — keeping initial flags", job_id, slug, e) + try: db.upsert_recipe_meta( household_id=household_id, diff --git a/cauldron/forge.py b/cauldron/forge.py index 3e7cd48..0d522a7 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -135,7 +135,7 @@ class Forge: meal_types=meal_types_clean, ) result = self.run(prompt, model=model or "sonnet") - parsed = _extract_plan_slots(result) + parsed, reading = _extract_plan_payload(result) if not isinstance(parsed, list): raise ForgeError("model output: 'slots' must be a list") if len(parsed) != expected_total: @@ -190,7 +190,7 @@ class Forge: "reason": (raw.get("reason") or "")[:500], "source": source, }) - return out + return {"slots": out, "reading": reading} @staticmethod def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None, @@ -247,6 +247,13 @@ class Forge: flags = [k for k, v in contains.items() if v] if flags: extras.append("has:" + ",".join(flags)) + # Per-user fit score (NEW): the recipe pool entry now carries + # a `fit` dict computed in server.py from picker_profiles × + # this recipe's meta. {sub: 1-5}. Render as "fit:cobb=5,abby=2" + fit = r.get("fit") or {} + if isinstance(fit, dict) and fit: + pieces = [f"{k}={v}" for k, v in list(fit.items())[:4]] + extras.append("fit:" + ",".join(pieces)) # Rotation history — let Sonnet avoid 3-weeks-in-a-row repeats history = r.get("history") or {} @@ -412,18 +419,24 @@ class Forge: ) output_shape = ( '{"slots": [{"day": "monday", "meal_type": "breakfast", ' - '"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}' + '"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...], ' + '"reading": ""}' ) else: output_shape = ( '{"slots": [{"day": "monday", "meal_type": "dinner", ' - '"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}' + '"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...], ' + '"reading": ""}' ) expected_total = slots * len(meal_types_clean) meal_types_label = "/".join(meal_types_clean) if is_multi_meal else "dinner" return ( - f"You are a family meal planner. Build a {slots}-day plan ({meal_types_label}) " + "You are Hecate, a Greek-mythology witch goddess of crossroads, " + "herbs, and magic — and the family's meal planner. You're advising " + "with the warm authority of someone who knows the household's tastes, " + "their week's rhythm, and what kind of nourishment fits the moment.\n\n" + f"Build a {slots}-day plan ({meal_types_label}) " f"for the week of {week_start}.\n\n" f"POOL (all available recipes):\n{pool_block}\n\n" f"PICKS (recipes the family pre-selected — every pick MUST appear in " @@ -452,6 +465,13 @@ class Forge: "- \"picker_subs\" is the array of authentik_sub strings of family " "members who picked this recipe (empty list if AI-chosen)\n" "- Day order: monday..sunday\n" + "- \"reading\" is YOUR voice — Hecate addressing the household. One " + "paragraph (~3-5 sentences). Describe the arc of the week: what " + "you're leaning into, who you've honored with their picks, where " + "the leftovers thread through, what mood the week takes on. " + "Witch-goddess tone: confident, wise, a little theatrical. " + "Reference specific recipes and members by name when fitting. " + "Don't restate every slot's reason — give the WEEK's character.\n" ) @@ -678,6 +698,17 @@ class Forge: ' "sesame": ,\n' ' "pork": // for halal/kosher-ish filters\n' ' },\n' + ' "pairings": {\n' + ' "serves_well_with": [<2-4 short phrases — sides/components>],\n' + ' "drinks": [<1-3 drink suggestions: wine, beer, soda, tea>]\n' + ' },\n' + ' "mood": {\n' + ' "cozy": ,\n' + ' "summer_fresh": ,\n' + ' "energizing": ,\n' + ' "comfort": \n' + ' },\n' + ' "leftover_potential": ,\n' ' "summary": "",\n' ' "best_for": ""\n' "}\n\n" @@ -691,13 +722,30 @@ class Forge: "If the recipe is a sauce/seasoning/drink with no useful per-serving notion, " "set them to null.\n" "- contains.* booleans: TRUE if the ingredient appears anywhere in the " - "recipe (even small amounts — these drive allergen filters, not " - "macro thresholds). dairy=true for butter, cream, cheese, milk, yogurt, " - "ghee, whey, casein. gluten=true for regular flour/bread/pasta/soy " - "sauce/beer/seitan; FALSE only when explicitly gluten-free or naturally " - "GF. soy=true for soy sauce, tofu, tempeh, edamame, miso. Conservative " - "default: when uncertain, set TRUE (false negatives can cause allergic " - "reactions; false positives just narrow choices).\n" + "recipe. dairy=true for butter, cream, cheese, milk, yogurt, ghee, whey, " + "casein. gluten=true for regular flour/bread/pasta/soy sauce/beer/seitan; " + "FALSE when explicitly gluten-free or naturally GF. soy=true for soy " + "sauce, tofu, tempeh, edamame, miso.\n" + " IMPORTANT: do NOT set contains.X=TRUE unless you can name an actual " + "ingredient in the recipe that triggers it. False positives clutter the " + "data. For ANAPHYLAXIS-risk allergens (peanuts, tree nuts, shellfish, " + "eggs, fish, sesame), if a sauce or compound ingredient could plausibly " + "contain it, set TRUE conservatively. For non-allergic exclusions " + "(pork — religious/dietary), set FALSE unless an actual pork ingredient " + "is listed.\n" + "- pairings.serves_well_with: 2-4 short phrases describing sides or " + "components that pair well. Examples: 'crusty bread', 'green salad', " + "'jasmine rice', 'roasted vegetables'. Don't list ingredients already " + "in the recipe.\n" + "- pairings.drinks: 1-3 drink suggestions (specific or generic). " + "Examples: 'iced tea', 'pinot noir', 'cold lager', 'sparkling water " + "with lime'.\n" + "- mood scores (1-5): how does the dish FEEL? cozy=cold-day-cuddle, " + "summer_fresh=hot-day-light, energizing=workout-fuel, comfort=nostalgic-warm. " + "These are independent — a recipe can score high on multiple.\n" + "- leftover_potential 1-5: 1=eat-fresh-only (crispy fries, salads with " + "wilting lettuce), 3=fine the next day, 5=actually BETTER as leftovers " + "(stews, braises, lasagna).\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; " @@ -711,6 +759,73 @@ class Forge: result = self.run(prompt, model=model or "sonnet", timeout_secs=180) return _extract_recipe_meta(result) + def verify_allergens( + self, + recipe: dict, + prior_contains: dict | None, + *, + model: str | None = None, + ) -> dict: + """Second-pass allergen check. Re-reads the ingredient list with a + strict prompt — must NAME the triggering ingredient or set FALSE. + Catches false-positive contains.* booleans from the initial enrich + (e.g. pork=true on a sweet potato recipe). Returns a corrected + contains dict with the same keys. + + Cheap call (~2-3s, focused prompt). The strictness here is the + opposite of the conservative-default in the main enrichment — + if you can't point to the ingredient, the answer is FALSE.""" + ings = recipe.get("recipeIngredient") or [] + ing_lines: list[str] = [] + for i in ings[:40]: + food = (i.get("food") or {}).get("name") if isinstance(i.get("food"), dict) else None + note = (i.get("note") or "").strip() + display = (i.get("display") or "").strip() + line = food or display or note + if line: + ing_lines.append(str(line).strip()[:120]) + + prompt = ( + f"Verify the allergen flags for this recipe.\n\n" + f"NAME: {recipe.get('name') or '(unnamed)'}\n" + f"INGREDIENTS:\n - " + "\n - ".join(ing_lines or ['(none listed)']) + "\n\n" + f"PRIOR FLAGS (may be wrong): {json.dumps(prior_contains or {})}\n\n" + "Output JSON ONLY, no prose:\n" + "{\n" + ' "contains": {\n' + ' "dairy": , "gluten": , "nuts": ,\n' + ' "peanuts": , "eggs": , "shellfish": ,\n' + ' "fish": , "soy": , "sesame": ,\n' + ' "pork": \n' + " },\n" + ' "evidence": {\n' + ' "": ""\n' + " }\n" + "}\n\n" + "Rules:\n" + "- ONLY set TRUE if you can name a SPECIFIC ingredient in the list above\n" + " that contains it. e.g. dairy=TRUE → evidence.dairy='butter, heavy cream'.\n" + " Otherwise set FALSE and evidence.=''.\n" + "- For ANAPHYLAXIS allergens (peanuts, tree nuts, shellfish, eggs, fish,\n" + " sesame, dairy): if a sauce/condiment 'might' contain the allergen but\n" + " it's not explicit, lean TRUE. Better safe than sorry.\n" + "- For NON-ALLERGIC exclusions (pork — religious/dietary): set FALSE\n" + " unless an actual pork ingredient is named (pork, ham, bacon,\n" + " pork sausage explicitly identified, prosciutto, pancetta, chorizo\n" + " if pork-based, lardo).\n" + "- gluten: TRUE for regular wheat flour/bread/pasta/soy sauce/beer/seitan/\n" + " semolina/couscous/bulgur. FALSE if explicitly gluten-free.\n" + "- soy: TRUE for soy sauce, tofu, tempeh, edamame, miso, soy oil.\n" + "- The prior flags are a hint, not authority. Override them based on\n" + " actual ingredient evidence." + ) + try: + result = self.run(prompt, model=model or "sonnet", timeout_secs=60) + except ForgeError: + # Verification failed — fall back to prior contains; better than nothing + return prior_contains or {} + return _extract_allergen_verification(result, prior_contains or {}) + 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: @@ -747,6 +862,29 @@ class Forge: return _extract_food_info(result) +def _extract_allergen_verification(forge_result: dict, prior: dict) -> dict: + """Pull the corrected contains dict out of the verify_allergens reply. + Falls back to prior on any shape problem — verification is best-effort.""" + if not isinstance(forge_result, dict): + return prior + inner = forge_result.get("result", forge_result) + if isinstance(inner, str): + try: + inner = _parse_json_blob(inner) + except Exception: + return prior + if not isinstance(inner, dict): + return prior + contains_raw = inner.get("contains") or {} + if not isinstance(contains_raw, dict): + return prior + return { + k: bool(contains_raw.get(k, prior.get(k, False))) + for k in ("dairy", "gluten", "nuts", "peanuts", "eggs", + "shellfish", "fish", "soy", "sesame", "pork") + } + + 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.""" @@ -797,6 +935,32 @@ def _extract_recipe_meta(forge_result: dict) -> dict: "shellfish", "fish", "soy", "sesame", "pork") } + pairings_raw = inner.get("pairings") or {} + if not isinstance(pairings_raw, dict): + pairings_raw = {} + pairings = { + "serves_well_with": [ + str(x).strip()[:80] for x in (pairings_raw.get("serves_well_with") or []) + if isinstance(x, str) and x.strip() + ][:6], + "drinks": [ + str(x).strip()[:60] for x in (pairings_raw.get("drinks") or []) + if isinstance(x, str) and x.strip() + ][:4], + } + + def _score(v, default=3): + try: + n = int(v) + return max(1, min(5, n)) + except (TypeError, ValueError): + return default + + mood_raw = inner.get("mood") or {} + if not isinstance(mood_raw, dict): + mood_raw = {} + mood = {k: _score(mood_raw.get(k)) for k in ("cozy", "summer_fresh", "energizing", "comfort")} + return { "tags": _str_list(inner.get("tags")), "cuisine": _str(inner.get("cuisine"), "unknown"), @@ -813,6 +977,9 @@ def _extract_recipe_meta(forge_result: dict) -> dict: "carbs_g": _int_or_none(inner.get("carbs_g")), "fat_g": _int_or_none(inner.get("fat_g")), "contains": contains, + "pairings": pairings, + "mood": mood, + "leftover_potential": _score(inner.get("leftover_potential")), "summary": _str_long(inner.get("summary")), "best_for": _str_long(inner.get("best_for")), } @@ -908,7 +1075,8 @@ def _extract_food_info(forge_result: dict) -> dict: def _extract_plan_slots(forge_result: dict): """clawdforge wraps its return; the JSON we asked for can sit in a few - different shapes. Normalize aggressively.""" + different shapes. Normalize aggressively. Returns (slots, reading) + where reading may be empty string.""" if not isinstance(forge_result, dict): raise ForgeError("forge result not a dict") inner = forge_result.get("result", forge_result) @@ -922,6 +1090,24 @@ def _extract_plan_slots(forge_result: dict): raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}") +def _extract_plan_payload(forge_result: dict) -> tuple[list, str]: + """Like _extract_plan_slots but ALSO pulls Hecate's weekly reading + text if present. Returns (slots_list, reading_str).""" + 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 isinstance(inner, list): + return inner, "" + if isinstance(inner, dict) and "slots" in inner: + reading = inner.get("reading") or inner.get("weekly_reading") or "" + if not isinstance(reading, str): + reading = "" + return inner["slots"], reading.strip()[:2000] + raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}") + + def _parse_json_blob(s: str): s = s.strip() # Strip code fences if Sonnet wrapped its output diff --git a/cauldron/server.py b/cauldron/server.py index e2dcd0b..da1ecf2 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -796,6 +796,9 @@ def create_app() -> Flask: elif isinstance(blob, dict): meta_by_slug[mr["recipe_slug"]] = blob history_by_slug = db.household_recipe_history(hid, lookback_days=180) + # Compute picker_profiles UP FRONT so per-user fit scoring can + # use them when building the recipe pool (fit goes inline per recipe). + picker_profiles = db.household_picker_profiles(hid, lookback_days=365) recipes = [] for r in rows: tags = [] @@ -811,6 +814,23 @@ def create_app() -> Flask: m = meta_by_slug.get(r["slug"]) if m: entry["meta"] = m + # Per-user fit score — match each member's picker profile + # (cuisines, proteins, comfort_tiers, tags) against this + # recipe's meta and produce a 1-5 score per user. Sonnet + # uses these to bias AI-chosen slots toward each member's + # demonstrated taste. + fit_scores: dict[str, int] = {} + for sub, prof in (picker_profiles or {}).items(): + if not isinstance(prof, dict): + continue + score = _compute_fit_score(m, prof) + if score > 0: + # Use display_name when available for readable prompt, + # else the sub. Cap at 12 chars to keep prompt slim. + nm = (prof.get("display_name") or sub).split("@")[0][:12] + fit_scores[nm] = score + if fit_scores: + entry["fit"] = fit_scores h = history_by_slug.get(r["slug"]) if h: # Compute weeks-ago for the prompt (relative human time @@ -818,13 +838,11 @@ def create_app() -> Flask: last = h.get("last_planned") weeks_ago: int | None = None if last is not None: - today = date.today() - delta_days = (today - last).days if hasattr(last, "days") is False else 0 try: - delta_days = (today - last).days + delta_days = (date.today() - last).days + weeks_ago = max(0, delta_days // 7) except Exception: - delta_days = 0 - weeks_ago = max(0, delta_days // 7) + weeks_ago = None entry["history"] = { "weeks_ago": weeks_ago, "count_30d": h.get("count_30d") or 0, @@ -835,11 +853,7 @@ def create_app() -> Flask: if not recipes: return jsonify({"error": "no_recipes_indexed"}), 409 - # Per-user picking profiles — what each member has historically - # pinned, joined with recipe meta. Lets the planner bias AI-chosen - # slots toward each member's actual preferences, not just blanket - # the household's collective average. - picker_profiles = db.household_picker_profiles(hid, lookback_days=365) + # picker_profiles already computed above for fit scoring; reuse it. # Numeric daily targets + allergen exclusions + meal_types parsed # from the persisted plan row @@ -863,7 +877,7 @@ def create_app() -> Flask: plan_meal_types = None try: - slots = forge.generate_plan( + payload = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), preference=plan.get("preference_prompt"), @@ -874,6 +888,8 @@ def create_app() -> Flask: ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 + slots = payload["slots"] if isinstance(payload, dict) else payload + reading = payload.get("reading", "") if isinstance(payload, dict) else "" inserted = db.save_plan_slots(plan["id"], slots) if inserted == 0: @@ -884,6 +900,8 @@ def create_app() -> Flask: "plan": _plan_payload(plan), }), 409 + if reading: + db.set_plan_hecate_reading(plan["id"], reading) plan = db.mark_plan_generated(plan["id"], u["sub"]) db.enrich_plan_with_slots(plan) return jsonify({"ok": True, "plan": _plan_payload(plan)}) @@ -982,7 +1000,7 @@ def create_app() -> Flask: plan_meal_types = None try: - slots = forge.generate_plan( + payload = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), preference=plan.get("preference_prompt"), @@ -993,8 +1011,12 @@ def create_app() -> Flask: ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 + slots = payload["slots"] if isinstance(payload, dict) else payload + reading = payload.get("reading", "") if isinstance(payload, dict) else "" db.save_plan_slots(plan["id"], slots) + if reading: + db.set_plan_hecate_reading(plan["id"], reading) plan = db.mark_plan_generated(plan["id"], u["sub"]) db.enrich_plan_with_slots(plan) return jsonify({"ok": True, "plan": _plan_payload(plan)}) @@ -1877,6 +1899,43 @@ def _resolve_sub_displays(db, plan: dict) -> dict[str, str]: return out +def _compute_fit_score(meta: dict, profile: dict) -> int: + """Score (1-5) how well a recipe's meta matches a user's picker profile. + Profile dimensions: cuisines, proteins, comfort_tiers, tags (each is a + {name → count} dict from picker history). Score logic: + - +1 base if profile is non-empty (we have signal at all) + - +1 if recipe.cuisine matches a top-3 cuisine the user has picked + - +1 if recipe.primary_protein matches a top-3 protein + - +1 if recipe.comfort_tier matches their top-2 comfort tier + - +1 if any of recipe's tags overlap their top-5 tags + Cap at 5. If no profile data exists, returns 3 (neutral, no signal).""" + if not isinstance(profile, dict) or not profile.get("total_picks"): + return 3 + score = 1 + + def _top_keys(d: dict | None, n: int) -> set: + if not isinstance(d, dict): + return set() + return set(list(d.keys())[:n]) + + cuisines = _top_keys(profile.get("cuisines"), 3) + proteins = _top_keys(profile.get("proteins"), 3) + tiers = _top_keys(profile.get("comfort_tiers"), 2) + tags_top = _top_keys(profile.get("tags"), 5) + + if meta.get("cuisine") and meta["cuisine"] in cuisines: + score += 1 + if meta.get("primary_protein") and meta["primary_protein"] in proteins: + score += 1 + if meta.get("comfort_tier") and meta["comfort_tier"] in tiers: + score += 1 + recipe_tags = set(meta.get("tags") or []) + if recipe_tags & tags_top: + score += 1 + + return min(5, score) + + def _job_payload(job: dict) -> dict: """JSON-serializable view of a sterilize job row (datetimes → iso).""" j = dict(job) diff --git a/cauldron/templates/plan.html b/cauldron/templates/plan.html index 1e0c0dd..e47b23d 100644 --- a/cauldron/templates/plan.html +++ b/cauldron/templates/plan.html @@ -274,6 +274,26 @@ font-size: 9px; letter-spacing: .2em; text-transform: uppercase; margin-bottom: 2px; } + + .hecate-reading { + color: var(--bone); + font-family: var(--serif); + font-size: 1.05em; line-height: 1.6; + padding: 14px 18px; + background: linear-gradient(135deg, + rgba(45, 29, 74, .35) 0%, + rgba(45, 29, 74, .15) 100%); + border-left: 3px solid var(--purple-bright); + border-radius: 6px; + font-style: italic; + letter-spacing: .01em; + } + .hecate-reading::before { + content: "✶ "; + color: var(--purple-bright); + font-style: normal; + font-size: 1.2em; + }
@@ -435,6 +455,16 @@ {% endif %} +{% if plan.hecate_reading %} +
+
+

hecate's reading

+ weekly +
+
{{ plan.hecate_reading }}
+
+{% endif %} + {% if plan.slots %}