Public Access
Sprint 14 (commit28f3212) adds Vitest + happy-dom + @testing-library/react + @testing-library/jest-dom as devDeps and locks the useOnboarding state-transition contract with 7 unit tests in 25 ms. Lifts the 'no new npm deps' rule for testing-only. Runtime bundle unchanged (503.82 kB). No pre-existing WIP files touched. Sprint 9's bug1562929(onComplete wired to .reset, the inverse op) shipped a post-deploy fix the same day. Sprint 14 prevents recurrence at npm test time. This commit updates the 6 running docs that track sprints: - .agent/plan.md — header changed to 'Active sprint: Sprint 14'; Sprint 14 section (S14.1-S14.4 + Done when + Out of scope) added after the Sprint 13 section. - .agent/context.md — Sprint 14 decisions (D1-D7), open Q1, and file:line references added. - Review/sprint14-verification.md — NEW: deploy + 7-case test table + verification commands + Case 7 S9-bug-catch proof + 5-risk table + open Q1 (component-level tests). - Review/ui-nielsen-audit.md — Sprint 14 status block (T7.1-T7.3) added after the Sprint 13 block. - fix-ui-audit.md — Sprint 14 section (T7.1-T7.5) added after the Sprint 13 section. - Review/handoff-ui-audit.md — Batch J line in deploy list, Sprint 14 section after Sprint 13, TL;DR Sprint 14 line, Last-updated footer updated. (Did NOT replace the Sprint 7/8/TL;DR/environment-quirks/active-risks content that was at the bottom of the file — that material is preserved unchanged.) - docs/HANDOFF.md — Sprint 14 section added after Sprint 13, Last-updated footer updated. All 6 docs now reflect Sprint 14. §Future backlog remaining: F9-full (local Ollama model pull on the host). Q4 (Vitest) is now closed. Sprint 14 is the smallest sprint in the cycle (devDeps + 1 test file + 2 config files + 2 scripts) and the first that adds a test infrastructure layer.
556 lines
38 KiB
Markdown
556 lines
38 KiB
Markdown
# 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)
|
||
|
||
- [x] Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL`.
|
||
- [x] Adds `meal_plan_vote.denial_scope VARCHAR(16) NULL`.
|
||
- [x] Partial index on `meal_plan_item.denial_expires_at` (postgresql_where IS NOT NULL) for the planner's soft-deny lookup.
|
||
- [x] Downgrade reverses all three.
|
||
|
||
### S8.2 — Model: `app/models/__init__.py`
|
||
|
||
- [x] `MealPlanItem.denial_expires_at` column added.
|
||
- [x] `MealPlanVote.denial_scope` column added.
|
||
|
||
### S8.3 — Schema: `app/schemas/__init__.py`
|
||
|
||
- [x] `MealPlanItemResponse.denial_expires_at: Optional[datetime]`.
|
||
- [x] `VoteRequest.denial_scope: Optional[str]` with `pattern=^(this_week|never_again)$`.
|
||
- [x] `VoteResponse.denial_scope: Optional[str]`.
|
||
|
||
### S8.4 — Backend helpers: `app/api/meals.py`
|
||
|
||
- [x] `_apply_denial(db, item, scope)` — single source of truth for the deny path. Returns `{item, promoted_to_permanent, scope}`. Commits.
|
||
- [x] `_ensure_never_suggest_recipe(db, family_id, recipe_id, reason)` — idempotent NeverSuggest insert. Returns `True` if new, `False` if existing.
|
||
- [x] `_has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None)` — count query for the 2-denial check.
|
||
- [x] `DENIAL_DECAY_DAYS = 90` constant.
|
||
|
||
### S8.5 — Backend endpoints: `app/api/meals.py`
|
||
|
||
- [x] `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).
|
||
- [x] `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`.
|
||
- [x] `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`
|
||
|
||
- [x] 3 direct-action links per recipe (Approve / Deny this week / Never again).
|
||
- [x] Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.
|
||
|
||
### S8.7 — Planner: `app/services/planner/generate.py`
|
||
|
||
- [x] `_load_blocklists` returns 3 sets: `(blocked_ingredients, blocked_recipes, soft_denied_recipes)`.
|
||
- [x] `soft_denied_recipes` is the **hard filter** (per user decision: same as `blocked_recipes`).
|
||
- [x] `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket.
|
||
|
||
### S8.8 — Frontend: `Dashboard.tsx` + `api/index.ts`
|
||
|
||
- [x] `api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`.
|
||
- [x] `Dashboard.tsx:38-50, 385-410` — `MealCard` accepts scope-aware `onDeny`; renders 3 buttons (Approve / Deny this week / Never again) for pending items.
|
||
- [x] `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag.
|
||
- [x] "Never again" is gated by `window.confirm` to prevent accidental permanent blocks.
|
||
- [x] Buttons only show on `pending` items (approved/denied items show the badge only).
|
||
|
||
### S8.9 — Verify
|
||
|
||
- [x] `npm run build` green for Sprint 8 (tsc 0 errors, vite 0 errors).
|
||
- [x] 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).
|
||
- [x] Static checks: all 6 new modules import cleanly, helper logic verified via Python AST + import-test against `backend/venv`.
|
||
- [x] `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)
|
||
|
||
- [x] `Review/ui-nielsen-audit.md` — Sprint 8 status block at the top (T2.1–T2.10).
|
||
- [x] `fix-ui-audit.md` — Sprint 8 plan section (T2.1–T2.10).
|
||
- [x] `Review/handoff-ui-audit.md` — "Active sprint" callout + bottom "Last updated" line.
|
||
- [x] `docs/HANDOFF.md` — Sprint 7 + Sprint 8 sections before the 2026-06-03 session.
|
||
- [x] `.agent/plan.md` — this section.
|
||
- [x] `.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)
|
||
|
||
- [x] Hand-rolled (no `react-joyride`) — keeps npm footprint flat.
|
||
- [x] 4 steps: Dashboard / Pantry / Recipes / Shopping List.
|
||
- [x] Anchors to `[data-tour="<id>"]` attributes on existing elements.
|
||
- [x] Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
|
||
- [x] Anchor highlight = primary-400 ring + soft scrim; tooltip is a real `<div role="dialog" aria-modal="true">`.
|
||
- [x] Step progress = 4 progress bars.
|
||
- [x] Keyboard: `1`–`4` jump, `←/→` step, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
|
||
- [x] `useOnboarding()` hook + `?reset-tour=1` re-trigger; localStorage key `mealplanner:onboarding-complete`.
|
||
- [x] Focus captured on open (primary action), restored on close.
|
||
- [x] All reads/writes to localStorage wrapped in try/catch (private mode safe).
|
||
|
||
### S9.2 — Anchor points (5 lines of code total)
|
||
|
||
- [x] `pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">` on the Weekly Overview grid.
|
||
- [x] `pages/Pantry.tsx:185` — `<div data-tour="pantry">` on the page header (always present).
|
||
- [x] `pages/Pantry.tsx:208` — second anchor on the add-form `<Card>` (when the form is open).
|
||
- [x] `pages/Recipes.tsx:124` — `<Button data-tour="recipes">` on the Filters button.
|
||
- [x] `pages/ShoppingList.tsx:231` — `<div data-tour="shopping-list">` on the page header.
|
||
|
||
### S9.3 — `App.tsx` mount
|
||
|
||
- [x] `useOnboarding()` at App root, `isComplete` passed to `<OnboardingTour>`.
|
||
- [x] `onComplete` mapped to `onboarding.reset()` (flips the flag so re-renders don't re-show).
|
||
- [x] Mounted as sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so `useLocation` / `useNavigate` work).
|
||
|
||
### S9.4 — Verify
|
||
|
||
- [x] `npm run build` green (tsc 0 errors, vite 0 errors).
|
||
- [x] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
|
||
- [x] No regression in Sprints 1–8 (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.
|
||
|
||
- [x] **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.
|
||
- [x] **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).
|
||
- [x] `npm run build` green on `docker-willester` after the fix (495.64 kB, no size change).
|
||
- [x] User confirmed post-deploy smoke test passes (2026-06-05).
|
||
- [x] 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)
|
||
|
||
- [x] `Review/ui-nielsen-audit.md` — Sprint 9 status block at the top.
|
||
- [x] `fix-ui-audit.md` — Sprint 9 plan section (T3.1–T3.4).
|
||
- [x] `Review/handoff-ui-audit.md` — Sprint 9 entry in the "How to take over" section + TL;DR row.
|
||
- [x] `docs/HANDOFF.md` — Sprint 9 section.
|
||
- [x] `.agent/plan.md` — this section.
|
||
- [x] `.agent/context.md` — Sprint 9 decisions + file:line references.
|
||
- [x] `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)
|
||
|
||
- [x] New endpoint in `app/api/never_suggest.py:60-86`. Family-facing (uses `require_session`).
|
||
- [x] Body: `{family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}`.
|
||
- [x] Idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`.
|
||
- [x] Returns the row joined with `recipe_name`.
|
||
|
||
### S10.2 — Backend: `DELETE /api/never-suggest/{ns_id}` (public)
|
||
|
||
- [x] New endpoint in `app/api/never_suggest.py:89-111`. Family-facing.
|
||
- [x] Row-level ownership check: 403 if `family_profile_id` doesn't match the session.
|
||
- [x] 404 if the row doesn't exist.
|
||
|
||
### S10.3 — Backend: `NeverSuggestRead.recipe_name` + `.ingredient_name`
|
||
|
||
- [x] New fields in `app/schemas/never_suggest.py:31-33`.
|
||
- [x] 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.
|
||
- [x] Falls back to `None` if the recipe/ingredient was deleted (FK is `ON DELETE CASCADE`).
|
||
|
||
### S10.4 — Frontend: API client
|
||
|
||
- [x] `mealPlannerApi.neverSuggest.list(familyProfileId)` — `frontend/src/api/index.ts:75-86`.
|
||
- [x] `mealPlannerApi.neverSuggest.add({...})` — POST.
|
||
- [x] `mealPlannerApi.neverSuggest.remove(nsId)` — DELETE.
|
||
|
||
### S10.5 — Frontend: `NeverSuggestButton` component (NEW)
|
||
|
||
- [x] `frontend/src/components/NeverSuggestButton.tsx` (~290 lines).
|
||
- [x] Two variants: `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar).
|
||
- [x] Popover with two reasons: `Allergy` (red, requires `window.confirm`) and `Dislike` (neutral, no confirm).
|
||
- [x] **Undo toast** via `showToast.undo()` (Sprint 3 B12 pattern, 6s window).
|
||
- [x] Pre-existing block detection: shows a "Blocked" state with an "Unblock" path.
|
||
- [x] Query invalidations: `['neverSuggest', familyId]`, `['recipes']`, `['recommendedRecipes', familyId]`, `['mealPlan']`.
|
||
- [x] A11y: `aria-label`, `aria-expanded`, `aria-haspopup="menu"`, `role="menu"`, Esc dismisses, outside click dismisses.
|
||
|
||
### S10.6 — Frontend: `Recipes.tsx` overlay
|
||
|
||
- [x] `RecipeCard` now has `position: relative` so the overlay anchors correctly.
|
||
- [x] Button is `opacity-0 group-hover:opacity-100` (visible on hover or focus).
|
||
- [x] `e.preventDefault()` + `e.stopPropagation()` on the click — doesn't navigate to the detail page.
|
||
|
||
### S10.7 — Frontend: `RecipeDetail.tsx` top bar
|
||
|
||
- [x] New "Deny forever" button group to the left of "Add to Plan".
|
||
- [x] Same popover + confirm/undo semantics as the card overlay.
|
||
|
||
### S10.8 — Verify
|
||
|
||
- [x] `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
|
||
- [x] Backend imports clean; routes registered.
|
||
- [x] 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)
|
||
|
||
- [x] `Review/ui-nielsen-audit.md` — Sprint 10 status block at the top.
|
||
- [x] `fix-ui-audit.md` — Sprint 10 plan section (T4.1–T4.9).
|
||
- [x] `Review/handoff-ui-audit.md` — Sprint 10 entry in the "How to take over" section + TL;DR row.
|
||
- [x] `docs/HANDOFF.md` — Sprint 10 section.
|
||
- [x] `.agent/plan.md` — this section.
|
||
- [x] `.agent/context.md` — Sprint 10 decisions, file:line references, verification gate.
|
||
- [x] `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.1–T6.4) at the top.
|
||
- [ ] `fix-ui-audit.md` — Sprint 12 plan section (T6.1–T6.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: '<prompt>'. 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.
|