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()
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:
with self.conn() as c, c.cursor() as cur:
cur.execute(

View file

@ -983,11 +983,19 @@ class Forge:
if isinstance(fit, dict) and fit:
fit_str = ",".join(f"{n}:{s}" for n, s in fit.items())
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}"
if extras:
line += f" [{' · '.join(extras)}]"
pool_lines.append(line)
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)
profile_block = ""
@ -1025,13 +1033,25 @@ class Forge:
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"
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 = (
"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"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"nothing already on this week's plan):\n{pool_block}\n"
f"{profile_block}"

View file

@ -1113,6 +1113,10 @@ def create_app() -> Flask:
eligible: list[dict] = []
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:
slug = r["slug"]
if slug in in_plan_slugs:
@ -1140,10 +1144,11 @@ def create_app() -> Flask:
if any(contains.get(e) for e in excl_set):
continue
entry = {
"slug": slug,
"slug": f"lib:{slug}",
"name": r["name"],
"meta": m,
"history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {},
"source": "library",
}
# Per-user fit (same scoring as planner)
fit_scores: dict[str, int] = {}
@ -1158,6 +1163,52 @@ def create_app() -> Flask:
entry["fit"] = fit_scores
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:
return jsonify({
"suggestions": [],
@ -1188,42 +1239,141 @@ def create_app() -> Flask:
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).
# 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")
rows_by_slug = {r["slug"]: r for r in rows}
decorated = []
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):
prefixed = s.get("recipe_slug") or ""
if prefixed.startswith("disc:"):
# Discover entry — image + meta come from cauldron_discovered_recipes
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
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 {}
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")
h = history_by_slug.get(slug) or {}
last_p = h.get("last_planned")
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")
@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?}."""
session user. Two kinds of suggestions:
- 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"]
hid = current_household_id()
if not hid:
return jsonify({"error": "no household"}), 409
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()
if not slug:
return jsonify({"error": "missing recipe_slug"}), 400
@ -1233,7 +1383,12 @@ def create_app() -> Flask:
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})
return jsonify({
"ok": True,
"kind": "library",
"recipe_slug": slug,
"added_to_picks": added,
})
@app.get("/list")
@require_session

View file

@ -468,11 +468,13 @@
<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>
<span class="ctx">your library + new finds from discover</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.
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>
<div class="btn-row">
<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.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 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 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 `
<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}
<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>
</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>
${footer}
<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>
</div>
</div>
@ -744,19 +767,31 @@ async function askHecate(btn, count) {
}
}
async function pinSuggestion(btn, slug) {
async function pinSuggestion(btn) {
btn.disabled = true;
const original = btn.textContent;
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 = '…';
body = { kind: 'library', recipe_slug: btn.dataset.slug };
}
try {
const r = await fetch('/api/plan/suggest/pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_slug: slug }),
body: JSON.stringify(body),
});
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';
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');
// Leave the card visible so user can see it pinned, but disable further pinning
} catch (e) {