Files
Meal-Planner/docs/HANDOFF.md
T
adminandClaude Opus 4.7 b4d92c07af docs: embed AM-1..AM-6 task list in HANDOFF.md for fresh-agent pickup
Breaks the auto-mint spec into 6 ordered, sized tasks with an explicit
halt-for-approval boundary at AM-2 (live scrape verification before
removing the env var). Restates the verified prerequisites (config.json
publicly readable; Firebase signUp returns valid JWT with proper headers;
Swiftly accepts the minted token) so a fresh agent doesn't have to
re-discover them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:08:53 -07:00

20 KiB

MealPlanner — Agent Handoff

You are taking over a project in mid-flight. Read docs/ORIENTATION.md first for the high-level. This file is the deep dive: what's real, what's stubbed, where the bodies are buried, and what to do next.

Date of handoff: 2026-05-06. Last commit before handoff: Swiftly auto-mint design spec (post-Phase 9).


TL;DR

The project completed a recovery pass (R1+R2+R3-0) from 2026-05-04 to 2026-05-05 (12 defects from a prior agent fixed), then shipped thin Phase 4 (recipe engine + ingredient↔grocery match layer + 30-recipe seed) and Phase 9 (meal-planner generation algorithm: filter → score → top-K=20 set enumeration with diversity penalty → persist MealPlan + items via POST /api/admin/meal-plans/generate).

The project's reason to exist is now real and verified end-to-end. 88/88 pytest tests pass.

Top remaining toil: SWIFTLY_BEARER_TOKEN expires hourly, requiring manual devtools capture. A complete redesign is specced in docs/specs/2026-05-06-swiftly-token-auto-mint.md (Path B: pure-HTTP token minting via Firebase REST, validated on 2026-05-06). That should ship first — see "Suggested next move".


What is real (verified)

Backend

  • backend/app/main.py imports cleanly with 22 routes wired.
  • Health: GET /health, GET /health/db.
  • Auth: bearer ADMIN_TOKEN for admin; signed-cookie session via app.security.require_session for mutations on family-facing routes; /api/auth/login with shared SESSION_PASSWORD.
  • Routers (backend/app/api/): profile.py, recipes.py, meals.py, pantry.py, shopping_list.py, admin.py, auth.py. CRUD shapes are stubbed/partial — they validate request bodies and persist correctly but business logic is thin.
  • POST /api/admin/scrape enqueues via FastAPI BackgroundTasks, returns 202 + scrape_log_id. Status polled via GET /api/admin/logs/{id}.
  • Lucky California ingestion (backend/app/scraper/lucky_ca_scraper.py) is a requests-based Swiftly JSON API client. Not Playwright — that path was deleted. Discovers 17 categories from https://luckysupermarkets.com/categories, fetches each from prod.swiftlyapi.net/search/api/v1/products/categories?cat=…&store=757&limit=10000. Bearer scoping: token only ever attached to prod.swiftlyapi.net requests, never to the public categories page.
  • Approval flow (backend/app/services/approval.py + meals router): per-voter URLSafeTimedSerializer tokens, TTL, single-use enforced in consume_token, GET renders an HTMLResponse vote page, POST records the vote and applies the rule (any deny → item denied; all approve → item approved; otherwise pending).
  • Email backend (backend/app/services/email.py): Protocol + ConsoleEmailBackend (writes JSONL to backend/var/email_outbox.jsonl) + SendGridEmailBackend stub that raises NotImplementedError. Selected via EMAIL_BACKEND env (default console).
  • 401 from Swiftly raises SwiftlyAuthError carrying the verbatim message "SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user (capture from luckysupermarkets.com network tab on a /search/api/v1 request)". The bg runner catches it and writes ScrapeLog.error_message so it surfaces via the admin logs endpoint.
  • Thin Phase 4: ingredient + recipe CRUD endpoints with admin gating; NeverSuggest CRUD (covers both ingredient blocklist and recipe blocklist via the existing schema); ingredient↔grocery_item match layer (rapidfuzz top-3 ranking with confidence threshold 0.75, manual override via /api/admin/ingredients/{id}/matches and /api/admin/ingredient-matches/{id}); 50 canonical ingredients seeded with aliases enriching pre-existing rows from migration 0002; 30 starter recipes spanning chicken/beef/turkey/pork/fish/vegetarian with varied cuisines, all under 45 min for 28/30. Match job runs after each successful scrape; matcher failures don't flip the scrape to FAILED.
  • Phase 9: meal-plan generation. POST /api/admin/meal-plans/generate runs the full filter→score→set-select pipeline against seeded recipes and produces a persisted MealPlan with up to 3 MealPlanItem dinners. Regenerate endpoint accepts relaxed constraint overrides (relax_time_max_minutes, relax_calorie_pct, relax_max_meal_cost) and deletes any prior plan for the same (family, week_start_date) before re-running. Per-meal cost matched against ingredient_grocery_match using the top-confidence grocery row. 88 tests green.

Database

  • Postgres 15. Seven migrations: 0001_initial_migration, 0002_seed_data, 0003_grocery_item_description, 0004_family_profile_calorie_target, 0005_grocery_item_external_id, 0006_thin_phase4 (ingredient.aliases, recipe.calories_per_serving, ingredient_grocery_match), 0007_seed_canonical_ingredients (50 ingredients + 30 recipes).
  • 0001 downgrade now does a DO $$ … DROP TABLE … DROP TYPE … END $$; block that preserves alembic_version. Round-trip works.
  • 0002 seed is idempotent (ON CONFLICT (name_lower) DO NOTHING).
  • Every SQLEnum(...) column carries values_callable=lambda obj: [e.value for e in obj] — without this, name-mode breaks reads against the lowercase Postgres enum values.
  • MealPlan.votes relationship was removed (it had no FK target). Votes are reachable via MealPlan.items[*].votes.
  • grocery_item upsert key is (source, external_id).

Frontend

  • React 18 + TS + Vite + Tailwind. Dashboard / MealDetail / Pantry / ShoppingList pages exist.
  • API client at frontend/src/api/index.ts uses withCredentials: true for cookie-based session auth and exposes auth.login(password) + auth.logout().
  • No login UI yet. No feedback page. No tests.

Tests

  • 59 tests under backend/tests/ (31 prior + Phase 4 additions: ingredient/recipe schema + API, matcher, match-hook, never-suggest, resolve-ingredient, thin-phase-4 smoke). All green when TEST_DATABASE_URL is set; requires_postgres marker auto-skips locally without it.
  • Live spike scripts in scripts/: spike_lucky_scrape.py (R2-A archived path), send_test_approval.py (email round-trip prover, supports --simulate-click {approve|deny}), spike_swiftly_ingest.py (R3-0 live ingestion prover; requires --confirm-live).

CI

  • .github/workflows/ci.yml: backend job (postgres:15 service, alembic + pytest) + frontend job (npm ci + build). Triggers on push and pull_request.

What is stubbed or missing

Phase 4 — Recipe Engine (thin slice complete)

  • Thin slice landed: ingredient + recipe CRUD, NeverSuggest CRUD, ingredient↔grocery match layer with rapidfuzz + manual override, 50 canonical ingredients + 30 starter recipes seeded. 59/59 tests green.
  • Phase 4 ingestion source (Spoonacular / TheMealDB / manual-only) — pros/cons table in docs/specs/2026-05-05-meal-planner-algorithm-design.md §6; decision deferred until Phase 9 lands.
  • Still thin: full-text recipe search, advanced tag filtering, bulk import endpoints — punted until the engine demonstrates which surfaces it actually needs.

Phase 5 — Meal-planner orchestration (not started)

  • The weekly cycle: scrape Sunday → generate Monday → email Monday-evening → deadline Thursday → finalize Friday.
  • All the parts exist (scrape works; email works; vote works; approval rule works) but nothing chains them.

Phase 6 — SendGrid (stub)

  • SendGridEmailBackend.send raises NotImplementedError("Wire SendGrid in R3-C"). Templates: meal proposal, reminder (T-24h), confirmation, denial.
  • from_email / reply_to config not added to Settings yet.

Phase 8 — Feedback UI (not started)

  • feedback table exists with rating, denial_reason, free-text. No frontend page reads or writes it. No /api/feedback router (folded into meals.py?).
  • The "learn from feedback" loop into Phase 9 is unscoped.

Phase 10 — Images (not started)

  • Recipe images: scrape from source sites first, AI fallback (AI_IMAGE_ENABLED=false flag exists, no implementation).

Phase 11 — Polish (not started)

  • APScheduler container with --workers 1 to run weekly cadence.
  • Variety analysis dashboard.
  • Budget tracking.
  • WhatsApp via Twilio (out of MVP scope).

Known caveats and traps

  1. Bootstrap login hatch. app/api/auth.py login: when no family_profile row exists, it signs the literal string "bootstrap" instead of a UUID. Anyone with SESSION_PASSWORD gets a session even with zero data in the DB. Acceptable for self-hosted on a trusted network. Replace with a proper first-run setup gate before exposing the system beyond the LAN/VPN. The decision is documented in .agent/context.md under "Decisions".

  2. SWIFTLY_BEARER_TOKEN expires hourly — and there's a designed-but-not-yet-built fix. Today the env var holds a Firebase anonymous-auth JWT for swiftly-lu-prod that expires hourly; on 401 the user manually captures a fresh one from devtools. The replacement is fully designed in docs/specs/2026-05-06-swiftly-token-auto-mint.md (~2-3 hours of work). The discovery: https://luckysupermarkets.com/config.json is publicly readable and exposes firebaseApiKey; with that, anonymous Firebase signUp via REST mints fresh tokens in <1 second. Verified end-to-end on 2026-05-06. Do this redesign first — it eliminates the only operator-toil step in the system. The scripts/refresh_swiftly_token.py (commit ccfb38a) seleniumbase approach is superseded; leave it for historical context but don't extend it.

  3. .env.example ships a real (expiring) token. Per user authorization. If it's already expired by the time you read this, that's expected — surface the refresh request to the user, OR (preferably) ship the auto-mint redesign per caveat #2 and remove the env var entirely.

  4. ScrapeStatus enum reuses STARTED for the queued state. R1-C didn't add a QUEUED value because that would have churned the Postgres enum type. Cosmetic. If you change it, add a migration.

  5. Pytest's transactional db fixture rolls back at teardown. Background tasks open their own SessionLocal() and don't see uncommitted data. test_swiftly_api.py::test_background_runner_writes_failed_with_token_message is the example of how to test bg-task behavior — use a separate non-fixture session, commit, run, verify, clean up explicitly.

  6. alembic downgrade base in 0001 preserves alembic_version table. Don't change this to DROP SCHEMA public CASCADE — that would also drop alembic_version and break the alembic state machine on the next upgrade.

  7. Login bootstrap aside, family_profile is currently empty in any fresh DB. Phase 9 must either seed it during the first-run flow or assume the admin manually created the row. Either way, document it.

  8. Routes use @router.get("") (no trailing slash). FastAPI's redirect_slashes=True (the default) will 307-redirect /api/profile/ to /api/profile. Tests assert canonical paths (no slash). The frontend client matches.

  9. 30 starter recipes seeded. Migration 0007 loads them; enough to exercise Phase 9 against real data. Bulk ingestion source still deferred.

  10. Frontend doesn't have a login UI. Until you build one, the family-facing flows can't actually be exercised by a real user — only by tests. The Dashboard/Pantry/etc. pages assume the cookie is already set.

  11. regenerate.exclude_recipe_ids accepted by the API for forward compat but not yet honored by the orchestrator — only NeverSuggest blocklist applies. ~30-line follow-up.

  12. GET /api/meal-plans/{id} returns persisted items but with score=0, components={}, and zeroed debug — those are only available in the immediate generate response. Acceptable for the email-approval flow which uses the generate response directly. To persist them, add columns to MealPlanItem.

  13. family_profile.calorie_target is treated as per-serving by the planner filter (matches spec §2.1 wording). The family-setup UI/API should clarify per-serving vs per-day to avoid confusion. Test families use ~500 cal/serving for a 4-person household.

  14. Cost estimation treats qty as dimensionless (no unit conversion). Produces a biased-but-monotonic ranking signal; sufficient for current use, revisit if real-dollar accuracy is needed (docs/specs/2026-05-05-meal-planner-algorithm-design.md §7).


Verification commands

Same as docs/ORIENTATION.md:

# Stack up
docker compose --env-file .env.test up -d db backend
docker compose --env-file .env.test exec backend alembic upgrade head

# Tests
docker compose --env-file .env.test exec \
  -e TEST_DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner \
  backend pytest -q tests/   # → 59 passed

# Frontend
cd frontend && npm ci && npm run build

# Email approval round-trip
docker cp scripts/send_test_approval.py mealplanner-backend-1:/app/send_test_approval.py
docker compose --env-file .env.test exec backend \
  python /app/send_test_approval.py --simulate-click approve

# Live Swiftly ingest (will hit the real API once)
docker cp scripts/spike_swiftly_ingest.py mealplanner-backend-1:/app/spike_swiftly_ingest.py
docker compose --env-file .env.test exec backend \
  python /app/spike_swiftly_ingest.py --confirm-live

Suggested next move

Swiftly token auto-mint first. Phases 4 (thin slice) and 9 are both shipped — the project's reason to exist is real. The remaining operator toil is the hourly bearer-token refresh. Fully designed in docs/specs/2026-05-06-swiftly-token-auto-mint.md: ~2-3 hours, no new deps, replaces SWIFTLY_BEARER_TOKEN env var with a process-local cache that mints fresh JWTs from Firebase via REST. Eliminates manual capture entirely.

Pre-broken-down task list (start here)

A fresh agent should pick these up in order. Halt at the AM-2 boundary to verify a real-world live scrape before stripping the env var.

  • AM-1: swiftly_auth.py + unit tests. New module backend/app/services/swiftly_auth.py with get_token(), mint_anonymous_token(), process-local cache (token, exp), new SwiftlyAuthMintError. Tests at backend/tests/test_swiftly_auth.py: 4 unit tests covering mint, cache hit, near-expiry re-mint, non-200 → error. ~80 lines, ~1 hr.
  • AM-2: Wire into lucky_ca_scraper.py. Replace settings.SWIFTLY_BEARER_TOKEN lookup with get_token(). Live-verify via scripts/spike_swiftly_ingest.py --confirm-live. Halt here for user confirmation before AM-3. ~30 min.
  • AM-3: Remove SWIFTLY_BEARER_TOKEN env var. Drop from .env.example, .env.test, docker-compose.yml, app/config.py Settings, .github/workflows/ci.yml. Optional: add SWIFTLY_FIREBASE_CONFIG_URL (default https://luckysupermarkets.com/config.json). ~15 min.
  • AM-4: Delete superseded seleniumbase script. rm scripts/refresh_swiftly_token.py; commit ccfb38a stays in history for context. ~5 min.
  • AM-5: Refresh docs. docs/HANDOFF.md (remove caveats #2/#3, mark spec implemented), docs/ORIENTATION.md env-var section + footer, docs/specs/2026-05-06-swiftly-token-auto-mint.md status header → "Implemented". ~15 min.
  • AM-6: Verification gate. Full pytest -q tests/ (expect 92+ green: 88 prior + 4 new); live scrape end-to-end via POST /api/admin/scrape returning success with item count > 0; matcher confirms ingredient_grocery_match rows populated. ~20 min.

Verified prerequisites (already validated 2026-05-06):

  • https://luckysupermarkets.com/config.json is publicly readable; firebaseApiKey = AIzaSyCnG97lkCEUvVTcRdSEJ6looOPQgX0WE2U
  • POST identitytoolkit.googleapis.com/v1/accounts:signUp?key=<API_KEY> with Origin: https://luckysupermarkets.com + Referer: https://luckysupermarkets.com/ returns a valid JWT (iss=https://securetoken.google.com/swiftly-lu-prod, aud=swiftly-lu-prod, provider=anonymous, 3600s TTL)
  • Swiftly API accepts the minted JWT (verified: 400 "Category is required" on a malformed test, NOT 401)

After AM-6, in priority order

  1. Phase 5 — meal-planner orchestration. Chain scrape → generate → email → vote → finalize on a weekly cadence. APScheduler container with --workers 1 was the original plan. All the parts exist (scrape, generate, email-stub, approval round-trip); nothing chains them.
  2. Phase 6 — SendGrid. Replace the ConsoleEmailBackend JSONL stub with real SendGrid. Templates: meal proposal, T-24h reminder, confirmation, denial. from_email/reply_to config still needs adding to Settings.
  3. Frontend login UI. /api/auth/login exists and the cookie-based session works, but no UI consumes it. Until this lands, family-facing flows (Pantry, MealDetail, ShoppingList) can only be exercised by tests.
  4. Phase 8 — feedback UI. Close the learning loop into Phase 9. The feedback table exists and the schema supports it; nothing reads/writes it from a UI.
  5. Phase 11 polish — APScheduler, variety analysis, budget tracking.
  6. Phase 10 — image strategy.

Brainstorm with the user before committing to non-trivial scope. Use the superpowers:brainstorming skill.


Open tasks

ID Subject Priority
AM-1..AM-6 Swiftly token auto-mint (see Suggested next move above) Top — start here
#8 ScrapeStatus enum could use a distinct QUEUED value Cosmetic

Other tasks in the recovery session were closed. See .agent/phase-summaries/ for the detailed write-ups of each phase (R1A test harness, R1B+D auth+paths, R1C async scrape, R2A live scrape, R2B email approval, R3-0 Swiftly ingestion).


Useful files map

.agent/
├── plan.md                              recovery plan (R1, R2, R3 phases)
├── context.md                           locked-in decisions
└── phase-summaries/                     per-phase write-ups
    ├── r1-r2-gate-pass.md
    ├── r3-0-gate-pass.md
    ├── r1a-summary.md
    ├── r1bd-summary.md
    ├── r1c-summary.md
    ├── r2a-summary.md
    ├── r2b-summary.md
    ├── r2b-blockers.md
    └── r3-0-summary.md

backend/app/
├── api/
│   ├── admin.py                         scrape trigger + logs (admin-gated)
│   ├── auth.py                          login/logout (R1-B+D)
│   ├── meals.py                         meal plans + vote routes (per-token)
│   ├── pantry.py
│   ├── profile.py
│   ├── recipes.py
│   └── shopping_list.py
├── scraper/
│   ├── base.py                          rate-limited HTTP base
│   └── lucky_ca_scraper.py              Swiftly JSON API client (R3-0)
├── services/
│   ├── approval.py                      per-voter token issue/verify/consume
│   ├── email.py                         Console + SendGrid stub
│   └── scraper_service.py               enqueue + bg runner
├── security.py                          require_admin / require_session
├── config.py                            pydantic Settings
├── database.py                          engine / SessionLocal / get_db
└── models/__init__.py                   all SQLAlchemy models

backend/alembic/versions/                0001 → 0005
backend/tests/                           31 tests
backend/tests/fixtures/lucky_ca/         categories.html, category_meat_seafood.json, weekly_ad.html (R2-A archive)

scripts/
├── send_test_approval.py                email round-trip prover
├── spike_lucky_scrape.py                R2-A archived
├── spike_swiftly_ingest.py              R3-0 live ingest prover
└── refresh_swiftly_token.py             SUPERSEDED — seleniumbase token capture
                                         (replaced by docs/specs/2026-05-06-swiftly-token-auto-mint.md)

docs/specs/
├── 2026-05-05-meal-planner-algorithm-design.md   Phase 9 + thin Phase 4 design
└── 2026-05-06-swiftly-token-auto-mint.md         next-up: replace SWIFTLY_BEARER_TOKEN env var

.github/workflows/ci.yml                 backend (postgres + pytest) + frontend (npm build)

Final words

Trust the tests. Trust the live runs. Don't trust prose claims that something is "complete" without running the verification gate yourself. The recovery happened because the prior agent did the latter without the former.

Last updated: 2026-05-06 — Phase 9 shipped; auto-mint design + AM-1..AM-6 task list embedded for fresh agent handoff.