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.
This commit is contained in:
Kayos 2026-04-29 22:02:20 -07:00
parent 36aba73f66
commit d649b99aef
6 changed files with 2444 additions and 100 deletions

View file

@ -215,6 +215,73 @@ class Forge:
)
def fetch_food_info(self, name: str, *, model: str | None = None) -> dict:
"""Ask Sonnet for density + unit class + common size of a single
food. Returns a dict shaped like:
{"density_g_per_ml": 1.04 | null,
"default_unit_class": "mass"|"volume"|"count",
"common_size_g": 150.0 | null,
"category": "produce"|"dairy"|... | null}
density_g_per_ml is null when the food doesn't sensibly convert
between mass and volume (e.g., whole onions, eggs these are
count-style). common_size_g lets the aggregator handle "1 onion"
as a count mass conversion. Cheap call, cached forever once
persisted to cauldron_foods.
"""
prompt = (
f"Give nutritional/cooking metadata for the food: {name!r}.\n\n"
"Output JSON ONLY, no prose: "
'{"density_g_per_ml": float|null, '
'"default_unit_class": "mass"|"volume"|"count", '
'"common_size_g": float|null, '
'"category": "produce"|"dairy"|"meat"|"grain"|"baking"|"pantry"'
'|"spice"|"oil"|"beverage"|"other"|null}\n\n'
"Rules:\n"
"- density_g_per_ml: typical packed/cooking density. Null if "
"the food is count-based (whole onions, eggs).\n"
"- default_unit_class: how this food is most often measured "
"(salt=mass; milk=volume; egg=count).\n"
"- common_size_g: the typical mass of one whole unit (1 onion "
"≈ 150g; 1 egg ≈ 50g). Null if the food isn't naturally counted.\n"
"- category: best single fit; null if uncertain.\n"
)
result = self.run(prompt, model=model or "sonnet", timeout_secs=60)
return _extract_food_info(result)
def _extract_food_info(forge_result: dict) -> dict:
"""Normalize clawdforge wrapper → food info dict. Defensive on shapes."""
if not isinstance(forge_result, dict):
raise ForgeError("forge result not a dict")
inner = forge_result.get("result", forge_result)
if isinstance(inner, str):
inner = _parse_json_blob(inner)
if not isinstance(inner, dict):
raise ForgeError(f"forge result not a dict: {str(inner)[:200]}")
cls = (inner.get("default_unit_class") or "mass").strip().lower()
if cls not in ("mass", "volume", "count", "mixed"):
cls = "mass"
def _f(v):
if v is None:
return None
try:
x = float(v)
return x if x > 0 else None
except (TypeError, ValueError):
return None
return {
"density_g_per_ml": _f(inner.get("density_g_per_ml")),
"default_unit_class": cls,
"common_size_g": _f(inner.get("common_size_g")),
"category": (inner.get("category") or None) and str(inner["category"])[:64],
}
def _extract_plan_slots(forge_result: dict):
"""clawdforge wraps its return; the JSON we asked for can sit in a few
different shapes. Normalize aggressively."""