# 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 `` blocks). ## Files changed ### Backend - **NEW** `backend/app/api/llm_plan.py` (~280 lines) — 1 endpoint + 4 helpers: `_ensure_ollama_configured`, `_serialize_library`, `_ask_llm` (mirrors `llm_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` — added `LLMPlanRequest` + `LLMPlanResponse`. - **MODIFIED** `backend/app/main.py` — registered `llm_plan_api.router` at `/api/llm`. ### Frontend - **MODIFIED** `frontend/src/api/index.ts` — added `llm.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 into `generateFromLibrary` + added `generateFromLLM`. New state: `showPromptModal`, `promptMode`, `promptText`, `promptBusy`. The CTA now opens the modal; the modal's submit button dispatches on the radio. ## Build verification ```text 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 ``` - `tsc` 0 errors, `vite` 0 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) ```bash # 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": "", "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/`. 1. **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. 2. **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)"`. 3. **Refresh the page.** Confirm the plan persists. The empty state does NOT re-appear. ## A11y check - The modal is a `
` with a click-outside-to-dismiss handler (skipped while `promptBusy` is true). - The radios are real `` with associated `