plan: multi-meal slots (breakfast/lunch/dinner) + rename agent → Sage
Big plan-generator addition: each plan can now span multiple meal types,
not just dinner. Default stays dinner-only for back-compat; opt-in via
checkboxes on /plan.
Schema (migrations 028-031):
- cauldron_meal_plan_slots gains meal_type ENUM('breakfast','lunch',
'dinner','snack','dessert','side') NOT NULL DEFAULT 'dinner'.
- Old UNIQUE key (plan_id, day) → (plan_id, day, meal_type) so a
Monday can have breakfast AND lunch AND dinner slots.
- cauldron_meal_plans gains meal_types_json (which meals to plan
for that week — list of strings, defaults to ["dinner"]).
Forge:
- generate_plan accepts meal_types list. Output schema gains meal_type
per slot. Validates expected_total = slots * len(meal_types) and
rejects duplicate (day, meal_type) pairs.
- _build_plan_prompt renders MEAL TYPES TO PLAN block, instructing
Sonnet to match recipe meta.meal_type to slot type (breakfast slot
→ recipe whose meta tags it as breakfast). Falls back gracefully
when the pool is thin for a particular meal type.
Server:
- /api/plan/generate + regenerate accept body.meal_types, persist via
db.set_plan_meal_types.
- plan_view decodes meal_types_json into plan["meal_types_list"] and
builds plan["meal_types_label"] for the readout.
UI (/plan):
- New checkbox row at the top of the pref-block: 🍳 breakfast / 🥪 lunch
/ 🍽️ dinner. Defaults to whatever's persisted (or just dinner).
- Day cards now group multiple meal_type slots per day with small
meal-type tags above each recipe row. Single-meal plans render the
same way they always did (no tag shown when only one meal_type).
- readMealTypes() in JS reads checkboxes and ships in the body.
DB:
- save_plan_slots accepts meal_type per slot, defaults to 'dinner'.
- list_plan_slots orders by day then meal_type via MEAL_ORDER.
==
UX rename: "claude" / "sonnet" → "Sage" across all user-visible copy.
Sage doubles as kitchen-herb (theme fit) and wise advisor (planner
role). The internal field name `sonnet_decision` on consolidate +
dedupe proposals is unchanged (it's a data field, not user-facing).
Renames touched plan, consolidate, dedupe_recipes, list, me,
enrich_recipes, sterilize templates. Cobb can swap to Mim or his own
name later — easy global s/sage/whatever/g.
==
The /list 'clear' button removed earlier today (b4cb48b) — not
re-introduced.
This commit is contained in:
parent
b4cb48bef8
commit
a6a28ef6e4
9 changed files with 260 additions and 57 deletions
|
|
@ -470,6 +470,33 @@ MIGRATIONS = [
|
|||
ALTER TABLE cauldron_meal_plans
|
||||
ADD COLUMN IF NOT EXISTS exclusions_json JSON
|
||||
""",
|
||||
# 028 — Multi-meal planning. Slots gain meal_type so a single plan
|
||||
# row can hold breakfast + lunch + dinner across the same 7 days.
|
||||
# Default 'dinner' so existing rows keep their semantics.
|
||||
"""
|
||||
ALTER TABLE cauldron_meal_plan_slots
|
||||
ADD COLUMN IF NOT EXISTS meal_type
|
||||
ENUM('breakfast','lunch','dinner','snack','dessert','side')
|
||||
NOT NULL DEFAULT 'dinner'
|
||||
""",
|
||||
# 029 — Old unique key was (plan_id, day) — now needs to include
|
||||
# meal_type so a Monday can have breakfast AND lunch AND dinner.
|
||||
# Drop-and-add, idempotent: catch the "doesn't exist" if already done.
|
||||
"""
|
||||
ALTER TABLE cauldron_meal_plan_slots
|
||||
DROP INDEX uk_plan_day
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE cauldron_meal_plan_slots
|
||||
ADD UNIQUE KEY uk_plan_day_meal (plan_id, day, meal_type)
|
||||
""",
|
||||
# 030 — Per-plan selection of which meal types to generate. List of
|
||||
# strings: ["breakfast","lunch","dinner"]. Default dinner-only so
|
||||
# generation is back-compat for users who haven't opted in.
|
||||
"""
|
||||
ALTER TABLE cauldron_meal_plans
|
||||
ADD COLUMN IF NOT EXISTS meal_types_json JSON
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -677,6 +704,27 @@ class DB:
|
|||
(clean, plan_id),
|
||||
)
|
||||
|
||||
def set_plan_meal_types(self, plan_id: int, meal_types: list | None) -> None:
|
||||
"""Persist which meal types this plan should generate. None / empty
|
||||
list defaults to ['dinner'] at generation time."""
|
||||
import json as _json
|
||||
clean: list | None = None
|
||||
if isinstance(meal_types, list) and meal_types:
|
||||
allowed = set(self.MEAL_ORDER)
|
||||
ce = []
|
||||
for m in meal_types:
|
||||
if isinstance(m, str) and m.strip().lower() in allowed:
|
||||
ce.append(m.strip().lower())
|
||||
# Preserve order: breakfast, lunch, dinner, snack, dessert, side
|
||||
order = {m: i for i, m in enumerate(self.MEAL_ORDER)}
|
||||
ce = sorted(set(ce), key=lambda m: order[m])
|
||||
clean = ce or None
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE cauldron_meal_plans SET meal_types_json=%s WHERE id=%s",
|
||||
(_json.dumps(clean) if clean else None, plan_id),
|
||||
)
|
||||
|
||||
def set_plan_targets_and_exclusions(
|
||||
self,
|
||||
plan_id: int,
|
||||
|
|
@ -779,14 +827,16 @@ class DB:
|
|||
# in calendar order.
|
||||
PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
||||
|
||||
MEAL_ORDER = ("breakfast", "lunch", "dinner", "snack", "dessert", "side")
|
||||
|
||||
def list_plan_slots(self, plan_id: int) -> list[dict]:
|
||||
"""All slots for a plan, ordered Mon..Sun. picker_subs is decoded
|
||||
from JSON to a list (or [] if null)."""
|
||||
"""All slots for a plan, ordered Mon..Sun then breakfast→dinner.
|
||||
picker_subs is decoded from JSON to a list (or [] if null)."""
|
||||
import json as _json
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, plan_id, day, recipe_slug, recipe_name, source,
|
||||
SELECT id, plan_id, day, meal_type, recipe_slug, recipe_name, source,
|
||||
picker_subs, reason, notes, created_at
|
||||
FROM cauldron_meal_plan_slots
|
||||
WHERE plan_id = %s
|
||||
|
|
@ -809,30 +859,39 @@ class DB:
|
|||
r["notes"] = _json.loads(n)
|
||||
except Exception:
|
||||
r["notes"] = None
|
||||
order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
|
||||
rows.sort(key=lambda r: order.get((r.get("day") or "").lower(), 99))
|
||||
day_order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
|
||||
meal_order = {m: i for i, m in enumerate(self.MEAL_ORDER)}
|
||||
rows.sort(key=lambda r: (
|
||||
day_order.get((r.get("day") or "").lower(), 99),
|
||||
meal_order.get((r.get("meal_type") or "dinner").lower(), 99),
|
||||
))
|
||||
return rows
|
||||
|
||||
def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int:
|
||||
"""INSERT IGNORE every slot. Returns count actually inserted —
|
||||
callers can use this to detect race contention (zero rows = someone
|
||||
else already saved this plan)."""
|
||||
else already saved this plan). Each slot must carry a meal_type
|
||||
(defaults to 'dinner' for back-compat)."""
|
||||
import json as _json
|
||||
if not slots:
|
||||
return 0
|
||||
inserted = 0
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
for s in slots:
|
||||
meal = (s.get("meal_type") or "dinner").lower()
|
||||
if meal not in self.MEAL_ORDER:
|
||||
meal = "dinner"
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT IGNORE INTO cauldron_meal_plan_slots
|
||||
(plan_id, day, recipe_slug, recipe_name, source,
|
||||
picker_subs, reason, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(plan_id, day, meal_type, recipe_slug, recipe_name,
|
||||
source, picker_subs, reason, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
plan_id,
|
||||
(s.get("day") or "").lower()[:10],
|
||||
meal,
|
||||
s["recipe_slug"][:255],
|
||||
(s.get("recipe_name") or s["recipe_slug"])[:500],
|
||||
s.get("source") or ("pick" if s.get("picker_subs") else "mealie"),
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class Forge:
|
|||
picker_profiles: dict | None = None,
|
||||
daily_targets: dict | None = None,
|
||||
exclusions: list | None = None,
|
||||
meal_types: list | None = None,
|
||||
model: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||
|
|
@ -111,17 +112,37 @@ class Forge:
|
|||
if slug:
|
||||
valid_by_slug.setdefault(slug, p.get("name") or slug)
|
||||
|
||||
# Default to dinner-only if not specified — back-compat with the
|
||||
# original single-meal-per-day behavior.
|
||||
meal_types_clean: list[str] = []
|
||||
allowed_meals = {"breakfast", "lunch", "dinner", "snack", "dessert", "side"}
|
||||
if isinstance(meal_types, list):
|
||||
for m in meal_types:
|
||||
if isinstance(m, str) and m.strip().lower() in allowed_meals:
|
||||
if m.strip().lower() not in meal_types_clean:
|
||||
meal_types_clean.append(m.strip().lower())
|
||||
if not meal_types_clean:
|
||||
meal_types_clean = ["dinner"]
|
||||
# Stable order: breakfast → lunch → dinner → snack → dessert → side
|
||||
meal_order = ["breakfast", "lunch", "dinner", "snack", "dessert", "side"]
|
||||
meal_types_clean.sort(key=lambda m: meal_order.index(m))
|
||||
expected_total = slots * len(meal_types_clean)
|
||||
|
||||
prompt = self._build_plan_prompt(
|
||||
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
||||
preference=preference, picker_profiles=picker_profiles,
|
||||
daily_targets=daily_targets, exclusions=exclusions,
|
||||
meal_types=meal_types_clean,
|
||||
)
|
||||
result = self.run(prompt, model=model or "sonnet")
|
||||
parsed = _extract_plan_slots(result)
|
||||
if not isinstance(parsed, list):
|
||||
raise ForgeError("model output: 'slots' must be a list")
|
||||
if len(parsed) != slots:
|
||||
raise ForgeError(f"model output: got {len(parsed)} slots, expected {slots}")
|
||||
if len(parsed) != expected_total:
|
||||
raise ForgeError(
|
||||
f"model output: got {len(parsed)} slots, expected {expected_total} "
|
||||
f"({slots} days × {len(meal_types_clean)} meals)"
|
||||
)
|
||||
|
||||
# Pick attribution lookup keyed by slug → list[sub]
|
||||
pick_subs_by_slug: dict[str, list[str]] = {}
|
||||
|
|
@ -131,17 +152,21 @@ class Forge:
|
|||
pick_subs_by_slug[slug] = list(p.get("picker_subs") or [])
|
||||
|
||||
out = []
|
||||
seen_days: set[str] = set()
|
||||
seen: set[tuple[str, str]] = set() # (day, meal_type) uniqueness
|
||||
for raw in parsed:
|
||||
if not isinstance(raw, dict):
|
||||
raise ForgeError("model output: each slot must be an object")
|
||||
day = (raw.get("day") or "").strip().lower()
|
||||
meal = (raw.get("meal_type") or "dinner").strip().lower()
|
||||
slug = (raw.get("recipe_slug") or "").strip()
|
||||
if day not in _DAYS:
|
||||
raise ForgeError(f"model output: bad day '{day}'")
|
||||
if day in seen_days:
|
||||
raise ForgeError(f"model output: duplicate day '{day}'")
|
||||
seen_days.add(day)
|
||||
if meal not in meal_types_clean:
|
||||
raise ForgeError(f"model output: unexpected meal_type '{meal}' (expected one of {meal_types_clean})")
|
||||
key = (day, meal)
|
||||
if key in seen:
|
||||
raise ForgeError(f"model output: duplicate slot {day}/{meal}")
|
||||
seen.add(key)
|
||||
if not slug or slug not in valid_by_slug:
|
||||
raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
|
||||
|
||||
|
|
@ -158,6 +183,7 @@ class Forge:
|
|||
|
||||
out.append({
|
||||
"day": day,
|
||||
"meal_type": meal,
|
||||
"recipe_slug": slug,
|
||||
"recipe_name": valid_by_slug[slug],
|
||||
"picker_subs": picker_subs,
|
||||
|
|
@ -169,7 +195,7 @@ class Forge:
|
|||
@staticmethod
|
||||
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None,
|
||||
picker_profiles=None, daily_targets=None,
|
||||
exclusions=None) -> str:
|
||||
exclusions=None, meal_types=None) -> str:
|
||||
pool_lines = []
|
||||
for r in recipes:
|
||||
slug = r.get("slug") or ""
|
||||
|
|
@ -364,29 +390,61 @@ class Forge:
|
|||
"fill the remaining slots.\n"
|
||||
)
|
||||
|
||||
# Multi-meal block: when meal_types is more than just dinner, the
|
||||
# output is N×7 slots with explicit meal_type. Each pool entry has
|
||||
# a meta.meal_type tag — Sonnet must match those (a "breakfast"
|
||||
# slot gets a recipe whose meta.meal_type is "breakfast" or
|
||||
# something close like "lunch" if no breakfast match).
|
||||
meal_types_clean = list(meal_types) if isinstance(meal_types, list) and meal_types else ["dinner"]
|
||||
is_multi_meal = len(meal_types_clean) > 1
|
||||
meal_block = ""
|
||||
if is_multi_meal:
|
||||
meal_block = (
|
||||
f"\nMEAL TYPES TO PLAN: {', '.join(meal_types_clean)}\n"
|
||||
"Each day must have exactly one slot per meal type listed above.\n"
|
||||
"When picking a recipe for a meal slot, prefer recipes whose "
|
||||
"meta.meal_type matches the slot. e.g. for a 'breakfast' slot, "
|
||||
"look at the pool entries with `meta meal_type=breakfast` or those "
|
||||
"tagged 'breakfast'/'brunch'/'morning' in their meta tags. Don't "
|
||||
"use a 'dessert' meal_type recipe for a dinner slot. If the pool "
|
||||
"is thin for a meal type, fall back to general pool entries that "
|
||||
"could plausibly fit (e.g. a hearty breakfast salad for lunch).\n"
|
||||
)
|
||||
output_shape = (
|
||||
'{"slots": [{"day": "monday", "meal_type": "breakfast", '
|
||||
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}'
|
||||
)
|
||||
else:
|
||||
output_shape = (
|
||||
'{"slots": [{"day": "monday", "meal_type": "dinner", '
|
||||
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}'
|
||||
)
|
||||
expected_total = slots * len(meal_types_clean)
|
||||
meal_types_label = "/".join(meal_types_clean) if is_multi_meal else "dinner"
|
||||
|
||||
return (
|
||||
f"You are a family meal planner. Build a {slots}-day dinner plan "
|
||||
f"You are a family meal planner. Build a {slots}-day plan ({meal_types_label}) "
|
||||
f"for the week of {week_start}.\n\n"
|
||||
f"POOL (all available recipes):\n{pool_block}\n\n"
|
||||
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
||||
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
|
||||
f"PICKS (recipes the family pre-selected — every pick MUST appear in "
|
||||
f"some appropriate slot if pool size allows; no repeats):\n"
|
||||
f"{picks_block}\n"
|
||||
f"{profile_block}"
|
||||
f"{meal_block}"
|
||||
f"{pref_block}"
|
||||
f"{targets_block}"
|
||||
f"{excl_block}"
|
||||
"Output JSON ONLY, no prose: "
|
||||
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
||||
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
||||
f"Output JSON ONLY, no prose: {output_shape}\n\n"
|
||||
"Rules:\n"
|
||||
f"- Use exactly {slots} recipes\n"
|
||||
f"- Use exactly {expected_total} slots ({slots} days × {len(meal_types_clean)} meals)\n"
|
||||
"- Distribute picks evenly across the week — don't bunch them\n"
|
||||
"- ROTATION: prefer recipes with `last:NNw-ago` further in the past "
|
||||
"or no history shown. If a recipe has been served 2+ times in 30d "
|
||||
"(`2×/30d` or higher), DEMOTE it strongly unless it's a household "
|
||||
"pick. Never repeat the same recipe slug within this 7-day plan.\n"
|
||||
"- VARIETY: don't fill 5 of 7 slots with the same primary_protein or "
|
||||
"the same cuisine. Mix it up across the week.\n"
|
||||
"pick. Never repeat the same recipe slug within this plan.\n"
|
||||
"- VARIETY: don't fill 5 of 7 dinner slots with the same primary_protein "
|
||||
"or the same cuisine. Mix it up across the week.\n"
|
||||
"- meal_type MUST be one of the listed meal types and match the slot.\n"
|
||||
"- \"reason\" is a one-line user-facing rationale "
|
||||
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\", "
|
||||
"\"high-protein lean — pairs with the gym week\", "
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ def create_app() -> Flask:
|
|||
pick_count = len(db.list_household_pick_slugs(hid))
|
||||
|
||||
# Decode JSON columns for the template + build readout labels
|
||||
for k in ("daily_targets_json", "exclusions_json"):
|
||||
for k in ("daily_targets_json", "exclusions_json", "meal_types_json"):
|
||||
v = plan.get(k)
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
|
|
@ -626,6 +626,9 @@ def create_app() -> Flask:
|
|||
plan[k] = None
|
||||
targets = plan.get("daily_targets_json") if isinstance(plan.get("daily_targets_json"), dict) else None
|
||||
exclusions = plan.get("exclusions_json") if isinstance(plan.get("exclusions_json"), list) else None
|
||||
plan_meal_types = plan.get("meal_types_json") if isinstance(plan.get("meal_types_json"), list) else None
|
||||
plan["meal_types_list"] = plan_meal_types or ["dinner"]
|
||||
plan["meal_types_label"] = " + ".join(plan["meal_types_list"]) if len(plan["meal_types_list"]) > 1 else ""
|
||||
plan["targets_label"] = ""
|
||||
if targets:
|
||||
bits = []
|
||||
|
|
@ -761,12 +764,16 @@ def create_app() -> Flask:
|
|||
plan["preference_prompt"] = preference[:1000]
|
||||
targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None
|
||||
exclusions_body = body.get("exclusions") if isinstance(body.get("exclusions"), list) else None
|
||||
meal_types_body = body.get("meal_types") if isinstance(body.get("meal_types"), list) else None
|
||||
if targets_body is not None or exclusions_body is not None:
|
||||
db.set_plan_targets_and_exclusions(
|
||||
plan["id"],
|
||||
targets=targets_body,
|
||||
exclusions=exclusions_body,
|
||||
)
|
||||
if meal_types_body is not None:
|
||||
db.set_plan_meal_types(plan["id"], meal_types_body)
|
||||
if targets_body is not None or exclusions_body is not None or meal_types_body is not None:
|
||||
# Re-fetch so the local plan dict has the persisted values
|
||||
plan = db.get_or_create_plan(hid, this_monday)
|
||||
|
||||
|
|
@ -834,7 +841,8 @@ def create_app() -> Flask:
|
|||
# the household's collective average.
|
||||
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
|
||||
|
||||
# Numeric daily targets + allergen exclusions parsed from the persisted plan row
|
||||
# Numeric daily targets + allergen exclusions + meal_types parsed
|
||||
# from the persisted plan row
|
||||
plan_targets = plan.get("daily_targets_json")
|
||||
if isinstance(plan_targets, str):
|
||||
try:
|
||||
|
|
@ -847,6 +855,12 @@ def create_app() -> Flask:
|
|||
plan_exclusions = _json_loads(plan_exclusions)
|
||||
except Exception:
|
||||
plan_exclusions = None
|
||||
plan_meal_types = plan.get("meal_types_json")
|
||||
if isinstance(plan_meal_types, str):
|
||||
try:
|
||||
plan_meal_types = _json_loads(plan_meal_types)
|
||||
except Exception:
|
||||
plan_meal_types = None
|
||||
|
||||
try:
|
||||
slots = forge.generate_plan(
|
||||
|
|
@ -856,6 +870,7 @@ def create_app() -> Flask:
|
|||
picker_profiles=picker_profiles,
|
||||
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
|
||||
exclusions=plan_exclusions if isinstance(plan_exclusions, list) else None,
|
||||
meal_types=plan_meal_types if isinstance(plan_meal_types, list) else None,
|
||||
)
|
||||
except ForgeError as e:
|
||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
|
|
@ -909,12 +924,16 @@ def create_app() -> Flask:
|
|||
plan["preference_prompt"] = preference[:1000]
|
||||
targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None
|
||||
exclusions_body = body.get("exclusions") if isinstance(body.get("exclusions"), list) else None
|
||||
meal_types_body = body.get("meal_types") if isinstance(body.get("meal_types"), list) else None
|
||||
if targets_body is not None or exclusions_body is not None:
|
||||
db.set_plan_targets_and_exclusions(
|
||||
plan["id"],
|
||||
targets=targets_body,
|
||||
exclusions=exclusions_body,
|
||||
)
|
||||
if meal_types_body is not None:
|
||||
db.set_plan_meal_types(plan["id"], meal_types_body)
|
||||
if targets_body is not None or exclusions_body is not None or meal_types_body is not None:
|
||||
plan = db.get_or_create_plan(hid, this_monday)
|
||||
|
||||
# Now fall through to the same logic as generate
|
||||
|
|
@ -942,7 +961,7 @@ def create_app() -> Flask:
|
|||
# the household's collective average.
|
||||
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
|
||||
|
||||
# Persisted numeric targets + allergen exclusions for this re-roll
|
||||
# Persisted numeric targets + allergen exclusions + meal_types for re-roll
|
||||
plan_targets = plan.get("daily_targets_json")
|
||||
if isinstance(plan_targets, str):
|
||||
try:
|
||||
|
|
@ -955,6 +974,12 @@ def create_app() -> Flask:
|
|||
plan_exclusions = _json_loads(plan_exclusions)
|
||||
except Exception:
|
||||
plan_exclusions = None
|
||||
plan_meal_types = plan.get("meal_types_json")
|
||||
if isinstance(plan_meal_types, str):
|
||||
try:
|
||||
plan_meal_types = _json_loads(plan_meal_types)
|
||||
except Exception:
|
||||
plan_meal_types = None
|
||||
|
||||
try:
|
||||
slots = forge.generate_plan(
|
||||
|
|
@ -964,6 +989,7 @@ def create_app() -> Flask:
|
|||
picker_profiles=picker_profiles,
|
||||
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
|
||||
exclusions=plan_exclusions if isinstance(plan_exclusions, list) else None,
|
||||
meal_types=plan_meal_types if isinstance(plan_meal_types, list) else None,
|
||||
)
|
||||
except ForgeError as e:
|
||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
<h1>foods <span class="accent">consolidate</span></h1>
|
||||
<div class="lede">
|
||||
scan your mealie household foods, cluster lookalikes by similarity,
|
||||
let sonnet pick the survivor + the rest become aliases on it. mealie
|
||||
let sage pick the survivor + the rest become aliases on it. mealie
|
||||
rewrites every recipe ingredient to point at the canonical row.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
<p>no run yet. kick one off?</p>
|
||||
<button class="btn btn-purple" id="start-btn" type="button" onclick="startRun()">🪄 scan + cluster mealie foods</button>
|
||||
<p class="muted" style="margin-top:8px;">
|
||||
walks your household catalog, clusters by name similarity, asks sonnet for the survivor of each cluster.
|
||||
walks your household catalog, clusters by name similarity, asks sage for the survivor of each cluster.
|
||||
first run might take 5-15 min depending on cluster count.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
<div class="crumb">// dedupe · find duplicate recipes</div>
|
||||
<h1>recipe <span class="accent">dedupe</span></h1>
|
||||
<div class="lede">
|
||||
scan your household recipes for duplicates by name similarity. sonnet
|
||||
scan your household recipes for duplicates by name similarity. sage
|
||||
looks at ingredients + step counts + source URLs to decide whether
|
||||
similar-named recipes are actually the same dish. you confirm per
|
||||
cluster — DELETE in Mealie is permanent.
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
<div id="empty-pane" style="display:none;">
|
||||
<p>no run yet. scan now?</p>
|
||||
<button class="btn btn-purple" id="start-btn" type="button" onclick="startRun()">🪄 scan recipes for duplicates</button>
|
||||
<p class="muted" style="margin-top:8px;">walks your household recipes, clusters by name similarity, asks sonnet which clusters are real dupes. apply path uses Mealie's DELETE endpoint — irreversible. user-confirms per cluster.</p>
|
||||
<p class="muted" style="margin-top:8px;">walks your household recipes, clusters by name similarity, asks sage which clusters are real dupes. apply path uses Mealie's DELETE endpoint — irreversible. user-confirms per cluster.</p>
|
||||
</div>
|
||||
|
||||
<div id="progress-pane" style="display:none;">
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
meta.className = 'cluster-meta';
|
||||
meta.textContent = dec.duplicates
|
||||
? `delete ${(dec.delete_slugs||[]).length}, keep 1`
|
||||
: `keep all ${cluster.length} (sonnet says distinct)`;
|
||||
: `keep all ${cluster.length} (sage says distinct)`;
|
||||
left.appendChild(nm); left.appendChild(meta);
|
||||
|
||||
const tog = document.createElement('button');
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<div class="crumb">// enrich · per-recipe metadata for smarter planning</div>
|
||||
<h1>recipe <span class="accent">enrich</span></h1>
|
||||
<div class="lede">
|
||||
walk every household recipe and have sonnet generate structured metadata —
|
||||
walk every household recipe and have sage generate structured metadata —
|
||||
cuisine, complexity, macros, meal type, primary protein + carb,
|
||||
comfort tier, one-line summary. the plan generator uses this so
|
||||
"high protein week" actually filters the pool, not just biases the vibe.
|
||||
|
|
|
|||
|
|
@ -57,13 +57,13 @@
|
|||
</div>
|
||||
<p class="muted">mealie's parser is per-recipe; this kicks off a bulk pass over your whole library. review proposals, apply the good ones.</p>
|
||||
<p><a class="btn" href="/sterilize">🪄 bulk sterilize recipes →</a></p>
|
||||
<p class="muted" style="margin-top:14px;">scan your foods table for dupes, ask sonnet to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.</p>
|
||||
<p class="muted" style="margin-top:14px;">scan your foods table for dupes, ask sage to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.</p>
|
||||
<p><a class="btn" href="/consolidate">🔮 consolidate foods table →</a></p>
|
||||
|
||||
<p class="muted" style="margin-top:14px;">find duplicate recipes by name + ingredient similarity. sonnet picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.</p>
|
||||
<p class="muted" style="margin-top:14px;">find duplicate recipes by name + ingredient similarity. sage picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.</p>
|
||||
<p><a class="btn" href="/dedupe-recipes">🌀 dedupe recipes →</a></p>
|
||||
|
||||
<p class="muted" style="margin-top:14px;">have sonnet generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.</p>
|
||||
<p class="muted" style="margin-top:14px;">have sage generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.</p>
|
||||
<p><a class="btn" href="/enrich-recipes">✨ enrich recipes →</a></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,38 @@
|
|||
border-color: rgba(232, 96, 106, .3);
|
||||
color: var(--crit);
|
||||
}
|
||||
|
||||
.meals-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.meal-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--bone-dim);
|
||||
font-family: var(--sans); font-size: .9em;
|
||||
cursor: pointer; user-select: none;
|
||||
min-height: 32px;
|
||||
}
|
||||
.meal-chip input { margin: 0; cursor: pointer; }
|
||||
.meal-chip:has(input:checked) {
|
||||
background: var(--purple-deep);
|
||||
border-color: var(--purple-dim);
|
||||
color: var(--purple-bright);
|
||||
}
|
||||
|
||||
.meal-row {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
.meal-row:first-child { border-top: 0; }
|
||||
.meal-tag {
|
||||
display: inline-block;
|
||||
color: var(--purple); font-family: var(--mono);
|
||||
font-size: 9px; letter-spacing: .2em; text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-head">
|
||||
|
|
@ -305,10 +337,17 @@
|
|||
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</em></div>
|
||||
{% endif %}
|
||||
{% elif not plan.slots %}
|
||||
<p>no plan yet. summon claude to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire.</p>
|
||||
<p>no plan yet. summon sage to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire.</p>
|
||||
|
||||
<div class="pref-block">
|
||||
<label class="pref-label" for="pref-input">this week's vibe (optional)</label>
|
||||
<label class="pref-label">meals to plan</label>
|
||||
<div class="meals-row">
|
||||
<label class="meal-chip"><input type="checkbox" class="meal-check" value="breakfast" {% if 'breakfast' in plan.meal_types_list %}checked{% endif %}> 🍳 breakfast</label>
|
||||
<label class="meal-chip"><input type="checkbox" class="meal-check" value="lunch" {% if 'lunch' in plan.meal_types_list %}checked{% endif %}> 🥪 lunch</label>
|
||||
<label class="meal-chip"><input type="checkbox" class="meal-check" value="dinner" {% if 'dinner' in plan.meal_types_list %}checked{% endif %}> 🍽️ dinner</label>
|
||||
</div>
|
||||
|
||||
<label class="pref-label" for="pref-input" style="margin-top:14px;">this week's vibe (optional)</label>
|
||||
<textarea id="pref-input" class="pref-input" rows="2" maxlength="1000" placeholder="e.g. high protein low carb · carb load · light recovery week · no fish · vegetarian-leaning · spicy comfort food">{{ plan.preference_prompt or '' }}</textarea>
|
||||
<div class="pref-presets">
|
||||
<button type="button" class="pref-chip" onclick="setPref('high protein, low carb — lean and gym-friendly')">🥩 high protein</button>
|
||||
|
|
@ -373,7 +412,7 @@
|
|||
</div>
|
||||
|
||||
<button class="gen-cta" type="button" id="gen-btn" onclick="generatePlan(this)">🪄 generate this week's plan</button>
|
||||
<div class="gen-meta" id="gen-meta">sonnet · ~30s</div>
|
||||
<div class="gen-meta" id="gen-meta">sage · ~30s</div>
|
||||
{% else %}
|
||||
<p>plan's set.</p>
|
||||
{% if plan.preference_prompt %}
|
||||
|
|
@ -400,23 +439,36 @@
|
|||
<section class="panel purple">
|
||||
<div class="panel-head">
|
||||
<h2>the week</h2>
|
||||
<span class="ctx">{{ plan.slots|length }} day{{ '' if plan.slots|length == 1 else 's' }}</span>
|
||||
<span class="ctx">{{ plan.slots|length }} slot{{ '' if plan.slots|length == 1 else 's' }} · {{ plan.meal_types_list|join(' + ') }}</span>
|
||||
</div>
|
||||
{# Group slots by day, then render each meal_type within the day card #}
|
||||
{% set days_order = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] %}
|
||||
{% set meals_by_day = {} %}
|
||||
{% for s in plan.slots %}
|
||||
{% if s.day not in meals_by_day %}{% set _ = meals_by_day.update({s.day: []}) %}{% endif %}
|
||||
{% set _ = meals_by_day[s.day].append(s) %}
|
||||
{% endfor %}
|
||||
<div class="day-grid">
|
||||
{% for s in plan.slots %}
|
||||
<a class="day-card recipe-card {% if s.picker_subs %}from-pick{% endif %}" href="/recipes/{{ s.recipe_slug }}" data-slug="{{ s.recipe_slug }}" data-name="{{ s.recipe_name }}">
|
||||
<div class="dlabel">{{ s.day }}</div>
|
||||
<div class="rname">{{ s.recipe_name }}</div>
|
||||
{% if s.picker_subs %}
|
||||
<div class="pickers">
|
||||
{% for sub in s.picker_subs %}
|
||||
<span class="pchip">🍄 {{ sub_display.get(sub, 'family') }}</span>
|
||||
{% for d in days_order %}{% if d in meals_by_day %}
|
||||
{% set day_slots = meals_by_day[d] %}
|
||||
<div class="day-card {% if day_slots|selectattr('picker_subs')|list %}from-pick{% endif %}">
|
||||
<div class="dlabel">{{ d }}</div>
|
||||
{% for s in day_slots %}
|
||||
<div class="meal-row">
|
||||
{% if (plan.meal_types_list|length) > 1 %}<div class="meal-tag">{{ s.meal_type or 'dinner' }}</div>{% endif %}
|
||||
<a class="recipe-card rname" href="/recipes/{{ s.recipe_slug }}" data-slug="{{ s.recipe_slug }}" data-name="{{ s.recipe_name }}">{{ s.recipe_name }}</a>
|
||||
{% if s.picker_subs %}
|
||||
<div class="pickers">
|
||||
{% for sub in s.picker_subs %}
|
||||
<span class="pchip">🍄 {{ sub_display.get(sub, 'family') }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if s.reason %}<div class="reason">{{ s.reason }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if s.reason %}<div class="reason">{{ s.reason }}</div>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
|
@ -510,11 +562,18 @@ function readExclusions() {
|
|||
return out;
|
||||
}
|
||||
|
||||
function readMealTypes() {
|
||||
const out = [];
|
||||
document.querySelectorAll('.meal-check:checked').forEach(el => out.push(el.value));
|
||||
// Default to dinner if nothing checked — never send empty
|
||||
return out.length ? out : ['dinner'];
|
||||
}
|
||||
|
||||
async function generatePlan(btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '🪄 summoning…';
|
||||
const meta = document.getElementById('gen-meta');
|
||||
if (meta) meta.textContent = 'sonnet building plan — hold tight';
|
||||
if (meta) meta.textContent = 'sage building plan — hold tight';
|
||||
try {
|
||||
const r = await fetch('/api/plan/generate', {
|
||||
method: 'POST',
|
||||
|
|
@ -524,6 +583,7 @@ async function generatePlan(btn) {
|
|||
preference: readPref(),
|
||||
targets: readTargets(),
|
||||
exclusions: readExclusions(),
|
||||
meal_types: readMealTypes(),
|
||||
}),
|
||||
});
|
||||
if (r.status === 409) {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
<h1>bulk <span class="accent">sterilize</span></h1>
|
||||
<div class="lede">
|
||||
walk every recipe in the household, send the unparsed ingredients to
|
||||
sonnet, review the proposals, apply the good ones. mealie's food
|
||||
sage, review the proposals, apply the good ones. mealie's food
|
||||
table gets cleaner; the shopping list math gets sharper.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -378,7 +378,7 @@
|
|||
<tbody>${rows}</tbody>`;
|
||||
card.appendChild(tbl);
|
||||
} else {
|
||||
// All rows were identity matches — sonnet thinks this recipe is
|
||||
// All rows were identity matches — sage thinks this recipe is
|
||||
// already clean. Show a marker; user can still skip/approve as
|
||||
// a no-op apply (which will be cheap, just refreshes food.id
|
||||
// resolution if any food row got renamed in Mealie).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue