diff --git a/cauldron/db.py b/cauldron/db.py index ae91939..3c9f60a 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -1714,7 +1714,10 @@ class DB: # sesame,pork} allergen booleans # v4: added pairings, mood scores, leftover_potential, plus second-pass # allergen verification to clean false-positive contains.* booleans - ENRICH_VERSION = 4 + # v5: split estimated_minutes → active+hands_off, equipment[], flavor_profile[], + # kid_friendly_score, fiber_g + sodium_mg, cost_per_serving_estimate, + # occasion_fit[], hecate_quip + ENRICH_VERSION = 5 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/forge.py b/cauldron/forge.py index 0d522a7..d2e1afa 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -241,6 +241,38 @@ class Forge: extras.append(f"carbs={meta['carbs_g']}g") if meta.get("fat_g"): extras.append(f"fat={meta['fat_g']}g") + if meta.get("fiber_g"): + extras.append(f"fiber={meta['fiber_g']}g") + if meta.get("sodium_mg"): + extras.append(f"sodium={meta['sodium_mg']}mg") + # v5: active/hands-off split lets the planner match a slot's + # available active time (e.g., "Wednesday I have 25 min hands-on") + am = meta.get("active_minutes") + ho = meta.get("hands_off_minutes") + if am is not None and ho is not None: + extras.append(f"active={am}m+offhand={ho}m") + elif am is not None: + extras.append(f"active={am}m") + # Equipment for "no oven this week" / "grill week" filters + eq = meta.get("equipment") or [] + if eq: + extras.append("eq:" + ",".join(eq[:3])) + # Flavor variety so the planner doesn't run 5 spicy nights + fp = meta.get("flavor_profile") or [] + if fp: + extras.append("flavor:" + "/".join(fp[:3])) + # Kid-fit signal for households with kids (Cobb has Leia + Luna) + kf = meta.get("kid_friendly_score") + if isinstance(kf, int) and kf > 0: + extras.append(f"kid={kf}") + # Cost — for budget-week filtering + cs = meta.get("cost_per_serving_estimate") + if cs: + extras.append(f"~${cs}/svg") + # Occasion fit — when is this dish best? + of = meta.get("occasion_fit") or [] + if of: + extras.append("for:" + "/".join(of[:3])) # Allergen flags — short-circuit list of "what's in this" contains = meta.get("contains") or {} if isinstance(contains, dict): @@ -686,6 +718,7 @@ class Forge: ' "protein_g": ,\n' ' "carbs_g": ,\n' ' "fat_g": ,\n' + ' "macros_confidence": "",\n' ' "contains": {\n' ' "dairy": , // milk/cream/butter/cheese/yogurt/whey\n' ' "gluten": , // wheat/barley/rye/regular pasta/bread/flour (not GF)\n' @@ -709,6 +742,18 @@ class Forge: ' "comfort": \n' ' },\n' ' "leftover_potential": ,\n' + ' "active_minutes": ,\n' + ' "hands_off_minutes": ,\n' + ' "equipment": [],\n' + ' "flavor_profile": [<2-5 from: "spicy","sweet","savory","umami","tangy","smoky","herby","citrusy","rich","fresh","bitter","earthy">],\n' + ' "kid_friendly_score": ,\n' + ' "fiber_g": ,\n' + ' "sodium_mg": ,\n' + ' "cost_per_serving_estimate": ,\n' + ' "occasion_fit": [],\n' + ' "hecate_quip": "",\n' ' "summary": "",\n' ' "best_for": ""\n' "}\n\n" @@ -716,11 +761,19 @@ class Forge: "- Return ONLY the JSON object, no markdown fences, no prose.\n" "- Be concrete: 'high-protein' goes in tags ONLY if the recipe genuinely " "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" + "- Macros (calories, protein_g, carbs_g, fat_g, fiber_g, sodium_mg): " + "PER-SERVING estimate computed step-by-step. Don't just guess a round " + "number — internally list each major ingredient (the protein, carb, " + "fat, dairy, anything substantial), approximate its contribution per " + "serving in grams using rough USDA averages, sum, then output. " + "Cross-check that protein_g × 4 + carbs_g × 4 + fat_g × 9 ≈ calories. " + "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" + "- macros_confidence: \"high\" when the recipe has explicit yields and " + "named cuts/quantities, \"medium\" when yields are inferred or some " + "ingredients are unspecified amounts, \"low\" when you had to guess " + "significantly (no yields, free-form 'to taste' amounts dominating, " + "exotic ingredients without clear USDA equivalents).\n" "- contains.* booleans: TRUE if the ingredient appears anywhere in the " "recipe. dairy=true for butter, cream, cheese, milk, yogurt, ghee, whey, " "casein. gluten=true for regular flour/bread/pasta/soy sauce/beer/seitan; " @@ -746,6 +799,35 @@ class Forge: "- 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" + "- active_minutes vs hands_off_minutes: split the total cook time. " + "Active = chopping, sautéing, stirring, plating — you must be present. " + "Hands-off = rise, marinate, braise, bake unattended, chill, ferment. " + "Together they should sum to ~estimated_minutes. A 4-hour beef stew " + "might be 30 active + 210 hands-off.\n" + "- equipment: pick from the listed set what's REQUIRED to make the " + "recipe. A recipe that uses both stovetop AND oven gets both. Don't " + "include fundamental tools (knife, pan) — only the bigger appliances " + "or cooking modes that gate availability.\n" + "- flavor_profile: 2-5 dominant taste/mood notes. Don't overlist; " + "pick the ones that actually characterize this dish.\n" + "- kid_friendly_score 1-5: 1=adults-only (super spicy, weird textures, " + "olives + capers), 3=most kids will eat, 5=kids LOVE this (mac and " + "cheese, pancakes, chicken nuggets).\n" + "- fiber_g, sodium_mg: per-serving estimates from the ingredient list. " + "Same precision as the other macros — rough USDA averages.\n" + "- cost_per_serving_estimate: rough USD per serving. A bean-and-rice " + "bowl is $2-3, salmon for two is $8-12, a fancy roast is $15+. Best-effort, " + "current-ish (2026) US prices. Set null if too volatile (game meat, " + "regional specialty).\n" + "- occasion_fit: when is this dish AT HOME? A roast chicken can be " + "weeknight AND date-night. Mac and cheese is weeknight + kids-birthday. " + "Be generous but discerning.\n" + "- hecate_quip: one line, ~10-20 words, in Hecate's voice. Not a " + "description of WHAT the dish is — a description of what it FEELS " + "like. Mythic-witch tone, evocative, a little theatrical. Examples: " + "\"Pure midwinter comfort — asks for a fire and quiet.\" / \"Sharp " + "and bright like a Tuesday morning resolution.\" / \"The kind of " + "feast that announces itself.\"\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; " @@ -961,27 +1043,40 @@ def _extract_recipe_meta(forge_result: dict) -> dict: mood_raw = {} mood = {k: _score(mood_raw.get(k)) for k in ("cozy", "summer_fresh", "energizing", "comfort")} + # v5 additions: split-time, equipment, flavor, kid-fit, fiber/sodium/cost, + # occasion fit, hecate's quip 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")), + "active_minutes": _int_or_none(inner.get("active_minutes")), + "hands_off_minutes": _int_or_none(inner.get("hands_off_minutes")), + "equipment": _str_list(inner.get("equipment")), + "flavor_profile": _str_list(inner.get("flavor_profile")), "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"), + "kid_friendly_score": _score(inner.get("kid_friendly_score")), "season_fit": _str_list(inner.get("season_fit")) or ["year-round"], + "occasion_fit": _str_list(inner.get("occasion_fit")), "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")), + "macros_confidence": _str(inner.get("macros_confidence"), "medium"), + "fiber_g": _int_or_none(inner.get("fiber_g")), + "sodium_mg": _int_or_none(inner.get("sodium_mg")), + "cost_per_serving_estimate": _int_or_none(inner.get("cost_per_serving_estimate")), "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")), + "hecate_quip": _str_long(inner.get("hecate_quip")), }