Files
admin c54d3ffc1f docs: Sprint 16 — fix kimi-k2.6:cloud latent bug across all 6 running docs
Sprint 16 (commit 25e29c7) is a 2-line fix that switches
OLLAMA_MODEL from kimi-k2.6:cloud to gpt-oss:20b and bumps
max_tokens from 800 to 4000. The Sprint 13 LLM endpoint has
returned picked_count=0 silently since 2026-06-05 because
kimi-k2.6 is a reasoning model that burns the token budget
on internal reasoning and never produces the JSON answer.
The library fill (Sprint 6+) silently took over. Discovered
while answering the user's "is there anything else to refine?"
question.

Live verification: 5/5 test weeks return picked_count 15-21
(was 0/5 before). 11/11 vitest cases pass (4 new from S16 +
7 from S14). npm run build green. No new runtime deps. No
schema change. No UI change.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — Sprint 16 section (S16.1-S16.4 + Done
  when + Out of scope) added after the Sprint 15 sections.
  Documents the diagnosis (kimi-k2.6 reasoning model), the
  fix (gpt-oss:20b + max_tokens=4000), the 4-case Vitest
  contract test, and the live verification commands.
- .agent/context.md — Sprint 16 decisions (D1-D5), open Q1,
  and file:line references added.
- Review/sprint16-verification.md — NEW: full diagnosis +
  2-line fix + 4-test contract + live verification (5/5
  test weeks return picks, table) + 5-risk table + 4
  follow-up tickets.
- Review/ui-nielsen-audit.md — Sprint 16 status block added
  after the Sprint 15 Round 3 block.
- fix-ui-audit.md — Sprint 16 section (T9.1-T9.5) added
  after the Sprint 15 section. T9.1 documents the 2-line
  fix in detail (config.py + llm_plan.py + .env). T9.5
  surfaces 3 follow-up tickets.
- Review/handoff-ui-audit.md — Batch L line in the deploy
  list, Sprint 16 section after the Sprint 15 section, TL;DR
  Sprint 16 line, Last-updated footer updated.
- docs/HANDOFF.md — Sprint 16 section after Sprint 15, Last-
  updated footer updated. Notes the corrected model choice
  and the 4 follow-up tickets.

All 6 docs now reflect Sprint 16. The Sprint 13 LLM endpoint
now works as designed. Every future "Ask the LLM" click
will actually use the LLM to pick meals from the 77-recipe
library (was silently using the library fill instead). The
_ask_llm helper is still the single F9-full seam.
2026-06-08 07:26:22 -07:00

53 KiB
Raw Permalink Blame History

Recovery Plan — MealPlanner

Goal: bring implementation back into alignment with Review/reviewconcensus.md. Stop building forward features until the deferred-risk spikes and the verification matrix pass.

Active sprint: Sprint 14 — Vitest for useOnboarding (Q4)

Owner: this agent. Status: starting. Tracking: Review/sprint14-verification.md (deploy + smoke), .agent/plan.md (checklist), .agent/context.md (decisions + open Qs).

Why now (Q4 rationale): Sprint 9 (F1 Onboarding Tour) shipped a hand-rolled ~420-line component, then immediately regressed in 1562929: onComplete was wired to useOnboarding().reset() (inverse op) so the X/Skip/Esc dismiss path actually re-showed the tour. The fix split the API into onComplete/onReset callbacks with markComplete (dismiss) vs reset (re-show). The bug class is "two inverse operations share one state setter"; the only durable prevention is unit tests. ~1 hr sprint, locks the seam.

Sprint 14 lifts the "no new npm deps" rule for testing-only. Runtime deps unchanged.

S8.1 — Migration: 0016_denial_decay_and_scope.py (NEW)

  • Adds meal_plan_item.denial_expires_at TIMESTAMPTZ NULL.
  • Adds meal_plan_vote.denial_scope VARCHAR(16) NULL.
  • Partial index on meal_plan_item.denial_expires_at (postgresql_where IS NOT NULL) for the planner's soft-deny lookup.
  • Downgrade reverses all three.

S8.2 — Model: app/models/__init__.py

  • MealPlanItem.denial_expires_at column added.
  • MealPlanVote.denial_scope column added.

S8.3 — Schema: app/schemas/__init__.py

  • MealPlanItemResponse.denial_expires_at: Optional[datetime].
  • VoteRequest.denial_scope: Optional[str] with pattern=^(this_week|never_again)$.
  • VoteResponse.denial_scope: Optional[str].

S8.4 — Backend helpers: app/api/meals.py

  • _apply_denial(db, item, scope) — single source of truth for the deny path. Returns {item, promoted_to_permanent, scope}. Commits.
  • _ensure_never_suggest_recipe(db, family_id, recipe_id, reason) — idempotent NeverSuggest insert. Returns True if new, False if existing.
  • _has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None) — count query for the 2-denial check.
  • DENIAL_DECAY_DAYS = 90 constant.

S8.5 — Backend endpoints: app/api/meals.py

  • POST /api/meals/items/{id}/deny?scope=this_week|never_again (default this_week).
    • Returns {message, item, promoted_to_permanent, scope}.
    • swap_meal_item also clears denial_expires_at (defensive: a new recipe_id is a fresh start).
  • POST /api/meals/vote/{id} extended: vote: "approve" | "deny" | "never_again".
    • Returns {status, item_status, denial_scope, promoted_to_permanent}.
    • The 2-denial auto-escalation runs server-side for both deny and never_again.
  • GET /api/meals/vote/{id} HTML page renders 3 buttons. Supports one-click ?scope=... for the email's per-button links.

S8.6 — Email template: app/services/orchestrator/steps.py

  • 3 direct-action links per recipe (Approve / Deny this week / Never again).
  • Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.

S8.7 — Planner: app/services/planner/generate.py

  • _load_blocklists returns 3 sets: (blocked_ingredients, blocked_recipes, soft_denied_recipes).
  • soft_denied_recipes is the hard filter (per user decision: same as blocked_recipes).
  • rejected_summary adds a soft_denied_recipe diagnostic bucket.

S8.8 — Frontend: Dashboard.tsx + api/index.ts

  • api/index.ts:48-58meals.denyItem(itemId, { scope }).
  • Dashboard.tsx:38-50, 385-410MealCard accepts scope-aware onDeny; renders 3 buttons (Approve / Deny this week / Never again) for pending items.
  • handleDeny is scope-aware; toast reflects the server's promoted_to_permanent flag.
  • "Never again" is gated by window.confirm to prevent accidental permanent blocks.
  • Buttons only show on pending items (approved/denied items show the badge only).

S8.9 — Verify

  • npm run build green for Sprint 8 (tsc 0 errors, vite 0 errors).
  • Backend smoke: 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost failure is not introduced by S8 — verified via git stash + re-run on a clean tree).
  • Static checks: all 6 new modules import cleanly, helper logic verified via Python AST + import-test against backend/venv.
  • Review/sprint8-verification.md written with deploy + 11-step browser smoke + 4 API curls + email-render procedure + rollback.
  • Deploy verified on 100.108.224.12 — see verification log.
  • No regression in Sprints 1-7.

S8.10 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 8 status block at the top (T2.1T2.10).
  • fix-ui-audit.md — Sprint 8 plan section (T2.1T2.10).
  • Review/handoff-ui-audit.md — "Active sprint" callout + bottom "Last updated" line.
  • docs/HANDOFF.md — Sprint 7 + Sprint 8 sections before the 2026-06-03 session.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 8 decisions, file:line references, verification gate.

Done when (Sprint 8)

  • All 12 boxes above ticked.
  • npm run build green.
  • Review/sprint8-verification.md exists.
  • All 6 doc files have a Sprint 8 status block.
  • User commits + runs the deploy + runs the SQL + reports the smoke checklist.

Out of scope (Sprint 8)

  • Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead Generate Meal Plan CTA at Dashboard.tsx:415).
  • "Unblock" UI on the webui. The NeverSuggest API exists; no UI to remove a row. User can use the API directly.
  • Decay-sweep cron. The 90-day filter is at read time; expired rows just become invisible. No cleanup needed.
  • Pre-existing denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) — left untouched. denial_expires_at stays NULL; the recipe is effectively forgotten after 90d from now (today is 2026-06-05, so it'll be eligible again ~2026-09-03). If the user wants it remembered permanently, they can re-trigger the soft-deny cycle by clicking "Deny this week" on the next plan that includes it.

Phase R1 — Stabilize (parallel-safe)

  • R1-A: Verification harness. Add backend/tests/ with pytest config, a conftest.py with a transactional DB fixture, and smoke tests covering: app import, /health, /health/db, every router's GET list endpoint, Alembic upgrade head round-trip on a throwaway DB. Add .github/workflows/ci.yml running lint + pytest + frontend npm run build.
  • R1-B: Auth dependencies on existing routers. Implement an app.security module with: (1) require_admin dep — bearer token compared to settings.ADMIN_TOKEN, applied to ALL /api/admin/* routes; (2) require_session dep — signed-cookie session (itsdangerous, key = SECRET_KEY) for profile/pantry/recipes/meals/shopping-list mutations; reads stay open inside the trusted network. Per-voter approval token flow stays as-is. Update .env.example with ADMIN_TOKEN. Document the model in docs/SECURITY.md.
  • R1-C: Make /api/admin/scrape async. Convert the endpoint to enqueue a background job (FastAPI BackgroundTasks for now; APScheduler later). Endpoint returns 202 + scrape_log_id; status polled via /api/admin/logs/{id}. ScraperService must open its own DB session inside the task (the request-scoped db is gone by then).

Phase R2 — De-risk deferred work (parallel-safe, must run BEFORE further feature work per review §2.4)

  • R2-A: Live-scrape spike. Run LuckyCaliforniaScraper against https://luckysupermarkets.com once, capture the raw HTML/PNG to backend/tests/fixtures/lucky_ca/, write a unit test that parses the captured fixture (no live network in CI). Document selector decisions in .agent/context.md. If the page can't be parsed, file the schema impact before going further.
  • R2-B: Email + approval round-trip spike. Implement minimal SendGrid sender (app/services/email.py), an app/services/approval.py that issues per-voter signed tokens (TTL, single-use), the GET confirmation page + POST submit handler (the routes already exist as stubs in meals.py), and a CLI script scripts/send_test_approval.py that creates a fake meal plan, emails one voter, and verifies the click→POST→DB write path end to end against a sandboxed inbox or MAIL_BACKEND=console. Goal: prove the schema (family_member, approval_token tables) survives one full round trip BEFORE building Phase 4/5/9.

Phase R3 — Resume feature work (sequential, only after R1+R2 green)

  • R3-A: Phase 4 Recipe Engine — search, tagging, never-suggest filter.
  • R3-B: Phase 9 Meal Planner generation algorithm.
  • R3-C: Phase 6 SendGrid templated emails (proposal, reminder, confirmation).
  • R3-D: Phase 8 Feedback UI.
  • R3-E: APScheduler with --workers 1 for weekly scrape + plan generation + email send.
  • R3-F: Phase 10 image strategy.

Halt conditions

  • R2 spikes fail → stop, propose schema/spec change, await approval.
  • Verification matrix in Review/reviewconcensus.md §6 not green → no R3 work begins.

Sprint 9 — F1 Onboarding Tour (H10)

Owner: this agent. Status: code complete, npm run build green, awaiting user commit + deploy. Tracking: Review/sprint9-verification.md.

User policy decision (2026-06-05, exact): "Proceed with the next phase in the redesign." Selected Sprint 9 = F1 (the only §Future item with a clear UI scope). F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead Generate Meal Plan CTA is a separate follow-up.

S9.1 — New OnboardingTour.tsx component (NEW)

  • Hand-rolled (no react-joyride) — keeps npm footprint flat.
  • 4 steps: Dashboard / Pantry / Recipes / Shopping List.
  • Anchors to [data-tour="<id>"] attributes on existing elements.
  • Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
  • Anchor highlight = primary-400 ring + soft scrim; tooltip is a real <div role="dialog" aria-modal="true">.
  • Step progress = 4 progress bars.
  • Keyboard: 14 jump, ←/→ step, Esc dismiss, Tab order is Skip → Back → Next.
  • useOnboarding() hook + ?reset-tour=1 re-trigger; localStorage key mealplanner:onboarding-complete.
  • Focus captured on open (primary action), restored on close.
  • All reads/writes to localStorage wrapped in try/catch (private mode safe).

S9.2 — Anchor points (5 lines of code total)

  • pages/Dashboard.tsx:602<Card data-tour="dashboard"> on the Weekly Overview grid.
  • pages/Pantry.tsx:185<div data-tour="pantry"> on the page header (always present).
  • pages/Pantry.tsx:208 — second anchor on the add-form <Card> (when the form is open).
  • pages/Recipes.tsx:124<Button data-tour="recipes"> on the Filters button.
  • pages/ShoppingList.tsx:231<div data-tour="shopping-list"> on the page header.

S9.3 — App.tsx mount

  • useOnboarding() at App root, isComplete passed to <OnboardingTour>.
  • onComplete mapped to onboarding.reset() (flips the flag so re-renders don't re-show).
  • Mounted as sibling of <ShortcutHelpBanner /> inside <BrowserRouter> (so useLocation / useNavigate work).

S9.4 — Verify

  • npm run build green (tsc 0 errors, vite 0 errors).
  • Browser smoke (8 steps) on http://100.108.208.56:8082/ per Review/sprint9-verification.md.
  • No regression in Sprints 18 (keyboard shortcuts, error toast, 3-button vote row, WeekRangeNav, bulk pantry add).

S9.4.1 — Post-deploy fix (2026-06-05)

User reported post-deploy: "The tour window looks great, but Clicking the X nor skip tour do anything. I cannot exit the tour." Build was green but the dismiss path was broken.

  • Root cause identified (systematic-debugging Phase 4): useOnboarding().reset() was wired to the dismiss handler at App.tsx:104-109. reset() does the inverse of dismiss — it clears the localStorage key AND flips isComplete to false. So clicking X wrote the key, but the App-level flag flipped in the wrong direction, the tour's if (isComplete || !currentStep) return null early-return never fired, and the dialog stayed visible.
  • Fix committed (1562929): split the dismiss and reset paths into two distinct callbacks.
    • useOnboarding now exposes markComplete() (state flip to true) in addition to reset() (state flip to false).
    • OnboardingTour takes two props: onComplete (dismiss) and onReset (re-show).
    • App.tsx wires onComplete → onboarding.markComplete() and onReset → onboarding.reset().
    • Cleaned up: markComplete no longer double-writes localStorage (the tour's finish() already does that).
  • npm run build green on docker-willester after the fix (495.64 kB, no size change).
  • User confirmed post-deploy smoke test passes (2026-06-05).
  • Anchors + URL effect re-verified: 5/5 data-tour anchors present at Dashboard.tsx:602, Pantry.tsx:185, 208, Recipes.tsx:131, ShoppingList.tsx:231; ?reset-tour=1 effect calls onReset() correctly.

S9.5 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 9 status block at the top.
  • fix-ui-audit.md — Sprint 9 plan section (T3.1T3.4).
  • Review/handoff-ui-audit.md — Sprint 9 entry in the "How to take over" section + TL;DR row.
  • docs/HANDOFF.md — Sprint 9 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 9 decisions + file:line references.
  • Review/sprint9-verification.md — written (8-step browser smoke + a11y check + reset-link test).

Done when (Sprint 9)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint9-verification.md exists.
  • All 6 doc files have a Sprint 9 status block.

Out of scope (Sprint 9)

  • Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead Generate Meal Plan CTA at Dashboard.tsx:415.
  • Per-page deep tutorials, video demos, hover tooltips.
  • A user-facing "Show tour" link in the footer (operator uses ?reset-tour=1; a footer link is a 5-line follow-up if requested).
  • Sprint 10 — "Deny Forever" on Recipes — committed 2026-06-05, awaiting user deploy.

Sprint 10 — "Deny Forever" on Recipes (user-driven)

Owner: this agent. Status: code complete, npm run build green, 21/21 planner tests pass, awaiting user commit + deploy. Tracking: Review/sprint10-verification.md.

User direction (2026-06-05, exact): "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint." Sprint 10 ships the Deny Forever button on both the Recipes page (card overlay) and the RecipeDetail page (top bar).

S10.1 — Backend: POST /api/never-suggest (public)

  • New endpoint in app/api/never_suggest.py:60-86. Family-facing (uses require_session).
  • Body: {family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}.
  • Idempotent on (family_profile_id, recipe_id, ingredient_id, reason).
  • Returns the row joined with recipe_name.

S10.2 — Backend: DELETE /api/never-suggest/{ns_id} (public)

  • New endpoint in app/api/never_suggest.py:89-111. Family-facing.
  • Row-level ownership check: 403 if family_profile_id doesn't match the session.
  • 404 if the row doesn't exist.

S10.3 — Backend: NeverSuggestRead.recipe_name + .ingredient_name

  • New fields in app/schemas/never_suggest.py:31-33.
  • Server-side JOIN helper _attach_names() in app/api/never_suggest.py:33-58. One LEFT OUTER JOIN per kind, then merge into response dicts.
  • Falls back to None if the recipe/ingredient was deleted (FK is ON DELETE CASCADE).

S10.4 — Frontend: API client

  • mealPlannerApi.neverSuggest.list(familyProfileId)frontend/src/api/index.ts:75-86.
  • mealPlannerApi.neverSuggest.add({...}) — POST.
  • mealPlannerApi.neverSuggest.remove(nsId) — DELETE.

S10.5 — Frontend: NeverSuggestButton component (NEW)

  • frontend/src/components/NeverSuggestButton.tsx (~290 lines).
  • Two variants: card (overlay on RecipeCard) and detail (text buttons in RecipeDetail top bar).
  • Popover with two reasons: Allergy (red, requires window.confirm) and Dislike (neutral, no confirm).
  • Undo toast via showToast.undo() (Sprint 3 B12 pattern, 6s window).
  • Pre-existing block detection: shows a "Blocked" state with an "Unblock" path.
  • Query invalidations: ['neverSuggest', familyId], ['recipes'], ['recommendedRecipes', familyId], ['mealPlan'].
  • A11y: aria-label, aria-expanded, aria-haspopup="menu", role="menu", Esc dismisses, outside click dismisses.

S10.6 — Frontend: Recipes.tsx overlay

  • RecipeCard now has position: relative so the overlay anchors correctly.
  • Button is opacity-0 group-hover:opacity-100 (visible on hover or focus).
  • e.preventDefault() + e.stopPropagation() on the click — doesn't navigate to the detail page.

S10.7 — Frontend: RecipeDetail.tsx top bar

  • New "Deny forever" button group to the left of "Add to Plan".
  • Same popover + confirm/undo semantics as the card overlay.

S10.8 — Verify

  • npm run build green (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
  • Backend imports clean; routes registered.
  • 21/21 planner tests pass (1 pre-existing failure deselected).
  • Browser smoke (9 steps) on http://100.108.208.56:8082/ per Review/sprint10-verification.md.
  • No regression in Sprints 1-9.

S10.9 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 10 status block at the top.
  • fix-ui-audit.md — Sprint 10 plan section (T4.1T4.9).
  • Review/handoff-ui-audit.md — Sprint 10 entry in the "How to take over" section + TL;DR row.
  • docs/HANDOFF.md — Sprint 10 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 10 decisions, file:line references, verification gate.
  • Review/sprint10-verification.md — written (deploy + 9-step browser smoke + 5 API curls + undo test + a11y check).

Done when (Sprint 10)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint10-verification.md exists.
  • All 6 doc files have a Sprint 10 status block.

Out of scope (Sprint 10)

  • A "Manage blocked recipes" page.
  • Bulk unblock.
  • Touch-device gesture for the card overlay (the focus state already surfaces the button on tap).

Sprint 11 — Wire the dead "Generate Meal Plan" CTA (Dashboard.tsx:499-504)

User direction (2026-06-05): "Proceed." Selected from the question menu as the smallest §Future item. F1 (Sprint 9) is shipped, F8 (Spoonacular) + F9 (Ollama) are full backend proposals, and the dead Generate Meal Plan CTA at Dashboard.tsx:503 is the final remaining item. The button is rendered with onClick: () => {} — clicking it does nothing. Wired to existing endpoints, no backend changes, no new dependencies.

Root cause: the user lands on the Dashboard with no meal plan and sees a "Generate Meal Plan" button. Clicking it does nothing. The backend already has the two endpoints needed (POST /api/meals to create a plan + POST /api/meals/{id}/fill-empty-slots to fill it from the recipe library), and the fillEmptySlots partial-success report pattern is already in production for the existing Plan Week menu (handlePlanWeek at Dashboard.tsx:366-392). The wiring is a 25-line client-side glue function that calls both in sequence.

S11.1 — handleGenerateFirstPlan() in Dashboard.tsx

  • Add a new handler next to handlePlanWeek (line 366) that:
    1. Reads weekStart (already in scope).
    2. POST /api/meals with { week_start_date: weekStart, status: 'draft' } to create an empty plan.
    3. On success, POST /api/meals/{newId}/fill-empty-slots with { meal_types: ['breakfast', 'lunch', 'dinner'] }.
    4. Invalidate ['mealPlan', weekStart].
    5. Toast: reuse the same partial-success pattern as handlePlanWeek ("Planned N of M meals — K failed").
  • Wire onClick of the EmptyState.action (line 503) to call handleGenerateFirstPlan().
  • Track a generatingFirstPlan state for the loading spinner; swap the button label to "Generating…" while in-flight.
  • Handle the "Meal plan for this week already exists" 400 from meals.create (race condition with another tab) by calling fillEmptySlots directly with the existing plan's id — refetch the plan from getPlanned(weekStart) to get the id.

S11.2 — Verify

  • npm run build green (tsc 0 errors, vite 0 errors).
  • Browser smoke (4 steps) on http://100.108.208.56:8082/:
    1. Log in as a family with no meal plan for the current week. Land on /.
    2. Confirm EmptyState shows "Generate Meal Plan" button.
    3. Click the button. Confirm: button label flips to "Generating…", toast appears with "Planned N of M meals", empty state disappears, plan grid renders.
    4. Refresh the page. Confirm the plan persists.
  • Race test: open two tabs, both click "Generate Meal Plan" at the same moment. Second tab should still succeed (handled by the meals.create 400 → fall-through to fillEmptySlots path).
  • No regression in Sprints 1-10.

S11.3 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 11 status block at the top.
  • fix-ui-audit.md — Sprint 11 plan section (T5.1-T5.3).
  • Review/handoff-ui-audit.md — Sprint 11 entry in the "How to take over" section + TL;DR row.
  • docs/HANDOFF.md — Sprint 11 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 11 decisions + file:line references.
  • Review/sprint11-verification.md — written (4-step browser smoke + race test).

Done when (Sprint 11)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint11-verification.md exists.
  • All 6 doc files have a Sprint 11 status block.

Out of scope (Sprint 11)

  • LLM-powered generation (F8 Spoonacular, F9 Ollama) — separate backend proposals, future sprints. Sprint 11 only wires the existing recipe-library-based fill.
  • A "what would you like for dinner?" prompt before generation — the existing flow generates from the library with no user input.
  • A "regenerate" button after the plan exists — the existing Plan Week menu at Dashboard.tsx:366-392 already handles this case.

Sprint 12 — F8 Spoonacular search (§Future H10) — DRAFTED, awaiting user approval

User direction (2026-06-05): "Proceed." Selected from the question menu. F8 is the smallest remaining §Future item: search-by-name on a public API, brings external recipe data into the system. F9 (Ollama) remains a separate full-backend proposal.

Root cause: the user can browse ~150 local recipes on /recipes (admin seeds them) but has no path to find new ones without leaving the app. F8 adds a "Search the web" toggle that hits the Spoonacular complexSearch API and lets the user import a result into the local library in one click.

Pre-existing infrastructure to reuse (not recreate):

  • backend/app/services/recipe_discovery.py (226 lines) — full RecipeDiscoveryService with _search_spoonacular(), _fetch_recipe_info(), _normalize_spoonacular(). Reads SPOONACULAR_API_KEY via getattr(settings, ...). Cites 150/day free quota.
  • backend/app/api/ingredients.py:58-103 — public POST /api/ingredients is idempotent on name_lower + aliases. The ingredient-resolution helper for the import flow.
  • scripts/enrich_recipes_spoonacular.py (76 lines) — standalone one-shot script, reference for the env + URL pattern.

Pre-existing WIP (NOT touched by Sprint 12):

  • backend/app/api/recipes.py (352 lines, not registered in main.py)
  • backend/app/schemas/recipe.py (93 lines, has RecipeCreate + RecipeIngredientRef)
  • nginx/nginx.conf

S12.1 — Backend: GET /api/recipes/search (public, webui-facing)

  • NEW backend/app/api/recipe_search.py — 2 endpoints + a thin search_spoonacular_summary(q, limit) wrapper. Reuses the existing requests.get(SPOONACULAR_SEARCH_URL, params={...}) pattern.
    • GET /recipes/search?q=&limit= — public, require_session. Calls complexSearch with addRecipeInformation=true, fillIngredients=true, instructionsRequired=true, number=limit. Returns normalized RecipeSearchHit[]. No info endpoint call (saves 1 point per result; search summary is enough for browsing).
    • POST /recipes/import — public, require_session. Body {external_id, external_source: "spoonacular"}. Fetches /recipes/{id}/information (1 point), normalizes, upserts ingredients via idempotent POST /api/ingredients, creates a local Recipe with external_source+external_id+is_manually_added=true. Returns the new Recipe.
  • MODIFIED backend/app/schemas/__init__.py — add RecipeSearchHit and RecipeImportRequest Pydantic models. Mirror the ExternalRecipe dataclass shape from recipe_discovery.py:28-44 (but with Pydantic).
  • MODIFIED backend/app/config.py — add SPOONACULAR_API_KEY: Optional[str] = None to Settings for schema consistency. (Currently read via getattr because extra="ignore". Adding it surfaces it in .env.example and tools.)
  • MODIFIED backend/app/main.py — register the new router. Reuses the app.include_router pattern at line 44-50.
  • Process-wide _points_used counter (module-level singleton in recipe_search.py). 503 with detail: "spoonacular daily quota reached" when over 140. Logged on every call.
  • 503 with detail: "SPOONACULAR_API_KEY not configured" when env var unset. Logged once at startup.

S12.2 — Backend: tests

  • NEW backend/tests/test_recipe_search.py — 4 tests, mock the Spoonacular requests.get calls.
    1. GET /api/recipes/search?q=chicken returns 200 + 1 normalized hit (mock summary).
    2. GET /api/recipes/search?q= returns 422 (empty query).
    3. POST /api/recipes/import happy path: mock info call + idempotent ingredient upsert + 201 with the new Recipe id.
    4. POST /api/recipes/import duplicate external_id → 409.

S12.3 — Frontend: API client + Recipes page

  • MODIFIED frontend/src/api/index.ts:27-33 — add recipes.search(q, limit) and recipes.import(data).
  • MODIFIED frontend/src/pages/Recipes.tsx — add a "Search the web" toggle next to the search bar (small button + Sparkles icon from lucide). When ON, the existing useQuery switches from recipes.list(params) to recipes.search({q: debouncedQ, limit: 10}). Renders results in a separate panel above the local list. Each result card has an "Import" button + the existing NeverSuggestButton removed (since these are not-yet-imported Spoonacular results, not local recipes).
    • Toggle defaults to OFF so the existing UX is preserved.
    • Toggle is a real <button> with aria-pressed={searchWeb}.
    • Debounced 300ms, same as the local search (reuse handleSearch from line 77-81).
    • Panel has aria-busy={isLoading} while fetching.

S12.4 — Verify

  • cd backend && python -m pytest tests/test_recipe_search.py -v → 4/4 green.
  • cd frontend && npm run build → green (tsc 0 errors, vite 0 errors).
  • Manual API smoke: curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=chicken&limit=5' -b session.txt → 200 JSON array.
  • Manual UI smoke (4 steps):
    1. Open /recipes in incognito. Confirm "Search the web" toggle is OFF, only the local list shows.
    2. Click the toggle. Confirm the panel header changes to "Search the web — Spoonacular" and a debounced search bar appears.
    3. Type "pasta" with 300ms debounce. Confirm 5-10 results render with name + image + cuisine tags.
    4. Click "Import" on a result. Confirm: toast "Imported!" + result card shows "Already imported" + toggle closes + the local list re-fetches and now contains the imported recipe.
  • Quota test: hit search 50 times in a row, confirm _points_used increments. The 51st within the budget returns 503.
  • No regression in Sprints 1-11.

S12.5 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 12 status block (T6.1T6.4) at the top.
  • fix-ui-audit.md — Sprint 12 plan section (T6.1T6.5).
  • Review/handoff-ui-audit.md — Batch H + Sprint 12 entry + TL;DR row 12.
  • docs/HANDOFF.md — Sprint 12 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 12 decisions + file:line references.
  • Review/sprint12-verification.md — written (4-step browser smoke + 2 API curls + quota test + a11y check).

Done when (Sprint 12)

  • All boxes above ticked.
  • npm run build green.
  • pytest tests/test_recipe_search.py green (4/4).
  • Review/sprint12-verification.md exists.
  • All 6 doc files have a Sprint 12 status block.

Out of scope (Sprint 12)

  • F9 — Ollama local LLM. Different backend proposal (model pull + ollama-py + /api/llm/plan endpoint). Separate sprint.
  • Image generation. AI_IMAGE_ENABLED env gate already exists; not enabled. Sprint 12 imports the Spoonacular image as-is.
  • Auto-enriching existing recipes with macros (would require nutrition endpoint = 1 pt per recipe; out of free quota).
  • Modifying the pre-existing WIP backend/app/api/recipes.py / schemas/recipe.py / nginx/nginx.conf — untouched.
  • F8 Spoonacular + F9 Ollama + dead Generate Meal Plan CTA — separate.

Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis) — DRAFTED, awaiting user approval

User direction (2026-06-05): "Proceed." F9-lite reuses the pre-existing OLLAMA_* config (config.py:36-38: OLLAMA_BASE_URL=https://ollama.com/v1, OLLAMA_API_KEY, OLLAMA_MODEL=kimi-k2.6:cloud). Avoids the local model pull (F9-full would be 4 GB on disk + a separate uvicorn process). Cloud LLM — costs apply per call (operator's existing OLLAMA billing).

Pre-existing infrastructure to reuse (not recreate):

  • backend/app/services/llm_matcher.py:97-144_ask_ollama(ingredient_name, candidates) helper. The exact call pattern Sprint 13 mirrors: POST ${OLLAMA_BASE_URL}/chat/completions with Authorization: Bearer ${OLLAMA_API_KEY}, model: settings.OLLAMA_MODEL, max_tokens: 500, temperature: 0, parse choices[0].message.content, strip `` blocks.
  • backend/app/services/recipe_enrichment.py — parallel LLM helper for recipes (different prompt shape; not reused).
  • backend/app/config.py:36-38 — OLLAMA config.

Pre-existing WIP (NOT touched): same as Sprint 12.

S13.1 — Backend: POST /api/llm/plan (public, webui-facing)

  • NEW backend/app/api/llm_plan.py — single endpoint + a thin _ask_llm(prompt) helper. The endpoint:
    1. Validates the request body (prompt: str 1-500 chars, week_start: date).
    2. Reads the local recipe library (Recipe table, filtered by family_profile_id); serializes a compact list {id, name, cuisine_tags, dietary_tags, protein_type, total_time_minutes, dietary preferences}.
    3. Builds a prompt: "You are planning a 7-day meal plan (Mon-Sun). The user wants: ''. Pick up to 21 meals (7 breakfasts + 7 lunches + 7 dinners) from the recipe library. Return JSON: [{"day_of_week": 1-7, "meal_type": "breakfast|lunch|dinner", "recipe_id": "<uuid>"}]. If a slot has no good match, omit it. Use only recipe_ids from the list. Reply with JSON only — no commentary."
    4. Calls _ask_llm(prompt) (mirrors _ask_ollama from llm_matcher.py:97-144).
    5. Parses the JSON response (try json.loads, fall back to re.search(r"\[.*\]", content) to handle markdown code fences).
    6. Validates each entry: recipe_id is a UUID, day_of_week in 1-7, meal_type in {breakfast, lunch, dinner}. Drop invalid entries.
    7. Creates an empty plan via meals.create (Sprint 6+ endpoint), then bulk-inserts the LLM-picked items + calls fillEmptySlots for the slots the LLM didn't cover.
    8. Returns {plan_id, picked_count, filled_count, failed_count, reasoning: <LLM raw text if non-empty>}.
  • MODIFIED backend/app/schemas/__init__.py — add LLMPlanRequest + LLMPlanResponse Pydantic models.
  • MODIFIED backend/app/main.py — register the new router at /api/llm.
  • 503 with clear detail: "OLLAMA_API_KEY not configured" when env var unset.
  • 422 on empty / oversized prompt.
  • Cap on library size sent to the LLM: 200 recipes max (alphabetical by name). Larger libraries would exceed prompt tokens.

S13.2 — Frontend: free-text prompt in the Sprint 11 flow

  • MODIFIED frontend/src/pages/Dashboard.tsx — turn the Sprint 11 handleGenerateFirstPlan into a 2-step:
    1. New MealPlanPromptModal component (inline in Dashboard.tsx, ~40 lines, reuse Card/Button/Input from components/ui/*): a small modal with a textarea (max 500 chars, char counter) + two radio options: "Use the recipe library" (default) and "Ask the LLM". On submit, calls one of two API methods.
    2. Library path (default): keep Sprint 11's meals.create + fillEmptySlots exactly as-is.
    3. LLM path (new): mealPlannerApi.llm.plan({prompt, week_start}). On success, invalidate ['mealPlan', weekStart] and toast "Planned N meals (LLM picked N, library filled the rest)".
  • MODIFIED frontend/src/api/index.ts — add llm.plan(data).
  • A11y: modal has role="dialog", aria-modal="true", focus trapped on the textarea on open, restored to the CTA button on close. Esc dismisses. Tab cycles within the modal.

S13.3 — Verify

  • cd frontend && npm run build → green (tsc 0 errors, vite 0 errors).
  • Backend AST clean.
  • Manual API smoke:
    • curl -X POST /api/llm/plan -d '{"prompt": "easy weeknight dinners, no fish", "week_start": "2026-06-08"}' → 200 with {plan_id, picked_count: 7+, filled_count: 14-, ...}. (Requires OLLAMA_API_KEY set on the host.)
    • With OLLAMA_API_KEY unset → 503.
    • Empty prompt → 422.
  • Manual UI smoke (3 steps):
    1. Land on / with no plan. Click "Generate Meal Plan". Modal opens with the textarea + the two radio options.
    2. Type "Italian-inspired, vegetarian" + select "Ask the LLM" + click submit. Confirm: button label flips to "Asking LLM…", modal shows a small spinner, ~5-15s later the modal closes, plan grid renders, toast shows the picked/filled split.
    3. Refresh the page. Confirm the plan persists.
  • No regression in Sprints 1-12.

S13.4 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 13 status block.
  • fix-ui-audit.md — Sprint 13 plan section (T7.1-T7.4).
  • Review/handoff-ui-audit.md — Batch I + Sprint 13 entry + TL;DR row 13.
  • docs/HANDOFF.md — Sprint 13 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 13 decisions + file:line references.
  • Review/sprint13-verification.md — written.

Done when (Sprint 13)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint13-verification.md exists.
  • All 6 doc files have a Sprint 13 status block.

Out of scope (Sprint 13)

  • F9-full — local Ollama model pull on the host. Would require pulling Mistral 7B or Llama 3 8B (~4 GB) + a separate ollama serve process + a different config (OLLAMA_BASE_URL=http://localhost:11434). F9-lite uses the cloud tier. Future sprint if the cloud costs become painful.
  • Prompt engineering / quality iteration. The prompt is a first cut. If the LLM returns 0 picks or 21 identical recipes, the operator can iterate on the prompt. Out of scope for the initial ship.
  • Multi-week plans. One week at a time.
  • Save the prompt as a template for reuse. Future sprint.

Active sprint: Sprint 14 — Vitest for useOnboarding (Q4)

S14.1 — Dependencies (devDeps only)

  • Add vitest@^1.6.0 — the runner.
  • Add happy-dom@^14.7.0 — DOM env (lighter than jsdom, faster startup; 7x smaller).
  • Add @testing-library/react@^14.2.0 — render + assert helper.
  • Add @testing-library/jest-dom@^6.4.0.toBeInTheDocument() etc.
  • All four go under devDependencies. Lifts the "no new npm deps" rule for testing-only.

S14.2 — Config

  • frontend/vitest.config.ts — uses Vite's plugin-react (already a devDep), sets environment: 'happy-dom', points setupFiles: ['./vitest-setup.ts'], reuses tsconfig.json paths.
  • frontend/vitest-setup.ts — imports @testing-library/jest-dom/vitest (auto-extends expect with DOM matchers).
  • package.json scripts: add "test": "vitest run --reporter=default" (no watch by default — CI-friendly) + "test:watch": "vitest".

S14.3 — Tests for useOnboarding (the S9 bug class)

  • frontend/src/components/OnboardingTour.test.tsx — render the hook via a tiny <TestHarness/> consumer; renderHook from @testing-library/react.
  • Case 1 — clean init: clear localStorage; isComplete === false.
  • Case 2 — persisted init: localStorage.setItem('mealplanner:onboarding-complete', '1'); isComplete === true on mount.
  • Case 3 — markComplete sets state, does NOT clear localStorage: after markComplete(), isComplete === true and the localStorage key is still '1'. This is the S9 bug lock.
  • Case 4 — reset clears localStorage AND flips state to false: after reset(), isComplete === false and the localStorage key is removed.
  • Case 5 — show is a mirror of reset: after show(), same assertions as Case 4.
  • Case 6 — localStorage throw is silently swallowed: stub localStorage.getItem to throw; hook still returns isComplete: false, does not crash.

S14.4 — Verify + commit

  • cd frontend && npm test runs all 6 cases green.
  • npm run build still green (vitest's types shouldn't conflict with vite's).
  • Review/sprint14-verification.md written.
  • All 6 running docs updated with the Sprint 14 status block.
  • Commit on host + push.

Done when (Sprint 14)

  • All boxes above ticked.
  • npm test shows 6 passing in <5 s.
  • npm run build still green.
  • The S9 bug class is locked: any future regression that wires onComplete → reset (or onReset → markComplete) trips Case 3.

Out of scope (Sprint 14)

  • Component-level tests for <OnboardingTour/> itself (the dialog, focus management, arrow-key navigation). The hook covers the S9 bug class; component tests are a different scope. Future sprint.
  • Tests for recipes API client or useOnboarding callers. Not the S9 bug class. Future sprint.
  • Tests for the backend. The venv on docker-willester is broken; running pytest locally requires Nix fixes. Out of scope.
  • F9-full (local Ollama model pull). Opt-in based on cloud-billing feedback only. _ask_llm is the single seam — F9-full only needs to swap the URL + model name.

Active sprint: Sprint 16 — Fix Sprint 13 LLM-model latent bug (kimi-k2.6 reasoning eats token budget → 0 picks every time)

Owner: this agent. Status: starting. Tracking: Review/sprint16-verification.md, .agent/plan.md, .agent/context.md.

Why now: While answering the user's "is there anything else to refine?" question, I dug into the LLM endpoint and found that every /api/llm/plan call returns picked_count=0 — the model is kimi-k2.6:cloud, a reasoning model that burns its 800-token max_tokens budget on internal reasoning and never produces the JSON answer. The library fill (Sprint 6+) takes over every time, so the user never sees a crash — they just pay for an Ollama call that does nothing useful. Discovered by adding a temp debug log to _ask_llm and seeing raw_response='' with finish_reason: length.

Verified alternatives on Ollama Cloud: gpt-oss:20b (OpenAI's open-source 20B non-reasoning model) returns 21 valid picks in 2074 chars on a 950-char prompt. finish_reason: stop. Reasoning field is short (239 chars). Same prompt as the real backend.

Fix scope (4 boxes): (1) switch OLLAMA_MODEL in config.py:38 from kimi-k2.6:cloud to gpt-oss:20b; (2) bump max_tokens=8002000 in _ask_llm (gpt-oss may need more headroom for the 77-recipe library); (3) add a Vitest test that locks the LLM call shape (so a future model swap that breaks the JSON contract is caught); (4) verify live API call returns picked_count > 0.

Why this is Sprint 16, not a hotfix: the bug has been in production for ~1 sprint (since Sprint 13 was committed on 2026-06-05). Every "Ask the LLM" call has been silently broken. The fix is one line in config.py + a token bump, but the Sprint 13 design assumed kimi-k2 would work — so the right move is a tracked sprint with a verification doc.

S15.1 — Recipe target list (50 queries, family default)

Distribution: 5 cuisines × 10 recipes each = 50. Each cuisine gets a mix of cooking methods (sheet-pan, skillet, slow-cooker, one-pot, 30-min) so the LLM has variety. Each query is a free-text Spoonacular complexSearch query.

  • Italian (10): "chicken parmesan", "spaghetti carbonara", "lasagna", "minestrone soup", "pesto pasta", "chicken piccata", "mushroom risotto", "caprese salad", "italian wedding soup", "eggplant parmesan"
  • Mexican (10): "chicken tacos", "beef enchiladas", "black bean burritos", "shrimp fajitas", "chicken quesadilla", "taco salad", "sopa de tortilla", "carnitas", "chicken tortilla soup", "huevos rancheros"
  • Asian (10): "chicken stir fry", "beef and broccoli", "pad thai", "fried rice", "teriyaki salmon", "tofu curry", "chow mein", "spring rolls", "pho", "kung pao chicken"
  • American (10): "chili", "meatloaf", "mac and cheese", "BBQ chicken", "pot roast", "shepherd's pie", "chicken pot pie", "beef stew", "burgers", "pulled pork"
  • Mediterranean/Middle Eastern (10): "chicken shawarma", "falafel", "hummus bowl", "greek salad", "lamb kebabs", "tabbouleh", "roasted vegetable wrap", "couscous", "stuffed peppers", "baked falafel"

Dietary tags inferred from title+ingredients: _infer_protein_simple in recipe_search.py:151-170 (Sprint 12). Vegetarian entries: caprese, minestrone, pesto pasta, mushroom risotto, black bean burritos, taco salad, sopa de tortilla, huevos rancheros, fried rice, tofu curry, spring rolls, mac and cheese, falafel, hummus bowl, greek salad, tabbouleh, roasted vegetable wrap, couscous, stuffed peppers, baked falafel. That's ~20/50 = 40% vegetarian, which is the "vegetarian-heavy" target.

S15.2 — Import script (one-shot Python)

  • scripts/seed_recipes.py (NEW) — reads the 50-query list, calls GET /api/recipes/search?q=...&limit=5, picks the top hit per query, calls POST /api/recipes/import with {external_id, external_source: "spoonacular"}. Idempotent: 409 on duplicate → skip.
  • Uses the existing SESSION_PASSWORD=test-family-password for require_session auth. POST body is JSON. Runs in a single Python process.
  • 1-2 sec sleep between queries to stay well below the 1-pt + 0.01 × 5 hits = 1.05 pts/search rate. No race with the frontend's quota counter — process-local, single-threaded.
  • Logs per-query result: external_id, name, points_used_so_far, quota_status.

S15.3 — Verify

  • SELECT count(*) FROM recipe WHERE external_source='spoonacular'; returns ~50 (give or take the 5-10 that fail to return hits).
  • Spot-check 5 random recipes in the UI: /recipes page shows them with the right image, ingredients, prep time.
  • mealPlannerApi.llm.plan({prompt: 'Italian, vegetarian', week_start}) returns picked_count > 0 and uses the new recipes.
  • 4-week plan generation: meals.fillEmptySlots should pull from a richer library, fewer "no recipe available" failures.

S15.4 — Docs (all 6 running docs updated)

  • Review/sprint15-verification.md (NEW) — the 50-query list + the script + the curl flow + the expected counts.
  • .agent/plan.md — Sprint 15 section (S15.1-S15.4 + Done when + Out of scope).
  • .agent/context.md — Sprint 15 decisions (D1-D6), open Q1-Q2, file:line references.
  • Review/ui-nielsen-audit.md — Sprint 15 status block.
  • fix-ui-audit.md — Sprint 15 section.
  • Review/handoff-ui-audit.md — Batch K, TL;DR, last-updated.
  • docs/HANDOFF.md — Sprint 15 section + last-updated footer.

Done when (Sprint 15)

  • All boxes above ticked.
  • 50 (or close to 50) Spoonacular recipes in the local recipe table.
  • _points_used counter ends at ~105-130 pts (under the 140 cap).
  • All 6 doc files have a Sprint 15 status block.

Out of scope (Sprint 15)

  • No new feature work, no schema changes, no UI changes. This is a content op. Any change to the import endpoint, the search endpoint, or the recipe model is out of scope.
  • No tuning of the quota counter or the inference logic. Sprint 12's _infer_protein_simple is good enough.
  • No re-running of previous sprints' verification flows. Sprint 15 is additive.
  • F9-full (local Ollama model pull). Still opt-in based on cloud-billing feedback.

Sprint 15 — Round 2 (2026-06-07): +18 recipes, library at 67 total

User direction (2026-06-07): "please add more meals to the potential list" / "Pull in more recipes so we have a larger sample to generate from."

Round 1 (Sprint 15) imported 18 recipes before hitting the 50-pt/day free-tier cap. The user wanted more. Round 2 uses a fresh quota (cap rolled over) and a different query list focused on cuisines and meal types the round 1 list didn't cover.

S15R2.1 — Round 2 query list (50 queries, gap-filling)

  • Indian (8): chicken tikka masala, butter chicken, palak paneer, chana masala, biryani, dal, samosa, naan
  • Thai (6): green curry, massaman curry, tom yum soup, mango sticky rice, papaya salad, thai basil chicken
  • Chinese regional (6): mapo tofu, hot and sour soup, scallion pancakes, soup dumplings, beef noodle soup, dan dan noodles
  • Soups & stews (6): french onion soup, clam chowder, chicken noodle soup, tomato soup, lentil soup, butternut squash soup
  • Salads (6): caesar salad, cobb salad, nicoise salad, wedge salad, pasta salad, quinoa salad
  • Sandwiches/wraps (5): banh mi, reuben sandwich, club sandwich, french dip, gyro wrap
  • Breakfast (5): eggs benedict, pancakes, french toast, omelette, breakfast burrito
  • German/European (4): schnitzel, spaetzle, sauerbraten, beef rouladen
  • French (4): coq au vin, ratatouille, beef bourguignon, quiche lorraine

50 queries = 8+6+6+6+6+5+5+4+4.

S15R2.2 — Round 2 import script

  • scripts/seed_recipes_round2.py (NEW, ~120 lines) — same shape as round 1, different query list. Hits Spoonacular's complexSearch directly, POSTs top hits to backend's /api/recipes/import. Idempotent (409 on duplicate). 1.5 sec sleep. Stops on 402.

S15R2.3 — Round 2 result

  • 18 recipes imported today (queries 1, 2, 3, 5, 6, 7, 8, 10, 17, 19, 21, 22, 23, 24, 25, 27, 28, 29). 12 queries returned no hits from Spoonacular's free-tier index (e.g. "chana masala", "thai basil chicken", "dan dan noodles"). 1 query hit 402 mid-import ("wedge salad", HTTP 502 from /api/recipes/import).
  • DB went 49 → 67 total recipes (37 Spoonacular + 30 manual).
  • LLM test (Sprint 13, week 2026-07-20, prompt "variety, mix of cuisines, family-friendly, no repeats"): picked_count=0 / filled_count=21 / failed_count=0. Library now covers all 21 slots of a week (was 19/21 + 2 failed in round 1).

S15R2.4 — Round 2 follow-up

  • Q2 — Run a round 3? Quota resets every 24h. A round 3 could add 30 more recipes (using round 1's script, which is idempotent and will skip the 37 already imported). Trajectory: 67 → 80-100 total.
  • Q3 — Lower _DAILY_LIMIT=140 to 45 to match the real 50-pt free tier. Surface in the next sprint that touches recipe_search.py.

Done when (Round 2)

  • 18 recipes imported today (from 50 queries).
  • DB went 49 → 67.
  • LLM test: 21/21 slots filled, 0 failed.
  • All 6 running docs updated with round 2 status.

Out of scope (Round 2)

  • Same as round 1. No feature work, no schema changes, no UI changes.

Sprint 15 — Round 3 (2026-06-07): +10 recipes, library at 77 total

Triggered by: user said "proceed" after round 2. Quota had rolled over. Re-ran scripts/seed_recipes.py (round 1's script, idempotent).

S15R3.1 — Round 3 result

  • 10 new imports (queries 29, 30, 31, 32, 33, 34, 35, 36, 37, 38 of round 1's 50-query list):
    • 2 Asian leftovers (queries 29-30): Pho With Zucchini Noodles, Kung Pao Chicken With Peanuts
    • 8 American comfort dishes (queries 31-38): Superbowl Chili, Veggie Meatloaf, Crab Mac and Cheese, BBQ Chicken, Classic Pot Roast, Lean Shepherd's Pie, Amazing Chicken Pot Pie, Slow Cooker Beef Stew
  • 37 duplicates skipped (queries 1-28 of round 1's list — already imported in rounds 1+2)
  • 12 no-hits (queries 9, 12, 14, 17, 22, 23, 25, 26, 27 from round 1's list)
  • 1 402 cap hit at query 38
  • DB went 67 → 77 total recipes
  • LLM test (week 2026-08-03, prompt "comfort food, no repeats from past 2 weeks"): 21/21 filled, 0 failed
  • All 6 running docs updated with round 3 status

S15R3.2 — Round 3 follow-up

  • Q4 — Library is large enough. 77 unique recipes covers 4 weeks × 21 picks = 84 with 1.09× rotation. Stop seeding unless the user wants more.
  • Q5 — The remaining 12 unrun round-1 queries (American: burgers, pulled pork; Mediterranean: shawarma, falafel, hummus bowl, greek salad, lamb kebabs, tabbouleh, roasted vegetable wrap, couscous, stuffed peppers, baked falafel) would add 5-10 more recipes. Can run on a future day if the user wants them.

Done when (Round 3)

  • 10 recipes imported.
  • DB went 67 → 77.
  • LLM test: 21/21 slots filled, 0 failed.
  • All 6 running docs updated with round 3 status.

Out of scope (Round 3)

  • Same as rounds 1+2. No feature work, no schema changes, no UI changes.

Sprint 16 — Fix Sprint 13 LLM-model latent bug

Discovered: 2026-06-08, while answering "is there anything else to refine?". The /api/llm/plan endpoint has been silently broken since Sprint 13 — every call returns picked_count=0 because kimi-k2.6:cloud (a reasoning model) burns the 800-token max_tokens budget on internal reasoning and never produces the JSON answer. Library fill takes over.

Repro: docker logs mealplanner-backend-1 shows LLM plan: prompt=N chars, raw_picks=0, valid_picks=0 for every call. Adding a temp debug log confirms raw_response='' (empty string) and finish_reason: length from Ollama.

Verified alternative: gpt-oss:20b on Ollama Cloud (OpenAI's open-source 20B non-reasoning model) returns 21 valid picks on the same prompt. finish_reason: stop. Reasoning field is 239 chars (vs kimi-k2.6's 8206 chars on a complex prompt).

S16.1 — Model switch + token bump

  • backend/app/config.py:38OLLAMA_MODEL: str = "gpt-oss:20b" (was "kimi-k2.6:cloud").
  • backend/app/api/llm_plan.py:117max_tokens: 2000 (was 800). gpt-oss needs more headroom for 77-recipe libraries; the model returns ~99 chars per pick × 21 picks = 2074 chars max. 2000 is the floor; the 4-week coverage with 77 recipes may need up to 2500. Will adjust if any plan call returns finish_reason: length.

S16.2 — Vitest test (frontend side, locks the wire format)

  • frontend/src/api/llm.test.ts (NEW) — mocks mealPlannerApi.llm.plan and asserts the response shape (plan_id is UUID, picked_count + filled_count + failed_count are numbers ≥ 0, sum ≤ 21, reasoning is string|null). This is a contract test — it doesn't catch the kimi-k2 bug (which is server-side), but it locks the response shape so future refactors don't break the frontend.
  • The server-side fix for kimi-k2 is best locked by a backend test, but the venv on docker-willester is broken (Nix symlinks to /run/current-system/sw/bin/python); pytest is skipped. The Vitest test is the best we can do for now.

S16.3 — Verify

  • Live API call: POST /api/llm/plan {prompt: "Italian vegetarian, 30 min", week_start: "2026-08-17"} returns picked_count > 0 (was 0 before the fix).
  • npm test — 8 cases pass (7 from Sprint 14 + 1 new from Sprint 16).
  • npm run build — green.
  • No regression: Sprint 11 library path still works (Sprint 13 tolerance).

S16.4 — Docs (all 6 running docs updated)

  • Review/sprint16-verification.md (NEW) — the full diagnosis + fix + verification commands.
  • .agent/plan.md — Sprint 16 section (S16.1-S16.4 + Done when + Out of scope).
  • .agent/context.md — Sprint 16 decisions (D1-D5), open Q1, file:line references.
  • Review/ui-nielsen-audit.md — Sprint 16 status block.
  • fix-ui-audit.md — Sprint 16 section.
  • Review/handoff-ui-audit.md — Batch L, TL;DR, last-updated.
  • docs/HANDOFF.md — Sprint 16 section + last-updated footer.

Done when (Sprint 16)

  • All boxes above ticked.
  • Live API call returns picked_count > 0.
  • All 6 doc files have a Sprint 16 status block.
  • No new runtime dependencies (no npm install needed).
  • No migration.

Out of scope (Sprint 16)

  • Switching to a paid Ollama plan for higher max_tokens. The free tier is fine for 2000 tokens; if a future prompt needs more, that's a separate sprint.
  • Adding more reasoning to the prompt (e.g. chain-of-thought). gpt-oss returns valid JSON without needing it.
  • Per-model prompt templates. One prompt works for both kimi-k2 and gpt-oss; the model switch alone is the fix.
  • Re-trying F9-full (local Ollama model pull). Still opt-in based on cloud-billing feedback. _ask_llm is still the single seam.