diff --git a/cauldron/db.py b/cauldron/db.py index 48322ee..bfa329d 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -1547,7 +1547,12 @@ class DB: # --- recipe enrichment ------------------------------------------------ - ENRICH_VERSION = 1 + # Bump when the meta schema or prompt changes meaningfully so existing + # rows get re-enriched on next walk (or on user-clicks "force re-enrich"). + # 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 def get_recipe_meta(self, household_id: int, recipe_slug: str) -> dict | None: with self.conn() as c, c.cursor() as cur: @@ -1560,6 +1565,185 @@ class DB: row = cur.fetchone() return dict(row) if row else None + def household_picker_profiles( + self, household_id: int, *, lookback_days: int = 365 + ) -> dict[str, dict]: + """For each member of the household, aggregate the recipes they've + explicitly picked (current cauldron_meal_picks + historical + meal_plan_slots.picker_subs across the lookback window). Cross + with cauldron_recipe_meta to derive their picking pattern: + + { + "cobb@sulkta.com": { + "display_name": "cobb", + "total_picks": 24, + "cuisines": {"asian": 6, "mexican": 4, "italian": 3, ...}, + "proteins": {"chicken": 8, "beef": 5, "fish": 2, ...}, + "comfort_tiers": {"weeknight-easy": 15, "hearty-comfort": 4, ...}, + "tags": {"high-protein": 9, "weeknight": 11, "spicy": 7, ...}, + }, + ... + } + + Used by the plan generator to bias AI-chosen slots toward what + each member has historically wanted, even when nobody actively + pins for the upcoming week. Picks-attribution is golden signal — + the user explicitly said 'I want this.'""" + import json as _json + # Step 1: gather (sub, recipe_slug) pairs from both sources + attributions: list[tuple[str, str]] = [] + with self.conn() as c, c.cursor() as cur: + # Current picks (high-signal, recent activity) + cur.execute( + """SELECT mp.authentik_sub AS sub, mp.recipe_slug + FROM cauldron_meal_picks mp + JOIN cauldron_household_members m + ON m.authentik_sub = mp.authentik_sub + WHERE m.household_id = %s""", + (household_id,), + ) + for r in cur.fetchall(): + attributions.append((r["sub"], r["recipe_slug"])) + # Historical picker_subs in plan slots (what actually got cooked + # because someone wanted it) + cur.execute( + """SELECT s.recipe_slug, s.picker_subs + FROM cauldron_meal_plan_slots s + JOIN cauldron_meal_plans p ON p.id = s.plan_id + WHERE p.household_id = %s + AND p.week_start >= CURDATE() - INTERVAL %s DAY + AND s.picker_subs IS NOT NULL""", + (household_id, lookback_days), + ) + for r in cur.fetchall(): + blob = r["picker_subs"] + if isinstance(blob, str): + try: + blob = _json.loads(blob) + except Exception: + continue + if not isinstance(blob, list): + continue + for sub in blob: + if isinstance(sub, str) and sub: + attributions.append((sub, r["recipe_slug"])) + + if not attributions: + return {} + + # Step 2: pull recipe meta for any slug in the attribution set + slugs = list({s for _, s in attributions}) + meta_by_slug: dict[str, dict] = {} + if slugs: + placeholders = ",".join(["%s"] * len(slugs)) + with self.conn() as c, c.cursor() as cur: + cur.execute( + f"""SELECT recipe_slug, meta_json + FROM cauldron_recipe_meta + WHERE household_id=%s AND recipe_slug IN ({placeholders})""", + (household_id, *slugs), + ) + for r in cur.fetchall(): + blob = r["meta_json"] + if isinstance(blob, str): + try: + meta_by_slug[r["recipe_slug"]] = _json.loads(blob) + except Exception: + pass + elif isinstance(blob, dict): + meta_by_slug[r["recipe_slug"]] = blob + + # Step 3: pull display names for the subs we found + subs = list({s for s, _ in attributions}) + display_by_sub: dict[str, str] = {} + if subs: + placeholders = ",".join(["%s"] * len(subs)) + with self.conn() as c, c.cursor() as cur: + cur.execute( + f"""SELECT authentik_sub, email, display_name + FROM cauldron_users + WHERE authentik_sub IN ({placeholders})""", + tuple(subs), + ) + for r in cur.fetchall(): + nm = r["display_name"] or (r["email"] or "").split("@")[0] or r["authentik_sub"] + display_by_sub[r["authentik_sub"]] = nm + + # Step 4: aggregate per user + from collections import Counter + per_user: dict[str, dict] = {} + for sub, slug in attributions: + meta = meta_by_slug.get(slug) or {} + prof = per_user.setdefault(sub, { + "display_name": display_by_sub.get(sub, sub), + "total_picks": 0, + "cuisines": Counter(), + "proteins": Counter(), + "comfort_tiers": Counter(), + "tags": Counter(), + }) + prof["total_picks"] += 1 + cuisine = meta.get("cuisine") + if cuisine and cuisine not in ("unknown", "other"): + prof["cuisines"][cuisine] += 1 + prot = meta.get("primary_protein") + if prot and prot != "none": + prof["proteins"][prot] += 1 + tier = meta.get("comfort_tier") + if tier: + prof["comfort_tiers"][tier] += 1 + for t in (meta.get("tags") or [])[:8]: + if t: + prof["tags"][t] += 1 + + # Step 5: convert Counters to plain top-N dicts for prompt-friendly output + out: dict[str, dict] = {} + for sub, prof in per_user.items(): + out[sub] = { + "display_name": prof["display_name"], + "total_picks": prof["total_picks"], + "cuisines": dict(prof["cuisines"].most_common(5)), + "proteins": dict(prof["proteins"].most_common(5)), + "comfort_tiers": dict(prof["comfort_tiers"].most_common(3)), + "tags": dict(prof["tags"].most_common(8)), + } + return out + + def household_recipe_history( + self, household_id: int, *, lookback_days: int = 180 + ) -> dict[str, dict]: + """For each recipe slug that's appeared in this household's meal plans + within the lookback window, return: + {recipe_slug: {last_planned: date, count_30d: int, count_180d: int}} + + Used by the plan generator to surface rotation context to Sonnet + ("don't suggest the same recipe 3 weeks running"). Joins on + meal_plan_slots → meal_plans → household.""" + with self.conn() as c, c.cursor() as cur: + cur.execute( + """ + SELECT + s.recipe_slug, + MAX(p.week_start) AS last_planned, + SUM(CASE WHEN p.week_start >= CURDATE() - INTERVAL 30 DAY THEN 1 ELSE 0 END) AS count_30d, + SUM(CASE WHEN p.week_start >= CURDATE() - INTERVAL %s DAY THEN 1 ELSE 0 END) AS count_long + FROM cauldron_meal_plan_slots s + JOIN cauldron_meal_plans p ON p.id = s.plan_id + WHERE p.household_id = %s + AND p.week_start >= CURDATE() - INTERVAL %s DAY + GROUP BY s.recipe_slug + """, + (lookback_days, household_id, lookback_days), + ) + out: dict[str, dict] = {} + for r in cur.fetchall(): + out[r["recipe_slug"]] = { + "last_planned": r["last_planned"], + "count_30d": int(r["count_30d"] or 0), + "count_long": int(r["count_long"] or 0), + } + return out + 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: diff --git a/cauldron/forge.py b/cauldron/forge.py index aea742a..60df5e3 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -77,6 +77,7 @@ class Forge: slots: int = 7, week_start: str, preference: str | None = None, + picker_profiles: dict | None = None, model: str | None = None, ) -> list[dict]: """Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts @@ -110,7 +111,7 @@ class Forge: prompt = self._build_plan_prompt( picks=picks, recipes=recipes, slots=slots, week_start=week_start, - preference=preference, + preference=preference, picker_profiles=picker_profiles, ) result = self.run(prompt, model=model or "sonnet") parsed = _extract_plan_slots(result) @@ -163,7 +164,8 @@ class Forge: return out @staticmethod - def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None) -> str: + def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None, + picker_profiles=None) -> str: pool_lines = [] for r in recipes: slug = r.get("slug") or "" @@ -201,11 +203,42 @@ class Forge: 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 + if meta.get("calories"): + extras.append(f"~{meta['calories']}cal") + if meta.get("protein_g"): + extras.append(f"protein={meta['protein_g']}g") + if meta.get("carbs_g"): + extras.append(f"carbs={meta['carbs_g']}g") + if meta.get("fat_g"): + extras.append(f"fat={meta['fat_g']}g") + # Allergen flags — short-circuit list of "what's in this" + contains = meta.get("contains") or {} + if isinstance(contains, dict): + flags = [k for k, v in contains.items() if v] + if flags: + extras.append("has:" + ",".join(flags)) + + # Rotation history — let Sonnet avoid 3-weeks-in-a-row repeats + history = r.get("history") or {} + if history: + wa = history.get("weeks_ago") + c30 = history.get("count_30d") or 0 + cl = history.get("count_long") or 0 + hist_bits = [] + if wa is not None: + hist_bits.append(f"last:{wa}w-ago" if wa > 0 else "last:this-week") + if c30: + hist_bits.append(f"{c30}×/30d") + if cl: + hist_bits.append(f"{cl}×/180d") + if hist_bits: + extras.append(" ".join(hist_bits)) + + if meta and 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}") @@ -222,6 +255,50 @@ class Forge: picks_block = "\n".join(pick_lines) if pick_lines else "(none)" pool_block = "\n".join(pool_lines) + # Picker profiles: per-member historical picking patterns. Helps + # Sonnet bias AI-chosen slots toward each member's actual taste. + profile_block = "" + if picker_profiles: + lines: list[str] = [] + for sub, prof in picker_profiles.items(): + if not isinstance(prof, dict): + continue + name = prof.get("display_name") or sub + total = prof.get("total_picks") or 0 + bits = [] + cuisines = prof.get("cuisines") or {} + if cuisines: + bits.append("cuisines=[" + ", ".join( + f"{k}:{v}" for k, v in list(cuisines.items())[:4] + ) + "]") + proteins = prof.get("proteins") or {} + if proteins: + bits.append("proteins=[" + ", ".join( + f"{k}:{v}" for k, v in list(proteins.items())[:4] + ) + "]") + tags = prof.get("tags") or {} + if tags: + bits.append("tags=[" + ", ".join( + f"{k}:{v}" for k, v in list(tags.items())[:5] + ) + "]") + tier = prof.get("comfort_tiers") or {} + if tier: + bits.append("tier=[" + ", ".join( + f"{k}:{v}" for k, v in list(tier.items())[:2] + ) + "]") + if bits: + lines.append(f" - {name} (sub={sub}, {total} picks): " + " · ".join(bits)) + if lines: + profile_block = ( + "\nPICKER PROFILES — per-member historical picking patterns:\n" + + "\n".join(lines) + "\n\n" + "Use these to bias AI-chosen slots toward each member's " + "preferences. e.g., if Cobb's profile shows cuisines=[asian:6, " + "mexican:4] and proteins=[chicken:8], lean toward asian-chicken " + "recipes for the AI-filled slots when other constraints permit. " + "Picks still take precedence over profile bias.\n" + ) + pref_clean = (preference or "").strip() pref_block = "" if pref_clean: @@ -244,6 +321,7 @@ class Forge: f"PICKS (recipes the family pre-selected — every pick MUST appear " f"if pool size >= {slots}; no repeats unless pool < {slots}):\n" f"{picks_block}\n" + f"{profile_block}" f"{pref_block}" "Output JSON ONLY, no prose: " '{"slots": [{"day": "monday", "recipe_slug": "...", ' @@ -251,9 +329,16 @@ class Forge: "Rules:\n" f"- Use exactly {slots} recipes\n" "- Distribute picks evenly across the week — don't bunch them\n" + "- ROTATION: prefer recipes with `last:NNw-ago` further in the past " + "or no history shown. If a recipe has been served 2+ times in 30d " + "(`2×/30d` or higher), DEMOTE it strongly unless it's a household " + "pick. Never repeat the same recipe slug within this 7-day plan.\n" + "- VARIETY: don't fill 5 of 7 slots with the same primary_protein or " + "the same cuisine. Mix it up across the week.\n" "- \"reason\" is a one-line user-facing rationale " "(e.g., \"balances heavy and light meals\", \"honors abby's pick\", " - "\"high-protein lean — pairs with the gym week\")\n" + "\"high-protein lean — pairs with the gym week\", " + "\"haven't had this in 8 weeks — fresh on the rotation\")\n" "- \"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" @@ -467,13 +552,42 @@ class Forge: ' "veg_forward": "",\n' ' "comfort_tier": "",\n' ' "season_fit": [],\n' + ' "calories": ,\n' + ' "protein_g": ,\n' + ' "carbs_g": ,\n' + ' "fat_g": ,\n' + ' "contains": {\n' + ' "dairy": , // milk/cream/butter/cheese/yogurt/whey\n' + ' "gluten": , // wheat/barley/rye/regular pasta/bread/flour (not GF)\n' + ' "nuts": , // tree nuts (almonds, cashews, pecans, walnuts, ...)\n' + ' "peanuts": , // tracked separately from tree nuts\n' + ' "eggs": ,\n' + ' "shellfish": , // shrimp, crab, lobster, scallops, ...\n' + ' "fish": ,\n' + ' "soy": , // soy sauce, tofu, tempeh, edamame\n' + ' "sesame": ,\n' + ' "pork": // for halal/kosher-ish filters\n' + ' },\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" + "qualifies (≥30g protein per serving is a useful threshold).\n" + "- Macros (calories, protein_g, carbs_g, fat_g): best-effort PER-SERVING " + "estimate from the ingredient list and yields. Use rough USDA averages — " + "we want signal not precision. If yields aren't clear, assume 4 servings. " + "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" "- 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; " @@ -555,6 +669,24 @@ def _extract_recipe_meta(forge_result: dict) -> dict: except (TypeError, ValueError): return default + def _int_or_none(v): + if v is None: + return None + try: + n = int(v) + return n if n > 0 else None + except (TypeError, ValueError): + return None + + contains_raw = inner.get("contains") or {} + if not isinstance(contains_raw, dict): + contains_raw = {} + contains = { + k: bool(contains_raw.get(k)) + for k in ("dairy", "gluten", "nuts", "peanuts", "eggs", + "shellfish", "fish", "soy", "sesame", "pork") + } + return { "tags": _str_list(inner.get("tags")), "cuisine": _str(inner.get("cuisine"), "unknown"), @@ -566,6 +698,11 @@ def _extract_recipe_meta(forge_result: dict) -> dict: "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"], + "calories": _int_or_none(inner.get("calories")), + "protein_g": _int_or_none(inner.get("protein_g")), + "carbs_g": _int_or_none(inner.get("carbs_g")), + "fat_g": _int_or_none(inner.get("fat_g")), + "contains": contains, "summary": _str_long(inner.get("summary")), "best_for": _str_long(inner.get("best_for")), } diff --git a/cauldron/server.py b/cauldron/server.py index 9787758..cf6b1f3 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -669,10 +669,11 @@ def create_app() -> Flask: db.set_plan_preference(plan["id"], preference) plan["preference_prompt"] = preference[:1000] - # 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. + # Pull picks + recipe pool. The pool splices in: + # 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes) + # 2. cook history from cauldron_meal_plan_slots (rotation context) + # Both let the planner match preferences to actual characteristics + # AND avoid serving the same recipe 3 weeks running. 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) @@ -686,6 +687,7 @@ def create_app() -> Flask: pass elif isinstance(blob, dict): meta_by_slug[mr["recipe_slug"]] = blob + history_by_slug = db.household_recipe_history(hid, lookback_days=180) recipes = [] for r in rows: tags = [] @@ -701,16 +703,42 @@ def create_app() -> Flask: m = meta_by_slug.get(r["slug"]) if m: entry["meta"] = m + h = history_by_slug.get(r["slug"]) + if h: + # Compute weeks-ago for the prompt (relative human time + # is more useful to Sonnet than ISO dates) + 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 + except Exception: + delta_days = 0 + weeks_ago = max(0, delta_days // 7) + entry["history"] = { + "weeks_ago": weeks_ago, + "count_30d": h.get("count_30d") or 0, + "count_long": h.get("count_long") or 0, + } recipes.append(entry) 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) + try: slots = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), preference=plan.get("preference_prompt"), + picker_profiles=picker_profiles, ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 @@ -781,11 +809,18 @@ 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) + try: slots = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), preference=plan.get("preference_prompt"), + picker_profiles=picker_profiles, ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502