v0.3 step 3+4: AI plan generator + /list shopping aggregation
- migrations 012 + 013: cauldron_meal_plan_slots + cauldron_pick_points - db: list_plan_slots, save_plan_slots, delete_plan_slots, mark_plan_generated, clear_plan_generated, award_pick_points, enrich_plan_with_slots; scoreboard extended with points (sum from pick_points) and weeks_locked alias - forge.generate_plan: sonnet prompt builds 7-day plan respecting picks, validates slot count + day uniqueness + slug-in-pool, fills picker_subs from ground-truth picks (model output is advisory) - POST /api/plan/generate: race-safe (existing slots → 409 with plan), lock-aware (locked → 409), idempotent - POST /api/plan/regenerate: re-roll for the original generator, gated by ownership + lock; wipes slots + pick_points then re-runs generate - plan.html: generate CTA + 7 day cards with picker chips + AI reason + re-roll button (generator-only, pre-lock); scoreboard now shows points + wins - /list: pulls plan slots, queries Mealie for ingredients, runs aggregator, renders 48px-tall checkbox shopping list with localStorage state per plan_id - tests: 13 new tests across forge.generate_plan + /api/plan/generate routes + /list view + scoreboard SQL inspection. conftest+_testenv stub pymysql/oidc/foods at import time so tests run against module-level app without a live DB. Both pytest and `unittest discover` paths green (27/27). Defers: bulk sterilizer admin (A1), foods dedupe (A2), Mealie shopping-list- export (button rendered but disabled). 7-slot count is fixed at the endpoint (no UI for slot-count selection yet). Spec: memory/spec-cauldron-v0.3.md
This commit is contained in:
parent
cc6222139d
commit
36aba73f66
9 changed files with 1724 additions and 33 deletions
|
|
@ -1,4 +1,7 @@
|
|||
"""Thin HTTP client for clawdforge — we're a consumer."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
|
|
@ -6,6 +9,9 @@ class ForgeError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
||||
|
||||
|
||||
class Forge:
|
||||
def __init__(self, *, base_url: str, token: str, default_model: str, default_timeout: int):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
|
@ -62,3 +68,175 @@ class Forge:
|
|||
raise ForgeError(f"upstream {r.status_code}: {r.text[:500]}")
|
||||
|
||||
return r.json()
|
||||
|
||||
def generate_plan(
|
||||
self,
|
||||
*,
|
||||
picks: list[dict],
|
||||
recipes: list[dict],
|
||||
slots: int = 7,
|
||||
week_start: str,
|
||||
model: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||
shaped like:
|
||||
{"day": "monday", "recipe_slug": "...", "recipe_name": "...",
|
||||
"picker_subs": [...], "reason": "...", "source": "pick"|"mealie"}
|
||||
|
||||
Validates structure aggressively — wrong shape / wrong slot count /
|
||||
slug-not-in-pool → ForgeError. Caller surfaces a 502 to the user.
|
||||
|
||||
recipes: [{slug, name, tags?}], picks: [{slug, name, picker_subs}].
|
||||
Picks are the family's pinned recipes; the prompt mandates each one
|
||||
appears exactly once when the pool is large enough.
|
||||
"""
|
||||
if slots < 1 or slots > 14:
|
||||
raise ForgeError(f"bad slot count: {slots}")
|
||||
if not recipes:
|
||||
raise ForgeError("recipe pool empty — cannot generate")
|
||||
|
||||
# Build a slug → name map for validation. Use the recipe pool plus
|
||||
# picks (picks should already be in the pool, but be defensive).
|
||||
valid_by_slug: dict[str, str] = {}
|
||||
for r in recipes:
|
||||
slug = r.get("slug")
|
||||
if slug:
|
||||
valid_by_slug[slug] = r.get("name") or slug
|
||||
for p in picks:
|
||||
slug = p.get("slug")
|
||||
if slug:
|
||||
valid_by_slug.setdefault(slug, p.get("name") or slug)
|
||||
|
||||
prompt = self._build_plan_prompt(
|
||||
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
||||
)
|
||||
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}")
|
||||
|
||||
# Pick attribution lookup keyed by slug → list[sub]
|
||||
pick_subs_by_slug: dict[str, list[str]] = {}
|
||||
for p in picks:
|
||||
slug = p.get("slug")
|
||||
if slug:
|
||||
pick_subs_by_slug[slug] = list(p.get("picker_subs") or [])
|
||||
|
||||
out = []
|
||||
seen_days: set[str] = set()
|
||||
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()
|
||||
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 not slug or slug not in valid_by_slug:
|
||||
raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
|
||||
|
||||
# Trust the model's picker_subs only if they intersect the real
|
||||
# set. We have ground truth in pick_subs_by_slug — prefer it.
|
||||
real_pickers = pick_subs_by_slug.get(slug, [])
|
||||
model_pickers = raw.get("picker_subs") or []
|
||||
if not isinstance(model_pickers, list):
|
||||
model_pickers = []
|
||||
picker_subs = real_pickers if real_pickers else [
|
||||
s for s in model_pickers if isinstance(s, str)
|
||||
]
|
||||
source = "pick" if real_pickers else "mealie"
|
||||
|
||||
out.append({
|
||||
"day": day,
|
||||
"recipe_slug": slug,
|
||||
"recipe_name": valid_by_slug[slug],
|
||||
"picker_subs": picker_subs,
|
||||
"reason": (raw.get("reason") or "")[:500],
|
||||
"source": source,
|
||||
})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _build_plan_prompt(*, picks, recipes, slots, week_start) -> str:
|
||||
pool_lines = []
|
||||
for r in recipes:
|
||||
slug = r.get("slug") or ""
|
||||
name = r.get("name") or slug
|
||||
tags = r.get("tags") or []
|
||||
tag_str = ""
|
||||
if tags:
|
||||
# First 3 tags only — keeps prompt token count under control
|
||||
cleaned = []
|
||||
for t in tags[:3]:
|
||||
if isinstance(t, dict):
|
||||
cleaned.append(t.get("name") or "")
|
||||
elif isinstance(t, str):
|
||||
cleaned.append(t)
|
||||
cleaned = [c for c in cleaned if c]
|
||||
if cleaned:
|
||||
tag_str = f" [{', '.join(cleaned)}]"
|
||||
pool_lines.append(f"- {slug}: {name}{tag_str}")
|
||||
|
||||
pick_lines = []
|
||||
for p in picks:
|
||||
slug = p.get("slug") or ""
|
||||
name = p.get("name") or slug
|
||||
pickers = p.get("pickers") or []
|
||||
picker_subs = p.get("picker_subs") or []
|
||||
who = ", ".join(pickers) if pickers else "household"
|
||||
subs_repr = json.dumps(picker_subs)
|
||||
pick_lines.append(f"- {slug}: {name} (picked by [{who}], picker_subs={subs_repr})")
|
||||
|
||||
picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
|
||||
pool_block = "\n".join(pool_lines)
|
||||
|
||||
return (
|
||||
f"You are a family meal planner. Build a {slots}-day dinner plan "
|
||||
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_block}\n\n"
|
||||
"Output JSON ONLY, no prose: "
|
||||
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
||||
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
||||
"Rules:\n"
|
||||
f"- Use exactly {slots} recipes\n"
|
||||
"- Distribute picks evenly across the week — don't bunch them\n"
|
||||
"- \"reason\" is a one-line user-facing rationale "
|
||||
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\")\n"
|
||||
"- \"picker_subs\" is the array of authentik_sub strings of family "
|
||||
"members who picked this recipe (empty list if AI-chosen)\n"
|
||||
"- Day order: monday..sunday\n"
|
||||
)
|
||||
|
||||
|
||||
def _extract_plan_slots(forge_result: dict):
|
||||
"""clawdforge wraps its return; the JSON we asked for can sit in a few
|
||||
different shapes. Normalize aggressively."""
|
||||
if not isinstance(forge_result, dict):
|
||||
raise ForgeError("forge result not a dict")
|
||||
inner = forge_result.get("result", forge_result)
|
||||
# `result` may be a string when claude returned non-JSON — try to scrape
|
||||
if isinstance(inner, str):
|
||||
inner = _parse_json_blob(inner)
|
||||
if isinstance(inner, dict) and "slots" in inner:
|
||||
return inner["slots"]
|
||||
if isinstance(inner, list):
|
||||
return inner
|
||||
raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}")
|
||||
|
||||
|
||||
def _parse_json_blob(s: str):
|
||||
s = s.strip()
|
||||
# Strip code fences if Sonnet wrapped its output
|
||||
s = re.sub(r"^```(?:json)?\s*", "", s)
|
||||
s = re.sub(r"\s*```$", "", s)
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception as e:
|
||||
raise ForgeError(f"could not parse model JSON: {e}; head={s[:200]!r}") from e
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue