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:
Kayos 2026-04-29 06:26:54 -07:00
parent cc6222139d
commit 36aba73f66
9 changed files with 1724 additions and 33 deletions

View file

@ -186,6 +186,48 @@ MIGRATIONS = [
FOREIGN KEY (cauldron_food_id) REFERENCES cauldron_foods(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 012 — AI-generated meal plan slots. One row per (plan, day). Created
# when a household member triggers /api/plan/generate. picker_subs JSON
# holds the authentik_subs of household members who pinned this slot's
# recipe (empty list if AI-chosen). reason is the AI's user-facing
# rationale. notes is reserved for future swap/edit history.
"""
CREATE TABLE IF NOT EXISTS cauldron_meal_plan_slots (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
plan_id BIGINT NOT NULL,
day VARCHAR(10) NOT NULL,
recipe_slug VARCHAR(255) NOT NULL,
recipe_name VARCHAR(500) NOT NULL,
source ENUM('mealie','pick') NOT NULL DEFAULT 'mealie',
picker_subs JSON,
reason VARCHAR(500),
notes JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_plan_day (plan_id, day),
INDEX idx_plan (plan_id),
FOREIGN KEY (plan_id) REFERENCES cauldron_meal_plans(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 013 — pick-points ledger. 1pt awarded when a member's pick lands in
# a generated plan ('pick_used'). Reserved reasons for v0.4: first-to-
# lock + streak bonuses. Joins to households + plans + users so a row
# disappears cleanly if any of them are removed.
"""
CREATE TABLE IF NOT EXISTS cauldron_pick_points (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
household_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL,
authentik_sub VARCHAR(190) NOT NULL,
points INT NOT NULL,
reason ENUM('pick_used','first_to_lock','streak_bonus') NOT NULL,
awarded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_household_user (household_id, authentik_sub),
INDEX idx_plan (plan_id),
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE,
FOREIGN KEY (plan_id) REFERENCES cauldron_meal_plans(id) ON DELETE CASCADE,
FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
@ -393,31 +435,59 @@ class DB:
return cur.rowcount
def household_scoreboard(self, household_id: int) -> list[dict]:
"""Per-user lock counts + most recent lock time. Joins to users for
display name. Excludes auto-locks (those are no-one's win)."""
"""Per-user lock counts + pick-points + most recent lock time.
Three numbers per row:
wins user-locked weeks (excludes auto-locks)
weeks_locked alias for wins, preserved for older callers
points sum of cauldron_pick_points for this user/household
Sort: points desc, then wins desc, then last_win desc points are
the headline metric in v0.3 (every pick lands matters daily).
We compute lock counts and points as separate scalar subqueries so
the JOIN doesn't blow up on the cartesian (members × plans × points).
"""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT
u.authentik_sub AS sub,
u.email AS email,
u.display_name AS display_name,
COUNT(p.id) AS wins,
MAX(p.locked_at) AS last_win
u.authentik_sub AS sub,
u.email AS email,
u.display_name AS display_name,
COALESCE((
SELECT COUNT(*) FROM cauldron_meal_plans p
WHERE p.locked_by_sub = m.authentik_sub
AND p.household_id = m.household_id
AND p.locked_reason = 'user'
), 0) AS wins,
(
SELECT MAX(p.locked_at) FROM cauldron_meal_plans p
WHERE p.locked_by_sub = m.authentik_sub
AND p.household_id = m.household_id
AND p.locked_reason = 'user'
) AS last_win,
COALESCE((
SELECT SUM(pp.points) FROM cauldron_pick_points pp
WHERE pp.household_id = m.household_id
AND pp.authentik_sub = m.authentik_sub
), 0) AS points
FROM cauldron_household_members m
LEFT JOIN cauldron_users u
ON u.authentik_sub = m.authentik_sub
LEFT JOIN cauldron_meal_plans p
ON p.locked_by_sub = m.authentik_sub
AND p.household_id = m.household_id
AND p.locked_reason = 'user'
WHERE m.household_id = %s
GROUP BY u.authentik_sub, u.email, u.display_name
ORDER BY wins DESC, last_win DESC
ORDER BY points DESC, wins DESC, last_win DESC
""",
(household_id,),
)
return [dict(r) for r in cur.fetchall()]
out = []
for r in cur.fetchall():
d = dict(r)
d["points"] = int(d.get("points") or 0)
d["wins"] = int(d.get("wins") or 0)
d["weeks_locked"] = d["wins"]
out.append(d)
return out
def household_streak(self, household_id: int) -> dict | None:
"""Compute current win streak: walk back from most recent locked week,
@ -451,6 +521,143 @@ class DB:
"count": count,
}
# --- plan slots + pick points (v0.3 A4) --------------------------------
# Day order is stable Mon..Sun. Used everywhere we need to render slots
# in calendar order.
PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
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)."""
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,
picker_subs, reason, notes, created_at
FROM cauldron_meal_plan_slots
WHERE plan_id = %s
""",
(plan_id,),
)
rows = [dict(r) for r in cur.fetchall()]
for r in rows:
ps = r.get("picker_subs")
if isinstance(ps, str):
try:
r["picker_subs"] = _json.loads(ps)
except Exception:
r["picker_subs"] = []
elif ps is None:
r["picker_subs"] = []
n = r.get("notes")
if isinstance(n, str):
try:
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))
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)."""
import json as _json
if not slots:
return 0
inserted = 0
with self.conn() as c, c.cursor() as cur:
for s in slots:
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,
(s.get("day") or "").lower()[:10],
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"),
_json.dumps(s.get("picker_subs") or []),
(s.get("reason") or "")[:500] or None,
_json.dumps(s["notes"]) if s.get("notes") is not None else None,
),
)
inserted += cur.rowcount
return inserted
def delete_plan_slots(self, plan_id: int) -> int:
"""Wipe slots for a plan (used by re-roll). Also nukes the matching
pick_points so the ledger doesn't double-count on regenerate."""
with self.conn() as c, c.cursor() as cur:
cur.execute("DELETE FROM cauldron_meal_plan_slots WHERE plan_id=%s", (plan_id,))
slots_removed = cur.rowcount
cur.execute("DELETE FROM cauldron_pick_points WHERE plan_id=%s", (plan_id,))
return slots_removed
def mark_plan_generated(self, plan_id: int, sub: str) -> dict:
"""Set generated_by_sub + generated_at IF not already set. Returns
the post-update plan row. Idempotent for the same generator."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
UPDATE cauldron_meal_plans
SET generated_by_sub = %s,
generated_at = NOW()
WHERE id = %s AND generated_at IS NULL
""",
(sub, plan_id),
)
cur.execute("SELECT * FROM cauldron_meal_plans WHERE id=%s", (plan_id,))
return dict(cur.fetchone())
def clear_plan_generated(self, plan_id: int) -> None:
"""Re-roll path: clear the generated_by/at marker so the next
generate writes fresh metadata."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
UPDATE cauldron_meal_plans
SET generated_by_sub = NULL,
generated_at = NULL
WHERE id = %s
""",
(plan_id,),
)
def award_pick_points(
self,
household_id: int,
plan_id: int,
sub: str,
points: int,
reason: str = "pick_used",
) -> int:
"""Insert one ledger row. Returns the new row id. Reason must be one
of the ENUM values; we don't validate here — DB will reject bad ones."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_pick_points
(household_id, plan_id, authentik_sub, points, reason)
VALUES (%s, %s, %s, %s, %s)
""",
(household_id, plan_id, sub, int(points), reason),
)
return cur.lastrowid
def enrich_plan_with_slots(self, plan: dict) -> dict:
"""In-place: add `slots` key to a plan dict. Returns the same dict
for chaining. Empty list if there are no slots yet."""
plan["slots"] = self.list_plan_slots(plan["id"]) if plan.get("id") else []
return plan
# --- meal picks ---------------------------------------------------------
def add_meal_pick(self, sub: str, slug: str, name: str) -> bool: