cauldron/cauldron/foods.py
Kayos d649b99aef v0.3 step 5: lean shopping list — claude on-demand foods + game strip
Two changes:

1. foods catalog grows organically. Switch the canonical seed from the
   noisy USDA dump (2462 rows of "'s, classic chicken noodle soup")
   to the Sonnet-curated cut (229 clean rows). search_food() is now
   exact + case-insensitive — Mealie's parser already canonicalizes
   food names household-side, so cauldron just needs to look them up
   verbatim. On miss, the /list view calls forge.fetch_food_info() to
   ask Sonnet for {density_g_per_ml, default_unit_class, common_size_g,
   category}, persists the row with source='claude', and the household's
   actual kitchen catalog builds itself out as Abby uses it.

   Killer case verified end-to-end: "2 cups + 50g + 1.25 lb rice"
   collapses to a single "2.25 lb rice" line on the shopping list once
   rice has a density row.

2. Game system stripped from /plan. Scoreboard panel, streak banner,
   "first to lock takes the week" / "🏆 you locked this one in" copy
   all gone. award_pick_points calls in /api/plan/generate +
   /api/plan/regenerate stopped firing. household_scoreboard /
   household_streak DB methods kept as dead code; cauldron_pick_points
   table left in place — non-destructive, easy to revive later if
   gamification comes back. Goal: get the base flow (pick → plan →
   list) working for Abby first, layer features on after.
2026-04-29 22:02:20 -07:00

143 lines
5.2 KiB
Python

"""Foods catalog — canonical food rows + the seed loader.
Seed = cauldron/data/foods_seed.json (Sonnet-curated, ~229 clean rows
with proper densities and unit classes). The raw USDA dump still lives
at foods_seed_usda.json as a reference; we don't load it directly.
Lookup is exact + case-insensitive. Mealie's parser already
canonicalizes food names household-side via its own alias system, so
the food.name we get from Mealie is consistent across recipes. When a
Mealie food name has no match in cauldron_foods, server.py's
ensure_food() calls clawdforge to fetch density+unit_class+common_size_g
for that exact name, persists it with source='claude', and the
household's catalog grows organically.
"""
import json
from pathlib import Path
SEED_PATH = Path(__file__).parent / "data" / "foods_seed.json"
def seed_count(db) -> int:
with db.conn() as c, c.cursor() as cur:
cur.execute("SELECT COUNT(*) AS n FROM cauldron_foods")
return cur.fetchone()["n"]
def load_seed_if_empty(db) -> int:
"""If cauldron_foods is empty, load the USDA seed JSON. Returns rows
inserted (0 if already populated). Called by app startup after migrate."""
if not SEED_PATH.exists():
return 0
if seed_count(db) > 0:
return 0
return _load_seed_file(db, SEED_PATH)
def reload_seed(db) -> int:
"""Force-reload the seed file (used by /api/foods/reload-seed). Won't
overwrite existing rows — INSERT IGNORE on canonical_name. Returns
rows inserted on this run."""
if not SEED_PATH.exists():
return 0
return _load_seed_file(db, SEED_PATH)
def _load_seed_file(db, path: Path) -> int:
with path.open() as f:
data = json.load(f)
inserted = 0
with db.conn() as c, c.cursor() as cur:
for entry in data:
try:
cur.execute(
"""
INSERT IGNORE INTO cauldron_foods
(canonical_name, category, density_g_per_ml,
common_size_g, default_unit_class, usda_fdc_id,
usda_description, source)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'usda')
""",
(
entry["canonical_name"][:255],
entry.get("category"),
entry.get("density_g_per_ml"),
entry.get("common_size_g"),
entry.get("default_unit_class") or "mass",
entry.get("usda_fdc_id"),
(entry.get("usda_description") or "")[:500],
),
)
inserted += cur.rowcount
except Exception:
# Skip malformed rows — seed cleanup is iterative
continue
return inserted
def search_food(db, name: str, *, limit: int = 1) -> list[dict]:
"""Case-insensitive exact match. Returns [] on miss.
`limit` kept for backwards-compat with callers; only ever returns 0 or 1.
"""
name_clean = (name or "").strip().lower()
if not name_clean:
return []
with db.conn() as c, c.cursor() as cur:
cur.execute(
"""SELECT id, canonical_name, category, density_g_per_ml,
default_unit_class, common_size_g, source
FROM cauldron_foods
WHERE LOWER(canonical_name) = %s
LIMIT 1""",
(name_clean,),
)
row = cur.fetchone()
return [dict(row)] if row else []
def upsert_claude_food(
db,
*,
canonical_name: str,
density_g_per_ml: float | None,
default_unit_class: str,
common_size_g: float | None,
category: str | None = None,
) -> dict:
"""Insert (or update if a row already exists) a canonical food row from
a clawdforge response. Returns the row as a dict. Idempotent."""
name = canonical_name.strip().lower()[:255]
cls = (default_unit_class or "mass").lower()
if cls not in ("mass", "volume", "count", "mixed"):
cls = "mass"
with db.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_foods
(canonical_name, category, density_g_per_ml,
common_size_g, default_unit_class, source)
VALUES (%s, %s, %s, %s, %s, 'claude')
ON DUPLICATE KEY UPDATE
category=COALESCE(VALUES(category), category),
density_g_per_ml=COALESCE(VALUES(density_g_per_ml), density_g_per_ml),
common_size_g=COALESCE(VALUES(common_size_g), common_size_g),
default_unit_class=VALUES(default_unit_class),
source='claude'
""",
(name, category, density_g_per_ml, common_size_g, cls),
)
cur.execute(
"""SELECT id, canonical_name, category, density_g_per_ml,
default_unit_class, common_size_g, source
FROM cauldron_foods WHERE LOWER(canonical_name)=%s LIMIT 1""",
(name,),
)
return dict(cur.fetchone())
def get_food(db, food_id: int) -> dict | None:
with db.conn() as c, c.cursor() as cur:
cur.execute("SELECT * FROM cauldron_foods WHERE id=%s", (food_id,))
return cur.fetchone()