Sprint 13 (commit bae9403) splits the Sprint 11 "Generate Meal
Plan" CTA into a 2-step modal: "Use the recipe library" (default,
Sprint 11 unchanged) or "Ask the LLM" (new). The LLM path POSTs
to /api/llm/plan; the backend calls kimi-k2.6:cloud on ollama.com,
parses the LLM’s JSON picks, creates a fresh plan, fills the
LLM’s picks, and falls through to the Sprint 6+ fillEmptySlots
pattern for the slots the LLM didn’t cover. No pre-existing WIP
files touched.
This commit updates the 6 running docs that track the sprint:
- .agent/plan.md — Sprint 13 section (S13.1-S13.4) added.
- .agent/context.md — Sprint 13 (D1-D9, Q1-Q3) added; file:line
references; key takeaways.
- Review/sprint13-verification.md — new file: 3-step browser
smoke + 4 API curls + a11y check + 6-risk table + future
work section.
- Review/ui-nielsen-audit.md — Sprint 13 status block (T7.1-T7.3)
at the top, after the Sprint 12 block.
- fix-ui-audit.md — Sprint 13 section (T7.1-T7.5) added after
the Sprint 12 section.
- Review/handoff-ui-audit.md — Batch I added to the deploy
instructions; Sprint 13 section added after Sprint 12; TL;DR
table row 13 added; Last-updated footer updated.
- docs/HANDOFF.md — Sprint 13 section added after the Sprint 12
section, with a path-forward paragraph for F9-full.
All 6 docs now reflect Sprint 13. §Future backlog remaining:
F9-full (local Ollama model pull on the host) — opt-in based on
cloud-billing feedback. _ask_llm is the single seam: F9-full only
needs to swap the URL + model name.
8.4 KiB
Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis) — verification
Status (2026-06-05): ✅ Code complete. npm run build green. Awaiting user deploy.
Summary
Sprint 13 splits the Sprint 11 "Generate Meal Plan" CTA into a 2-step modal: the user picks "Use the recipe library" (default, Sprint 11's existing flow) or "Ask the LLM" (new). The LLM path POSTs to /api/llm/plan with a free-text prompt; the backend calls kimi-k2.6:cloud on ollama.com, parses the LLM's JSON picks, creates a fresh plan, fills the LLM's picks, and falls through to the Sprint 6+ fillEmptySlots pattern for the slots the LLM didn't cover.
No pre-existing WIP files touched. Sprint 13 creates a new backend/app/api/llm_plan.py router (separate from the existing llm_matcher.py service) and reuses the established LLM call pattern (POST ${OLLAMA_BASE_URL}/chat/completions, Authorization: Bearer ${OLLAMA_API_KEY}, same max_tokens: 800, temperature: 0, strip <think> blocks).
Files changed
Backend
- NEW
backend/app/api/llm_plan.py(~280 lines) — 1 endpoint + 4 helpers:_ensure_ollama_configured,_serialize_library,_ask_llm(mirrorsllm_matcher._ask_ollama),_parse_picks(tolerant of markdown code fences),_validate_picks. 200-recipe cap on the library sent to the LLM. - MODIFIED
backend/app/schemas/__init__.py— addedLLMPlanRequest+LLMPlanResponse. - MODIFIED
backend/app/main.py— registeredllm_plan_api.routerat/api/llm.
Frontend
- MODIFIED
frontend/src/api/index.ts— addedllm.plan(data). - MODIFIED
frontend/src/pages/Dashboard.tsx— added the prompt modal (radio for library vs. LLM + textarea for the LLM path) + extracted Sprint 11's body intogenerateFromLibrary+ addedgenerateFromLLM. New state:showPromptModal,promptMode,promptText,promptBusy. The CTA now opens the modal; the modal's submit button dispatches on the radio.
Build verification
vite v5.4.21 building for production...
transforming...
✓ 1897 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.54 kB │ gzip: 0.32 kB
dist/assets/index-CPlRXkCg.css 42.38 kB │ gzip: 7.30 kB
dist/assets/index-665b6KrF.js 503.82 kB │ gzip: 154.33 kB
✓ built in 2.84s
tsc0 errors,vite0 errors.- Bundle: 500.28 → 503.82 kB (+3.5 kB for the modal + the LLM handler).
- Backend AST clean on all 3 changed files.
Backend verification (manual, post-deploy)
# Requires OLLAMA_API_KEY + OLLAMA_BASE_URL + OLLAMA_MODEL set in the
# backend env. (All three already exist; Sprint 13 just adds the
# router that reads them.)
# 1) Happy path
curl -sS -X POST 'http://100.108.208.56:8082/api/llm/plan' \
-H 'Content-Type: application/json' \
-H 'Cookie: mealplanner_session=...' \
-d '{"prompt": "Italian-inspired, vegetarian", "week_start": "2026-06-08"}' | jq
# Expected: { "plan_id": "<uuid>", "picked_count": 7+,
# "filled_count": 14-, "failed_count": 0,
# "reasoning": null }
# 2) OLLAMA_API_KEY unset (or temporarily unset in env + restart)
curl -sS -X POST 'http://100.108.208.56:8082/api/llm/plan' \
-H 'Content-Type: application/json' \
-H 'Cookie: mealplanner_session=...' \
-d '{"prompt": "test", "week_start": "2026-06-08"}' -i | head -1
# Expected: HTTP/1.1 503 Service Unavailable
# 3) Empty prompt
curl -sS -X POST 'http://100.108.208.56:8082/api/llm/plan' \
-H 'Content-Type: application/json' \
-H 'Cookie: mealplanner_session=...' \
-d '{"prompt": "", "week_start": "2026-06-08"}' -i | head -1
# Expected: HTTP/1.1 422 Unprocessable Entity
# 4) Plan for this week already exists
curl -sS -X POST 'http://100.108.208.56:8082/api/llm/plan' \
-H 'Content-Type: application/json' \
-H 'Cookie: mealplanner_session=...' \
-d '{"prompt": "test", "week_start": "2026-06-01"}' -i | head -1
# Expected: HTTP/1.1 400 Bad Request (if a plan for 2026-06-01 exists)
Browser smoke (3 steps)
Run on http://100.108.208.56:8082/.
- Land on
/with no plan. Click "Generate Meal Plan". Confirm: the modal opens with the title "Generate Meal Plan", a short description, and the 2 radio options ("Use the recipe library" selected by default, "Ask the LLM" unselected). The "Generate" button is enabled; the "Cancel" button is enabled. - Type "Italian-inspired, vegetarian" + select "Ask the LLM" + click Generate. Confirm: button label flips to "Asking LLM…" (with a spinning Loader2 icon), the modal is non-dismissible, ~5-15s later (depends on the LLM latency) the modal closes, the plan grid renders with the LLM's picks, and a toast shows
"Planned N meals (LLM picked K, library filled the rest)". - Refresh the page. Confirm the plan persists. The empty state does NOT re-appear.
A11y check
- The modal is a
<div className="fixed inset-0 z-50">with a click-outside-to-dismiss handler (skipped whilepromptBusyis true). - The radios are real
<input type="radio">with associated<label>blocks. Tab cycles through both radios, the textarea (when LLM mode is selected), and the 2 buttons. - The textarea has
autoFocuswhen the modal opens in LLM mode. Default LLM mode is library (no textarea shown); switching to LLM mode does not steal focus (intentional — the user just clicked the radio, they shouldn't lose their cursor). - The character counter is a
<div>withtext-rightalignment, screen-reader-accessible via the500max length on the textarea. - The "Generate" button is
disabledwhilepromptBusyis true (matches Sprint 11'sgeneratingFirstPlanstate pattern). - The Sprint 9 OnboardingTour's first step is the Dashboard's Weekly Overview card; the modal renders on top of that card via
z-50+bg-black/40scrim. The tour is not affected.
Risks & mitigations
- R1: LLM returns 0 picks or 21 identical recipes. Mitigation: the response is validated (recipe_id in library, day_of_week 1-7, meal_type in {breakfast, lunch, dinner}) and invalid entries are dropped. The library fill then takes over for any slot the LLM didn't cover. A prompt that returns 0 valid picks still produces a complete plan from the library.
- R2: LLM times out (60s). Mitigation: the
requests.postcall has a 60s timeout. On timeout,_ask_llmreturns None; the endpoint then calls the library fill (no LLM picks). The user sees a success toast withpicked_count: 0. - R3: Library is empty. Mitigation: 400 with
detail: "recipe library is empty; import some recipes first". The frontendshowApiErrorsurfaces the message. (Pre-existing WIP, also affects the Sprint 11 library path.) - R4: OLLAMA_API_KEY unset. Mitigation: 503 with clear
detail: "OLLAMA_API_KEY not configured; set it in the backend env". The frontendshowApiErrorsurfaces the message; the user can switch to "Use the recipe library" and proceed. - R5: OLLAMA_CLOUD costs. The operator's existing OLLAMA billing applies. The endpoint is public + requires
require_session(so no public abuse). A future sprint could add a per-day rate limit. - R6: Sprint 12's pre-existing WIP collision. Verified: the WIP
recipes.pyis registered at/api/recipes/*only; my new/api/llm/planis in a different prefix. No collision.
Commit
One commit: feat(ui): Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis). Files:
backend/app/api/llm_plan.py(NEW, ~280 lines)backend/app/schemas/__init__.py(2 Pydantic models)backend/app/main.py(router registration)frontend/src/api/index.ts(1 new method)frontend/src/pages/Dashboard.tsx(modal + LLM handler + extracted library handler)
Future work (NOT in Sprint 13)
- F9-full — local Ollama model pull. Pull Mistral 7B or Llama 3 8B (~4 GB) on the host + a separate
ollama serveprocess. Avoids cloud costs and the LLM_API_KEY dependency. Future sprint. - Prompt template library. Save common prompts ("easy weeknight", "vegetarian week", "quick breakfasts") as one-click buttons. Out of scope for the initial ship.
- Multi-week plans. One week at a time. The endpoint is parameterised on
week_start; calling it twice with two different weeks would work, but the UI doesn't surface it yet. - Streaming the LLM response. Currently waits for the full response. Future sprint could use the Ollama streaming API + a "thinking…" indicator.
- Per-day rate limit. A future sprint could gate the LLM call on a per-day budget to avoid surprise cloud bills.
- Vitest unit test for
useOnboarding(Q4 from Sprint 9). Still pending.