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
235
cauldron/db.py
235
cauldron/db.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue