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:
parent
37d7d60a8b
commit
d561a9373e
4 changed files with 509 additions and 0 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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, '&').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 ? `<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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue