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.
143 lines
5.2 KiB
Python
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()
|