Commit Graph
16 Commits
Author SHA1 Message Date
admin 7f5757094e feat(meals): suggest complementary sides
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
2026-06-29 14:59:30 -07:00
admin bae94037f3 feat(ui): Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis)
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 — operator’s existing OLLAMA billing applies per call.

Sprint 13 splits the Sprint 11 "Generate Meal Plan" CTA into a
2-step modal: "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.

Backend:
- backend/app/api/llm_plan.py (NEW, ~280 lines). 1 endpoint
  (POST /api/llm/plan body {prompt, week_start}) + 4 helpers:
  - _ensure_ollama_configured — 503 on missing OLLAMA_API_KEY.
  - _serialize_library — reads up to 200 recipes for the
    family, sorted alphabetically. Cap prevents prompt-token
    overflow on kimi-k2.
  - _ask_llm — mirrors llm_matcher._ask_ollama (same URL,
    same headers, max_tokens=800, temperature=0, strips think
    blocks, 60s timeout).
  - _parse_picks — tolerant JSON parser. Handles markdown code
    fences, trailing commentary, and bare JSON. On failure
    returns []; the library fill takes over.
  - _validate_picks — drops invalid entries: missing fields,
    out-of-range day_of_week, unknown meal_type, unknown
    recipe_id. Returns a list of LLMPickedItem.
  Flow: rejects duplicate week (400) and empty library (400),
  builds the prompt, calls the LLM, validates picks, creates
  the plan, inserts the LLM-picked items, fills the rest from
  the library (Sprint 6+ pattern, re-implemented inline to
  avoid a self-HTTP-call), returns {plan_id, picked_count,
  filled_count, failed_count, reasoning}.
- backend/app/schemas/__init__.py — added LLMPlanRequest +
  LLMPlanResponse.
- backend/app/main.py:65-66 — registered llm_plan_api.router
  at the /api/llm prefix. No collision with the pre-existing
  WIP recipes.py.

Frontend:
- frontend/src/api/index.ts — added llm.plan(data) method.
- frontend/src/pages/Dashboard.tsx — added the prompt modal
  (radio for library vs. LLM + textarea for the LLM path with
  500-char counter) + new state (showPromptModal, promptMode,
  promptText, promptBusy) + extracted Sprint 11’s body into
  generateFromLibrary + added generateFromLLM. The modal is
  inline (not a separate component) because it depends on 4
  local states + 3 handlers. Click-outside-to-dismiss is
  disabled while promptBusy is true. The textarea autoFocuses
  when LLM mode is selected. Added the Button import.

LLM tolerance: a 60s timeout, parse-failure (markdown code
fences, trailing commentary), or empty response all return 0
picks; the library fill takes over. The user never sees a
crash — at worst, picked_count: 0 and the toast reads "Planned
N meals (LLM picked 0, library filled the rest)".

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 500.28 → 503.82 kB (+3.5 kB). Backend AST clean on
all 3 changed files. No new dependencies, no migration, no
pre-existing WIP files touched.

Deploy: git pull + docker compose up -d --build backend
frontend (no migration, no new dependencies).
2026-06-05 16:59:03 -07:00
admin 11b4595cf7 feat(ui): Sprint 12 — F8 Spoonacular search (web-search toggle + import)
Sprint 12 wires a "Search the web" toggle on /recipes that hits
Spoonacular’s complexSearch API. Each result has an "Import"
button that pulls the full recipe info (1 point) and writes a
local Recipe row with the right schema fields. Spoonacular
ingredients are upserted into the local Ingredient table via the
existing idempotent logic (mirrors POST /api/ingredients without
the HTTP roundtrip).

No pre-existing WIP files touched. Sprint 12 creates a new
backend/app/api/recipe_search.py router (separate from the WIP
recipes.py) and adds 2 Pydantic models to backend/app/schemas/
__init__.py (the canonical location). The WIP recipes.py is
registered in main.py (lines 54-55) and handles GET /api/recipes,
GET /api/recipes/recommended, GET /api/recipes/{id} — none of
which collide with my new endpoints.

Backend:
- backend/app/api/recipe_search.py (NEW, ~270 lines). 2 endpoints:
  - GET /api/recipes/search?q=&limit= — calls complexSearch with
    addRecipeInformation=true, fillIngredients=true,
    instructionsRequired=true. Returns normalized
    RecipeSearchHit[]. NO info endpoint call (saves 1 pt per
    result; the pre-existing _search_spoonacular calls the info
    endpoint for every result, burning the whole daily quota on a
    10-result search).
  - POST /api/recipes/import — fetches /recipes/{id}/information
    (1 pt), normalizes, upserts ingredients via the existing
    idempotent helper, creates a local Recipe with
    external_source="spoonacular" + external_id +
    is_manually_added=True, returns the new recipe id.
  - Process-wide _points_used counter (module-level singleton +
    threading.Lock). 503 with detail: "spoonacular daily quota
    reached; try again tomorrow" when over 140 (10-pt safety
    margin under the 150-pt free tier). Resets on process restart.
  - 503 with clear "SPOONACULAR_API_KEY not configured" when env
    var unset.
  - Idempotent import: 409 on duplicate (external_source,
    external_id).
- backend/app/config.py — added SPOONACULAR_API_KEY: Optional[str]
  to Settings (was previously read via getattr since extra=ignore).
- backend/app/schemas/__init__.py — added RecipeSearchHit +
  RecipeImportRequest.
- backend/app/main.py:62-63 — registered recipe_search_api.router
  at the /api/recipes prefix. No collision with the WIP.

Frontend:
- frontend/src/api/index.ts — added 5 new methods to
  mealPlannerApi.recipes: search, importRecipe, recommended,
  listIngredients, createIngredient. The last 3 are stubs for
  pre-existing call sites in Pantry/MealDetail/Recommended.tsx
  that were previously hidden by a smaller API surface.
- frontend/src/pages/Recipes.tsx — added searchWeb toggle state
  + importedExternalIds set + webHits query (enabled: searchWeb &&
  debouncedQ.length >= 2) + importMutation (toast on success,
  showApiError on failure) + the toggle button (with
  aria-pressed={searchWeb}) + the web-search panel (<div
  role="region" aria-label="Web recipe search"
  aria-busy={webLoading}>). The panel reuses the existing q +
  handleSearch (300ms debounce) so the local search bar drives
  both. The Import button has a 3-state machine: Import
  (Sparkles) → Importing… (Loader2) → Imported (Check, disabled).
- frontend/src/types/index.ts — added optional ingredient +
  is_optional to RecipeIngredient (for pre-existing MealDetail.tsx
  call sites).

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 496.48 → 500.28 kB (+3.8 kB). Backend AST clean on all 4
changed files. Backend pytest skipped (venv on docker-willester
is broken, pre-existing).

Deploy: git pull + docker compose up -d --build backend frontend
(backend has the new router; frontend has the new toggle). No
migration, no new dependencies.
2026-06-05 16:31:39 -07:00
admin 0b6c5dcfb7 feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)
User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.

Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
  (family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
  ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
  via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.

Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
  (overlay on RecipeCard) and detail (text buttons in RecipeDetail
  top bar). Popover with Allergy (red, window.confirm) + 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.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
  opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
  + e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
  of Add to Plan.

Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).

Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
2026-06-05 13:29:04 -07:00
MealPlanner efd1fc695f feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)
User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'

The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.

Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
  denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.

Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.

Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
  and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
  _has_prior_active_soft_denial) — single source of truth for the
  deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
  (default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
  Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
  one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
  plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
  is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
  / Never again) for pending items. handleDeny is scope-aware;
  toast reflects promoted_to_permanent. window.confirm on
  'Never again' prevents accidental permanent blocks.

Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
  failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
  curls + email-render procedure + rollback.

Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
  380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md

Deploy (user runs on deployment host):
  cd ~/MealPlanner && git pull
  docker compose exec backend alembic upgrade head
  docker compose -f docker-compose.yml up -d --build backend frontend
2026-06-05 10:24:35 -07:00
admin 8ad4ef67a9 feat(ui): bulk pantry add + plan-the-week button (Sprint 6 F3+F4)
F3 — Bulk 'add checked to pantry' on ShoppingList (the audit's F3 /
H7 finding). ShoppingList already had a 'checked' Set keyed on
ingredient_id and persisted to localStorage — that selection state
is the natural substrate for a bulk action.

Backend (POST /api/pantry/bulk):
- New endpoint that accepts {items: HomePantryCreate[]} and returns
  HomePantryBulkResult with per-item status (added / updated /
  skipped) and totals. Each item follows the same upsert semantics
  as POST /api/pantry (insert or overwrite qty/unit/expires_at).
- Items with an unknown ingredient id are reported as 'skipped'
  with reason='Unknown ingredient' rather than aborting the batch.
  Per-item failure is the chosen model (partial-success) so the
  user gets a precise count of what actually went in.
- New Pydantic schemas: HomePantryBulkCreate, HomePantryBulkResult,
  HomePantryBulkResultItem.

Frontend:
- mealPlannerApi.pantry.addBulk(items) is the API binding.
- ShoppingList gets a new 'Add N to pantry' primary button (next
  to the existing Reset button) that appears when checked.size > 0.
  Click → POST /api/pantry/bulk → toast shows 'added X, updated Y,
  skipped Z' counts. On success, only the items that actually
  landed in the pantry are removed from the checked set; skipped
  items stay checked so the user can see what failed.
- Disabled state with 'Adding…' label while the request is in
  flight; button text shows the count dynamically (matches the
  F4 design language: tell the user what they're about to do).

F4 — Plan the whole week (the audit's F4 / H7 finding).

Backend (POST /api/meals/{id}/fill-empty-slots):
- New endpoint that takes {meal_types: [str, ...]} and fills every
  empty slot in the plan whose meal_type is in the request. Per-day
  iteration (1-7) per meal_type, skipping already-occupied slots.
  Recipe selection: prefer un-used, fall back to any (same as the
  existing generate-item).
- Per-slot failure model: never aborts mid-batch. Returns
  FillEmptySlotsResult { filled: [{day, meal_type, item}],
  failed: [{day, meal_type, reason}] }. Invalid meal_types
  (e.g. 'brunch') return immediately with a single FailedSlot
  explaining why.
- Same approval_status=pending semantics as generate-item.

Frontend:
- mealPlannerApi.meals.fillEmptySlots(planId, mealTypes) is the
  API binding.
- New 'Plan the week' button on the Dashboard header (next to the
  week-nav control from Sprint 5). Primary color, Sparkles icon,
  ChevronDown caret indicates a dropdown. Disabled + spinner
  ('Planning…') while the request runs.
- Dropdown has two options: 'Dinners only' (sends
  meal_types=['dinner']) and 'All meals' (sends
  meal_types=['breakfast','lunch','dinner']). Each option has a
  one-line secondary label explaining the action.
- Toast on success: 'Planned N meal slots' (full) or 'Planned N
  of M meal slots — X failed (e.g. <reason>)' (partial). The
  query is then invalidated so the new slots show up.

Files: backend/app/api/meals.py, backend/app/api/pantry.py,
backend/app/schemas/__init__.py, frontend/src/api/index.ts,
frontend/src/pages/Dashboard.tsx, frontend/src/pages/ShoppingList.tsx.

Build: tsc 0 errors, vite 0 errors. Bundle +3.6KB (the new code
fits in the existing chunk).
Curl smoke on local dev DB confirms both new endpoints behave as
designed: /api/pantry/bulk returns proper skipped count for
unknown ingredients, /api/meals/{id}/fill-empty-slots returns
the partial-success result for the dinners-only call.
2026-06-04 14:00:31 -07:00
admin c364b8b222 feat(backend): recipe enrichment with side dishes & detailed instructions
- Add SideDish/SideDishIngredient schemas and recipe.side_dishes JSONB column
- Add recipe_enrichment.py service using Ollama LLM to:
  - Rewrite vague instructions with specific temps, quantities, timing, sauce breakdowns
  - Suggest 1-2 complementary side dishes with ingredients & prep notes
- Wire enrichment into recipe_ingestion.py discovery pipeline
- Add admin trigger endpoint /api/recipes/{id}/enrich for on-demand enrichment
- Migration 0014: Add side_dishes JSONB to recipe table
- Fix schemas/__init__.py imports: restore RecipeBase/Create/Read exports, add datetime/date for PydanticOptional compatibility
- Deployed to docker-willester and migrated to alembic 0014
2026-05-28 06:42:46 -07:00
admin 98d611d7b3 feat(backend): tunable planner weights via family profile config
- Add planner_config JSONB to family_profile model + migration
- Add PlannerConfig.merge(overrides) + to_dict() for family-level override merging
- generate_meal_plan merges family.planner_config into DEFAULT before filtering/scoring/selection
- New endpoints on /api/profile:
  - GET /planner-config — returns merged effective config
  - PUT /planner-config — partial override validation + merge
  - DELETE /planner-config — reset to system defaults
- Schemas: PlannerConfigOverride, PlannerConfigResponse, PlannerConfigUpdateRequest
  with weight-sum validation (0.999–1.001)
- Export RecipeBase/Create/Read/Update from schemas/__init__ to resolve forward refs
2026-05-25 16:03:46 -07:00
admin e22eae1ecd feat(backend): persist plan scores on MealPlanItem
- Migration 0012 adds score (float) and components (jsonb) to meal_plan_item
- generate.py: populates score and components at create time
- schemas/MealPlanItemResponse: include score + components fields
- GET /api/meal-plans/{id}: returns persisted values instead of zeros
2026-05-24 20:20:11 -07:00
admin 3885d7d0dc feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
2026-05-24 13:17:39 -07:00
admin f7ed10651b feat: Phase 8 Feedback UI + API endpoints
- New backend/app/api/feedback.py: GET/POST for meal_plan_item feedback
- MealDetail.tsx: star rating, never-suggest checkbox, reason dropdown,
  free-text comments, displays saved feedback
- frontend/src/api/index.ts + types: feedback API + TypeScript interface
- backend/app/schemas/__init__.py: model_validator maps qty→quantity for
  RecipeIngredient (fixes Pydantic validation on recipe JSONB)
- docs/HANDOFF.md: mark Phase 8 complete, update file map and date
2026-05-14 09:54:25 -07:00
admin 75e4bdb7a6 feat: POST /api/admin/meal-plans/generate + regenerate + get endpoints 2026-05-06 09:15:16 -07:00
admin 1f7b9bac23 feat: never-suggest CRUD endpoints (ingredient and recipe blocklist) 2026-05-06 06:26:16 -07:00
admin be7f698779 feat: add RecipeCreate/Update/Read schemas with canonical ingredient refs 2026-05-05 20:50:07 -07:00
admin c8382b37e7 feat: add IngredientCreate/Update/Read and IngredientGroceryMatchRead schemas 2026-05-05 20:48:49 -07:00
admin c735d21661 feat: implement Phase 2 - Alembic migrations, Pydantic schemas, and real API endpoints
- Add initial Alembic migration with full PostgreSQL schema (enums, tables, indexes, constraints)
- Add seed data migration with basic ingredients (70+) and family profile
- Add Pydantic schemas for all models (FamilyProfile, Recipe, MealPlan, etc.)
- Implement /api/profile endpoints (CRUD, family member management)
- Implement /api/recipes endpoints (CRUD, ingredients, filtering)
- Implement /api/meals endpoints (meal plans, voting, approval tokens)
- Implement /api/pantry endpoints (CRUD for home pantry)
- Implement /api/shopping-list endpoints (aggregation, print-ready HTML)
- Implement /api/admin endpoints (scrape trigger, logs, stats)
- Update ORIENTATION.md with Phase 2 progress
2026-05-04 20:21:20 -07:00