plan: Flavor B — 🔮 forgotten gems pulls from Discover too

The suggestion panel now folds enriched-but-unimported Discover entries
into the same Hecate-driven pool as library recipes. Pinning a discover
suggestion auto-imports it to the household's Mealie library and adds
the resulting slug to cauldron_meal_picks.

- db: list_discover_eligible_for_group(mealie_group_id, limit=80) — joins
  cauldron_discovered_recipes against cauldron_discover_imports to find
  enriched rows no household in the caller's group has imported yet
- forge.suggest_recipes: accepts a per-entry `source` field; when any
  entry is source='discover' the prompt gains an explicit hint so Hecate
  treats discover entries as "something new to try" with a soft cap on
  proportion (~half max unless library exhausted)
- /api/plan/suggest: builds a unified pool with prefixed IDs (lib:<slug>
  vs disc:<id>) so library and discover entries can coexist in Sonnet's
  validation map; decorates each suggestion with kind+image+meta_summary+
  hecate_quip; discover entries also carry source_url and source_host
- /api/plan/suggest/pin: new dispatch on body.kind:
  - kind=library (default — back-compat with existing Flavor A callers):
    same as before
  - kind=discover: looks up the discover row, short-circuits to cached
    Mealie slug if THIS household already imported it, else
    mealie.import_from_url() + record_discover_import() + add to picks.
    Returns {kind, mealie_slug, imported, added_to_picks}
- plan.html: card render is kind-aware. Discover entries get a
  "📬 from Discover · <host>" footer instead of last-planned recall, an
  "import + pin" button label, and use data-* attributes for the click
  payload so the JS doesn't need to template-interpolate slugs into
  onclick handlers
This commit is contained in:
Kayos 2026-05-01 20:57:44 -07:00
parent b41c93e559
commit 8752fcd340
4 changed files with 285 additions and 35 deletions

View file

@ -2338,6 +2338,46 @@ class DB:
row["imported_at"] = row["imported_at"].isoformat() row["imported_at"] = row["imported_at"].isoformat()
return row return row
def list_discover_eligible_for_group(
self, *, mealie_group_id: str | None, limit: int = 100
) -> list[dict]:
"""Return enriched discover rows that no household in the given Mealie
group has imported yet. Used by Flavor B's pool builder — these are
the 'new gems' Hecate can suggest alongside library recipes.
If `mealie_group_id` is None we still return the unimported global
list (no group context yet first-boot edge case)."""
with self.conn() as c, c.cursor() as cur:
if mealie_group_id:
cur.execute(
"""SELECT d.id, d.slug, d.source_url, d.name, d.description,
d.image_url, d.meta_json
FROM cauldron_discovered_recipes d
LEFT JOIN cauldron_discover_imports i
ON i.discover_id = d.id
LEFT JOIN cauldron_households h
ON h.id = i.household_id
AND h.mealie_group_id = %s
WHERE d.status = 'enriched'
AND h.id IS NULL
ORDER BY d.scraped_at DESC
LIMIT %s""",
(mealie_group_id, int(limit)),
)
else:
cur.execute(
"""SELECT d.id, d.slug, d.source_url, d.name, d.description,
d.image_url, d.meta_json
FROM cauldron_discovered_recipes d
LEFT JOIN cauldron_discover_imports i ON i.discover_id = d.id
WHERE d.status = 'enriched'
AND i.discover_id IS NULL
ORDER BY d.scraped_at DESC
LIMIT %s""",
(int(limit),),
)
return list(cur.fetchall() or [])
def get_discovered_recipe(self, discover_id: int) -> dict | None: def get_discovered_recipe(self, discover_id: int) -> dict | None:
with self.conn() as c, c.cursor() as cur: with self.conn() as c, c.cursor() as cur:
cur.execute( cur.execute(

View file

@ -983,11 +983,19 @@ class Forge:
if isinstance(fit, dict) and fit: if isinstance(fit, dict) and fit:
fit_str = ",".join(f"{n}:{s}" for n, s in fit.items()) fit_str = ",".join(f"{n}:{s}" for n, s in fit.items())
extras.append(f"fit:{fit_str}") extras.append(f"fit:{fit_str}")
# Source hint — Flavor B passes "library" or "discover" so Hecate
# can reason about whether an entry is already in the household's
# Mealie library (free pick) vs scraped from the open web (would
# need to be imported on pin).
src = r.get("source")
if src:
extras.append(f"source:{src}")
line = f"- {slug} | {name}" line = f"- {slug} | {name}"
if extras: if extras:
line += f" [{' · '.join(extras)}]" line += f" [{' · '.join(extras)}]"
pool_lines.append(line) pool_lines.append(line)
pool_block = "\n".join(pool_lines) pool_block = "\n".join(pool_lines)
has_discover = any(r.get("source") == "discover" for r in eligible_pool)
# Picker profile block (same shape as planner uses, abbreviated) # Picker profile block (same shape as planner uses, abbreviated)
profile_block = "" profile_block = ""
@ -1025,13 +1033,25 @@ class Forge:
if in_plan_slugs: 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" 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"
discover_hint = (
"\nNOTE: some entries below have `source:discover` — those are "
"RECIPES NEW TO THIS HOUSEHOLD, scraped from the open web and "
"enriched but not yet imported to the family's Mealie library. "
"Treat them as 'something new to try' — bias toward them when "
"the family's history is heavy on the same handful of cuisines/"
"proteins, but don't overdo it (max ~half the picks should be "
"discover-source unless the library is exhausted). Pinning a "
"discover entry will trigger an automatic import.\n"
if has_discover else ""
)
prompt = ( prompt = (
"You are Hecate, a Greek-mythology witch goddess of crossroads, " "You are Hecate, a Greek-mythology witch goddess of crossroads, "
"herbs, and magic — and the family's meal planner. The household " "herbs, and magic — and the family's meal planner. The household "
"asked you to look through their recipe library and surface " "asked you to look through their recipe library and surface "
"'forgotten gems': recipes that fit who they are but haven't " "'forgotten gems': recipes that fit who they are but haven't "
"been served recently (or ever).\n\n" "been served recently (or ever).\n\n"
f"This is for the week of {week_start}.\n\n" f"This is for the week of {week_start}.\n"
f"{discover_hint}\n"
f"ELIGIBLE POOL (already filtered: nothing seen in the last 90 days, " 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"nothing already on this week's plan):\n{pool_block}\n"
f"{profile_block}" f"{profile_block}"

View file

@ -1113,6 +1113,10 @@ def create_app() -> Flask:
eligible: list[dict] = [] eligible: list[dict] = []
today = date.today() today = date.today()
# Library entries get prefixed slugs ("lib:<slug>") so they can
# coexist with discover entries ("disc:<id>") in the same Sonnet
# pool without collision. forge.suggest_recipes only needs the
# slug to be unique; the prefix is server-side bookkeeping.
for r in rows: for r in rows:
slug = r["slug"] slug = r["slug"]
if slug in in_plan_slugs: if slug in in_plan_slugs:
@ -1140,10 +1144,11 @@ def create_app() -> Flask:
if any(contains.get(e) for e in excl_set): if any(contains.get(e) for e in excl_set):
continue continue
entry = { entry = {
"slug": slug, "slug": f"lib:{slug}",
"name": r["name"], "name": r["name"],
"meta": m, "meta": m,
"history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {}, "history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {},
"source": "library",
} }
# Per-user fit (same scoring as planner) # Per-user fit (same scoring as planner)
fit_scores: dict[str, int] = {} fit_scores: dict[str, int] = {}
@ -1158,6 +1163,52 @@ def create_app() -> Flask:
entry["fit"] = fit_scores entry["fit"] = fit_scores
eligible.append(entry) eligible.append(entry)
# Flavor B — also pull from Discover for "things you've never had"
# variety. Group-aware: skip rows ANY household in this group has
# already imported (since group-shared read makes them visible to
# everyone). Tag entries with source='discover' so Hecate's prompt
# treats them differently and the pin endpoint dispatches to
# import-then-pick.
my_household = db.get_household(hid)
my_group_id = (my_household or {}).get("mealie_group_id")
discover_rows = db.list_discover_eligible_for_group(
mealie_group_id=my_group_id, limit=80
)
discover_by_id: dict[int, dict] = {}
for d in discover_rows:
d_meta = d.get("meta_json")
if isinstance(d_meta, str):
try:
d_meta = _json_loads(d_meta)
except Exception:
d_meta = None
if not isinstance(d_meta, dict):
continue # Hecate needs meta to score fit — skip un-enriched
if excl_set:
contains = d_meta.get("contains") or {}
if any(contains.get(e) for e in excl_set):
continue
d_id = d["id"]
discover_by_id[d_id] = {**d, "meta_json": d_meta}
entry = {
"slug": f"disc:{d_id}",
"name": d.get("name") or d.get("slug") or f"discover-{d_id}",
"meta": d_meta,
"history": {}, # never-planned by definition
"source": "discover",
}
fit_scores: dict[str, int] = {}
for sub, prof in (picker_profiles or {}).items():
if not isinstance(prof, dict):
continue
score = _compute_fit_score(d_meta, 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: if not eligible:
return jsonify({ return jsonify({
"suggestions": [], "suggestions": [],
@ -1188,11 +1239,47 @@ def create_app() -> Flask:
return jsonify({"error": "forge_failed", "detail": str(e)}), 502 return jsonify({"error": "forge_failed", "detail": str(e)}), 502
# Decorate with image_url + a tiny meta summary for the UI cards. # 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). # Each suggestion's `recipe_slug` is prefixed (lib: or disc:) — split
# on the prefix to figure out where to source the display data.
meta_summary_keys = ("cuisine", "complexity", "estimated_minutes", "primary_protein", "comfort_tier") meta_summary_keys = ("cuisine", "complexity", "estimated_minutes", "primary_protein", "comfort_tier")
rows_by_slug = {r["slug"]: r for r in rows} rows_by_slug = {r["slug"]: r for r in rows}
decorated = []
for s in suggestions: for s in suggestions:
slug = s["recipe_slug"] prefixed = s.get("recipe_slug") or ""
if prefixed.startswith("disc:"):
# Discover entry — image + meta come from cauldron_discovered_recipes
try:
d_id = int(prefixed[5:])
except ValueError:
continue
d = discover_by_id.get(d_id)
if not d:
continue
d_meta = d.get("meta_json") or {}
source_url = d.get("source_url") or ""
source_host = ""
if source_url:
try:
from urllib.parse import urlparse
source_host = urlparse(source_url).hostname or ""
except Exception:
source_host = ""
decorated.append({
"kind": "discover",
"discover_id": d_id,
"recipe_name": d.get("name") or s.get("recipe_name"),
"fit_score": s.get("fit_score"),
"reason": s.get("reason"),
"image_url": d.get("image_url"),
"source_url": source_url,
"source_host": source_host,
"meta_summary": {k: d_meta.get(k) for k in meta_summary_keys if d_meta.get(k)},
"hecate_quip": d_meta.get("hecate_quip") or "",
"last_planned": None,
})
else:
# Library entry — prefixed as "lib:<slug>"; strip and look up.
slug = prefixed[4:] if prefixed.startswith("lib:") else prefixed
m = meta_by_slug.get(slug) or {} m = meta_by_slug.get(slug) or {}
r = rows_by_slug.get(slug) or {} r = rows_by_slug.get(slug) or {}
img = None img = None
@ -1204,26 +1291,89 @@ def create_app() -> Flask:
raw = None raw = None
if isinstance(raw, dict): if isinstance(raw, dict):
img = raw.get("image") or raw.get("imageUrl") 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 {} h = history_by_slug.get(slug) or {}
last_p = h.get("last_planned") last_p = h.get("last_planned")
s["last_planned"] = last_p.isoformat() if last_p is not None else None decorated.append({
"kind": "library",
"recipe_slug": slug,
"recipe_name": s.get("recipe_name") or r.get("name") or slug,
"fit_score": s.get("fit_score"),
"reason": s.get("reason"),
"image_url": img,
"meta_summary": {k: m.get(k) for k in meta_summary_keys if m.get(k)},
"hecate_quip": m.get("hecate_quip") or "",
"last_planned": last_p.isoformat() if last_p is not None else None,
})
return jsonify({"suggestions": suggestions}) return jsonify({"suggestions": decorated})
@app.post("/api/plan/suggest/pin") @app.post("/api/plan/suggest/pin")
@require_session @require_session
def plan_suggest_pin(): def plan_suggest_pin():
"""Pin a suggested recipe → adds to cauldron_meal_picks for the """Pin a suggested recipe → adds to cauldron_meal_picks for the
session user. The next /api/plan/generate will naturally pull it session user. Two kinds of suggestions:
in as one of that user's picks. Body: {recipe_slug, recipe_name?}."""
- kind=library (default): {recipe_slug} recipe must already be
in the household's Mealie library (indexed). Direct pin.
- kind=discover: {discover_id} recipe lives in the discover
corpus only. Imports via Mealie's create-from-url first, records
the import to cauldron_discover_imports, then pins the resulting
Mealie slug.
Back-compat: a body with only {recipe_slug} (no kind) is treated
as kind=library so existing Flavor A callers keep working."""
u = session["user"] u = session["user"]
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
return jsonify({"error": "no household"}), 409 return jsonify({"error": "no household"}), 409
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
kind = (body.get("kind") or "library").strip().lower()
if kind == "discover":
try:
discover_id = int(body.get("discover_id"))
except (TypeError, ValueError):
return jsonify({"error": "missing discover_id"}), 400
row = db.get_discovered_recipe(discover_id)
if not row:
return jsonify({"error": "discover_not_found"}), 404
if row.get("status") == "rejected":
return jsonify({"error": "row_rejected"}), 409
# If THIS household has already imported this discover row we
# short-circuit to its existing Mealie slug — no double-import,
# no Mealie 'Cookies (1)' duplicate.
existing = db.discover_imported_by_household(
discover_id=discover_id, household_id=hid
)
if existing:
new_slug = existing["mealie_slug"]
else:
client = current_user_mealie()
if client is None:
return jsonify({"error": "mealie_not_connected"}), 409
try:
new_slug = client.import_from_url(row["source_url"])
except MealieError as e:
return jsonify({"error": "mealie_import_failed", "detail": str(e)[:300]}), 502
db.record_discover_import(
discover_id=discover_id,
household_id=hid,
mealie_slug=new_slug,
imported_by_sub=u["sub"],
)
display_name = row.get("name") or new_slug
added = db.add_meal_pick(u["sub"], new_slug, display_name)
return jsonify({
"ok": True,
"kind": "discover",
"mealie_slug": new_slug,
"imported": existing is None,
"added_to_picks": added,
})
# kind = library (default)
slug = (body.get("recipe_slug") or "").strip() slug = (body.get("recipe_slug") or "").strip()
if not slug: if not slug:
return jsonify({"error": "missing recipe_slug"}), 400 return jsonify({"error": "missing recipe_slug"}), 400
@ -1233,7 +1383,12 @@ def create_app() -> Flask:
if not idx: if not idx:
return jsonify({"error": "recipe_not_indexed"}), 404 return jsonify({"error": "recipe_not_indexed"}), 404
added = db.add_meal_pick(u["sub"], slug, idx.get("name") or slug) added = db.add_meal_pick(u["sub"], slug, idx.get("name") or slug)
return jsonify({"ok": True, "added": added}) return jsonify({
"ok": True,
"kind": "library",
"recipe_slug": slug,
"added_to_picks": added,
})
@app.get("/list") @app.get("/list")
@require_session @require_session

View file

@ -468,11 +468,13 @@
<section class="panel" id="suggest-panel"> <section class="panel" id="suggest-panel">
<div class="panel-head"> <div class="panel-head">
<h2>forgotten gems</h2> <h2>forgotten gems</h2>
<span class="ctx">recipes you haven't seen in 90+ days</span> <span class="ctx">your library + new finds from discover</span>
</div> </div>
<p style="margin: 4px 0 12px 0; opacity: 0.8;"> <p style="margin: 4px 0 12px 0; opacity: 0.8;">
let hecate look through your library for recipes that fit who you are let hecate look through your library for recipes that fit who you are
but haven't crossed your table recently. but haven't crossed your table recently — and pull in fresh enriched
finds from /discover the family hasn't imported yet. pinning a discover
pick auto-imports it to your mealie household.
</p> </p>
<div class="btn-row"> <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 btn-purple" type="button" id="suggest-btn" onclick="askHecate(this, 3)">🔮 suggest 3 gems</button>
@ -691,22 +693,43 @@ function _renderSuggestion(s) {
if (meta.primary_protein) metaBits.push('protein: ' + _escapeHtml(meta.primary_protein)); if (meta.primary_protein) metaBits.push('protein: ' + _escapeHtml(meta.primary_protein));
if (meta.comfort_tier) metaBits.push('comfort: ' + _escapeHtml(meta.comfort_tier)); 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 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 isDiscover = s.kind === 'discover';
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 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>` : ''; const quip = s.hecate_quip ? `<div style="font-style: italic; opacity: 0.85; margin: 6px 0;">"${_escapeHtml(s.hecate_quip)}"</div>` : '';
// Source-aware footer line: for library entries, show last-planned recall;
// for discover entries, show source host so the user knows where it came from.
let footer;
if (isDiscover) {
const host = s.source_host || 'web';
footer = `<div class="ctx" style="margin: 4px 0 8px 0; color: var(--purple-bright);">📬 from Discover · ${_escapeHtml(host)}</div>`;
} else {
const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household';
footer = `<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div>`;
}
// Identifier used for the pin click + DOM data attr. Library uses recipe_slug;
// discover uses discover_id (passed back to the pin endpoint as kind='discover').
const ident = isDiscover ? `disc-${s.discover_id}` : (s.recipe_slug || '');
const pinAttr = isDiscover
? `data-kind="discover" data-discover-id="${s.discover_id}"`
: `data-kind="library" data-slug="${_escapeHtml(s.recipe_slug)}"`;
// Discover entries don't have a Mealie page yet → no clickable name link.
const nameHtml = isDiscover
? `<span class="rname" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</span>`
: `<a class="rname" href="/recipes/${_escapeHtml(s.recipe_slug)}" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</a>`;
const pinLabel = isDiscover ? '📌 import + pin' : '📌 pin to picks';
return ` return `
<div class="day-card" data-slug="${_escapeHtml(s.recipe_slug)}" style="margin-bottom: 12px;"> <div class="day-card" data-ident="${_escapeHtml(ident)}" style="margin-bottom: 12px;">
${img} ${img}
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 8px;"> <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> ${nameHtml}
<span title="hecate's fit score (1-5)" style="font-size: 0.85em; opacity: 0.75; white-space: nowrap;">${fitDots}</span> <span title="hecate's fit score (1-5)" style="font-size: 0.85em; opacity: 0.75; white-space: nowrap;">${fitDots}</span>
</div> </div>
${metaLine} ${metaLine}
${quip} ${quip}
<div style="margin: 6px 0; opacity: 0.9;">${_escapeHtml(s.reason || '')}</div> <div style="margin: 6px 0; opacity: 0.9;">${_escapeHtml(s.reason || '')}</div>
<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div> ${footer}
<div class="btn-row"> <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 btn-purple" type="button" ${pinAttr} onclick="pinSuggestion(this)">${pinLabel}</button>
<button class="btn" type="button" onclick="this.closest('.day-card').style.display='none'">✗ skip</button> <button class="btn" type="button" onclick="this.closest('.day-card').style.display='none'">✗ skip</button>
</div> </div>
</div> </div>
@ -744,19 +767,31 @@ async function askHecate(btn, count) {
} }
} }
async function pinSuggestion(btn, slug) { async function pinSuggestion(btn) {
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
const kind = btn.dataset.kind || 'library';
let body;
if (kind === 'discover') {
btn.textContent = 'importing…';
body = { kind: 'discover', discover_id: parseInt(btn.dataset.discoverId, 10) };
} else {
btn.textContent = '…'; btn.textContent = '…';
body = { kind: 'library', recipe_slug: btn.dataset.slug };
}
try { try {
const r = await fetch('/api/plan/suggest/pin', { const r = await fetch('/api/plan/suggest/pin', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_slug: slug }), body: JSON.stringify(body),
}); });
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || data.error || r.status); if (!r.ok) throw new Error(data.detail || data.error || r.status);
btn.textContent = data.added ? '✓ pinned' : '✓ already pinned'; if (data.kind === 'discover') {
btn.textContent = data.imported ? '✓ imported & pinned' : '✓ already in library — pinned';
} else {
btn.textContent = data.added_to_picks ? '✓ pinned' : '✓ already pinned';
}
btn.classList.remove('btn-purple'); btn.classList.remove('btn-purple');
// Leave the card visible so user can see it pinned, but disable further pinning // Leave the card visible so user can see it pinned, but disable further pinning
} catch (e) { } catch (e) {