cauldron/cauldron/db.py
Sulkta c9603a07c1 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
2026-04-29 06:26:54 -07:00

855 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""DB access + migrations against mariadb.
Uses PyMySQL with a tiny per-request connection (no pool) — Cauldron is
LAN-only family-internal, traffic is single-digit qps. If load ever grows
swap in DBUtils.PooledDB or SQLAlchemy.
"""
from contextlib import contextmanager
from pathlib import Path
import pymysql
import pymysql.cursors
MIGRATIONS = [
# 001 — bookkeeping
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(16) PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 002 — users (Authentik subject is the PK)
"""
CREATE TABLE IF NOT EXISTS cauldron_users (
authentik_sub VARCHAR(190) PRIMARY KEY,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME,
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 003 — per-user encrypted Mealie tokens
"""
CREATE TABLE IF NOT EXISTS cauldron_user_mealie_tokens (
authentik_sub VARCHAR(190) PRIMARY KEY,
encrypted_token BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_validated DATETIME,
last_failure_at DATETIME,
last_failure_reason VARCHAR(500),
FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 004 — chat / AI run log (joins to clawdforge runs server-side)
"""
CREATE TABLE IF NOT EXISTS cauldron_chat_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
authentik_sub VARCHAR(190) NOT NULL,
ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
intent VARCHAR(64),
forge_duration_ms INT,
forge_model VARCHAR(64),
prompt_chars INT,
result_chars INT,
ok BOOLEAN NOT NULL DEFAULT TRUE,
error VARCHAR(500),
INDEX idx_user_ts (authentik_sub, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 005 — meal picks: per-user list of recipes the user wants in the next
# AI meal plan run. Pre-populated wishlist that the planner respects.
"""
CREATE TABLE IF NOT EXISTS cauldron_meal_picks (
authentik_sub VARCHAR(190) NOT NULL,
recipe_slug VARCHAR(255) NOT NULL,
recipe_name VARCHAR(500) NOT NULL,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (authentik_sub, recipe_slug),
INDEX idx_user_added (authentik_sub, added_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 006 — households (cached mirror of Mealie's household) + membership.
# Keyed by Mealie's UUID. Multiple cauldron users join via the same
# Mealie household to share picks/plans.
"""
CREATE TABLE IF NOT EXISTS cauldron_households (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
mealie_household_id VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS cauldron_household_members (
household_id BIGINT NOT NULL,
authentik_sub VARCHAR(190) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'member',
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (household_id, authentik_sub),
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE,
FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 007 — meal plans (per household per week). Lock state + race metadata.
# week_start = Monday (date) of the week.
"""
CREATE TABLE IF NOT EXISTS cauldron_meal_plans (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
household_id BIGINT NOT NULL,
week_start DATE NOT NULL,
generated_by_sub VARCHAR(190),
generated_at DATETIME,
locked_by_sub VARCHAR(190),
locked_at DATETIME,
locked_reason ENUM('user','auto') DEFAULT NULL,
UNIQUE KEY uk_household_week (household_id, week_start),
INDEX idx_locked_by (locked_by_sub),
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 008 — local recipe index for fast in-process search. Mirrors enough
# of Mealie's recipe shape to fuzzy-rank without round-tripping to
# Mealie on every keystroke. Refreshed on demand (on first /recipes
# load, after pin/unpin, every 5min, or on /me 'refresh' button).
"""
CREATE TABLE IF NOT EXISTS cauldron_recipe_index (
household_id BIGINT NOT NULL,
slug VARCHAR(255) NOT NULL,
name VARCHAR(500) NOT NULL,
description TEXT,
tags_text TEXT,
cats_text TEXT,
foods_text TEXT,
ings_text TEXT,
date_updated DATETIME,
date_added DATETIME,
last_made DATETIME,
total_time VARCHAR(64),
recipe_yield VARCHAR(255),
raw_json JSON,
indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (household_id, slug),
FULLTEXT KEY ft_text (name, description, tags_text, cats_text, foods_text),
INDEX idx_household (household_id),
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 009 — refresh state per household
"""
CREATE TABLE IF NOT EXISTS cauldron_recipe_index_state (
household_id BIGINT PRIMARY KEY,
last_refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
recipe_count INT NOT NULL DEFAULT 0,
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 010 — canonical foods table for the unit-aware aggregator. Each row is
# ONE food (e.g. "rice", "butter", "onion") with density + unit class.
# Seeded from USDA SR Legacy via scripts/build_foods_seed.py; will be
# extended with claude-curated entries in v0.3 step 2.
"""
CREATE TABLE IF NOT EXISTS cauldron_foods (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
canonical_name VARCHAR(255) NOT NULL,
plural_name VARCHAR(255),
category VARCHAR(64),
density_g_per_ml DECIMAL(6,3),
common_size_g DECIMAL(8,2),
default_unit_class ENUM('mass','volume','count','mixed') NOT NULL DEFAULT 'mass',
usda_fdc_id INT,
usda_description VARCHAR(500),
notes JSON,
source ENUM('usda','claude','manual') NOT NULL DEFAULT 'usda',
last_updated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_canonical (canonical_name),
INDEX idx_category (category),
INDEX idx_usda (usda_fdc_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 011 — Mealie food_id → cauldron food_id mapping per household. The
# foods dedupe step (v0.3 A2) populates this. Aggregator joins through
# this to group ingredients across recipes by canonical food.
"""
CREATE TABLE IF NOT EXISTS cauldron_food_mapping (
household_id BIGINT NOT NULL,
mealie_food_id VARCHAR(64) NOT NULL,
cauldron_food_id BIGINT NOT NULL,
confidence DECIMAL(4,2) NOT NULL DEFAULT 1.00,
mapped_by ENUM('exact','fuzzy','claude','manual') NOT NULL DEFAULT 'fuzzy',
mapped_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (household_id, mealie_food_id),
INDEX idx_canonical (cauldron_food_id),
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE,
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
""",
]
class DB:
def __init__(self, *, host: str, port: int, name: str, user: str, password: str):
self.kwargs = dict(
host=host,
port=port,
user=user,
password=password,
database=name,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
@contextmanager
def conn(self):
c = pymysql.connect(**self.kwargs)
try:
yield c
c.commit()
except Exception:
c.rollback()
raise
finally:
c.close()
def migrate(self) -> list[str]:
"""Apply pending migrations. Returns list of versions applied."""
applied: list[str] = []
with self.conn() as c:
with c.cursor() as cur:
cur.execute(MIGRATIONS[0]) # bootstrap migrations table
cur.execute("SELECT version FROM schema_migrations")
done = {r["version"] for r in cur.fetchall()}
for i, sql in enumerate(MIGRATIONS, start=1):
ver = f"{i:03d}"
if ver in done:
continue
cur.execute(sql)
# IGNORE to tolerate the multi-worker boot race where two
# gunicorn workers both bootstrap an empty migrations table
cur.execute(
"INSERT IGNORE INTO schema_migrations (version) VALUES (%s)", (ver,)
)
applied.append(ver)
return applied
# --- user ops -----------------------------------------------------------
def upsert_user(self, *, sub: str, email: str, display_name: str | None) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_users (authentik_sub, email, display_name, last_seen)
VALUES (%s, %s, %s, NOW())
ON DUPLICATE KEY UPDATE
email = VALUES(email),
display_name = COALESCE(VALUES(display_name), display_name),
last_seen = NOW()
""",
(sub, email, display_name),
)
def get_user(self, sub: str) -> dict | None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT authentik_sub, email, display_name, last_seen FROM cauldron_users WHERE authentik_sub=%s",
(sub,),
)
return cur.fetchone()
# --- mealie token ops ---------------------------------------------------
def get_user_mealie_token_blob(self, sub: str) -> bytes | None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT encrypted_token FROM cauldron_user_mealie_tokens WHERE authentik_sub=%s",
(sub,),
)
row = cur.fetchone()
return row["encrypted_token"] if row else None
def set_user_mealie_token_blob(self, sub: str, blob: bytes) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_user_mealie_tokens (authentik_sub, encrypted_token, last_validated)
VALUES (%s, %s, NOW())
ON DUPLICATE KEY UPDATE
encrypted_token = VALUES(encrypted_token),
last_validated = NOW(),
last_failure_at = NULL,
last_failure_reason = NULL
""",
(sub, blob),
)
def delete_user_mealie_token(self, sub: str) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"DELETE FROM cauldron_user_mealie_tokens WHERE authentik_sub=%s",
(sub,),
)
def mark_user_mealie_token_failure(self, sub: str, reason: str) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
UPDATE cauldron_user_mealie_tokens
SET last_failure_at = NOW(), last_failure_reason = %s
WHERE authentik_sub = %s
""",
(reason[:500], sub),
)
# --- households ---------------------------------------------------------
def upsert_household(self, *, mealie_household_id: str, name: str) -> int:
"""Create or update a household record. Returns local PK (id)."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_households (mealie_household_id, name)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE name = VALUES(name), id = LAST_INSERT_ID(id)
""",
(mealie_household_id, name),
)
return cur.lastrowid
def add_household_member(self, household_id: int, sub: str, role: str = "member") -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT IGNORE INTO cauldron_household_members
(household_id, authentik_sub, role)
VALUES (%s, %s, %s)
""",
(household_id, sub, role),
)
def get_user_household_id(self, sub: str) -> int | None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT household_id FROM cauldron_household_members WHERE authentik_sub=%s LIMIT 1",
(sub,),
)
row = cur.fetchone()
return row["household_id"] if row else None
def list_household_member_subs(self, household_id: int) -> list[str]:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT authentik_sub FROM cauldron_household_members WHERE household_id=%s",
(household_id,),
)
return [r["authentik_sub"] for r in cur.fetchall()]
# --- meal plans (per household per week) -------------------------------
def get_or_create_plan(self, household_id: int, week_start) -> dict:
"""Get the plan record for a (household, week_start), creating an
empty one if it doesn't exist. week_start is a date (Monday)."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT IGNORE INTO cauldron_meal_plans (household_id, week_start)
VALUES (%s, %s)
""",
(household_id, week_start),
)
cur.execute(
"SELECT * FROM cauldron_meal_plans WHERE household_id=%s AND week_start=%s",
(household_id, week_start),
)
return dict(cur.fetchone())
def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict:
"""Lock a plan if not already locked. Returns updated plan dict."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
UPDATE cauldron_meal_plans
SET locked_by_sub = %s, locked_at = NOW(), locked_reason = %s
WHERE id = %s AND locked_at IS NULL
""",
(sub, reason, plan_id),
)
cur.execute("SELECT * FROM cauldron_meal_plans WHERE id=%s", (plan_id,))
return dict(cur.fetchone())
def auto_lock_past_unlocked_plans(self, household_id: int, before_date) -> int:
"""Mark any past unlocked plans as auto-locked. Returns count."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
UPDATE cauldron_meal_plans
SET locked_at = NOW(), locked_reason = 'auto'
WHERE household_id = %s AND week_start < %s AND locked_at IS NULL
""",
(household_id, before_date),
)
return cur.rowcount
def household_scoreboard(self, household_id: int) -> list[dict]:
"""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,
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
WHERE m.household_id = %s
ORDER BY points DESC, wins DESC, last_win DESC
""",
(household_id,),
)
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,
counting consecutive weeks won by the same user. Returns
{sub, display_name, count} or None if no locks."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT p.week_start, p.locked_by_sub, u.display_name, u.email
FROM cauldron_meal_plans p
LEFT JOIN cauldron_users u ON u.authentik_sub = p.locked_by_sub
WHERE p.household_id = %s
AND p.locked_at IS NOT NULL
AND p.locked_reason = 'user'
ORDER BY p.week_start DESC
""",
(household_id,),
)
rows = cur.fetchall()
if not rows:
return None
leader = rows[0]["locked_by_sub"]
count = 0
for r in rows:
if r["locked_by_sub"] != leader:
break
count += 1
return {
"sub": leader,
"display_name": rows[0]["display_name"] or rows[0]["email"],
"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:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT IGNORE INTO cauldron_meal_picks (authentik_sub, recipe_slug, recipe_name)
VALUES (%s, %s, %s)
""",
(sub, slug, name[:500]),
)
return cur.rowcount > 0
def remove_meal_pick(self, sub: str, slug: str) -> bool:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"DELETE FROM cauldron_meal_picks WHERE authentik_sub=%s AND recipe_slug=%s",
(sub, slug),
)
return cur.rowcount > 0
def list_meal_picks(self, sub: str) -> list[dict]:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT recipe_slug, recipe_name, added_at FROM cauldron_meal_picks "
"WHERE authentik_sub=%s ORDER BY added_at DESC",
(sub,),
)
return [dict(r) for r in cur.fetchall()]
def list_meal_pick_slugs(self, sub: str) -> set[str]:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT recipe_slug FROM cauldron_meal_picks WHERE authentik_sub=%s",
(sub,),
)
return {r["recipe_slug"] for r in cur.fetchall()}
def list_household_pick_slugs(self, household_id: int) -> set[str]:
"""Union of picks across all members of the household."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT p.recipe_slug
FROM cauldron_meal_picks p
JOIN cauldron_household_members m ON m.authentik_sub = p.authentik_sub
WHERE m.household_id = %s
""",
(household_id,),
)
return {r["recipe_slug"] for r in cur.fetchall()}
def list_household_picks_with_pickers(self, household_id: int) -> list[dict]:
"""All picks across the household, grouped by slug, with the list of
members who picked each (so the UI can show 'pinned by Alice · Bob').
Latest pick added_at per slug for ordering."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT
p.recipe_slug AS slug,
MIN(p.recipe_name) AS name,
GROUP_CONCAT(
DISTINCT COALESCE(NULLIF(u.display_name, ''),
SUBSTRING_INDEX(u.email, '@', 1))
ORDER BY p.added_at ASC
SEPARATOR '|'
) AS pickers,
GROUP_CONCAT(
DISTINCT u.authentik_sub
ORDER BY p.added_at ASC
SEPARATOR '|'
) AS picker_subs,
MAX(p.added_at) AS last_pick_at,
COUNT(*) AS pick_count
FROM cauldron_meal_picks p
JOIN cauldron_household_members m ON m.authentik_sub = p.authentik_sub
LEFT JOIN cauldron_users u ON u.authentik_sub = p.authentik_sub
WHERE m.household_id = %s
GROUP BY p.recipe_slug
ORDER BY last_pick_at DESC
""",
(household_id,),
)
out = []
for r in cur.fetchall():
d = dict(r)
d["pickers"] = (d["pickers"] or "").split("|") if d["pickers"] else []
d["picker_subs"] = (d["picker_subs"] or "").split("|") if d["picker_subs"] else []
out.append(d)
return out
# --- recipe index -------------------------------------------------------
def get_index_state(self, household_id: int) -> dict | None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT last_refreshed_at, recipe_count FROM cauldron_recipe_index_state WHERE household_id=%s",
(household_id,),
)
return cur.fetchone()
def replace_recipe_index(self, household_id: int, rows: list[dict]) -> int:
"""Atomic-ish replace of the index for one household. Drops + reinserts."""
import json as _json
with self.conn() as c, c.cursor() as cur:
cur.execute("DELETE FROM cauldron_recipe_index WHERE household_id=%s", (household_id,))
for r in rows:
cur.execute(
"""
INSERT INTO cauldron_recipe_index
(household_id, slug, name, description, tags_text, cats_text,
foods_text, ings_text, date_updated, date_added, last_made,
total_time, recipe_yield, raw_json)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
household_id,
r["slug"],
r["name"][:500],
(r.get("description") or "")[:65000],
(r.get("tags_text") or "")[:65000],
(r.get("cats_text") or "")[:65000],
(r.get("foods_text") or "")[:65000],
(r.get("ings_text") or "")[:65000],
r.get("date_updated"),
r.get("date_added"),
r.get("last_made"),
(r.get("total_time") or "")[:64],
(r.get("recipe_yield") or "")[:255],
_json.dumps(r.get("raw") or {}, default=str),
),
)
cur.execute(
"""
INSERT INTO cauldron_recipe_index_state (household_id, last_refreshed_at, recipe_count)
VALUES (%s, NOW(), %s)
ON DUPLICATE KEY UPDATE last_refreshed_at=NOW(), recipe_count=VALUES(recipe_count)
""",
(household_id, len(rows)),
)
return len(rows)
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]:
"""Pull the indexed recipe rows. Used both for non-search browse + as
the candidate set for in-process fuzzy ranking on search."""
order_col = {
"date_added": "date_added",
"date_updated": "date_updated",
"last_made": "last_made",
"name": "name",
}.get(order_by, "date_added")
order_dir_sql = "DESC" if order_dir.lower() != "asc" else "ASC"
sql = f"""
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
"""
params: list = [household_id]
if category:
sql += " AND cats_text LIKE %s"
params.append(f"%{category}%")
sql += f" ORDER BY {order_col} {order_dir_sql} LIMIT %s OFFSET %s"
params += [limit, offset]
with self.conn() as c, c.cursor() as cur:
cur.execute(sql, params)
return [dict(r) for r in cur.fetchall()]
# --- chat log -----------------------------------------------------------
def log_chat(
self,
*,
sub: str,
intent: str,
duration_ms: int,
model: str,
prompt_chars: int,
result_chars: int,
ok: bool,
error: str | None = None,
) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_chat_log
(authentik_sub, intent, forge_duration_ms, forge_model,
prompt_chars, result_chars, ok, error)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(sub, intent, duration_ms, model, prompt_chars, result_chars, ok, (error or "")[:500] or None),
)