plan: Flavor A — 🔮 suggest forgotten gems

Hecate looks through the household's library and surfaces 1-6 recipes
that fit the picker profiles but haven't been served in 90+ days (or
ever). Server-side filters drop recently-planned + already-on-this-week
+ already-on-picks-list + allergen-conflict before Sonnet sees the pool,
keeping the prompt focused. Each suggestion comes with a 1-5 fit score
and a one-line reason in Hecate's voice. Pin → adds to the user's picks
so the next /plan/generate will naturally pull it in. Skip → hides the
card.

Endpoints:
  POST /api/plan/suggest        body {count?: 1-6, week?: ISO}
  POST /api/plan/suggest/pin    body {recipe_slug}

Layered on top of 37d7d60 (parser fix, committed not deployed). Both
should land together once enrich job 3 drains.
This commit is contained in:
Kayos 2026-05-01 00:07:03 -07:00
parent 37d7d60a8b
commit d561a9373e
4 changed files with 509 additions and 0 deletions

View file

@ -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]:

View file

@ -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": "<one-line in Hecate's voice>"}]
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

View file

@ -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():

View file

@ -465,6 +465,23 @@
</section>
{% endif %}
<section class="panel" id="suggest-panel">
<div class="panel-head">
<h2>forgotten gems</h2>
<span class="ctx">recipes you haven't seen in 90+ days</span>
</div>
<p style="margin: 4px 0 12px 0; opacity: 0.8;">
let hecate look through your library for recipes that fit who you are
but haven't crossed your table recently.
</p>
<div class="btn-row">
<button class="btn btn-purple" type="button" id="suggest-btn" onclick="askHecate(this, 3)">🔮 suggest 3 gems</button>
<button class="btn" type="button" onclick="askHecate(this, 5)">🔮 suggest 5</button>
</div>
<div class="gen-meta" id="suggest-meta" style="margin-top: 6px;">hecate · ~10s</div>
<div id="suggest-results" style="margin-top: 14px;"></div>
</section>
{% if plan.slots %}
<section class="panel purple">
<div class="panel-head">
@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 ? `<div class="ctx" style="margin: 4px 0;">${metaBits.join(' · ')}</div>` : '';
const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household';
const img = s.image_url ? `<img src="${_escapeHtml(s.image_url)}" alt="" style="width: 100%; max-height: 140px; object-fit: cover; border-radius: 6px; margin-bottom: 8px;">` : '';
const quip = s.hecate_quip ? `<div style="font-style: italic; opacity: 0.85; margin: 6px 0;">"${_escapeHtml(s.hecate_quip)}"</div>` : '';
return `
<div class="day-card" data-slug="${_escapeHtml(s.recipe_slug)}" style="margin-bottom: 12px;">
${img}
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 8px;">
<a class="rname" href="/recipes/${_escapeHtml(s.recipe_slug)}" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</a>
<span title="hecate's fit score (1-5)" style="font-size: 0.85em; opacity: 0.75; white-space: nowrap;">${fitDots}</span>
</div>
${metaLine}
${quip}
<div style="margin: 6px 0; opacity: 0.9;">${_escapeHtml(s.reason || '')}</div>
<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div>
<div class="btn-row">
<button class="btn btn-purple" type="button" onclick="pinSuggestion(this, '${_escapeHtml(s.recipe_slug)}')">📌 pin to picks</button>
<button class="btn" type="button" onclick="this.closest('.day-card').style.display='none'">✗ skip</button>
</div>
</div>
`;
}
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 = `<p style="opacity: 0.85;">${_escapeHtml(data.detail || 'no eligible recipes — every recipe in your library has been planned recently or is already on this week.')}</p>`;
} 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);
}
}
</script>
{% endblock %}