# 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` (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": "<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/`.

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 `<div className="fixed inset-0 z-50">` with a click-outside-to-dismiss handler (skipped while `promptBusy` is 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 `autoFocus` when 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>` with `text-right` alignment, screen-reader-accessible via the `500` max length on the textarea.
- The "Generate" button is `disabled` while `promptBusy` is true (matches Sprint 11's `generatingFirstPlan` state 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/40` scrim. 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.post` call has a 60s timeout. On timeout, `_ask_llm` returns None; the endpoint then calls the library fill (no LLM picks). The user sees a success toast with `picked_count: 0`.
- **R3: Library is empty.** Mitigation: 400 with `detail: "recipe library is empty; import some recipes first"`. The frontend `showApiError` surfaces 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 frontend `showApiError` surfaces 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.py` is registered at `/api/recipes/*` only; my new `/api/llm/plan` is 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 serve` process. 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.
