Commit graph

16 commits

Author SHA1 Message Date
8752fcd340 plan: Flavor B — 🔮 forgotten gems pulls from Discover too
The suggestion panel now folds enriched-but-unimported Discover entries
into the same Hecate-driven pool as library recipes. Pinning a discover
suggestion auto-imports it to the household's Mealie library and adds
the resulting slug to cauldron_meal_picks.

- db: list_discover_eligible_for_group(mealie_group_id, limit=80) — joins
  cauldron_discovered_recipes against cauldron_discover_imports to find
  enriched rows no household in the caller's group has imported yet
- forge.suggest_recipes: accepts a per-entry `source` field; when any
  entry is source='discover' the prompt gains an explicit hint so Hecate
  treats discover entries as "something new to try" with a soft cap on
  proportion (~half max unless library exhausted)
- /api/plan/suggest: builds a unified pool with prefixed IDs (lib:<slug>
  vs disc:<id>) so library and discover entries can coexist in Sonnet's
  validation map; decorates each suggestion with kind+image+meta_summary+
  hecate_quip; discover entries also carry source_url and source_host
- /api/plan/suggest/pin: new dispatch on body.kind:
  - kind=library (default — back-compat with existing Flavor A callers):
    same as before
  - kind=discover: looks up the discover row, short-circuits to cached
    Mealie slug if THIS household already imported it, else
    mealie.import_from_url() + record_discover_import() + add to picks.
    Returns {kind, mealie_slug, imported, added_to_picks}
- plan.html: card render is kind-aware. Discover entries get a
  "📬 from Discover · <host>" footer instead of last-planned recall, an
  "import + pin" button label, and use data-* attributes for the click
  payload so the JS doesn't need to template-interpolate slugs into
  onclick handlers
2026-05-01 20:57:44 -07:00
d561a9373e plan: Flavor A — 🔮 suggest forgotten gems
Hecate looks through the household's library and surfaces 1-6 recipes
that fit the picker profiles but haven't been served in 90+ days (or
ever). Server-side filters drop recently-planned + already-on-this-week
+ already-on-picks-list + allergen-conflict before Sonnet sees the pool,
keeping the prompt focused. Each suggestion comes with a 1-5 fit score
and a one-line reason in Hecate's voice. Pin → adds to the user's picks
so the next /plan/generate will naturally pull it in. Skip → hides the
card.

Endpoints:
  POST /api/plan/suggest        body {count?: 1-6, week?: ISO}
  POST /api/plan/suggest/pin    body {recipe_slug}

Layered on top of 37d7d60 (parser fix, committed not deployed). Both
should land together once enrich job 3 drains.
2026-05-01 00:07:03 -07:00
37d7d60a8b forge: tolerant JSON parsing — extract first object even if Sonnet
appends extra prose

Job 3 hit one recipe (healthy-chicken-stir-fry-with-vegetables) where
Sonnet returned a valid JSON object + appended prose afterward, in
violation of 'no prose' rule. json.loads choked with 'Extra data:
line 54'. _parse_json_blob now falls back to JSONDecoder.raw_decode
which extracts the first complete JSON value and ignores anything
after — any trailing notes / fence remnants / inline commentary get
silently dropped.

Plain json.loads is still tried first (fastest path on clean output).
The fallback only kicks in for malformed-but-recoverable cases.
2026-04-30 22:50:12 -07:00
1445e0cbab enrich v5: more agent context — cooking, kid-fit, expanded macros, occasion,
hecate's quip + macro confidence + chain-of-thought macro estimation

Eight new fields packed in before Cobb's force re-enrich rerun:

(1) active_minutes + hands_off_minutes — split estimated_minutes into
    "you must be present" vs "rise/marinade/braise unattended". Lets
    weeknight planning match real available active time. A 4-hour beef
    stew might be 30 active + 210 hands-off — fine for Sunday but
    misleading as "240 minutes" on a busy weeknight.

(2) equipment[] — required appliances/modes from a fixed enum:
    oven, stovetop, grill, instant-pot, slow-cooker, sheet-pan,
    cast-iron, air-fryer, food-processor, blender, mixer, smoker,
    sous-vide, no-cook. Foundation for "no oven this week" filters.

(3) flavor_profile[] — 2-5 dominant flavor notes (spicy, sweet,
    savory, umami, tangy, smoky, herby, citrusy, rich, fresh, bitter,
    earthy). Lets the planner enforce variety so we don't get five
    spicy nights in a row.

(4) kid_friendly_score 1-5 — separate from comfort_tier. Cobb has
    Leia and Luna; this is real signal. 1=adults-only (capers, blue
    cheese, super spicy), 5=kids beg for it (mac and cheese, pancakes).

(5) fiber_g + sodium_mg per serving — extends the macro coverage.
    Sodium for heart-health weeks, fiber for gut weeks.

(6) cost_per_serving_estimate — rough USD per serving (2026 prices).
    Bean bowl $2, salmon for two $8-12, fancy roast $15+. Foundation
    for budget-week preference. Set null for too-volatile items.

(7) occasion_fit[] — when is this dish AT HOME? weeknight, weekend,
    brunch, date-night, party, picnic, camping, holiday, game-day,
    kids-birthday, quiet-night-in.

(8) hecate_quip — one-line voice description in Hecate's mythic-witch
    tone. Pure flavor for tooltips/detail pages. ~10-20 words. Examples:
    "Pure midwinter comfort — asks for a fire and quiet."
    "Sharp and bright like a Tuesday morning resolution."

MACRO QUALITY (Cobb's separate ask):
- Prompt now instructs Sonnet to compute macros chain-of-thought:
  list each major ingredient, approximate its per-serving contribution
  in grams, sum, output. Plus a sanity check: protein×4 + carbs×4 +
  fat×9 ≈ calories. Not free precision but better than guess-the-total.
- New macros_confidence field (low/medium/high) — Sonnet rates own
  certainty so users know when to trust the numbers and the planner
  can avoid summing low-confidence macros over a 7-day budget.
- Future commit: deterministic USDA-FDC backfill into cauldron_food_
  metadata for true ingredient-by-ingredient sums. Same shape as the
  existing density backfill we did in Step 2.

Plan generator pool prompt expanded — every recipe entry now carries
inline:
  active=Nm+offhand=Mm
  eq:oven,stovetop
  flavor:savory/herby/rich
  kid=4
  ~$5/svg
  for:weeknight/date-night
  fiber=Ng / sodium=Nmg

ENRICH_VERSION 4→5. Cobb's force re-enrich gets all of these on the
existing 222 recipes in one walk.
2026-04-30 22:12:29 -07:00
89f33f237c plan agent: per-user fit + pairings + mood + leftover_potential +
Hecate's weekly reading + 2nd-pass allergen verification

Five new context dimensions for the planner agent (and one quality
fix), bumping ENRICH_VERSION 3→4 so existing meta gets refreshed on
the next walk:

(A) Per-user fit score. Computed at plan-gen time from picker_profiles
    × meta — no extra Sonnet calls. _compute_fit_score scores each
    recipe 1-5 per household member based on cuisine match, protein
    match, comfort_tier match, and tag overlap with that user's
    historical picks. Renders in the recipe pool prompt as
    'fit:cobb=5,abby=2,leia=3'. Gives Sonnet per-user signal to
    bias AI-chosen slots toward whoever's home that week, AND makes
    pick rationale explainable.

(B) Pairings per recipe. New meta fields:
        pairings.serves_well_with: ["crusty bread","green salad",...]
        pairings.drinks: ["pinot noir","iced tea",...]
    Sonnet enriches both during the main pass. Foundation for future
    "auto-suggest matching side" UX in multi-meal slots.

(C) Leftover potential 1-5. Per-recipe score: 1=eat-now-only (crispy
    things, fresh salads), 5=actually BETTER as leftovers (stews,
    braises, lasagna). Lets the planner thread Sunday's slow-cooked
    pot roast into Monday's lunch slots intentionally.

(D) Mood scores. Per-recipe 1-5 ratings on:
        cozy / summer_fresh / energizing / comfort
    Independent dimensions — a recipe can score high on multiple.
    Foundation for future weather/mood-aware planning ("rainy week
    → bias cozy>=4").

(E) Hecate's weekly reading. New TEXT column hecate_reading on
    cauldron_meal_plans (migration 032). The plan-gen prompt asks
    Hecate to write a 1-paragraph narrative voice description of the
    week — "this week leans into the brisk turn of the season, three
    hearty one-pots front-load your weekday energy, salmon Wednesday
    for Cobb's gym push..." Confident, wise, theatrical voice. Pure
    flavor, no functional impact, but makes Hecate FEEL like an
    advisor not an opaque function. Plus the planner system prompt
    now opens with the Hecate persona ("You are Hecate, Greek-
    mythology witch goddess of crossroads, herbs, and magic — and
    the family's meal planner"). Output schema gains a "reading"
    field alongside "slots".

ALLERGEN VERIFICATION (the quality fix Cobb explicitly asked for):
- Sonnet sometimes flags pork=true on a sweet potato recipe via the
  conservative-default rule. Cobb wants CLEAN data.
- New forge.verify_allergens — second Sonnet pass after main enrich
  with a strict prompt: "name the SPECIFIC ingredient triggering each
  allergen flag, or set FALSE." For non-anaphylaxis exclusions like
  pork, set FALSE unless an actual pork ingredient is named. For
  ANAPHYLAXIS allergens, conservative TRUE still applies.
- Cost: ~3s/recipe extra. Wired into enrich_recipes.run_enrich after
  initial enrich; failure is non-fatal (falls through to original).
- Eliminates the pork-on-sweet-potatoes class of false positives.

Code restructure: forge.generate_plan now returns {slots, reading}
instead of just slots. _extract_plan_payload pulls both. Server-side
generate + regenerate paths unwrap and persist reading via the new
db.set_plan_hecate_reading. Plan template renders the reading in a
purple-bordered serif callout above the day grid.

Schema:
- migration 032 adds hecate_reading TEXT to cauldron_meal_plans.
- Cauldron_recipe_meta gets new fields persisted in meta_json (no
  schema change there — JSON column already accommodates).
2026-04-30 22:04:46 -07:00
a6a28ef6e4 plan: multi-meal slots (breakfast/lunch/dinner) + rename agent → Sage
Big plan-generator addition: each plan can now span multiple meal types,
not just dinner. Default stays dinner-only for back-compat; opt-in via
checkboxes on /plan.

Schema (migrations 028-031):
- cauldron_meal_plan_slots gains meal_type ENUM('breakfast','lunch',
  'dinner','snack','dessert','side') NOT NULL DEFAULT 'dinner'.
- Old UNIQUE key (plan_id, day) → (plan_id, day, meal_type) so a
  Monday can have breakfast AND lunch AND dinner slots.
- cauldron_meal_plans gains meal_types_json (which meals to plan
  for that week — list of strings, defaults to ["dinner"]).

Forge:
- generate_plan accepts meal_types list. Output schema gains meal_type
  per slot. Validates expected_total = slots * len(meal_types) and
  rejects duplicate (day, meal_type) pairs.
- _build_plan_prompt renders MEAL TYPES TO PLAN block, instructing
  Sonnet to match recipe meta.meal_type to slot type (breakfast slot
  → recipe whose meta tags it as breakfast). Falls back gracefully
  when the pool is thin for a particular meal type.

Server:
- /api/plan/generate + regenerate accept body.meal_types, persist via
  db.set_plan_meal_types.
- plan_view decodes meal_types_json into plan["meal_types_list"] and
  builds plan["meal_types_label"] for the readout.

UI (/plan):
- New checkbox row at the top of the pref-block: 🍳 breakfast / 🥪 lunch
  / 🍽️ dinner. Defaults to whatever's persisted (or just dinner).
- Day cards now group multiple meal_type slots per day with small
  meal-type tags above each recipe row. Single-meal plans render the
  same way they always did (no tag shown when only one meal_type).
- readMealTypes() in JS reads checkboxes and ships in the body.

DB:
- save_plan_slots accepts meal_type per slot, defaults to 'dinner'.
- list_plan_slots orders by day then meal_type via MEAL_ORDER.

==

UX rename: "claude" / "sonnet" → "Sage" across all user-visible copy.
Sage doubles as kitchen-herb (theme fit) and wise advisor (planner
role). The internal field name `sonnet_decision` on consolidate +
dedupe proposals is unchanged (it's a data field, not user-facing).
Renames touched plan, consolidate, dedupe_recipes, list, me,
enrich_recipes, sterilize templates. Cobb can swap to Mim or his own
name later — easy global s/sage/whatever/g.

==

The /list 'clear' button removed earlier today (b4cb48b) — not
re-introduced.
2026-04-30 21:44:56 -07:00
b4cb48bef8 list: drop confusing 'clear' button + bump enrich timeout 90→180s
(1) /list had a 'clear' button that just unchecked all the localStorage
shopping checkboxes. Cobb correctly flagged it as confusing — looks
like a destructive action ('clear what?'). It was just a UX nicety
for re-using the same shopping list, not a plan-touching operation.
The plan-level reset already lives on /plan via a properly-scoped
button. Removing the /list 'clear' so there's exactly one reset
concept in the app: /plan → ⌫ reset week.

(2) forge.enrich_recipe was hitting clawdforge with timeout_secs=90
which proved too short for one recipe (peppery-barbecue-glazed-shrimp
type long ingredient list). Bumped to 180s to match the sterilizer's
post-fix timeout. Walks now have more slack before timing out a single
recipe.
2026-04-30 21:34:51 -07:00
07dab10c4b plan UI: numeric daily macro targets + allergen exclusions
The next-tier of structured planning constraints, building on the
free-form preference textarea. Now the household can set hard rules
like '2200 cal/day, 150g protein, no dairy or shellfish' and the
planner enforces them.

Schema (migrations 026 + 027):
- daily_targets_json on cauldron_meal_plans: {calories?, protein_g?,
  carbs_g?, fat_g?} per-day budget. Sonnet sums per-recipe macros
  across the 7-day plan and aims for ±15% of the weekly total.
- exclusions_json on cauldron_meal_plans: list of contains.* keys
  (subset of {dairy, gluten, nuts, peanuts, eggs, shellfish, fish,
  soy, sesame, pork}). Hard filter on AI-chosen slots; picks still
  apply but get flagged with the conflict in their reason field.

DB:
- set_plan_targets_and_exclusions normalizes inputs (drops zeros,
  validates against allowed enum values, lowercases).
- The plan_view path decodes the JSON columns server-side and adds
  display labels (`targets_label`, `exclusions_label`) and the parsed
  dict/list (`targets_dict`, `exclusions_list`) for the template.

Forge:
- generate_plan accepts daily_targets dict + exclusions list.
- New DAILY MACRO TARGETS prompt block — instructs Sonnet to sum
  per-serving macros across all 7 slots and land within ±15% of
  weekly totals; tradeoff slots when needed.
- New STRICT EXCLUSIONS prompt block — recipes whose has: list
  intersects exclusions are forbidden in AI-chosen slots. Picks that
  conflict still appear (explicit user choice) but get flagged in
  the slot reason ("contains dairy — household pick").

Server:
- /api/plan/generate accepts {preference, targets, exclusions}.
  Persists all three before kicking off Sonnet so re-rolls reuse them.
- /api/plan/regenerate same — empty body reuses persisted constraints.

UI (/plan):
- New <details> section "numeric targets + allergen exclusions
  (optional)" tucked under the existing vibe textarea + presets.
- 4 numeric inputs (cal/protein/carbs/fat per day) with quick-set
  preset chips: balanced 2200 / protein lean 2400 / carb load 2600 /
  cut 1800 / clear.
- 10 allergen checkbox chips with red-pill styling when checked
  (uses CSS :has(input:checked) — modern browsers).
- Hydration: persisted targets pre-fill the inputs, persisted
  exclusions pre-check the boxes.
- After generation, three readouts above the action buttons show
  active vibe / macros / exclusions.

Cobb's exact ask "2200 cal/day, protein and carb balanced, no dairy"
now maps to: targets={calories: 2200, protein_g: 150, carbs_g: 250}
+ exclusions=[dairy] + free-form preference (optional). All persist.
Re-rolls iterate within those constraints automatically.
2026-04-30 20:31:28 -07:00
4db447edad plan: cook history + per-serving macros + allergens + picker profiles
Tier-1 data additions for the planner — turning the AI from a title-
matching guesser into a structured-data consumer. ENRICH_VERSION bumped
2→3 so existing meta gets refreshed with the new fields on next walk.

(A) Cook history. db.household_recipe_history aggregates recipe slug
    → {last_planned_date, count_30d, count_long} from cauldron_meal_
    plan_slots over a 180-day window. The plan generator's pool prompt
    now renders each recipe with rotation context: "last:8w-ago 0×/30d
    1×/180d". New planner rule: ROTATION — demote recipes shown 2+
    times in 30d unless they're picks; never repeat the same slug
    within the 7-day plan. New planner rule: VARIETY — don't fill 5
    of 7 slots with the same primary_protein or cuisine.

(B) Per-serving macros in enrichment. forge.enrich_recipe now asks
    Sonnet for calories, protein_g, carbs_g, fat_g per serving (rough
    USDA-grade estimates from ingredient list + yields). Renders into
    the pool prompt as "~480cal protein=32g carbs=45g fat=18g". Lets
    "high protein week" become a quantitative filter instead of a
    title-keyword match.

(C) Allergen booleans. New contains.* block in enrichment:
    {dairy, gluten, nuts, peanuts, eggs, shellfish, fish, soy, sesame,
    pork} — bool per allergen, conservatively defaulting to TRUE when
    uncertain since false negatives can hurt people. Pool prompt
    renders as "has:dairy,gluten,eggs". Foundation for upcoming
    "no dairy this week" exclusion-list UI on /plan.

(D) Picker profiles. db.household_picker_profiles unions current
    cauldron_meal_picks + historical meal_plan_slots.picker_subs over
    365 days, joins with cauldron_recipe_meta, aggregates per-user:
        {display_name, total_picks, cuisines, proteins, comfort_tiers,
         tags} — top-N counters each. Plan generator includes a new
    PICKER PROFILES block in the prompt:
        - cobb (sub=cobb@sulkta.com, 24 picks):
            cuisines=[asian:6, mexican:4, italian:3] ·
            proteins=[chicken:8, beef:5, fish:2] ·
            tags=[weeknight:11, high-protein:9, spicy:7]
    Sonnet uses these to bias AI-chosen slots toward each member's
    actual demonstrated taste — golden signal that's been sitting in
    the database the whole time. Picks still override profile bias.

Cost: cook history is a single SQL aggregate (free, sub-100ms). New
macro+allergen fields fold into the existing ~5s/recipe Sonnet call
with maybe 30 more output tokens. Picker profiles are 2-3 SQL queries
totaling sub-200ms even at scale. No new network round-trips.

Net effect once Cobb runs /enrich-recipes against ENRICH_VERSION 3:
plan generator has structured macros + allergen flags + cook-history
rotation context + per-user preferences to work with. The free-form
preference textarea ("high protein, no dairy") becomes a real query
against actual data, not just a Sonnet vibe-prompt.
2026-04-30 20:23:13 -07:00
10849e0e95 recipe enrichment: per-recipe Sonnet meta for smarter planning
The 'fancy data fun' Cobb wanted: pre-compute structured metadata for
every recipe so the plan generator can match preferences to actual
recipe characteristics, not just match keywords on names.

Sonnet returns per recipe:
  - tags[]: curated descriptors (high-protein, weeknight, one-pan,
    leftovers-good, kid-friendly, etc — picks 3-8 that genuinely apply)
  - cuisine, complexity (easy/medium/involved), estimated_minutes
  - meal_type (breakfast/lunch/dinner/snack/dessert/side/sauce/drink)
  - primary_protein (chicken/beef/pork/fish/seafood/tofu/...)
  - primary_carb (rice/pasta/bread/potato/tortilla/quinoa/...)
  - veg_forward (veg-forward/mixed/meat-forward)
  - comfort_tier (weeknight-easy/hearty-comfort/fancy-occasion/...)
  - season_fit[] + summary one-liner + best_for short phrase

Schema:
- Migration 024: cauldron_recipe_meta keyed by (household_id, recipe_slug),
  meta_json + enrich_version (bumping the version invalidates the cache
  and forces re-walk). One row per Mealie recipe Cobb owns.
- Migration 025: cauldron_enrich_jobs — job runner state. No
  proposals/review needed since metadata is purely additive.

Forge:
- enrich_recipe(recipe) builds a compact prompt with name + description
  + ingredients + steps (capped at 2000 chars total) + yields, asks
  Sonnet for the structured blob. _extract_recipe_meta validates and
  coerces types.

Module enrich_recipes.py:
- Daemon thread runner, walks all household recipes, skips already-
  enriched at current ENRICH_VERSION (idempotent), respects external
  cancel + stuck-job recovery. Skips cross-household recipes (Lake
  Elsinore stuff visible but not enrichable).

Plan generator hookup:
- /api/plan/generate + regenerate now pulls cauldron_recipe_meta and
  splices it into the recipe pool prompt. Each pool line goes from:
      - chicken-stir-fry: Chicken Stir Fry  [asian]
  to:
      - chicken-stir-fry: Chicken Stir Fry  [asian · easy · 30min ·
        protein:chicken · carb:rice · high-protein/weeknight/one-pan]
        quick weeknight stir-fry with leftover-friendly portions
  Sonnet now has rich attributes to actually match a 'high protein
  week' or 'comfort food' or 'quick' preference against, instead of
  guessing from titles.

Endpoints:
- /enrich-recipes UI page (progress bar + start + force re-enrich +
  cancel; no review/approve since meta is additive)
- /api/recipes/enrich-{start,status,cancel} session-authed
- /api/admin/recipes/enrich-start bearer-authed for kayos kick-off

Cost (one-time): ~5s/recipe × 226 = ~20 min walk. Subsequent runs
only process new/changed recipes.
2026-04-30 20:08:20 -07:00
820d65171b plan generator: per-week diet/vibe preference + preset chips
The killer-feature one Cobb wanted on /plan — a free-form textarea
above the generate button so the household can bias the week's plan
toward "high protein low carb" / "carb load" / "light recovery" /
etc. Plus 8 preset chips that one-click-fill the textarea with common
templates.

Schema:
- Migration 023 adds preference_prompt VARCHAR(1000) to
  cauldron_meal_plans (idempotent ALTER ADD COLUMN IF NOT EXISTS)
- Persisted per-week, per-household. Survives re-rolls so the same
  preference applies unless explicitly changed.

Forge:
- generate_plan accepts optional preference kwarg
- _build_plan_prompt splices in HOUSEHOLD PREFERENCE block when set:
  "BIAS your AI-chosen slots toward recipes from the pool that match
  it... preference does NOT override picks — every pick still appears.
  It DOES change which other recipes you choose to fill the rest."
- Examples in the prompt cover diet, occasion, shopping constraints,
  vibe categories so Sonnet has full coverage of what users might mean.

Server:
- POST /api/plan/generate accepts {preference: "..."} body, persists
  to plan row before kicking off Sonnet
- POST /api/plan/regenerate (re-roll): if body has preference, persist
  + use it; else reuse the persisted preference. Lets re-rolls iterate
  on the same vibe.

UI (/plan):
- Textarea visible when no slots yet, with maxlength=1000 and a
  placeholder showing example prompts
- 8 preset chips: 🥩 high protein / 🍞 carb load / 🥗 light & lean /
  🌱 vegetarian / 🍲 comfort food /  quick / 💧 recovery / 🌍 global —
  click fills the textarea with a curated phrase Cobb can edit
- After generation: read-only "vibe this week:" callout above the
  action buttons so the household can see what was used
- Locked plan also shows the preference (audit / nostalgia)
- Generate + re-roll JS now sends JSON body with preference

Cost: ~30 extra tokens in the prompt when preference is set, ~0 when
unset. No additional Sonnet calls.
2026-04-30 20:00:01 -07:00
d48f70603b recipe dedupe: cluster + Sonnet decide + DELETE via Mealie
New bulk job at /dedupe-recipes (and /api/recipes/dedupe-* + admin
variants). Mirrors the consolidate_foods pattern but for recipes
themselves rather than the foods table:

Walk:
- Pull all household recipes via list_recipes (paginated, ~250 for Cobb)
- Cluster by name token_set_ratio ≥ 85
- For each multi-recipe cluster, fetch full bodies + build a summary
  (slug, name, source_url, ingredient_summary, step_count, yields)
- Ask Sonnet via forge.recipe_dedupe_decision: are these the same
  dish? canonical_slug + delete_slugs + reason
- Persist proposal

Apply:
- Per approved proposal: DELETE each delete_slug via Mealie API
- Mark applied / record error per cluster

Schema: migrations 021+022 (cauldron_recipe_dedupe_jobs +
cauldron_recipe_dedupe_proposals). Same state machine: running →
review → applying → done/failed/cancelled. Same daemon-thread runner
with cancel-respect + stuck-recovery.

Sonnet integration:
- recipe_dedupe_decision prompt is conservative-by-default. duplicates=
  false on the slightest doubt (different ingredient sets, suggestive
  name differences, etc). Picks canonical = cleanest name + most
  complete data + lex-older slug as tiebreaker.

Mealie integration:
- mealie.delete_recipe(slug) → DELETE /api/recipes/<slug>. Permanent.
  Permission-scoped per-household (cross-household will 403).

UI:
- /dedupe-recipes — same shape as /consolidate but with side-by-side
  recipe cells (canonical marked ★ KEEP, deletes marked × DELETE in
  red). Source URLs link out so user can sanity-check.
- DEFAULT TO NOT-APPROVED — recipe deletion is destructive, user must
  opt in per cluster. Bulk "approve all dupes" is one click but the
  apply confirm explicitly counts how many recipes will die.
- Linked from /me alongside sterilize + consolidate.

Cobb confirmed earlier: "we can't lose recipe data" — answered by
(1) conservative Sonnet decisions, (2) opt-in default, (3) explicit
permanent-deletion confirm, (4) same-pattern logging + DB audit trail
on every attempt.
2026-04-30 18:16:56 -07:00
69e05b1f92 Step 3: foods consolidator — cluster + merge dupes via Mealie's API
New bulk job that scans Mealie's foods table for duplicate-feeling
clusters, asks Sonnet to pick the canonical survivor + flag the rest
as merge candidates, and uses Mealie's PUT /api/foods/merge to
consolidate. After each successful merge, alias_additions get pushed
onto the survivor so Mealie's CRF/NLP parser fuzzy-matches the
discarded variant names from then on.

Architecture mirrors bulk_sterilize.py:
- Migrations 018+019 add cauldron_consolidate_jobs +
  cauldron_consolidate_proposals (state machine: running → review →
  applying → done/failed/cancelled)
- New consolidate_foods.py — daemon-thread runner with cancel-respect
  and stuck-job recovery
- /api/foods/consolidate-{start,status,jobs,apply,cancel} for session
  users + /api/admin/foods/consolidate-{start,jobs,cancel} for kayos

Sonnet integration:
- forge.cluster_decision(foods) → returns {merge, canonical_id,
  canonical_name, discard_ids, alias_additions, reason}
- Conservative-by-default: when in doubt Sonnet returns merge=false
  (the "olive oil vs olive" false-positive case from the prompt)
- Alias rules in the prompt explain why we want discarded names to
  travel back to the survivor as aliases (parser future-proofing)

Mealie integration:
- mealie.merge_foods(from_id, to_id) → PUT /api/foods/merge
- mealie.update_food(food_id, body) → for pushing aliases onto the
  survivor after merges land
- Apply path catches 403/permission errors and surfaces them as the
  per-cluster apply_error (cross-household merge attempts will fail
  here, same way as the sterilize cross-household path)

Clustering:
- rapidfuzz token_set_ratio ≥ 88 (slightly stricter than Mealie's
  parser threshold of 85 to reduce false-positive clusters)
- Single-link agglomerative — O(n²) but Cobb's ~3000 foods = ~9M
  comparisons, runs in seconds
- Singleton clusters (no merge candidates) are dropped, not stored

UI:
- /consolidate — same shape as /sterilize: progress bar → review grid
  → apply button. Cards show member chips with the canonical marked
  ★, discards marked × in red, alias_additions listed in green, plus
  Sonnet's one-line reasoning. Mergeable approved by default; user
  toggles individual clusters off if they disagree.
- Linked from /me → tools section, alongside bulk sterilize.

Total: ~600 LoC across 6 files. Foundation for the "Mealie owns
canonical names" architectural rule is now actually enforceable —
cobb runs this once, his foods table gets cleaned up, and Sonnet's
catalog-aware parser (Step 1) starts matching aliases for free.
2026-04-30 12:00:20 -07:00
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
36aba73f66 v0.3 step 3+4: AI plan generator + /list shopping aggregation
- 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
2026-04-29 06:26:54 -07:00
130f96a34f v0.1 — backend bones + ingredient sterilizer
LAN-only Flask API that consumes Mealie (source of truth for recipes / plans
/ lists) and clawdforge (centralized claude -p runner) to do AI work.

v0.1 surface:
  GET  /healthz                          liveness + clawdforge upstream
  GET  /api/recipes                      proxy Mealie recipe list
  POST /api/sterilize/preview/<slug>     dry-run AI parse, return proposals
  POST /api/sterilize/apply/<slug>       write parses back to Mealie

Why sterilizer first: Mealie's CRF parser is mediocre and Cobb's hand-typed
recipes have lots of free-form ingredient strings ("about 2 cups cooked
white rice", "a pinch of salt") that don't aggregate cleanly into a
shopping list. We batch all ingredients of one recipe into a single Sonnet
call via clawdforge, get back parallel structured parses, then on apply
link each to Mealie food/unit records (creating missing by name) and PUT
the recipe back. Preview is non-destructive.

No UI in v0.1 — bearer-auth API only. Frontend + Authentik OIDC + Abby's
swamp/meadow/forest palette arrives in v0.2.

Auth: simple shared bearer in env (ADMIN_BEARER) until OIDC lands. LAN-only
deploy means the bearer is the only gate; no public exposure.

Stack: python:3.12-slim + Flask 3 + gunicorn + requests. No DB in v0.1.
2026-04-28 16:59:11 -07:00