diff --git a/cauldron/db.py b/cauldron/db.py index 3c9f60a..92bcde1 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -1104,6 +1104,23 @@ class DB: ) return len(rows) + def find_indexed_recipe(self, household_id: int, slug: str) -> dict | None: + """Look up a single indexed recipe by household + slug. Returns None + if the slug isn't in this household's catalog. Used by /api/plan/ + suggest/pin to validate a slug before adding it to picks.""" + with self.conn() as c, c.cursor() as cur: + cur.execute( + """SELECT slug, name, description, tags_text, cats_text, + foods_text, date_updated, date_added, last_made, + total_time, recipe_yield, raw_json + FROM cauldron_recipe_index + WHERE household_id = %s AND slug = %s + LIMIT 1""", + (household_id, slug), + ) + r = cur.fetchone() + return dict(r) if r else None + def list_indexed_recipes(self, household_id: int, *, category: str | None = None, order_by: str = "date_added", order_dir: str = "desc", limit: int = 1000, offset: int = 0) -> list[dict]: diff --git a/cauldron/forge.py b/cauldron/forge.py index 68d1292..31d37ad 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -908,6 +908,182 @@ class Forge: return prior_contains or {} return _extract_allergen_verification(result, prior_contains or {}) + def suggest_recipes( + self, + *, + eligible_pool: list[dict], + count: int = 3, + week_start: str, + preference: str | None = None, + daily_targets: dict | None = None, + exclusions: list | None = None, + picker_profiles: dict | None = None, + in_plan_slugs: list[str] | None = None, + model: str | None = None, + ) -> list[dict]: + """Ask Hecate to nominate `count` 'forgotten gems' from an already- + filtered pool (caller has dropped recipes seen in the last 90 days + and recipes already in the current week's plan). Returns: + + [{"recipe_slug": "...", "recipe_name": "...", "fit_score": 1-5, + "reason": ""}] + + Each suggestion's slug MUST be in the eligible pool — Forge raises + ForgeError if the model hallucinates one. The caller surfaces the + ForgeError as a 502.""" + if count < 1 or count > 6: + raise ForgeError(f"bad suggestion count: {count}") + if not eligible_pool: + raise ForgeError("eligible pool empty — nothing to suggest") + # Need at least `count` items to pick from, otherwise just return what we have + target = min(count, len(eligible_pool)) + + valid_by_slug: dict[str, str] = {} + for r in eligible_pool: + slug = r.get("slug") + if slug: + valid_by_slug[slug] = r.get("name") or slug + + # Render pool entries the same way the planner does (compact, meta-rich) + pool_lines = [] + for r in eligible_pool: + slug = r.get("slug") or "" + name = r.get("name") or slug + meta = r.get("meta") or {} + extras: list[str] = [] + 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"]) + if meta.get("comfort_tier"): + extras.append(f"comfort:{meta['comfort_tier']}") + if meta.get("kid_friendly"): + extras.append(f"kid:{meta['kid_friendly']}") + meta_tags = meta.get("tags") or [] + if meta_tags: + extras.append("/".join(meta_tags[:5])) + quip = meta.get("hecate_quip") + if quip: + extras.append(f"vibe:{str(quip)[:80]}") + h = r.get("history") or {} + if h.get("weeks_ago") is not None: + extras.append(f"last:{h['weeks_ago']}w-ago") + else: + extras.append("never-planned") + fit = r.get("fit") or {} + if isinstance(fit, dict) and fit: + fit_str = ",".join(f"{n}:{s}" for n, s in fit.items()) + extras.append(f"fit:{fit_str}") + line = f"- {slug} | {name}" + if extras: + line += f" [{' · '.join(extras)}]" + pool_lines.append(line) + pool_block = "\n".join(pool_lines) + + # Picker profile block (same shape as planner uses, abbreviated) + profile_block = "" + if isinstance(picker_profiles, dict) and picker_profiles: + lines = [] + for sub, prof in picker_profiles.items(): + if not isinstance(prof, dict): + continue + disp = prof.get("display_name") or sub + cuisines = list((prof.get("cuisines") or {}).keys())[:3] + proteins = list((prof.get("proteins") or {}).keys())[:3] + bits = [] + if cuisines: + bits.append("cuisines=" + "/".join(cuisines)) + if proteins: + bits.append("proteins=" + "/".join(proteins)) + lines.append(f" - {disp}: " + ", ".join(bits) if bits else f" - {disp}: (no signal yet)") + if lines: + profile_block = "\nFAMILY PICKER PROFILES (their demonstrated tastes):\n" + "\n".join(lines) + "\n" + + pref_block = f"\nWEEK PREFERENCE: {preference}\n" if preference else "" + excl_block = "" + if isinstance(exclusions, list) and exclusions: + excl_block = f"\nMUST AVOID (allergens/exclusions): {', '.join(str(e) for e in exclusions)}\n" + targets_block = "" + if isinstance(daily_targets, dict) and any(daily_targets.get(k) for k in ("calories", "protein_g", "carbs_g", "fat_g")): + parts = [] + for k, label in [("calories", "cal"), ("protein_g", "protein g"), ("carbs_g", "carbs g"), ("fat_g", "fat g")]: + v = daily_targets.get(k) + if v: + parts.append(f"{label}={v}") + if parts: + targets_block = f"\nDAILY TARGETS: {', '.join(parts)} (per day, on average)\n" + already_block = "" + if in_plan_slugs: + already_block = f"\n(For context: this week's plan already has {len(in_plan_slugs)} recipe(s). They're NOT in the pool above.)\n" + + prompt = ( + "You are Hecate, a Greek-mythology witch goddess of crossroads, " + "herbs, and magic — and the family's meal planner. The household " + "asked you to look through their recipe library and surface " + "'forgotten gems': recipes that fit who they are but haven't " + "been served recently (or ever).\n\n" + f"This is for the week of {week_start}.\n\n" + f"ELIGIBLE POOL (already filtered: nothing seen in the last 90 days, " + f"nothing already on this week's plan):\n{pool_block}\n" + f"{profile_block}" + f"{pref_block}" + f"{targets_block}" + f"{excl_block}" + f"{already_block}" + f"\nPick exactly {target} recipe(s) from the pool above.\n\n" + "Output JSON ONLY, no prose:\n" + '{"suggestions": [{"recipe_slug": "...", "fit_score": 1-5, "reason": "..."}]}\n\n' + "Rules:\n" + f"- Exactly {target} suggestion(s); each recipe_slug MUST be in the pool above\n" + "- VARIETY: don't pick 3 of the same cuisine or 3 of the same primary_protein\n" + "- BIAS toward recipes whose meta best fits the family's picker profiles " + "and any week preference/targets stated above\n" + "- AVOID anything with an exclusion conflict (allergens, dietary)\n" + "- fit_score is YOUR 1-5 confidence this is a hit for THIS family THIS week " + "(5 = strong match, 1 = adventurous reach)\n" + "- reason is one line in Hecate's voice — a witch-goddess remarking " + "on the crossroads. Reference the meta you saw " + "(e.g., \"haven't crossed your table since June — light, herby, " + "fits the picker profile\", \"a quiet salmon pivot from the " + "chicken-heavy week you've been weaving\"). ~12-25 words.\n" + ) + result = self.run(prompt, model=model or "sonnet") + suggestions = _extract_suggestions(result) + + out = [] + seen_slugs: set[str] = set() + for s in suggestions: + slug = (s.get("recipe_slug") or "").strip() + if not slug or slug not in valid_by_slug: + raise ForgeError(f"model output: unknown recipe_slug '{slug}'") + if slug in seen_slugs: + raise ForgeError(f"model output: duplicate suggestion '{slug}'") + seen_slugs.add(slug) + fit = s.get("fit_score") + try: + fit_int = int(fit) if fit is not None else 3 + except (TypeError, ValueError): + fit_int = 3 + fit_int = max(1, min(5, fit_int)) + out.append({ + "recipe_slug": slug, + "recipe_name": valid_by_slug[slug], + "fit_score": fit_int, + "reason": (s.get("reason") or "")[:500], + }) + if not out: + raise ForgeError("model returned no usable suggestions") + return out + 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: @@ -1203,6 +1379,30 @@ def _extract_plan_payload(forge_result: dict) -> tuple[list, str]: raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}") +def _extract_suggestions(forge_result: dict) -> list[dict]: + """Pull a list of {recipe_slug, fit_score, reason} dicts out of the + suggest_recipes reply. Caller validates each slug against the eligible + pool — this just normalizes the shape.""" + 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, dict) and "suggestions" in inner: + items = inner["suggestions"] + elif isinstance(inner, list): + items = inner + else: + raise ForgeError(f"forge result missing 'suggestions' key: {str(inner)[:200]}") + if not isinstance(items, list): + raise ForgeError("'suggestions' must be a list") + out: list[dict] = [] + for s in items: + if isinstance(s, dict): + out.append(s) + return out + + def _parse_json_blob(s: str): """Parse the FIRST balanced JSON value out of a string. Tolerates Sonnet appending extra prose/notes after the JSON object (which violates the diff --git a/cauldron/server.py b/cauldron/server.py index da1ecf2..89a3bc2 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -1021,6 +1021,190 @@ def create_app() -> Flask: db.enrich_plan_with_slots(plan) return jsonify({"ok": True, "plan": _plan_payload(plan)}) + @app.post("/api/plan/suggest") + @require_session + def plan_suggest(): + """Hecate's 'forgotten gems' — surface 1-6 recipes from the + household's existing library that haven't been served recently + and fit the picker profiles. Body: {count: int=3, week?: ISO}. + Returns {suggestions: [{recipe_slug, recipe_name, fit_score, + reason, image_url?, meta_summary}]}. + + Filtering happens server-side BEFORE Sonnet sees the pool: + - drop recipes with last_planned within the last 90 days + - drop recipes already in this week's plan slots + - drop recipes flagged by allergen exclusions on this plan + - drop recipes already on the household's pick list (those + are already going to be planned next round)""" + hid = current_household_id() + if not hid: + return jsonify({"error": "no household"}), 409 + + body = request.get_json(silent=True) or {} + try: + count = int(body.get("count") or 3) + except (TypeError, ValueError): + count = 3 + count = max(1, min(6, count)) + target_monday = _resolve_week(body) + plan = db.get_or_create_plan(hid, target_monday) + + # Build the recipe pool the same way /generate does, then apply + # Flavor A's extra filters. + 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 + + history_by_slug = db.household_recipe_history(hid, lookback_days=180) + picker_profiles = db.household_picker_profiles(hid, lookback_days=365) + existing_slots = db.list_plan_slots(plan["id"]) + in_plan_slugs = {s.get("recipe_slug") for s in existing_slots if s.get("recipe_slug")} + already_picked = db.list_household_pick_slugs(hid) + + # Plan exclusions for the chosen week (allergen avoid list) + plan_exclusions = plan.get("exclusions_json") + if isinstance(plan_exclusions, str): + try: + plan_exclusions = _json_loads(plan_exclusions) + except Exception: + plan_exclusions = None + excl_set = set() + if isinstance(plan_exclusions, list): + excl_set = {str(e).strip().lower() for e in plan_exclusions if e} + + eligible: list[dict] = [] + today = date.today() + for r in rows: + slug = r["slug"] + if slug in in_plan_slugs: + continue + if slug in already_picked: + continue + h = history_by_slug.get(slug) + weeks_ago: int | None = None + if h: + last = h.get("last_planned") + if last is not None: + try: + delta_days = (today - last).days + except Exception: + delta_days = 9999 + if delta_days < 90: + # Seen recently — not a forgotten gem + continue + weeks_ago = max(0, delta_days // 7) + m = meta_by_slug.get(slug) or {} + # Apply allergen exclusions defensively. `meta.contains` is the + # 2nd-pass-verified bool dict. + if excl_set: + contains = m.get("contains") or {} + if any(contains.get(e) for e in excl_set): + continue + entry = { + "slug": slug, + "name": r["name"], + "meta": m, + "history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {}, + } + # Per-user fit (same scoring as planner) + 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: + nm = (prof.get("display_name") or sub).split("@")[0][:12] + fit_scores[nm] = score + if fit_scores: + entry["fit"] = fit_scores + eligible.append(entry) + + if not eligible: + return jsonify({ + "suggestions": [], + "detail": "no eligible recipes — every recipe in your library has been planned in the last 90 days, or already on this week's plan / picks list", + }), 200 + + # Persisted week-level prefs/targets/exclusions (so Hecate's reasoning + # can reference them). + plan_targets = plan.get("daily_targets_json") + if isinstance(plan_targets, str): + try: + plan_targets = _json_loads(plan_targets) + except Exception: + plan_targets = None + + try: + suggestions = forge.suggest_recipes( + eligible_pool=eligible, + count=count, + week_start=target_monday.isoformat(), + preference=plan.get("preference_prompt"), + daily_targets=plan_targets if isinstance(plan_targets, dict) else None, + exclusions=list(excl_set) if excl_set else None, + picker_profiles=picker_profiles, + in_plan_slugs=list(in_plan_slugs), + ) + except ForgeError as e: + return jsonify({"error": "forge_failed", "detail": str(e)}), 502 + + # Decorate with image_url + a tiny meta summary for the UI cards. + # Look up image_url from the indexed recipe row (raw_json.image when present). + meta_summary_keys = ("cuisine", "complexity", "estimated_minutes", "primary_protein", "comfort_tier") + rows_by_slug = {r["slug"]: r for r in rows} + for s in suggestions: + slug = s["recipe_slug"] + m = meta_by_slug.get(slug) or {} + r = rows_by_slug.get(slug) or {} + img = None + raw = r.get("raw_json") + if isinstance(raw, str): + try: + raw = _json_loads(raw) + except Exception: + raw = None + if isinstance(raw, dict): + img = raw.get("image") or raw.get("imageUrl") + s["image_url"] = img + s["meta_summary"] = {k: m.get(k) for k in meta_summary_keys if m.get(k)} + s["hecate_quip"] = m.get("hecate_quip") or "" + h = history_by_slug.get(slug) or {} + last_p = h.get("last_planned") + s["last_planned"] = last_p.isoformat() if last_p is not None else None + + return jsonify({"suggestions": suggestions}) + + @app.post("/api/plan/suggest/pin") + @require_session + def plan_suggest_pin(): + """Pin a suggested recipe → adds to cauldron_meal_picks for the + session user. The next /api/plan/generate will naturally pull it + in as one of that user's picks. Body: {recipe_slug, recipe_name?}.""" + u = session["user"] + hid = current_household_id() + if not hid: + return jsonify({"error": "no household"}), 409 + body = request.get_json(silent=True) or {} + slug = (body.get("recipe_slug") or "").strip() + if not slug: + return jsonify({"error": "missing recipe_slug"}), 400 + # Look up the canonical name from the indexed catalog so the pin's + # display name matches Mealie's truth (don't trust the body). + idx = db.find_indexed_recipe(hid, slug) + if not idx: + return jsonify({"error": "recipe_not_indexed"}), 404 + added = db.add_meal_pick(u["sub"], slug, idx.get("name") or slug) + return jsonify({"ok": True, "added": added}) + @app.get("/list") @require_session def list_view(): diff --git a/cauldron/templates/plan.html b/cauldron/templates/plan.html index e47b23d..410fcd4 100644 --- a/cauldron/templates/plan.html +++ b/cauldron/templates/plan.html @@ -465,6 +465,23 @@ {% endif %} +
+
+

forgotten gems

+ recipes you haven't seen in 90+ days +
+

+ let hecate look through your library for recipes that fit who you are + but haven't crossed your table recently. +

+
+ + +
+
hecate · ~10s
+
+
+ {% if plan.slots %}
@@ -657,6 +674,97 @@ async function rerollPlan(btn) { // Day cards have class .recipe-card → the base modal handler picks them up // automatically (single-click → modal, ctrl/cmd-click → new tab). + +function _escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function _renderSuggestion(s) { + const fitDots = '★'.repeat(s.fit_score || 0) + '☆'.repeat(Math.max(0, 5 - (s.fit_score || 0))); + const meta = s.meta_summary || {}; + const metaBits = []; + if (meta.cuisine) metaBits.push(_escapeHtml(meta.cuisine)); + if (meta.complexity) metaBits.push(_escapeHtml(meta.complexity)); + if (meta.estimated_minutes) metaBits.push(meta.estimated_minutes + 'min'); + if (meta.primary_protein) metaBits.push('protein: ' + _escapeHtml(meta.primary_protein)); + if (meta.comfort_tier) metaBits.push('comfort: ' + _escapeHtml(meta.comfort_tier)); + const metaLine = metaBits.length ? `
${metaBits.join(' · ')}
` : ''; + const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household'; + const img = s.image_url ? `` : ''; + const quip = s.hecate_quip ? `
"${_escapeHtml(s.hecate_quip)}"
` : ''; + return ` +
+ ${img} +
+ ${_escapeHtml(s.recipe_name)} + ${fitDots} +
+ ${metaLine} + ${quip} +
${_escapeHtml(s.reason || '')}
+
${_escapeHtml(lastSeen)}
+
+ + +
+
+ `; +} + +async function askHecate(btn, count) { + const meta = document.getElementById('suggest-meta'); + const results = document.getElementById('suggest-results'); + const original = btn.textContent; + btn.disabled = true; + btn.textContent = '🔮 consulting…'; + if (meta) meta.textContent = 'hecate is reading the threads — ~10s'; + results.innerHTML = ''; + try { + const r = await fetch('/api/plan/suggest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ week: PLAN_WEEK, count: count }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.detail || data.error || r.status); + const list = data.suggestions || []; + if (!list.length) { + results.innerHTML = `

${_escapeHtml(data.detail || 'no eligible recipes — every recipe in your library has been planned recently or is already on this week.')}

`; + } else { + results.innerHTML = list.map(_renderSuggestion).join(''); + } + if (meta) meta.textContent = `hecate offered ${list.length} gem${list.length === 1 ? '' : 's'}`; + } catch (e) { + if (meta) meta.textContent = 'failed: ' + e.message; + } finally { + btn.disabled = false; + btn.textContent = original; + } +} + +async function pinSuggestion(btn, slug) { + btn.disabled = true; + const original = btn.textContent; + btn.textContent = '…'; + try { + const r = await fetch('/api/plan/suggest/pin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipe_slug: slug }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.detail || data.error || r.status); + btn.textContent = data.added ? '✓ pinned' : '✓ already pinned'; + btn.classList.remove('btn-purple'); + // Leave the card visible so user can see it pinned, but disable further pinning + } catch (e) { + btn.disabled = false; + btn.textContent = original; + alert('pin failed: ' + e.message); + } +} {% endblock %}