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
This commit is contained in:
2026-05-14 09:54:25 -07:00
parent e618b2bd5a
commit f7ed10651b
8 changed files with 353 additions and 26 deletions
+62
View File
@@ -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 |
+84
View File
@@ -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
+2
View File
@@ -34,6 +34,7 @@ from app.api import ingredients as ingredients_api
from app.api import recipes as recipes_api from app.api import recipes as recipes_api
from app.api import never_suggest as never_suggest_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 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(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) 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(never_suggest_api.admin_router)
app.include_router(meal_plans_api.admin_router) app.include_router(meal_plans_api.admin_router)
app.include_router(meal_plans_api.public_router) app.include_router(meal_plans_api.public_router)
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"])
+13 -2
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from typing import Optional, List, Any from typing import Optional, List, Any
from uuid import UUID from uuid import UUID
from datetime import date, datetime from datetime import date, datetime
@@ -122,10 +122,21 @@ class FamilyProfileUpdate(BaseModel):
class RecipeIngredient(BaseModel): class RecipeIngredient(BaseModel):
ingredient_id: Optional[UUID] = None ingredient_id: Optional[UUID] = None
name: str name: Optional[str] = None
quantity: Optional[float] = None quantity: Optional[float] = None
unit: Optional[str] = None unit: Optional[str] = None
is_optional: bool = False 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): class RecipeBase(BaseModel):
+12 -20
View File
@@ -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. 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 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) - 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=<ingredient>`) to find items outside the Swiftly weekly ad - **Approved plan:** Ollama LLM matcher as a second pass using Lucky's product search API (`luckysupermarkets.com/search/products?q=<ingredient>`) to find items outside the Swiftly weekly ad
### Phase 8 — Feedback UI (not started) ### Phase 8 — Feedback UI (done)
- `feedback` table exists; no UI reads/writes it - `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) ## File map (additions from this session)
``` ```
backend/app/scraper/lucky_ca_scraper.py — removed price guard in map_product() backend/app/api/feedback.py — new: GET/POST feedback endpoints
backend/app/services/matcher.py — full rewrite: ingredient-centric, precision×recall frontend/src/pages/MealDetail.tsx — added Feedback section (rating, never-suggest, reasons)
backend/app/services/orchestrator/ frontend/src/api/index.ts — added feedback API methods
steps.py — vote email: ingredient list + cooking steps frontend/src/types/index.ts — added Feedback interface
shopping list: grouped by meal, current_price fix backend/app/schemas/__init__.py — RecipeIngredient model_validator qty→quantity
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)
``` ```
--- ---
@@ -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. 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.
+5
View File
@@ -75,6 +75,11 @@ export const mealPlannerApi = {
getStats: () => api.get('/admin/stats'), getStats: () => api.get('/admin/stats'),
testEmail: (email: string) => api.post('/admin/test-email', null, { params: { email } }), 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 export default api
+162 -3
View File
@@ -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 { useParams } from 'react-router-dom'
import { mealPlannerApi } from '../api' 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 (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
className={`text-2xl ${n <= value ? 'text-yellow-400' : 'text-gray-300'} hover:text-yellow-400`}
aria-label={`Rate ${n} stars`}
>
</button>
))}
</div>
)
}
export default function MealDetail() { export default function MealDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const queryClient = useQueryClient()
const { data: item, isLoading } = useQuery<MealPlanItem>({ const { data: item, isLoading } = useQuery<MealPlanItem>({
queryKey: ['mealItem', id], queryKey: ['mealItem', id],
@@ -12,6 +41,26 @@ export default function MealDetail() {
enabled: !!id, enabled: !!id,
}) })
const { data: existingFeedback } = useQuery<Feedback | null>({
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) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center py-12"> <div className="flex justify-center items-center py-12">
@@ -30,6 +79,19 @@ export default function MealDetail() {
} }
const recipe = item.recipe 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -100,6 +162,103 @@ export default function MealDetail() {
</ol> </ol>
</div> </div>
{/* Feedback Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Feedback</h2>
{feedback?.rating ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Your rating:</span>
<div className="flex text-yellow-400">
{[1, 2, 3, 4, 5].map(n => (
<span key={n} className={n <= (feedback.rating || 0) ? 'text-yellow-400' : 'text-gray-300'}>★</span>
))}
</div>
</div>
{feedback.never_suggest && (
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800">
Never suggest this recipe again
</div>
)}
{feedback.denial_reason && (
<p className="text-sm text-gray-600 capitalize">
Reason: {feedback.denial_reason.replace('_', ' ')}
</p>
)}
{feedback.feedback_text && (
<p className="text-sm text-gray-700 italic">"{feedback.feedback_text}"</p>
)}
<button
onClick={() => setSubmitted(false)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Edit feedback
</button>
</div>
) : submitted ? (
<div className="text-green-700 bg-green-50 rounded-lg p-4">
Thanks for your feedback!
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">How was this meal?</label>
<StarRating value={rating} onChange={setRating} />
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="neverSuggest"
checked={neverSuggest}
onChange={(e) => setNeverSuggest(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="neverSuggest" className="text-sm text-gray-700">
Never suggest this recipe again
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Why not? (optional)</label>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{DENIAL_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Additional comments</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Anything else you'd like to share..."
/>
</div>
<button
type="submit"
disabled={submitMutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{submitMutation.isPending ? 'Saving...' : 'Submit Feedback'}
</button>
{submitMutation.isError && (
<p className="text-sm text-red-600">Failed to save feedback. Please try again.</p>
)}
</form>
)}
</div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => window.print()} onClick={() => window.print()}
@@ -116,4 +275,4 @@ export default function MealDetail() {
</div> </div>
</div> </div>
) )
} }
+13 -1
View File
@@ -151,4 +151,16 @@ export interface SystemStats {
recipes: number recipes: number
ingredients: number ingredients: number
meal_plans: number meal_plans: number
} }
export interface Feedback {
id: string
family_profile_id: string
family_member_id?: string
meal_plan_item_id: string
rating?: number
never_suggest: boolean
denial_reason?: string
feedback_text?: string
created_at?: string
}