Phase A foundation. Alice 2026-04-29: 'go big or go home' on density-table
aggregator — this commit lands the schema + seed data so the aggregator
engine has something to look up against in step 2.
DB:
- migration 010: cauldron_foods (canonical_name PK, density_g_per_ml,
default_unit_class enum mass/volume/count/mixed, common_size_g,
category, usda_fdc_id, source enum)
- migration 011: cauldron_food_mapping (per-household Mealie food_id →
cauldron canonical food_id, used by aggregator + foods-dedupe later)
Seed data:
- scripts/build_foods_seed.py — extractor that walks USDA SR Legacy
foodPortions, derives density g/ml from cup/tbsp/tsp/fl-oz/ml/etc
measurements (handles SR Legacy's quirk of putting unit in 'modifier'
with measureUnit.name='undetermined'), filters out babyfood / branded
/ fast-food / alcoholic-beverage clutter, normalizes names, categorizes
via longest-keyword-wins
- cauldron/data/foods_seed_usda.json — 2,462 foods with density values
derived from USDA. 636KB, ships in the image.
- cauldron/data/README.md — regen instructions + known issues / iteration
plan (next pass: claude-curated cleanup → ~500-800 high-relevance entries
+ count-based foods like egg/onion that USDA doesn't cover)
Loader (cauldron/foods.py):
- load_seed_if_empty(db) called on app startup right after migrate().
Idempotent — won't reload if table is non-empty.
- reload_seed(db) for forced reloads (INSERT IGNORE).
- search_food(db, name) helper for the aggregator + UI.
Categories present in seed:
produce-vegetable: 300, spice: 256, dairy: 207, condiment: 197,
legume: 189, meat: 166, beverage: 153, baking: 129, produce-fruit: 128,
oil-fat: 126, nut-seed: 115, grain: 89, other: 407
The 407 'other' bucket and the verbose USDA names ('mayonnaise, reduced
fat, with olive oil') will get cleaned up via clawdforge in step 3.
For now the aggregator can already do the math against this seed; the
unit-conversion engine is the next commit.
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
"""Foods catalog — canonical food rows + the seed loader.
|
|
|
|
Phase A step 1 (v0.3): seed cauldron_foods from USDA SR Legacy via the
|
|
JSON file at cauldron/data/foods_seed_usda.json. Idempotent — running
|
|
multiple times is fine, INSERT IGNORE on the unique canonical_name key.
|
|
|
|
Phase A step 2 (next commit): aggregator engine reads these rows + the
|
|
per-household cauldron_food_mapping to group recipe ingredients.
|
|
|
|
Phase A step 3 (later): claude-curated cleanup of the USDA seed (better
|
|
names, missing common foods, count-based foods like 'egg' / 'onion').
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
|
|
SEED_PATH = Path(__file__).parent / "data" / "foods_seed_usda.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,
|
|
default_unit_class, usda_fdc_id, usda_description, source)
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'usda')
|
|
""",
|
|
(
|
|
entry["canonical_name"][:255],
|
|
entry.get("category"),
|
|
entry.get("density_g_per_ml"),
|
|
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 = 5) -> list[dict]:
|
|
"""Best-effort canonical food lookup by name (used by aggregator + UI)."""
|
|
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
|
|
FROM cauldron_foods
|
|
WHERE canonical_name LIKE %s OR canonical_name LIKE %s
|
|
ORDER BY
|
|
(CASE WHEN canonical_name = %s THEN 0
|
|
WHEN canonical_name LIKE %s THEN 1
|
|
ELSE 2 END),
|
|
CHAR_LENGTH(canonical_name)
|
|
LIMIT %s
|
|
""",
|
|
(f"{name}%", f"%{name}%", name, f"{name}%", limit),
|
|
)
|
|
return [dict(r) for r in cur.fetchall()]
|
|
|
|
|
|
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()
|