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 %}