diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..156df92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# context-mode — MANDATORY routing rules + +You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session. + +## BLOCKED commands — do NOT attempt these + +### curl / wget — BLOCKED +Any Bash command containing `curl` or `wget` is intercepted and replaced with an error message. Do NOT retry. +Instead use: +- `ctx_fetch_and_index(url, source)` to fetch and index web pages +- `ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox + +### Inline HTTP — BLOCKED +Any Bash command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` is intercepted and replaced with an error message. Do NOT retry with Bash. +Instead use: +- `ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context + +### WebFetch — BLOCKED +WebFetch calls are denied entirely. The URL is extracted and you are told to use `ctx_fetch_and_index` instead. +Instead use: +- `ctx_fetch_and_index(url, source)` then `ctx_search(queries)` to query the indexed content + +## REDIRECTED tools — use sandbox equivalents + +### Bash (>20 lines output) +Bash is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands. +For everything else, use: +- `ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call +- `ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context + +### Read (for analysis) +If you are reading a file to **Edit** it → Read is correct (Edit needs content in context). +If you are reading to **analyze, explore, or summarize** → use `ctx_execute_file(path, language, code)` instead. Only your printed summary enters context. The raw file content stays in the sandbox. + +### Grep (large results) +Grep results can flood context. Use `ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context. + +## Tool selection hierarchy + +1. **GATHER**: `ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls. +2. **FOLLOW-UP**: `ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call. +3. **PROCESSING**: `ctx_execute(language, code)` | `ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context. +4. **WEB**: `ctx_fetch_and_index(url, source)` then `ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context. +5. **INDEX**: `ctx_index(content, source)` — Store content in FTS5 knowledge base for later search. + +## Subagent routing + +When spawning subagents (Agent/Task tool), the routing block is automatically injected into their prompt. Bash-type subagents are upgraded to general-purpose so they have access to MCP tools. You do NOT need to manually instruct subagents about context-mode. + +## Output constraints + +- Keep responses under 500 words. +- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description. +- When indexing content, use descriptive source labels so others can `ctx_search(source: "label")` later. + +## ctx commands + +| Command | Action | +|---------|--------| +| `ctx stats` | Call the `ctx_stats` MCP tool and display the full output verbatim | +| `ctx doctor` | Call the `ctx_doctor` MCP tool, run the returned shell command, display as checklist | +| `ctx upgrade` | Call the `ctx_upgrade` MCP tool, run the returned shell command, display as checklist | diff --git a/backend/app/api/feedback.py b/backend/app/api/feedback.py new file mode 100644 index 0000000..8d68c7b --- /dev/null +++ b/backend/app/api/feedback.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import FamilyProfile, Feedback, MealPlanItem +from app.schemas import FeedbackCreate, FeedbackResponse +from app.security import require_session + +router = APIRouter() + + +@router.get("/{meal_plan_item_id}", response_model=FeedbackResponse | None) +def get_feedback( + meal_plan_item_id: UUID, + db: Session = Depends(get_db), + family_id: str = Depends(require_session), +): + """Get existing feedback for a meal plan item.""" + item = db.query(MealPlanItem).filter(MealPlanItem.id == meal_plan_item_id).first() + if not item: + raise HTTPException(status_code=404, detail="Meal plan item not found") + + feedback = ( + db.query(Feedback) + .filter( + Feedback.meal_plan_item_id == meal_plan_item_id, + Feedback.family_profile_id == UUID(family_id), + ) + .first() + ) + return feedback + + +@router.post("", response_model=FeedbackResponse) +def create_feedback( + data: FeedbackCreate, + db: Session = Depends(get_db), + family_id: str = Depends(require_session), +): + """Create or update feedback for a meal plan item.""" + item = db.query(MealPlanItem).filter( + MealPlanItem.id == data.meal_plan_item_id + ).first() + if not item: + raise HTTPException(status_code=404, detail="Meal plan item not found") + + profile = db.query(FamilyProfile).filter(FamilyProfile.id == UUID(family_id)).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + existing = ( + db.query(Feedback) + .filter( + Feedback.meal_plan_item_id == data.meal_plan_item_id, + Feedback.family_profile_id == UUID(family_id), + ) + .first() + ) + + if existing: + existing.rating = data.rating + existing.never_suggest = data.never_suggest + existing.denial_reason = data.denial_reason.value if data.denial_reason else None + existing.feedback_text = data.feedback_text + db.commit() + db.refresh(existing) + return existing + + feedback = Feedback( + family_profile_id=UUID(family_id), + meal_plan_item_id=data.meal_plan_item_id, + rating=data.rating, + never_suggest=data.never_suggest, + denial_reason=data.denial_reason.value if data.denial_reason else None, + feedback_text=data.feedback_text, + ) + db.add(feedback) + db.commit() + db.refresh(feedback) + return feedback diff --git a/backend/app/main.py b/backend/app/main.py index 4d5b23b..8b17b7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,6 +34,7 @@ from app.api import ingredients as ingredients_api from app.api import recipes as recipes_api from app.api import never_suggest as never_suggest_api from app.api import meal_plans as meal_plans_api +from app.api import feedback as feedback_api app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) @@ -50,3 +51,4 @@ app.include_router(never_suggest_api.public_router) app.include_router(never_suggest_api.admin_router) app.include_router(meal_plans_api.admin_router) app.include_router(meal_plans_api.public_router) +app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index c8b3497..4b6cc0d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from typing import Optional, List, Any from uuid import UUID from datetime import date, datetime @@ -122,10 +122,21 @@ class FamilyProfileUpdate(BaseModel): class RecipeIngredient(BaseModel): ingredient_id: Optional[UUID] = None - name: str + name: Optional[str] = None quantity: Optional[float] = None unit: Optional[str] = None is_optional: bool = False + notes: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def _normalize(cls, data): + if not isinstance(data, dict): + return data + # JSONB stores qty; schema uses quantity + if "qty" in data and "quantity" not in data: + data["quantity"] = data.pop("qty") + return data class RecipeBase(BaseModel): diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index ed6e0c3..1d8cb18 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -2,13 +2,11 @@ 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-12. Last commits before handoff:** +**Date of handoff: 2026-05-14. Last commits before handoff:** ``` +[pending] feat: Phase 8 Feedback UI + API endpoints +[pending] fix: RecipeIngredient schema qty→quantity model_validator b522760 fix: cast qty/unit to str before html.escape in vote email shopping preview -dbc26bc feat: LLM-powered second-pass ingredient matcher + matcher improvements -b03e7f8 feat: Spoonacular recipe enrichment — images + descriptions for 25/30 recipes -a98f0dc docs: update HANDOFF.md for 2026-05-10 session -2373883 fix: exact-name fast path in matcher + save priceless produce in scraper ``` --- @@ -182,8 +180,9 @@ The Swiftly API is the same for ALL product categories, not just the weekly ad: - These show "—" in shopping list — correct behavior (better than wrong match) - **Approved plan:** Ollama LLM matcher as a second pass using Lucky's product search API (`luckysupermarkets.com/search/products?q=`) to find items outside the Swiftly weekly ad -### Phase 8 — Feedback UI (not started) -- `feedback` table exists; no UI reads/writes it +### Phase 8 — Feedback UI (done) +- `feedback` table now read/written via REST API +- Meal detail page shows star rating, never-suggest, reason dropdown, free-text comments --- @@ -274,18 +273,11 @@ Next Friday at 02:00 PT the scheduler runs automatically. No action needed. All ## File map (additions from this session) ``` -backend/app/scraper/lucky_ca_scraper.py — removed price guard in map_product() -backend/app/services/matcher.py — full rewrite: ingredient-centric, precision×recall -backend/app/services/orchestrator/ - steps.py — vote email: ingredient list + cooking steps - shopping list: grouped by meal, current_price fix -nginx/nginx.conf — Docker DNS resolver, proxy via set $var pattern -frontend/src/pages/Login.tsx — password form (Login page) -frontend/src/App.tsx — /login route, Sign out button -frontend/src/api/index.ts — 401 interceptor -scripts/seed_family.py — Woolery family + real emails -docs/superpowers/plans/ - 2026-05-09-mvp-login.md — MVP login plan (executed, merged) +backend/app/api/feedback.py — new: GET/POST feedback endpoints +frontend/src/pages/MealDetail.tsx — added Feedback section (rating, never-suggest, reasons) +frontend/src/api/index.ts — added feedback API methods +frontend/src/types/index.ts — added Feedback interface +backend/app/schemas/__init__.py — RecipeIngredient model_validator qty→quantity ``` --- @@ -294,4 +286,4 @@ docs/superpowers/plans/ Trust the tests. Trust the live runs. Don't trust prose claims that something is "complete" without running the verification gate yourself. -**Last updated: 2026-05-12** — Spoonacular enrichment (102/107 recipes), LLM matcher built (Ollama Cloud kimi-k2.6), AUTO matcher improved (plural normalization, precision floor 0.45→0.30), vote email confirmed polished by Peter. Next: run Spoonacular script again for 5 remaining recipes; Phase 8 Feedback UI. +**Last updated: 2026-05-14** — Spoonacular enrichment complete (102/107 matched, 5 unmatched). Phase 8 Feedback UI built: REST API + frontend. Pydantic validation fixed for recipe ingredients (qty↔quantity schema drift). .env restored after accidental overwrite. diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 78a30f2..d6703d0 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -75,6 +75,11 @@ export const mealPlannerApi = { getStats: () => api.get('/admin/stats'), testEmail: (email: string) => api.post('/admin/test-email', null, { params: { email } }), }, + + feedback: { + get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`), + create: (data: any) => api.post('/feedback', data), + }, } export default api \ No newline at end of file diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx index 3aa0fa0..7fde253 100644 --- a/frontend/src/pages/MealDetail.tsx +++ b/frontend/src/pages/MealDetail.tsx @@ -1,10 +1,39 @@ -import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useParams } from 'react-router-dom' import { mealPlannerApi } from '../api' -import type { MealPlanItem } from '../types' +import type { MealPlanItem, Feedback } from '../types' + +const DENIAL_REASONS = [ + { value: '', label: 'Select a reason...' }, + { value: 'too_expensive', label: 'Too expensive' }, + { value: 'boring', label: 'Boring / not interesting' }, + { value: 'disliked_ingredient', label: 'Disliked ingredient' }, + { value: 'cultural', label: 'Cultural / dietary preference' }, + { value: 'other', label: 'Other' }, +] + +function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) { + return ( +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ ) +} export default function MealDetail() { const { id } = useParams<{ id: string }>() + const queryClient = useQueryClient() const { data: item, isLoading } = useQuery({ queryKey: ['mealItem', id], @@ -12,6 +41,26 @@ export default function MealDetail() { enabled: !!id, }) + const { data: existingFeedback } = useQuery({ + queryKey: ['feedback', id], + queryFn: () => mealPlannerApi.feedback.get(id!).then(r => r.data), + enabled: !!id, + }) + + const [rating, setRating] = useState(0) + const [neverSuggest, setNeverSuggest] = useState(false) + const [reason, setReason] = useState('') + const [text, setText] = useState('') + const [submitted, setSubmitted] = useState(false) + + const submitMutation = useMutation({ + mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feedback', id] }) + setSubmitted(true) + }, + }) + if (isLoading) { return (
@@ -30,6 +79,19 @@ export default function MealDetail() { } const recipe = item.recipe + const feedback = existingFeedback + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!id) return + submitMutation.mutate({ + meal_plan_item_id: id, + rating: rating || null, + never_suggest: neverSuggest, + denial_reason: reason || null, + feedback_text: text || null, + }) + } return (
@@ -100,6 +162,103 @@ export default function MealDetail() {
+ {/* Feedback Section */} +
+

Feedback

+ + {feedback?.rating ? ( +
+
+ Your rating: +
+ {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+
+ {feedback.never_suggest && ( +
+ Never suggest this recipe again +
+ )} + {feedback.denial_reason && ( +

+ Reason: {feedback.denial_reason.replace('_', ' ')} +

+ )} + {feedback.feedback_text && ( +

"{feedback.feedback_text}"

+ )} + +
+ ) : submitted ? ( +
+ Thanks for your feedback! +
+ ) : ( +
+
+ + +
+ +
+ setNeverSuggest(e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + +
+ +
+ + +
+ +
+ +