diff --git a/cauldron/db.py b/cauldron/db.py index 123f818..9b9329a 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -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( diff --git a/cauldron/forge.py b/cauldron/forge.py index 31d37ad..c3a127f 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -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}" diff --git a/cauldron/server.py b/cauldron/server.py index edaa68e..dda48b5 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -1113,6 +1113,10 @@ def create_app() -> Flask: eligible: list[dict] = [] today = date.today() + # Library entries get prefixed slugs ("lib:") so they can + # coexist with discover entries ("disc:") 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:"; 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 diff --git a/cauldron/templates/plan.html b/cauldron/templates/plan.html index 410fcd4..173de7b 100644 --- a/cauldron/templates/plan.html +++ b/cauldron/templates/plan.html @@ -468,11 +468,13 @@

forgotten gems

- recipes you haven't seen in 90+ days + your library + new finds from discover

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.

@@ -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 ? `
${metaBits.join(' · ')}
` : ''; - 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 ? `` : ''; const quip = s.hecate_quip ? `
"${_escapeHtml(s.hecate_quip)}"
` : ''; + // 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 = `
📬 from Discover · ${_escapeHtml(host)}
`; + } else { + const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household'; + footer = `
${_escapeHtml(lastSeen)}
`; + } + // 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 + ? `${_escapeHtml(s.recipe_name)}` + : `${_escapeHtml(s.recipe_name)}`; + const pinLabel = isDiscover ? '📌 import + pin' : '📌 pin to picks'; return ` -
+
${img}
- ${_escapeHtml(s.recipe_name)} + ${nameHtml} ${fitDots}
${metaLine} ${quip}
${_escapeHtml(s.reason || '')}
-
${_escapeHtml(lastSeen)}
+ ${footer}
- +
@@ -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) {