Commit graph

20 commits

Author SHA1 Message Date
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
30928b482f sterilize: fix finalize WHERE — allow review→applying→done transitions
Bug: my anti-zombie guard from 4707e6a was too strict — WHERE clause
required state IN ('running','applying') to update. But the normal
flow goes running→review→applying→done. Once a job entered review,
NO state transition could fire — including the legitimate apply
sequence triggered by user clicking "apply selected".

Symptom Cobb hit: clicked apply on job 6, the daemon thread did the
work (11 of 13 proposals applied cleanly to Mealie), but the row
stayed at state='review' so the UI never moved off the review screen.
The 11 successful applies are real — Mealie has the updated
recipeIngredient food links. The bookkeeping just didn't follow.

Fix: change WHERE clause from a positive whitelist (running/applying)
to a negative blocklist (NOT IN done/failed/cancelled). This still
prevents the original failure mode (daemon overwriting a user-cancelled
job) — terminal states still can't be overwritten — but lets review
transition to applying when the user approves.

Same fix applied to finalize_consolidate_job since it copy-pasted the
same too-strict guard.
2026-04-30 17:55:13 -07:00
94c07ab156 Step 4 (partial): drop dead pick_points table + game-system DB methods
Migration 020 drops cauldron_pick_points (game system stripped from
/plan 2026-04-30 — all award_pick_points calls were already removed
from server.py). The DB methods household_scoreboard, household_streak,
and award_pick_points are deleted now too — they were unused dead code
since the strip. delete_plan_slots no longer DELETEs from the dropped
table (kept it minimal: just wipe the meal_plan_slots).

DEFERRED from Step 4: dropping cauldron_foods. The 229 Sonnet-curated
density rows + 2462 USDA seed rows are still useful raw material for
a fuzzy backfill into cauldron_food_metadata (only 128 of Mealie's
2895 foods got densities matched on the first exact-name pass). Until
we run a fuzzy backfill, holding onto cauldron_foods as a cold archive
is cheaper than losing the data. Will revisit once the metadata
catalog is more complete.

Recovery from 020: revert this migration, restore the CREATE TABLE
position-014 entry from db.py history. The points data was never
meaningfully populated (jobs 1+3 award attempts were folded into the
strip), so loss is essentially zero.
2026-04-30 12:02:58 -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
5e62da2013 Step 2 follow-up: use any usable Mealie token for the boot backfill
System MEALIE_API_TOKEN is 401 (legacy 'Cauldron' token from Mealie
mint long ago, since rotated/expired). The backfill tries it first;
on 401 falls back to any stored per-user token via the new
db.first_usable_mealie_token helper. Cobb's user is admin in Mealie
and his token returns the full group catalog, so the backfill works
without minting a fresh system token.
2026-04-30 11:53:34 -07:00
f74a627ac7 Step 2: re-key cauldron's food metadata by mealie_food_id
Aligns cauldron's data layer with the architectural rule "Mealie owns
canonical food names; cauldron only owns cooking metadata Mealie can't
store". The old parallel name catalog (cauldron_foods, 2462 noisy USDA
rows + ~229 Sonnet-curated names) was always going to drift from
Mealie's foods table over time. Now metadata follows Mealie's UUID, so
when Cobb merges or renames a food in Mealie the density+unit_class
travels with it automatically.

Schema:
- New table cauldron_food_metadata (migration 017): primary key is
  mealie_food_id VARCHAR(64); columns are density_g_per_ml,
  common_size_g, default_unit_class, category, source enum (seed /
  claude / manual), notes JSON.
- cauldron_foods table stays untouched in this step (Step 4 drops it
  after the backfill ledger has been verified in production).

Code:
- cauldron/foods.py rewritten:
  - get_metadata_by_food_id(db, mealie_food_id) — primary read
  - upsert_metadata(...) — write keyed by mealie_food_id
  - fetch_and_persist(db, mealie_food_id, food_name, forge) — Sonnet
    fallback, persists keyed by id
  - backfill_seed_from_legacy(db, mealie) — one-time migration helper
    called at app boot when metadata table is empty. Walks Mealie's
    foods, looks up each in legacy cauldron_foods by name/plural/alias,
    copies density into the new table keyed by Mealie's id. Returns
    {matched, missed, total_mealie} stats.
  - Legacy shims (search_food, upsert_claude_food, load_seed_if_empty)
    kept as no-ops so server boot doesn't break before full cutover.

- cauldron/aggregator.py:
  - Ingredient.mealie_food_id new optional field
  - aggregate() now keys by mealie_food_id when present, falls back to
    normalized name. Verified with rice-from-3-recipes synthetic:
    same id → consolidates to "2.25 lb rice" single line as before.
  - foods_lookup callable signature changed to (name, food_id) — id is
    primary, name is for display + Sonnet fallback.

- cauldron/server.py:
  - /list view captures Mealie's food.id from each recipe ingredient
    and threads it through the Ingredient. foods_lookup now does an
    id-keyed cauldron_food_metadata read; on miss with a known id,
    calls forge.fetch_food_info and persists. When food.id is missing
    (ingredient still in note form, no Mealie row linked), returns
    None and aggregator falls back to name grouping.
  - Boot: replaces the USDA seed loader with a one-time backfill of
    legacy cauldron_foods → cauldron_food_metadata via the system
    Mealie token. Runs only when metadata table is empty.

Net effect: rice in 3 recipes that all link to the same Mealie food
row now group by UUID, not by lowercased name. When Mealie's foods
get cleaned up (Step 3 consolidator), cauldron's metadata follows
because the ids are stable. Foundation for the consolidator is now
in place.
2026-04-30 11:52:25 -07:00
4707e6aacc sterilize bulk: respect external cancel mid-loop
Job 3 surfaced the bug — when I set state=cancelled in the DB, the
daemon thread kept running and finalize() at the end overwrote it
with 'done'. User cancellations were getting silently undone.

Two changes:

1. Runners (run_bulk_preview, run_bulk_apply) now check the job's
   current state at the top of every iteration via the new lightweight
   db.get_sterilize_job_state. If the state has moved to a terminal
   value (cancelled, failed, done) externally, the loop returns
   without finalizing.

2. db.finalize_sterilize_job now refuses to overwrite a non-running
   state — added "AND state IN ('running','applying')" to the WHERE
   clause. Belt-and-suspenders for the same problem: even if a runner
   races past the state check and limps to its finalize call, the DB
   itself won't let the cancellation be replaced.

Net: hitting cancel via the UI button (or a DB update) now actually
stops the runner mid-flight. Polling roundtrip per recipe is one
SELECT — negligible vs the multi-second clawdforge call that
dominates each iteration.
2026-04-30 10:02:53 -07:00
f7b30d3b65 sterilize: search-then-create + retry-on-UNIQUE-400 + don't mark errored as applied
Job 1's bulk run apply'd 184 recipes and 182 of them failed with the
same error: POST /api/foods -> 400 UNIQUE constraint failed:
ingredient_foods.name, ingredient_foods.group_id. Cause: Mealie's
name_normalized strips punctuation/whitespace/case more aggressively
than our local _build_name_index's plain .lower(), so the cache misses,
the create_food fires blindly, and Mealie's UNIQUE constraint kills
the call. Whole-recipe apply was wrapped in try/except at the bulk
runner so the recipe got marked errored — but applied_at was still
set to NOW(), making the rerun think we'd already tried. We had, but
the recipe's still unparsed.

Two fixes:

1. sterilizer._resolve_food / _resolve_unit replace the inline
   create-on-miss block. Order: local cache → Mealie search-endpoint
   tie-break → create. On any UNIQUE-flavored 400 from create, fall
   back to one more search to adopt whatever Mealie has under the
   normalized form. Mealie's search endpoint applies its own
   name_normalized internally so we don't have to mirror its rules.
   _search_for_match takes "foods" or "units" and looks for an exact
   case-insensitive match against name or pluralName, with a fallback
   to "trust Mealie's ranker" when there's exactly one hit.

2. db.mark_proposal_applied no longer sets applied_at on error. On
   success: applied_at=NOW(), apply_error=NULL. On error: applied_at
   stays NULL, apply_error gets the message. list_approved_unapplied_
   proposals keys off applied_at IS NULL, so a rerun naturally retries
   only the failed recipes.

Net effect: rerun can now successfully apply the 182 failed recipes
without re-walking them, and won't waste calls on the 2 that did go
through.
2026-04-30 06:05:19 -07:00
9368b64a81 v0.3 step 6: bulk sterilizer — automate mealie's per-recipe Parse toil
Mealie's "Parse" button on every recipe is per-recipe; clicking through
226 of them is the toil Cobb explicitly hates. The bulk sterilizer wraps
cauldron's existing per-recipe Sterilizer (clawdforge → Sonnet, parses
free-form ingredient strings into structured form) into a household-wide
job with progress tracking, batch human review, and one-shot apply.

Flow:
  1. POST /api/sterilize/bulk-start — creates cauldron_sterilize_jobs row,
     spawns a daemon thread that walks every recipe in the user's
     household. Recipes whose ingredients all already have food.id are
     skipped (no point re-parsing Cobb's manual cleanup). For each
     recipe needing work, Sonnet returns a structured proposal that gets
     persisted to cauldron_sterilize_proposals.
  2. GET /api/sterilize/bulk-status — polled every 2s by the UI for
     {state, processed_count, skipped_count, error_count, current_slug}.
  3. After the walk completes, state moves to 'review'. UI loads
     /api/sterilize/bulk-jobs/<id>/proposals and renders one card per
     recipe with a was→becomes diff per ingredient. User toggles
     approve/skip per recipe.
  4. POST /api/sterilize/bulk-apply/<id> with {approved_slugs: [...]}.
     A second daemon thread iterates approved proposals, calls
     Sterilizer.apply_recipe (which resolves food/unit IDs in Mealie,
     creating any missing rows, then PUT /api/recipes/<slug>).

Job state machine: running → review → applying → done (or 'cancelled' /
'failed' along the way). At app startup, fail_stuck_sterilize_jobs()
recovers any 'running' / 'applying' rows older than 10 min with no
progress — covers the case where the daemon thread's gunicorn worker
died mid-job. New job state lives in DB; thread is just a runner.

Concurrency: the start endpoint blocks if the household already has a
running/applying job. Sterilize calls cost clawdforge time and parallel
jobs would race on Mealie writes, so one-at-a-time per household is the
right ceiling.

UI lives at /sterilize. Linked from /me → "tools → bulk sterilize" since
it's a one-off admin action, not part of the daily flow. Mobile-aware,
diff cards expand on click. Disabled "apply" button when no recipes
are selected; preview-error rows can't be approved.

DB: two new tables (migrations 015+016). cauldron_sterilize_jobs tracks
overall job state with last_progress_at for the stuck-job recovery;
cauldron_sterilize_proposals holds per-recipe JSON proposals with the
approval flag and the final apply_at/apply_error.
2026-04-29 22:26:10 -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
edf679504d v0.3 step 1: foods schema + USDA SR Legacy density seed
Phase A foundation. Cobb 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.
2026-04-28 22:03:17 -07:00
c7ee84d70a search: local fuzzy recipe index — way smarter than Mealie's lexical default
Cobb: 'searching recipes is a bit off. lets make that way way more on
point. need to be the google of recipe searching.'

Architecture:
- New cauldron_recipe_index table mirrors enough of Mealie's recipe shape
  to fuzzy-rank locally without round-tripping. Migrations 008+009.
- Refresh on first /recipes load + every 5 minutes + on-demand button.
  Single page-200 pull from Mealie covers Cobb's 226 recipes in one trip.
- recipe_index.py — flatten_recipe(), refresh_household_index(),
  search_index().

Search algorithm (rapidfuzz):
- Multi-field weighted: name×1.00, tags×0.85, cats×0.80, foods×0.70,
  ings×0.55, description×0.45 (max-of wins, not sum, to avoid noise spike)
- Three scorers per field: WRatio (overall), partial_token_set_ratio
  (handles 'spag bol' → 'Spaghetti Bolognese'), token_set_ratio
  (order-independent)
- Substring-of-query in title bonus +20
- Floor 50 to filter junk
- Top-80 returned

API:
- /api/recipes.json now uses local index for both search and browse
- /recipes route same — first-page server-render from index
- POST /api/index/refresh — manual refresh button (admin-y)
- ?q=...  → ranked fuzzy results, paginated
- no q   → ordered browse from index, paginated, has_next via lookahead

Performance:
- Local index query: ~5ms for browse
- Search across 226 rows × 6 fields × 3 scorers: ~60ms
- Should feel instant compared to Mealie's network round-trip
2026-04-28 21:37:12 -07:00
1540c2f436 v0.2: household-shared picks pool + /plan with lock button + scoreboard + streak
DB:
- migration 006 — cauldron_households (mirrors Mealie household), members
- migration 007 — cauldron_meal_plans (per household per week, lock state)
- new helpers: upsert_household, add_household_member, get_user_household_id,
  list_household_member_subs, get_or_create_plan, lock_plan,
  auto_lock_past_unlocked_plans, household_scoreboard, household_streak,
  list_household_pick_slugs, list_household_picks_with_pickers

Backend:
- sync_user_household() — pulls Mealie's /api/users/self, upserts the
  household row, ensures membership. Fires on /connect-mealie POST and
  lazy on every /me / /picks / /plan / /recipes load.
- current_household_id() helper used by all user-facing routes
- /recipes + /api/recipes.json now mark items.picked=True if ANYONE in the
  household has pinned (shared pool, not per-user)
- /picks now renders household-pooled view with attribution (who pinned what)
- /plan replaces stub: shows current week's lock state + lock button +
  scoreboard + streak. POST /api/plan/lock locks this week's plan with
  reason='user'. Past unlocked weeks auto-lock on read with reason='auto'.
- /list still stub (v0.3 territory)

UI:
- picks.html: each pick shows '🍄 pinned by <name(s)>' attribution row,
  unpin button only on your own picks, household_size in lede
- plan.html: NEW. Lock state pill, lock button (open weeks only), scoreboard
  table with rank/wins/last_win, streak ribbon ('🔥 abby on a 3-week run')
  if streak >= 2
- me.html: shows household name + member count

Locking semantics match Cobb's pick:
- (c) both — user-lock = scoreboard win, auto-lock past calendar = no win.
  Auto-lock fires lazily on /plan view (no cron needed for v0.2).
- Picks pool = (a) shared across household.
- Game shape = medium (locked-by + tally + streak counter, room for richer
  badges/notifs in v0.4).
2026-04-28 21:11:11 -07:00
adec91486c v0.2 — meal picks, infinite scroll, search, mushroom vibes, mobile polish
Cobb pass: more mobile friendly + infinite scroll + auto-search +
add-to-AI-plan picks list + mushroom vibes.

DB:
- New migration 005 — cauldron_meal_picks (authentik_sub, recipe_slug,
  recipe_name, added_at). Per-user wishlist for the v0.3 AI meal-plan
  generator. add/remove/list helpers in DB.

Backend:
- GET  /api/recipes.json?page=N&q=Q — paginated + searchable, returns
  {items, page, total, total_pages, next}. Each item is annotated with
  picked: bool from the user's pick set.
- POST /api/picks/<slug>            — add (slug + optional name body)
- DEL  /api/picks/<slug>            — remove
- GET  /api/picks.json              — list current picks
- GET  /picks                       — picks page (replaces empty stub)
- Mealie.list_recipes() now accepts search=...

Frontend:
- recipes.html rebuilt:
  - sticky search bar with 250ms debounce, hits /api/recipes.json?q=
  - IntersectionObserver-driven infinite scroll, loads page+1 when the
    sentinel comes into view (200px rootMargin, AbortController for
    in-flight cancel on new search)
  - per-card mushroom toggle button (top-right) — POST/DELETE to picks
    with optimistic UI flip + rollback on failure
  - picked cards get a left purple-glow stripe + tinted background
- _recipe_card.html partial — first-page server-render shares markup with
  JS-rendered subsequent cards (mushroom SVG inline, same shape)
- recipe_detail.html — '🍄 pin for ai plan' button toggles state in place
- picks.html — list of current picks with remove button + v0.3 explainer
- Topbar nav: dropped /home, added /picks

Mushroom vibes:
- Hand-rolled SVG toadstool (purple cap, bone stem, dark spots) used as
  the pick toggle icon — it's the gesture itself
- Same mushroom tiled into the body bg pattern at ~5% opacity in the
  bottom-right of the 160px sigil tile, alongside the existing pentagram
- Mushroom emoji on the detail page button + picks page nudge

Mobile pass:
- Topbar nav scrolls horizontally on narrow screens, brand-sub hidden
  under 720px, larger tap targets on cards, font-size pulled in slightly
- Recipe grid: 1 col <560, 2 col 560-900, 3 col 900+
- Page-head + button + card padding all tightened on small screens
2026-04-28 20:53:07 -07:00
d3369bb141 db: INSERT IGNORE on schema_migrations to tolerate multi-worker boot race 2026-04-28 19:49:40 -07:00
213801ca70 v0.2 foundation — Authentik OIDC + sulkta-mariadb DB + Fernet crypto
Adds the multi-user plumbing layer underneath v0.1's batch-only API:

- DB module (db.py) — PyMySQL against sulkta-mariadb, in-process migrations.
  Tables: cauldron_users, cauldron_user_mealie_tokens, cauldron_chat_log,
  schema_migrations.
- Crypto module (crypto.py) — thin Fernet wrapper. Master key in env,
  per-row encryption of stored Mealie tokens, decrypt only in-process.
- OIDC module (oidc.py) — Authlib-based Authentik integration. Issuer
  https://auth.sulkta.com/application/o/cauldron/, sub_mode=user_email,
  scopes openid+email+profile. App gated to 'Sulkta Family' group.
- Two-tier Mealie shape — system_mealie (env token, admin batch) +
  current_user_mealie() helper that loads + decrypts the calling user's
  token from DB. Per the v0.2 design (memory/spec-cauldron-v0.2.md).
- Connect flow — /connect-mealie pages walk users through minting their
  own Mealie API token and pasting it back. Validated against
  /api/users/self before encryption + storage.
- Routes — /, /login, /auth/callback, /logout, /me, /connect-mealie,
  /disconnect-mealie. v0.1 admin endpoints kept under bearer auth.
- Mealie.who_am_i() helper added.
- Auth flow uses Authentik subject (sub) as the canonical user key.

UI is minimal — connect-mealie page uses the locked palette
(forest #1f2d1f, panels #2d3a2a, meadow #6b8e5a/#88a87a, parchment text
#f0e6cc/#ddd4ba) and Cormorant Garamond serif headers. Strict palette.
The fuller dashboard / plan / list / recipes views land in subsequent
commits.

Authentik provider PK 24, client_id ZIwEugWWWZinR1KcVC9IT9hpGoTds9ps8XDDHPPN.
Group 'Sulkta Family' (pk 6d0c75e9-...) created with cobb member.

Foundation only — Abby's branded UI and the meal-plan / shopping-list
features land in subsequent v0.2 commits.
2026-04-28 19:47:47 -07:00