- 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
Pure-Python module + 14 unit tests proving the centerpiece works:
test_rice_mixed:
in: [(2 cup, rice), (1.25 lb, rice)]
out: 2.25 lb rice (one line, properly mass+volume combined via density)
test_butter_mixed:
in: [(0.5 cup, butter), (4 oz, butter)]
out: ~227g butter (~8oz / 0.5 lb)
test_three_recipes:
feeds 9 ingredients across 3 recipes through the aggregator;
rice (cup + lb) collapses, garlic (cloves) sums, eggs count, salt as 'pinch'
bucketed as to-taste. All on one shopping list.
Algorithm in cauldron/aggregator.py:
1. Bucket ingredients by canonical food (foods_lookup callable injected — no DB coupling)
2. Within each food, classify each unit (mass / volume / count / vague / unknown)
3. CASE 1: only one unit class present → simple sum, display in canonical store-friendly unit
4. CASE 2: mass + volume (the killer) → use density_g_per_ml to combine to grams
5. CASE 3: count + (mass | volume) → use common_size_g to convert count to grams
6. CASE 4: anything that can't reconcile (no density, mixed unknown) → split into 1 line per class with is_split=True
7. vague (pinch, dash, to taste) → annotate as 'plus to-taste'
8. unknown units → emit verbatim with the original text
Display: store-friendly unit picker:
<30g → grams
<500g → ounces (nearest 0.5)
<2kg → pounds (nearest 0.25)
>2kg → big pounds
The aggregator is dependency-injection-friendly — foods_lookup(name) is
the only external call. Tests pass a stub dict; production will pass
foods.search_food(db, name). Decouples math from data quality.
Tests run via:
python3 -m unittest discover -s tests -v