From 0f0896658327eff7f03b7b8a606b932c427d9ea3 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 5 May 2026 20:15:12 -0700 Subject: [PATCH] docs: thin phase 4 implementation plan 15-task TDD plan covering migration 0006 (ingredient.aliases, recipe.calories_per_serving, ingredient_grocery_match), CRUD endpoints for ingredient/recipe/never-suggest, rapidfuzz matcher service hooked into scrape success, manual match override, resolve-ingredient assist, and migration 0007 seeding 30 starter recipes. Reuses existing NeverSuggest table for ingredient + recipe blocking rather than adding a new family_ingredient_block table. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-05-thin-phase-4-recipe-engine.md | 2525 +++++++++++++++++ 1 file changed, 2525 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md diff --git a/docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md b/docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md new file mode 100644 index 0000000..e8ba741 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md @@ -0,0 +1,2525 @@ +# Thin Phase 4 — Recipe Engine + Matching Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the minimum recipe-engine surface — recipe CRUD, ingredient CRUD, ingredient↔grocery_item fuzzy matching, never-suggest blocking, and a 30-recipe seed — that unblocks the Phase 9 meal-planner algorithm. + +**Architecture:** Adds two columns and one table to the existing schema. Recipes carry canonical `ingredient_id` references (no freeform text). A rapidfuzz-driven matcher runs after each scrape and stores top-3 grocery-item candidates per ingredient with confidence scores; admins can pin/unpin manually. Recipe creation requires every ingredient to be bound to an `Ingredient` row before save. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0, Alembic, Pydantic v2, PostgreSQL 15 (CITEXT, JSONB, ARRAY), pytest, `rapidfuzz` for fuzzy matching. + +**Spec reference:** `docs/specs/2026-05-05-meal-planner-algorithm-design.md` §3, §4. + +**Pre-existing state I am building on:** +- `Ingredient` (name, name_lower UNIQUE, plural_name, aisle, typical_price, unit, season_months) — already exists +- `Recipe` (prep_time_minutes, cook_time_minutes, servings, cuisine_tags ARRAY, protein_type, ingredients JSONB, instructions ARRAY) — already exists +- `NeverSuggest` (family_profile_id, ingredient_id NULL, recipe_id NULL, reason, notes) — already exists; covers BOTH ingredient blocklist (constraint #1) and recipe blocklist (constraint #2) +- `GroceryItem.ingredient_id` (FK) — already exists, currently unpopulated by the Swiftly scraper. Left alone for this phase. The new match table is the source of truth for ingredient↔grocery linkage. + +--- + +## File Structure + +**New files:** +- `backend/alembic/versions/0006_recipe_engine_thin.py` — schema migration +- `backend/alembic/versions/0007_seed_recipes.py` — 30 starter recipes + their canonical ingredients +- `backend/app/schemas/ingredient.py` — Pydantic IngredientCreate/Update/Read, IngredientGroceryMatchRead +- `backend/app/schemas/recipe.py` — Pydantic RecipeIngredientRef, RecipeCreate/Update/Read, ResolveIngredientRequest/Response +- `backend/app/schemas/never_suggest.py` — Pydantic NeverSuggestCreate/Read +- `backend/app/api/ingredients.py` — Ingredient CRUD + manual match override endpoints +- `backend/app/api/recipes.py` — Recipe CRUD + resolve-ingredient assist endpoint (replaces existing stub) +- `backend/app/api/never_suggest.py` — NeverSuggest CRUD endpoints +- `backend/app/services/matcher.py` — rapidfuzz match-job logic +- `backend/tests/test_ingredient_api.py` +- `backend/tests/test_recipe_api.py` +- `backend/tests/test_never_suggest_api.py` +- `backend/tests/test_matcher.py` +- `backend/tests/test_match_hook.py` + +**Modified files:** +- `backend/requirements.txt` — add `rapidfuzz==3.6.1` +- `backend/app/models/__init__.py` — add `Ingredient.aliases`, `Recipe.calories_per_serving`, new `IngredientGroceryMatch` model +- `backend/app/services/scraper_service.py` — call matcher on scrape success +- `backend/app/api/__init__.py` (or wherever routers register) — wire new routers +- `backend/app/main.py` — include new routers (verify against current shape) +- `docs/ORIENTATION.md` — phase status table +- `docs/HANDOFF.md` — recipe engine state + +--- + +## Task 1: Add rapidfuzz dependency + +**Files:** +- Modify: `backend/requirements.txt` + +- [ ] **Step 1: Add rapidfuzz line** + +Edit `backend/requirements.txt` and add this line near the other pinned deps (alphabetical): + +``` +rapidfuzz==3.6.1 +``` + +- [ ] **Step 2: Rebuild the backend image** + +Run: + +```bash +docker compose --env-file .env.test build backend +``` + +Expected: build succeeds with `Successfully tagged mealplanner-backend:latest` (or equivalent). + +- [ ] **Step 3: Verify rapidfuzz importable in the container** + +Run: + +```bash +docker compose --env-file .env.test run --rm backend python -c "from rapidfuzz import process, fuzz; print(fuzz.WRatio('chicken thighs', 'Foster Farms Chicken Thighs'))" +``` + +Expected: a number near `90` (proves the lib loads and works). + +- [ ] **Step 4: Commit** + +```bash +git add backend/requirements.txt +git commit -m "chore: add rapidfuzz==3.6.1 for ingredient matching" +``` + +--- + +## Task 2: Migration 0006 — schema changes + +**Files:** +- Create: `backend/alembic/versions/0006_recipe_engine_thin.py` + +- [ ] **Step 1: Write the migration** + +Create `backend/alembic/versions/0006_recipe_engine_thin.py`: + +```python +"""thin phase 4: ingredient.aliases, recipe.calories_per_serving, ingredient_grocery_match + +Revision ID: 0006_recipe_engine_thin +Revises: 0005_grocery_item_external_id +Create Date: 2026-05-05 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, ARRAY + + +revision = "0006_recipe_engine_thin" +down_revision = "0005_grocery_item_external_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ingredient", + sa.Column( + "aliases", + ARRAY(sa.Text()), + nullable=False, + server_default="{}", + ), + ) + + op.add_column( + "recipe", + sa.Column("calories_per_serving", sa.Integer(), nullable=True), + ) + + op.execute( + "CREATE TYPE ingredient_match_source_enum AS ENUM ('auto', 'manual')" + ) + + op.create_table( + "ingredient_grocery_match", + sa.Column( + "id", + UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "ingredient_id", + UUID(as_uuid=True), + sa.ForeignKey("ingredient.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "grocery_item_id", + UUID(as_uuid=True), + sa.ForeignKey("grocery_item.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("confidence", sa.Numeric(4, 3), nullable=False), + sa.Column( + "source", + sa.Enum( + "auto", + "manual", + name="ingredient_match_source_enum", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint( + "ingredient_id", + "grocery_item_id", + name="uq_ingredient_grocery_match_pair", + ), + ) + + op.create_index( + "ix_ingredient_grocery_match_ingredient_confidence", + "ingredient_grocery_match", + ["ingredient_id", sa.text("confidence DESC")], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_ingredient_grocery_match_ingredient_confidence", + table_name="ingredient_grocery_match", + ) + op.drop_table("ingredient_grocery_match") + op.execute("DROP TYPE IF EXISTS ingredient_match_source_enum") + op.drop_column("recipe", "calories_per_serving") + op.drop_column("ingredient", "aliases") +``` + +- [ ] **Step 2: Apply the migration** + +```bash +docker compose --env-file .env.test up -d db +docker compose --env-file .env.test run --rm backend alembic upgrade head +``` + +Expected: `Running upgrade 0005_grocery_item_external_id -> 0006_recipe_engine_thin`. + +- [ ] **Step 3: Round-trip the migration** + +```bash +docker compose --env-file .env.test run --rm backend alembic downgrade -1 +docker compose --env-file .env.test run --rm backend alembic upgrade head +``` + +Expected: both succeed with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add backend/alembic/versions/0006_recipe_engine_thin.py +git commit -m "feat: migration 0006 - ingredient.aliases, recipe.calories_per_serving, ingredient_grocery_match" +``` + +--- + +## Task 3: Update SQLAlchemy models + +**Files:** +- Modify: `backend/app/models/__init__.py` + +- [ ] **Step 1: Add `aliases` to Ingredient** + +In `backend/app/models/__init__.py`, find the `Ingredient` class. After the `season_months` column add: + +```python + aliases = Column(ARRAY(Text), nullable=False, server_default="{}") +``` + +- [ ] **Step 2: Add `calories_per_serving` to Recipe** + +In the `Recipe` class, after `spice_level`: + +```python + calories_per_serving = Column(Integer) +``` + +- [ ] **Step 3: Add the IngredientMatchSource enum and IngredientGroceryMatch model** + +Append near the other enum classes (top of file, after the existing enums): + +```python +class IngredientMatchSource(enum.Enum): + AUTO = "auto" + MANUAL = "manual" +``` + +Then append at the bottom of the file (after `EmailLog`): + +```python +class IngredientGroceryMatch(Base): + __tablename__ = "ingredient_grocery_match" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"), nullable=False) + grocery_item_id = Column(UUID(as_uuid=True), ForeignKey("grocery_item.id", ondelete="CASCADE"), nullable=False) + confidence = Column(Numeric(4, 3), nullable=False) + source = Column(SQLEnum(IngredientMatchSource, name="ingredient_match_source_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("ingredient_id", "grocery_item_id", name="uq_ingredient_grocery_match_pair"), + ) + + ingredient = relationship("Ingredient") + grocery_item = relationship("GroceryItem") +``` + +The `confidence` column type is `Numeric(4, 3)` in both migration and model so values like `0.875` round-trip without float drift. + +- [ ] **Step 4: Add reverse relationship on Ingredient** + +In the `Ingredient` class, after `grocery_item_links`: + +```python + matches = relationship("IngredientGroceryMatch", back_populates="ingredient", cascade="all, delete-orphan") +``` + +Then update `IngredientGroceryMatch.ingredient` to use `back_populates="matches"` instead of bare `relationship`: + +```python + ingredient = relationship("Ingredient", back_populates="matches") +``` + +- [ ] **Step 5: Smoke-test imports** + +```bash +docker compose --env-file .env.test run --rm backend python -c "from app.models import Ingredient, Recipe, IngredientGroceryMatch, IngredientMatchSource; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/models/__init__.py backend/alembic/versions/0006_recipe_engine_thin.py +git commit -m "feat: add IngredientGroceryMatch model and Ingredient.aliases / Recipe.calories_per_serving" +``` + +--- + +## Task 4: Pydantic schemas — ingredient + +**Files:** +- Create: `backend/app/schemas/ingredient.py` + +- [ ] **Step 1: Write the test** + +Create `backend/tests/test_ingredient_schema.py`: + +```python +from decimal import Decimal +from uuid import uuid4 + +from app.schemas.ingredient import IngredientCreate, IngredientRead, IngredientGroceryMatchRead + + +def test_ingredient_create_normalizes_aliases() -> None: + payload = IngredientCreate( + name="Chicken Thighs", + aliases=[" chicken thigh ", "BSL chicken thighs", ""], + aisle="meat_seafood", + unit="lb", + ) + assert payload.name == "Chicken Thighs" + assert payload.aliases == ["chicken thigh", "BSL chicken thighs"] + + +def test_ingredient_match_read_round_trip() -> None: + iid = uuid4() + gid = uuid4() + raw = { + "id": uuid4(), + "ingredient_id": iid, + "grocery_item_id": gid, + "confidence": Decimal("0.875"), + "source": "auto", + "grocery_item_name": "Foster Farms Chicken Thighs Family Pack", + "current_price": Decimal("3.99"), + "regular_price": Decimal("5.49"), + "is_on_sale": True, + } + parsed = IngredientGroceryMatchRead.model_validate(raw) + assert parsed.confidence == Decimal("0.875") + assert parsed.is_on_sale is True +``` + +- [ ] **Step 2: Run test, expect failure** + +```bash +docker compose --env-file .env.test run --rm backend pytest -q tests/test_ingredient_schema.py -v +``` + +Expected: ImportError on `app.schemas.ingredient`. + +- [ ] **Step 3: Write the schemas** + +Create `backend/app/schemas/ingredient.py`: + +```python +from __future__ import annotations + +from decimal import Decimal +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + + +class IngredientBase(BaseModel): + name: str = Field(min_length=1, max_length=200) + aliases: List[str] = Field(default_factory=list) + aisle: Optional[str] = Field(default=None, max_length=100) + unit: Optional[str] = Field(default=None, max_length=50) + typical_price: Optional[Decimal] = None + + @field_validator("aliases") + @classmethod + def _strip_and_drop_empty(cls, v: List[str]) -> List[str]: + return [s.strip() for s in v if s and s.strip()] + + +class IngredientCreate(IngredientBase): + pass + + +class IngredientUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=200) + aliases: Optional[List[str]] = None + aisle: Optional[str] = Field(default=None, max_length=100) + unit: Optional[str] = Field(default=None, max_length=50) + typical_price: Optional[Decimal] = None + + @field_validator("aliases") + @classmethod + def _strip_and_drop_empty(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is None: + return None + return [s.strip() for s in v if s and s.strip()] + + +class IngredientRead(IngredientBase): + id: UUID + + model_config = {"from_attributes": True} + + +class IngredientGroceryMatchRead(BaseModel): + id: UUID + ingredient_id: UUID + grocery_item_id: UUID + confidence: Decimal + source: str + grocery_item_name: Optional[str] = None + current_price: Optional[Decimal] = None + regular_price: Optional[Decimal] = None + is_on_sale: Optional[bool] = None + + model_config = {"from_attributes": True} +``` + +- [ ] **Step 4: Run tests, expect pass** + +```bash +docker compose --env-file .env.test run --rm backend pytest -q tests/test_ingredient_schema.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/ingredient.py backend/tests/test_ingredient_schema.py +git commit -m "feat: add IngredientCreate/Update/Read and IngredientGroceryMatchRead schemas" +``` + +--- + +## Task 5: Pydantic schemas — recipe + +**Files:** +- Create: `backend/app/schemas/recipe.py` +- Test: `backend/tests/test_recipe_schema.py` + +- [ ] **Step 1: Write the test** + +Create `backend/tests/test_recipe_schema.py`: + +```python +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.schemas.recipe import RecipeCreate, RecipeIngredientRef, ResolveIngredientRequest + + +def test_recipe_create_requires_canonical_ingredient_ids() -> None: + iid = uuid4() + payload = RecipeCreate( + name="Sheet-Pan Chicken Thighs", + servings=4, + prep_time_minutes=10, + cook_time_minutes=30, + cuisine_tags=["american"], + protein_type="chicken", + ingredients=[RecipeIngredientRef(ingredient_id=iid, qty=2.0, unit="lb")], + instructions=["Preheat oven", "Roast"], + ) + assert payload.ingredients[0].ingredient_id == iid + assert payload.servings == 4 + + +def test_recipe_create_rejects_empty_ingredients() -> None: + with pytest.raises(ValidationError): + RecipeCreate( + name="Empty", + servings=4, + prep_time_minutes=5, + cook_time_minutes=5, + cuisine_tags=[], + protein_type="vegetarian", + ingredients=[], + instructions=["nope"], + ) + + +def test_resolve_ingredient_request_round_trip() -> None: + req = ResolveIngredientRequest(text="1 lb chicken thighs") + assert req.text == "1 lb chicken thighs" +``` + +- [ ] **Step 2: Run test, expect failure** + +```bash +docker compose --env-file .env.test run --rm backend pytest -q tests/test_recipe_schema.py -v +``` + +Expected: ImportError. + +- [ ] **Step 3: Write the schemas** + +Create `backend/app/schemas/recipe.py`: + +```python +from __future__ import annotations + +from decimal import Decimal +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + + +class RecipeIngredientRef(BaseModel): + ingredient_id: UUID + qty: float = Field(gt=0) + unit: Optional[str] = Field(default=None, max_length=50) + notes: Optional[str] = None + + +class RecipeBase(BaseModel): + name: str = Field(min_length=1, max_length=300) + description: Optional[str] = None + image_url: Optional[str] = None + prep_time_minutes: int = Field(ge=0) + cook_time_minutes: int = Field(ge=0) + servings: int = Field(gt=0) + cuisine_tags: List[str] = Field(default_factory=list) + dietary_tags: List[str] = Field(default_factory=list) + protein_type: str = Field(min_length=1, max_length=50) + spice_level: Optional[int] = Field(default=None, ge=0, le=5) + calories_per_serving: Optional[int] = Field(default=None, ge=0) + ingredients: List[RecipeIngredientRef] = Field(min_length=1) + instructions: List[str] = Field(min_length=1) + source_url: Optional[str] = None + + +class RecipeCreate(RecipeBase): + pass + + +class RecipeUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + image_url: Optional[str] = None + prep_time_minutes: Optional[int] = Field(default=None, ge=0) + cook_time_minutes: Optional[int] = Field(default=None, ge=0) + servings: Optional[int] = Field(default=None, gt=0) + cuisine_tags: Optional[List[str]] = None + dietary_tags: Optional[List[str]] = None + protein_type: Optional[str] = None + spice_level: Optional[int] = None + calories_per_serving: Optional[int] = None + ingredients: Optional[List[RecipeIngredientRef]] = None + instructions: Optional[List[str]] = None + + +class RecipeRead(RecipeBase): + id: UUID + + model_config = {"from_attributes": True} + + +class ResolveIngredientRequest(BaseModel): + text: str = Field(min_length=1) + + +class ResolveIngredientCandidate(BaseModel): + ingredient_id: UUID + name: str + score: float + aisle: Optional[str] = None + + +class ResolveIngredientResponse(BaseModel): + parsed_qty: Optional[float] = None + parsed_unit: Optional[str] = None + parsed_text: str + candidates: List[ResolveIngredientCandidate] +``` + +- [ ] **Step 4: Run tests, expect pass** + +```bash +docker compose --env-file .env.test run --rm backend pytest -q tests/test_recipe_schema.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/recipe.py backend/tests/test_recipe_schema.py +git commit -m "feat: add RecipeCreate/Update/Read schemas with canonical ingredient refs" +``` + +--- + +## Task 6: Ingredient CRUD endpoints + +**Files:** +- Create: `backend/app/api/ingredients.py` +- Test: `backend/tests/test_ingredient_api.py` +- Modify: `backend/app/main.py` + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_ingredient_api.py`: + +```python +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin_headers() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def test_create_ingredient_returns_201_with_id(client): + body = { + "name": "Chicken Thighs", + "aliases": ["chicken thigh", "BSL chicken thighs"], + "aisle": "meat_seafood", + "unit": "lb", + } + r = client.post("/api/admin/ingredients", json=body, headers=_admin_headers()) + assert r.status_code == 201, r.text + data = r.json() + assert data["id"] + assert data["aliases"] == ["chicken thigh", "BSL chicken thighs"] + + +def test_create_ingredient_rejects_duplicate_name(client): + body = {"name": "Garlic", "aliases": [], "aisle": "produce", "unit": "clove"} + r1 = client.post("/api/admin/ingredients", json=body, headers=_admin_headers()) + assert r1.status_code == 201 + r2 = client.post("/api/admin/ingredients", json=body, headers=_admin_headers()) + assert r2.status_code == 409 + + +def test_list_ingredients_supports_search(client): + client.post( + "/api/admin/ingredients", + json={"name": "Olive Oil", "aliases": ["EVOO"], "aisle": "pantry", "unit": "tbsp"}, + headers=_admin_headers(), + ) + r = client.get("/api/ingredients?q=olive") + assert r.status_code == 200 + names = {row["name"] for row in r.json()} + assert "Olive Oil" in names + + +def test_create_ingredient_requires_admin_token(client): + body = {"name": "Lemon", "aisle": "produce", "unit": "ea", "aliases": []} + r = client.post("/api/admin/ingredients", json=body) + assert r.status_code == 401 + + +def test_update_ingredient_replaces_aliases(client): + create = client.post( + "/api/admin/ingredients", + json={"name": "Onion, Yellow", "aliases": ["yellow onion"], "aisle": "produce", "unit": "ea"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + r = client.patch( + f"/api/admin/ingredients/{iid}", + json={"aliases": ["yellow onion", "spanish onion"]}, + headers=_admin_headers(), + ) + assert r.status_code == 200 + assert r.json()["aliases"] == ["yellow onion", "spanish onion"] + + +def test_delete_ingredient_removes_row(client): + create = client.post( + "/api/admin/ingredients", + json={"name": "Sage", "aliases": [], "aisle": "produce", "unit": "tsp"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + r = client.delete(f"/api/admin/ingredients/{iid}", headers=_admin_headers()) + assert r.status_code == 204 + r2 = client.get(f"/api/ingredients/{iid}") + assert r2.status_code == 404 +``` + +- [ ] **Step 2: Run test, expect failure** + +```bash +docker compose --env-file .env.test run --rm backend pytest -q tests/test_ingredient_api.py -v +``` + +Expected: 404 / collection error — endpoints don't exist. + +- [ ] **Step 3: Implement the router** + +Create `backend/app/api/ingredients.py`: + +```python +from __future__ import annotations + +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import Ingredient +from app.schemas.ingredient import IngredientCreate, IngredientRead, IngredientUpdate +from app.security import require_admin + + +public_router = APIRouter(prefix="/api/ingredients", tags=["ingredients"]) +admin_router = APIRouter( + prefix="/api/admin/ingredients", + tags=["ingredients-admin"], + dependencies=[Depends(require_admin)], +) + + +@public_router.get("", response_model=List[IngredientRead]) +def list_ingredients( + q: Optional[str] = Query(default=None), + limit: int = Query(default=100, le=500), + db: Session = Depends(get_db), +) -> List[Ingredient]: + query = db.query(Ingredient) + if q: + like = f"%{q.lower()}%" + query = query.filter( + or_( + Ingredient.name_lower.ilike(like), + Ingredient.aliases.any(q), + ) + ) + return query.order_by(Ingredient.name).limit(limit).all() + + +@public_router.get("/{ingredient_id}", response_model=IngredientRead) +def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingredient: + row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() + if row is None: + raise HTTPException(status_code=404, detail="ingredient not found") + return row + + +@admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) +def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: + row = Ingredient( + name=payload.name, + name_lower=payload.name.lower(), + aliases=payload.aliases, + aisle=payload.aisle, + unit=payload.unit, + typical_price=payload.typical_price, + ) + db.add(row) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="ingredient name already exists") + db.refresh(row) + return row + + +@admin_router.patch("/{ingredient_id}", response_model=IngredientRead) +def update_ingredient( + ingredient_id: UUID, + payload: IngredientUpdate, + db: Session = Depends(get_db), +) -> Ingredient: + row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() + if row is None: + raise HTTPException(status_code=404, detail="ingredient not found") + data = payload.model_dump(exclude_unset=True) + if "name" in data: + row.name = data["name"] + row.name_lower = data["name"].lower() + for field in ("aliases", "aisle", "unit", "typical_price"): + if field in data: + setattr(row, field, data[field]) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="ingredient name conflict") + db.refresh(row) + return row + + +@admin_router.delete( + "/{ingredient_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> None: + row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() + if row is None: + raise HTTPException(status_code=404, detail="ingredient not found") + db.delete(row) + db.commit() +``` + +- [ ] **Step 4: Wire the routers in main.py** + +Open `backend/app/main.py`. After the existing `app.include_router(...)` lines add: + +```python +from app.api import ingredients as ingredients_api + +app.include_router(ingredients_api.public_router) +app.include_router(ingredients_api.admin_router) +``` + +- [ ] **Step 5: Run tests, expect pass** + +```bash +docker compose --env-file .env.test up -d db backend +docker compose --env-file .env.test exec backend pytest -q tests/test_ingredient_api.py -v +``` + +Expected: 6 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/ingredients.py backend/tests/test_ingredient_api.py backend/app/main.py +git commit -m "feat: ingredient CRUD endpoints with admin gating" +``` + +--- + +## Task 7: Recipe CRUD endpoints + +**Files:** +- Replace: `backend/app/api/recipes.py` (existing stub) +- Test: `backend/tests/test_recipe_api.py` + +Read the existing `backend/app/api/recipes.py` first to confirm what is there before replacing — the existing module is referenced by `main.py`. Preserve its router prefix conventions. + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_recipe_api.py`: + +```python +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin_headers() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def _new_ingredient(client, name: str) -> str: + r = client.post( + "/api/admin/ingredients", + json={"name": name, "aliases": [], "aisle": "pantry", "unit": "ea"}, + headers=_admin_headers(), + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def test_create_recipe_with_canonical_ingredients(client): + chicken = _new_ingredient(client, "Chicken Thighs Test1") + olive_oil = _new_ingredient(client, "Olive Oil Test1") + body = { + "name": "Sheet-Pan Chicken", + "prep_time_minutes": 10, + "cook_time_minutes": 30, + "servings": 4, + "cuisine_tags": ["american"], + "dietary_tags": [], + "protein_type": "chicken", + "calories_per_serving": 520, + "ingredients": [ + {"ingredient_id": chicken, "qty": 2.0, "unit": "lb"}, + {"ingredient_id": olive_oil, "qty": 2.0, "unit": "tbsp"}, + ], + "instructions": ["Preheat oven to 425", "Roast 30 min"], + } + r = client.post("/api/admin/recipes", json=body, headers=_admin_headers()) + assert r.status_code == 201, r.text + data = r.json() + assert data["id"] + assert len(data["ingredients"]) == 2 + + +def test_create_recipe_rejects_unknown_ingredient_id(client): + body = { + "name": "Bogus", + "prep_time_minutes": 5, + "cook_time_minutes": 5, + "servings": 4, + "cuisine_tags": [], + "dietary_tags": [], + "protein_type": "vegetarian", + "ingredients": [ + {"ingredient_id": "00000000-0000-0000-0000-000000000000", "qty": 1, "unit": "ea"} + ], + "instructions": ["nope"], + } + r = client.post("/api/admin/recipes", json=body, headers=_admin_headers()) + assert r.status_code == 422 + + +def test_list_recipes_returns_seeded_data(client): + chicken = _new_ingredient(client, "Chicken Thighs Test2") + client.post( + "/api/admin/recipes", + json={ + "name": "Listable Recipe", + "prep_time_minutes": 5, + "cook_time_minutes": 25, + "servings": 4, + "cuisine_tags": ["american"], + "dietary_tags": [], + "protein_type": "chicken", + "ingredients": [{"ingredient_id": chicken, "qty": 1, "unit": "lb"}], + "instructions": ["cook"], + }, + headers=_admin_headers(), + ) + r = client.get("/api/recipes") + assert r.status_code == 200 + names = {row["name"] for row in r.json()} + assert "Listable Recipe" in names +``` + +- [ ] **Step 2: Run test, expect failure** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_recipe_api.py -v +``` + +Expected: failures (endpoints don't exist or have wrong shape). + +- [ ] **Step 3: Implement the router** + +Replace `backend/app/api/recipes.py` with: + +```python +from __future__ import annotations + +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import Ingredient, Recipe +from app.schemas.recipe import RecipeCreate, RecipeRead, RecipeUpdate +from app.security import require_admin + + +public_router = APIRouter(prefix="/api/recipes", tags=["recipes"]) +admin_router = APIRouter( + prefix="/api/admin/recipes", + tags=["recipes-admin"], + dependencies=[Depends(require_admin)], +) + + +def _validate_ingredient_ids(db: Session, ingredient_refs) -> None: + ids = [ref.ingredient_id for ref in ingredient_refs] + found = ( + db.query(Ingredient.id).filter(Ingredient.id.in_(ids)).all() + ) + found_ids = {row[0] for row in found} + missing = [str(i) for i in ids if i not in found_ids] + if missing: + raise HTTPException( + status_code=422, + detail={"error": "unknown ingredient_ids", "missing": missing}, + ) + + +def _serialize(row: Recipe) -> dict: + return { + "id": row.id, + "name": row.name, + "description": row.description, + "image_url": row.image_url, + "prep_time_minutes": row.prep_time_minutes or 0, + "cook_time_minutes": row.cook_time_minutes or 0, + "servings": row.servings, + "cuisine_tags": list(row.cuisine_tags or []), + "dietary_tags": list(row.dietary_tags or []), + "protein_type": row.protein_type, + "spice_level": row.spice_level, + "calories_per_serving": row.calories_per_serving, + "ingredients": row.ingredients or [], + "instructions": list(row.instructions or []), + "source_url": row.source_url, + } + + +@public_router.get("", response_model=List[RecipeRead]) +def list_recipes( + q: Optional[str] = Query(default=None), + limit: int = Query(default=100, le=500), + db: Session = Depends(get_db), +): + query = db.query(Recipe) + if q: + like = f"%{q.lower()}%" + query = query.filter(Recipe.name.ilike(like)) + rows = query.order_by(Recipe.name).limit(limit).all() + return [_serialize(r) for r in rows] + + +@public_router.get("/{recipe_id}", response_model=RecipeRead) +def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)): + row = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if row is None: + raise HTTPException(status_code=404, detail="recipe not found") + return _serialize(row) + + +@admin_router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED) +def create_recipe(payload: RecipeCreate, db: Session = Depends(get_db)): + _validate_ingredient_ids(db, payload.ingredients) + row = Recipe( + name=payload.name, + description=payload.description, + image_url=payload.image_url, + prep_time_minutes=payload.prep_time_minutes, + cook_time_minutes=payload.cook_time_minutes, + servings=payload.servings, + cuisine_tags=payload.cuisine_tags, + dietary_tags=payload.dietary_tags, + protein_type=payload.protein_type, + spice_level=payload.spice_level, + calories_per_serving=payload.calories_per_serving, + ingredients=[ref.model_dump(mode="json") for ref in payload.ingredients], + instructions=payload.instructions, + source_url=payload.source_url, + is_manually_added=True, + ) + db.add(row) + db.commit() + db.refresh(row) + return _serialize(row) + + +@admin_router.patch("/{recipe_id}", response_model=RecipeRead) +def update_recipe(recipe_id: UUID, payload: RecipeUpdate, db: Session = Depends(get_db)): + row = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if row is None: + raise HTTPException(status_code=404, detail="recipe not found") + data = payload.model_dump(exclude_unset=True) + if "ingredients" in data and data["ingredients"] is not None: + _validate_ingredient_ids(db, payload.ingredients) + row.ingredients = [ref.model_dump(mode="json") for ref in payload.ingredients] + data.pop("ingredients") + for field, value in data.items(): + setattr(row, field, value) + db.commit() + db.refresh(row) + return _serialize(row) + + +@admin_router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)) -> None: + row = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if row is None: + raise HTTPException(status_code=404, detail="recipe not found") + db.delete(row) + db.commit() +``` + +- [ ] **Step 4: Update main.py to register both routers** + +In `backend/app/main.py`, replace any existing `recipes` include with: + +```python +from app.api import recipes as recipes_api + +app.include_router(recipes_api.public_router) +app.include_router(recipes_api.admin_router) +``` + +(Remove the old `recipes.router` include if present.) + +- [ ] **Step 5: Run tests** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_recipe_api.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/recipes.py backend/app/main.py backend/tests/test_recipe_api.py +git commit -m "feat: recipe CRUD endpoints with canonical ingredient validation" +``` + +--- + +## Task 8: Resolve-ingredient assist endpoint + +**Files:** +- Modify: `backend/app/api/recipes.py` (add the resolve endpoint to admin_router) +- Test: extend `backend/tests/test_recipe_api.py` or create `backend/tests/test_resolve_ingredient.py` + +- [ ] **Step 1: Write failing test** + +Create `backend/tests/test_resolve_ingredient.py`: + +```python +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def _seed_ingredient(client, name: str, aliases: list[str]) -> str: + r = client.post( + "/api/admin/ingredients", + json={"name": name, "aliases": aliases, "aisle": "pantry", "unit": "ea"}, + headers=_admin(), + ) + return r.json()["id"] + + +def test_resolve_ingredient_returns_top_three_candidates(client): + chicken_id = _seed_ingredient(client, "Chicken Thighs", ["chicken thigh"]) + breast_id = _seed_ingredient(client, "Chicken Breast", ["chicken breasts"]) + pork_id = _seed_ingredient(client, "Pork Chop", ["pork chops"]) + + r = client.post( + "/api/admin/recipes/resolve-ingredient", + json={"text": "1 lb chicken thighs"}, + headers=_admin(), + ) + assert r.status_code == 200, r.text + data = r.json() + assert data["parsed_qty"] == 1.0 + assert data["parsed_unit"] == "lb" + candidate_ids = [c["ingredient_id"] for c in data["candidates"]] + assert chicken_id in candidate_ids + assert candidate_ids[0] == chicken_id # highest score should be exact match + + +def test_resolve_ingredient_handles_no_unit(client): + _seed_ingredient(client, "Lemon", ["lemons"]) + r = client.post( + "/api/admin/recipes/resolve-ingredient", + json={"text": "2 lemons"}, + headers=_admin(), + ) + data = r.json() + assert data["parsed_qty"] == 2.0 + assert data["parsed_unit"] is None +``` + +- [ ] **Step 2: Run test, expect failure** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_resolve_ingredient.py -v +``` + +Expected: 404 — endpoint missing. + +- [ ] **Step 3: Add the parser + endpoint** + +Append to `backend/app/api/recipes.py`: + +```python +import re + +from rapidfuzz import fuzz, process + +from app.schemas.recipe import ( + ResolveIngredientCandidate, + ResolveIngredientRequest, + ResolveIngredientResponse, +) + + +_KNOWN_UNITS = { + "tsp", "tbsp", "cup", "cups", "oz", "ounce", "ounces", + "lb", "lbs", "pound", "pounds", "g", "kg", "ml", "l", + "clove", "cloves", "pinch", "dash", "ea", "each", +} + +_QTY_UNIT_RE = re.compile( + r"^\s*(?P\d+(?:\.\d+)?(?:/\d+)?)\s*(?P[a-zA-Z]+)?\s+(?P.+)$" +) + + +def _parse_qty_unit(text: str) -> tuple[Optional[float], Optional[str], str]: + m = _QTY_UNIT_RE.match(text) + if not m: + return None, None, text.strip() + qty_raw = m.group("qty") + if "/" in qty_raw: + num, denom = qty_raw.split("/") + qty = float(num) / float(denom) + else: + qty = float(qty_raw) + unit = m.group("unit") + rest = m.group("rest").strip() + if unit and unit.lower() not in _KNOWN_UNITS: + rest = f"{unit} {rest}" + unit = None + return qty, unit.lower() if unit else None, rest + + +@admin_router.post("/resolve-ingredient", response_model=ResolveIngredientResponse) +def resolve_ingredient( + payload: ResolveIngredientRequest, + db: Session = Depends(get_db), +) -> ResolveIngredientResponse: + qty, unit, rest = _parse_qty_unit(payload.text) + rows = db.query(Ingredient).all() + if not rows: + return ResolveIngredientResponse( + parsed_qty=qty, + parsed_unit=unit, + parsed_text=rest, + candidates=[], + ) + + pool: list[tuple[str, UUID, Optional[str]]] = [] + for row in rows: + pool.append((row.name, row.id, row.aisle)) + for alias in row.aliases or []: + pool.append((alias, row.id, row.aisle)) + + scored = process.extract( + rest, + [name for name, _, _ in pool], + scorer=fuzz.WRatio, + limit=10, + ) + seen: set[UUID] = set() + candidates: list[ResolveIngredientCandidate] = [] + for matched_name, score, idx in scored: + _, ing_id, aisle = pool[idx] + if ing_id in seen: + continue + seen.add(ing_id) + candidates.append( + ResolveIngredientCandidate( + ingredient_id=ing_id, + name=matched_name, + score=score / 100.0, + aisle=aisle, + ) + ) + if len(candidates) >= 3: + break + + return ResolveIngredientResponse( + parsed_qty=qty, + parsed_unit=unit, + parsed_text=rest, + candidates=candidates, + ) +``` + +- [ ] **Step 4: Run test, expect pass** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_resolve_ingredient.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/recipes.py backend/tests/test_resolve_ingredient.py +git commit -m "feat: POST /api/admin/recipes/resolve-ingredient with rapidfuzz top-3" +``` + +--- + +## Task 9: Matcher service (rapidfuzz top-3) + +**Files:** +- Create: `backend/app/services/matcher.py` +- Test: `backend/tests/test_matcher.py` + +- [ ] **Step 1: Write failing test** + +Create `backend/tests/test_matcher.py`: + +```python +import pytest + +from app.services.matcher import build_match_pool, rank_candidates + + +def test_build_match_pool_includes_aliases() -> None: + ingredients = [ + {"id": "i1", "name": "Chicken Thighs", "aliases": ["chicken thigh"]}, + {"id": "i2", "name": "Chicken Breast", "aliases": []}, + ] + pool = build_match_pool(ingredients) + # 1 name + 1 alias for i1, 1 name for i2 = 3 entries + assert len(pool) == 3 + assert ("Chicken Thighs", "i1") in pool + assert ("chicken thigh", "i1") in pool + + +def test_rank_candidates_top_n_with_threshold() -> None: + pool = [ + ("Chicken Thighs", "i1"), + ("chicken thigh", "i1"), + ("Chicken Breast", "i2"), + ("Pork Chops", "i3"), + ] + ranked = rank_candidates( + target="Foster Farms Chicken Thighs Family Pack", + pool=pool, + top_n=3, + threshold=0.75, + ) + ids = [item["ingredient_id"] for item in ranked] + assert ids[0] == "i1" + assert all(item["confidence"] >= 0.75 for item in ranked) + + +def test_rank_candidates_drops_below_threshold() -> None: + pool = [("Pork Chops", "i3")] + ranked = rank_candidates( + target="Frosted Flakes Cereal 18oz", + pool=pool, + top_n=3, + threshold=0.75, + ) + assert ranked == [] +``` + +- [ ] **Step 2: Run test, expect failure** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_matcher.py -v +``` + +Expected: ImportError on `app.services.matcher`. + +- [ ] **Step 3: Implement the matcher** + +Create `backend/app/services/matcher.py`: + +```python +"""Fuzzy ingredient↔grocery_item matcher. + +Builds a candidate pool of (text, ingredient_id) tuples from the +canonical ingredient table (name + aliases), ranks each grocery_item +name against the pool with rapidfuzz, and writes the top N matches +above a confidence threshold to the ingredient_grocery_match table. + +Manual matches (source='manual') are preserved across runs. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Iterable, List, Tuple +from uuid import UUID + +from rapidfuzz import fuzz, process +from sqlalchemy.orm import Session + +from app.models import ( + GroceryItem, + Ingredient, + IngredientGroceryMatch, + IngredientMatchSource, +) + + +@dataclass +class MatchResult: + ingredient_id: UUID + grocery_item_id: UUID + confidence: float + + +def build_match_pool(ingredients: Iterable[dict]) -> List[Tuple[str, str]]: + """Flatten (canonical name + aliases) into (text, ingredient_id) pairs.""" + pool: List[Tuple[str, str]] = [] + for ing in ingredients: + pool.append((ing["name"], ing["id"])) + for alias in ing.get("aliases") or []: + if alias: + pool.append((alias, ing["id"])) + return pool + + +def rank_candidates( + target: str, + pool: List[Tuple[str, str]], + top_n: int = 3, + threshold: float = 0.75, +) -> List[dict]: + """Return up to top_n unique-by-ingredient_id matches above threshold.""" + if not pool: + return [] + texts = [t for t, _ in pool] + extracted = process.extract(target, texts, scorer=fuzz.WRatio, limit=20) + seen: set[str] = set() + out: List[dict] = [] + for matched_text, score, idx in extracted: + confidence = score / 100.0 + if confidence < threshold: + continue + ingredient_id = pool[idx][1] + if ingredient_id in seen: + continue + seen.add(ingredient_id) + out.append( + { + "ingredient_id": ingredient_id, + "matched_text": matched_text, + "confidence": confidence, + } + ) + if len(out) >= top_n: + break + return out + + +def run_match_job( + db: Session, + *, + source_filter: str = "lucky_california", + top_n: int = 3, + threshold: float = 0.75, +) -> int: + """Refresh ingredient_grocery_match for every grocery_item from `source_filter`. + + Manual matches (source='manual') are NOT touched. Returns the number of + auto rows written/updated. + """ + ingredients = [ + {"id": str(row.id), "name": row.name, "aliases": list(row.aliases or [])} + for row in db.query(Ingredient).all() + ] + pool = build_match_pool(ingredients) + + grocery_rows = ( + db.query(GroceryItem) + .filter(GroceryItem.source == source_filter) + .all() + ) + + written = 0 + for grocery in grocery_rows: + target = " ".join(filter(None, [grocery.name, grocery.brand or ""])).strip() + ranked = rank_candidates(target, pool, top_n=top_n, threshold=threshold) + for r in ranked: + ing_id = UUID(r["ingredient_id"]) + existing = ( + db.query(IngredientGroceryMatch) + .filter( + IngredientGroceryMatch.ingredient_id == ing_id, + IngredientGroceryMatch.grocery_item_id == grocery.id, + ) + .first() + ) + if existing and existing.source == IngredientMatchSource.MANUAL: + continue + confidence = Decimal(str(round(r["confidence"], 3))) + if existing is None: + db.add( + IngredientGroceryMatch( + ingredient_id=ing_id, + grocery_item_id=grocery.id, + confidence=confidence, + source=IngredientMatchSource.AUTO, + ) + ) + else: + existing.confidence = confidence + existing.source = IngredientMatchSource.AUTO + written += 1 + + db.commit() + return written +``` + +- [ ] **Step 4: Run unit tests, expect pass** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_matcher.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/matcher.py backend/tests/test_matcher.py +git commit -m "feat: rapidfuzz-based ingredient<->grocery matcher with manual-pin preservation" +``` + +--- + +## Task 10: Wire matcher into scrape success path + +**Files:** +- Modify: `backend/app/services/scraper_service.py` +- Test: `backend/tests/test_match_hook.py` + +- [ ] **Step 1: Write failing integration test** + +Create `backend/tests/test_match_hook.py`: + +```python +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def test_run_match_job_persists_top_matches(monkeypatch): + """End-to-end: seed Ingredient + GroceryItem rows, run the matcher, + verify ingredient_grocery_match rows exist with confidence >= 0.75. + """ + from datetime import datetime, timezone + from decimal import Decimal + from uuid import uuid4 + + from app.database import SessionLocal + from app.models import ( + GroceryItem, + Ingredient, + IngredientGroceryMatch, + IngredientMatchSource, + ) + from app.services.matcher import run_match_job + + setup = SessionLocal() + ing_id = uuid4() + grocery_id = uuid4() + try: + setup.add( + Ingredient( + id=ing_id, + name="Chicken Thighs (test)", + name_lower="chicken thighs (test)", + aliases=["chicken thigh"], + aisle="meat", + unit="lb", + ) + ) + setup.add( + GroceryItem( + id=grocery_id, + name="Foster Farms Chicken Thighs Family Pack", + source="lucky_california", + external_id="ext-test-1", + current_price=Decimal("3.99"), + regular_price=Decimal("5.49"), + is_on_sale=True, + scraped_at=datetime.now(timezone.utc), + ) + ) + setup.commit() + finally: + setup.close() + + work = SessionLocal() + try: + written = run_match_job(work) + assert written >= 1 + rows = ( + work.query(IngredientGroceryMatch) + .filter(IngredientGroceryMatch.ingredient_id == ing_id) + .all() + ) + assert any( + r.grocery_item_id == grocery_id + and r.confidence >= Decimal("0.750") + and r.source == IngredientMatchSource.AUTO + for r in rows + ) + finally: + cleanup = SessionLocal() + try: + cleanup.query(IngredientGroceryMatch).filter( + IngredientGroceryMatch.ingredient_id == ing_id + ).delete() + cleanup.query(GroceryItem).filter(GroceryItem.id == grocery_id).delete() + cleanup.query(Ingredient).filter(Ingredient.id == ing_id).delete() + cleanup.commit() + finally: + cleanup.close() + work.close() +``` + +- [ ] **Step 2: Run test, expect pass** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_match_hook.py -v +``` + +Expected: 1 passed (matcher already implemented in Task 9). + +- [ ] **Step 3: Hook matcher into scrape success path** + +Read `backend/app/services/scraper_service.py` to find the `_run_scrape_in_background` function. Locate the line that sets `log.status = ScrapeStatus.SUCCESS` (the success branch). Immediately before the `db.commit()` that finalizes the success log, insert: + +```python + try: + from app.services.matcher import run_match_job + run_match_job(db, source_filter="lucky_california") + except Exception as e: # matcher failure must not flip scrape to FAILED + import logging + logging.exception("matcher failed after successful scrape: %s", e) +``` + +- [ ] **Step 4: Run all tests** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/ -v +``` + +Expected: 35+ passed (no regressions). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/scraper_service.py backend/tests/test_match_hook.py +git commit -m "feat: run matcher after successful scrape; failures don't flip scrape status" +``` + +--- + +## Task 11: Manual match override endpoints + +**Files:** +- Modify: `backend/app/api/ingredients.py` (add manual match override under admin_router) +- Test: extend `backend/tests/test_ingredient_api.py` + +- [ ] **Step 1: Write failing tests** + +Append to `backend/tests/test_ingredient_api.py`: + +```python +def _seed_grocery(db_session, name: str) -> str: + from datetime import datetime, timezone + from decimal import Decimal + from uuid import uuid4 + + from app.models import GroceryItem + + gid = uuid4() + db_session.add( + GroceryItem( + id=gid, + name=name, + source="lucky_california", + external_id=f"ext-{gid}", + current_price=Decimal("4.99"), + regular_price=Decimal("4.99"), + is_on_sale=False, + scraped_at=datetime.now(timezone.utc), + ) + ) + db_session.commit() + return str(gid) + + +def test_pin_manual_match(client, db_session): + create = client.post( + "/api/admin/ingredients", + json={"name": "Manual Pin Veggie", "aliases": [], "aisle": "produce", "unit": "ea"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + gid = _seed_grocery(db_session, "Some Other Veggie Brand") + + r = client.post( + f"/api/admin/ingredients/{iid}/matches", + json={"grocery_item_id": gid, "confidence": 1.0}, + headers=_admin_headers(), + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["source"] == "manual" + assert body["grocery_item_id"] == gid + + +def test_unpin_manual_match(client, db_session): + create = client.post( + "/api/admin/ingredients", + json={"name": "Unpin Test Item", "aliases": [], "aisle": "produce", "unit": "ea"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + gid = _seed_grocery(db_session, "Brand X Product") + pin = client.post( + f"/api/admin/ingredients/{iid}/matches", + json={"grocery_item_id": gid, "confidence": 1.0}, + headers=_admin_headers(), + ) + match_id = pin.json()["id"] + r = client.delete(f"/api/admin/ingredient-matches/{match_id}", headers=_admin_headers()) + assert r.status_code == 204 +``` + +(This requires a `db_session` fixture. Check `backend/tests/conftest.py` — if it does not already expose one yielding `SessionLocal()`, add it. The existing `client` fixture is required to already be present.) + +- [ ] **Step 2: Run tests, expect failure** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_ingredient_api.py::test_pin_manual_match -v +``` + +Expected: 404 — endpoint missing. + +- [ ] **Step 3: Implement endpoints** + +Append to `backend/app/api/ingredients.py`: + +```python +from decimal import Decimal + +from app.models import IngredientGroceryMatch, IngredientMatchSource +from app.schemas.ingredient import IngredientGroceryMatchRead + + +class _PinMatchBody(BaseModel): # type: ignore[name-defined] + grocery_item_id: UUID + confidence: float = 1.0 + + +# Re-import BaseModel near the top of the file if not already present. +``` + +(Move the BaseModel import to the top of `ingredients.py`: `from pydantic import BaseModel`. Then define the pin endpoint:) + +```python +@admin_router.post( + "/{ingredient_id}/matches", + response_model=IngredientGroceryMatchRead, + status_code=status.HTTP_201_CREATED, +) +def pin_match( + ingredient_id: UUID, + payload: _PinMatchBody, + db: Session = Depends(get_db), +): + existing = ( + db.query(IngredientGroceryMatch) + .filter( + IngredientGroceryMatch.ingredient_id == ingredient_id, + IngredientGroceryMatch.grocery_item_id == payload.grocery_item_id, + ) + .first() + ) + if existing: + existing.source = IngredientMatchSource.MANUAL + existing.confidence = Decimal(str(round(payload.confidence, 3))) + db.commit() + db.refresh(existing) + return existing + row = IngredientGroceryMatch( + ingredient_id=ingredient_id, + grocery_item_id=payload.grocery_item_id, + confidence=Decimal(str(round(payload.confidence, 3))), + source=IngredientMatchSource.MANUAL, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +_match_admin_router = APIRouter( + prefix="/api/admin/ingredient-matches", + tags=["ingredients-admin"], + dependencies=[Depends(require_admin)], +) + + +@_match_admin_router.delete( + "/{match_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def unpin_match(match_id: UUID, db: Session = Depends(get_db)) -> None: + row = db.query(IngredientGroceryMatch).filter(IngredientGroceryMatch.id == match_id).first() + if row is None: + raise HTTPException(status_code=404, detail="match not found") + db.delete(row) + db.commit() +``` + +In `backend/app/main.py` register the new router: + +```python +app.include_router(ingredients_api._match_admin_router) +``` + +- [ ] **Step 4: Run tests** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_ingredient_api.py -v +``` + +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/ingredients.py backend/app/main.py backend/tests/test_ingredient_api.py backend/tests/conftest.py +git commit -m "feat: manual match pin/unpin endpoints" +``` + +--- + +## Task 12: NeverSuggest CRUD endpoints + +**Files:** +- Create: `backend/app/api/never_suggest.py` +- Create: `backend/app/schemas/never_suggest.py` +- Test: `backend/tests/test_never_suggest_api.py` +- Modify: `backend/app/main.py` + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_never_suggest_api.py`: + +```python +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def _seed_family(db_session) -> str: + from uuid import uuid4 + from app.models import FamilyProfile + + fid = uuid4() + db_session.add( + FamilyProfile( + id=fid, + name="Test Family NS", + household_size=4, + adult_count=2, + child_count=2, + calorie_target=2400, + ) + ) + db_session.commit() + return str(fid) + + +def test_block_ingredient(client, db_session): + fid = _seed_family(db_session) + ing = client.post( + "/api/admin/ingredients", + json={"name": "Mushrooms (NS)", "aliases": [], "aisle": "produce", "unit": "oz"}, + headers=_admin(), + ) + iid = ing.json()["id"] + + r = client.post( + "/api/admin/never-suggest", + json={"family_profile_id": fid, "ingredient_id": iid, "reason": "dislike"}, + headers=_admin(), + ) + assert r.status_code == 201, r.text + + +def test_list_never_suggest_for_family(client, db_session): + fid = _seed_family(db_session) + ing = client.post( + "/api/admin/ingredients", + json={"name": "Cilantro (NS)", "aliases": [], "aisle": "produce", "unit": "tbsp"}, + headers=_admin(), + ) + client.post( + "/api/admin/never-suggest", + json={"family_profile_id": fid, "ingredient_id": ing.json()["id"], "reason": "dislike"}, + headers=_admin(), + ) + r = client.get(f"/api/never-suggest?family_profile_id={fid}") + assert r.status_code == 200 + assert len(r.json()) >= 1 + + +def test_unblock_removes_row(client, db_session): + fid = _seed_family(db_session) + ing = client.post( + "/api/admin/ingredients", + json={"name": "Anchovy (NS)", "aliases": [], "aisle": "pantry", "unit": "ea"}, + headers=_admin(), + ) + create = client.post( + "/api/admin/never-suggest", + json={"family_profile_id": fid, "ingredient_id": ing.json()["id"], "reason": "dislike"}, + headers=_admin(), + ) + nid = create.json()["id"] + r = client.delete(f"/api/admin/never-suggest/{nid}", headers=_admin()) + assert r.status_code == 204 +``` + +- [ ] **Step 2: Run tests, expect failure** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_never_suggest_api.py -v +``` + +Expected: 404. + +- [ ] **Step 3: Schemas** + +Create `backend/app/schemas/never_suggest.py`: + +```python +from __future__ import annotations + +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + + +class NeverSuggestCreate(BaseModel): + family_profile_id: UUID + ingredient_id: Optional[UUID] = None + recipe_id: Optional[UUID] = None + reason: Optional[str] = Field(default=None, max_length=50) + notes: Optional[str] = None + + @model_validator(mode="after") + def _exactly_one_target(self) -> "NeverSuggestCreate": + present = sum(x is not None for x in (self.ingredient_id, self.recipe_id)) + if present != 1: + raise ValueError("exactly one of ingredient_id or recipe_id must be set") + return self + + +class NeverSuggestRead(BaseModel): + id: UUID + family_profile_id: UUID + ingredient_id: Optional[UUID] = None + recipe_id: Optional[UUID] = None + reason: Optional[str] = None + notes: Optional[str] = None + + model_config = {"from_attributes": True} +``` + +- [ ] **Step 4: Endpoints** + +Create `backend/app/api/never_suggest.py`: + +```python +from __future__ import annotations + +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import NeverSuggest, NeverSuggestReason +from app.schemas.never_suggest import NeverSuggestCreate, NeverSuggestRead +from app.security import require_admin + + +public_router = APIRouter(prefix="/api/never-suggest", tags=["never-suggest"]) +admin_router = APIRouter( + prefix="/api/admin/never-suggest", + tags=["never-suggest-admin"], + dependencies=[Depends(require_admin)], +) + + +def _coerce_reason(raw: str | None) -> NeverSuggestReason | None: + if raw is None: + return None + try: + return NeverSuggestReason(raw) + except ValueError: + raise HTTPException(status_code=422, detail=f"unknown reason: {raw}") + + +@public_router.get("", response_model=List[NeverSuggestRead]) +def list_for_family( + family_profile_id: UUID = Query(...), + db: Session = Depends(get_db), +): + return ( + db.query(NeverSuggest) + .filter(NeverSuggest.family_profile_id == family_profile_id) + .all() + ) + + +@admin_router.post("", response_model=NeverSuggestRead, status_code=status.HTTP_201_CREATED) +def block(payload: NeverSuggestCreate, db: Session = Depends(get_db)): + row = NeverSuggest( + family_profile_id=payload.family_profile_id, + ingredient_id=payload.ingredient_id, + recipe_id=payload.recipe_id, + reason=_coerce_reason(payload.reason), + notes=payload.notes, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +@admin_router.delete("/{ns_id}", status_code=status.HTTP_204_NO_CONTENT) +def unblock(ns_id: UUID, db: Session = Depends(get_db)) -> None: + row = db.query(NeverSuggest).filter(NeverSuggest.id == ns_id).first() + if row is None: + raise HTTPException(status_code=404, detail="never-suggest entry not found") + db.delete(row) + db.commit() +``` + +- [ ] **Step 5: Wire into main.py** + +```python +from app.api import never_suggest as never_suggest_api + +app.include_router(never_suggest_api.public_router) +app.include_router(never_suggest_api.admin_router) +``` + +- [ ] **Step 6: Run tests** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/test_never_suggest_api.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/api/never_suggest.py backend/app/schemas/never_suggest.py backend/tests/test_never_suggest_api.py backend/app/main.py +git commit -m "feat: never-suggest CRUD endpoints (ingredient and recipe blocklist)" +``` + +--- + +## Task 13: Migration 0007 — seed canonical ingredients + +**Files:** +- Create: `backend/alembic/versions/0007_seed_recipes.py` + +This task lays down the canonical ingredients (~50) used by the 30 starter recipes. Recipes themselves go in Task 14 (same migration file, expanded). We split the work to keep the diff reviewable. + +- [ ] **Step 1: Write the seed migration scaffolding** + +Create `backend/alembic/versions/0007_seed_recipes.py`: + +```python +"""seed canonical ingredients and 30 starter recipes + +Revision ID: 0007_seed_recipes +Revises: 0006_recipe_engine_thin +Create Date: 2026-05-05 +""" +import json +import uuid +from decimal import Decimal + +from alembic import op +import sqlalchemy as sa + + +revision = "0007_seed_recipes" +down_revision = "0006_recipe_engine_thin" +branch_labels = None +depends_on = None + + +# Stable UUIDs so re-running upgrade after a downgrade is idempotent on FK refs. +INGREDIENTS = [ + # (uuid, name, aliases, aisle, unit, typical_price) + ("11111111-0000-0000-0000-000000000001", "Chicken Thighs, Boneless Skinless", ["chicken thigh", "BSL chicken thighs"], "meat_seafood", "lb", Decimal("4.99")), + ("11111111-0000-0000-0000-000000000002", "Chicken Breast, Boneless Skinless", ["chicken breast", "BSL chicken breast"], "meat_seafood", "lb", Decimal("5.99")), + ("11111111-0000-0000-0000-000000000003", "Ground Beef, 85/15", ["ground beef", "hamburger"], "meat_seafood", "lb", Decimal("6.99")), + ("11111111-0000-0000-0000-000000000004", "Ground Turkey", ["turkey mince"], "meat_seafood", "lb", Decimal("5.49")), + ("11111111-0000-0000-0000-000000000005", "Pork Chops, Bone-In", ["pork chop"], "meat_seafood", "lb", Decimal("4.49")), + ("11111111-0000-0000-0000-000000000006", "Salmon Fillet", ["salmon"], "meat_seafood", "lb", Decimal("12.99")), + ("11111111-0000-0000-0000-000000000007", "Shrimp, Peeled", ["shrimp"], "meat_seafood", "lb", Decimal("9.99")), + ("11111111-0000-0000-0000-000000000008", "Eggs, Large", ["egg"], "dairy", "ea", Decimal("0.40")), + ("11111111-0000-0000-0000-000000000009", "Black Beans, Canned", ["black bean"], "pantry", "can", Decimal("1.29")), + ("11111111-0000-0000-0000-00000000000a", "Chickpeas, Canned", ["garbanzo", "chickpea"], "pantry", "can", Decimal("1.49")), + + ("22222222-0000-0000-0000-000000000001", "Yellow Onion", ["onion"], "produce", "ea", Decimal("0.99")), + ("22222222-0000-0000-0000-000000000002", "Garlic", ["garlic clove"], "produce", "clove", Decimal("0.10")), + ("22222222-0000-0000-0000-000000000003", "Carrot", ["carrots"], "produce", "ea", Decimal("0.50")), + ("22222222-0000-0000-0000-000000000004", "Celery", [], "produce", "stalk", Decimal("0.30")), + ("22222222-0000-0000-0000-000000000005", "Bell Pepper, Red", ["red pepper"], "produce", "ea", Decimal("1.49")), + ("22222222-0000-0000-0000-000000000006", "Bell Pepper, Green", ["green pepper"], "produce", "ea", Decimal("0.99")), + ("22222222-0000-0000-0000-000000000007", "Tomato, Roma", ["tomato"], "produce", "ea", Decimal("0.79")), + ("22222222-0000-0000-0000-000000000008", "Lemon", [], "produce", "ea", Decimal("0.79")), + ("22222222-0000-0000-0000-000000000009", "Lime", [], "produce", "ea", Decimal("0.50")), + ("22222222-0000-0000-0000-00000000000a", "Cilantro", ["coriander leaf"], "produce", "bunch", Decimal("0.99")), + ("22222222-0000-0000-0000-00000000000b", "Parsley, Italian", ["parsley"], "produce", "bunch", Decimal("0.99")), + ("22222222-0000-0000-0000-00000000000c", "Spinach, Fresh", ["spinach"], "produce", "oz", Decimal("0.40")), + ("22222222-0000-0000-0000-00000000000d", "Broccoli", [], "produce", "lb", Decimal("2.49")), + ("22222222-0000-0000-0000-00000000000e", "Zucchini", [], "produce", "ea", Decimal("0.99")), + ("22222222-0000-0000-0000-00000000000f", "Sweet Potato", [], "produce", "ea", Decimal("1.29")), + + ("33333333-0000-0000-0000-000000000001", "Olive Oil", ["EVOO", "extra virgin olive oil"], "pantry", "tbsp", Decimal("0.20")), + ("33333333-0000-0000-0000-000000000002", "Soy Sauce", [], "pantry", "tbsp", Decimal("0.15")), + ("33333333-0000-0000-0000-000000000003", "Rice, Long-Grain White", ["white rice", "rice"], "pantry", "cup", Decimal("0.40")), + ("33333333-0000-0000-0000-000000000004", "Pasta, Penne", ["penne"], "pantry", "lb", Decimal("1.29")), + ("33333333-0000-0000-0000-000000000005", "Pasta, Spaghetti", ["spaghetti"], "pantry", "lb", Decimal("1.29")), + ("33333333-0000-0000-0000-000000000006", "Tortilla, Flour", ["flour tortilla"], "pantry", "ea", Decimal("0.30")), + ("33333333-0000-0000-0000-000000000007", "Tortilla, Corn", ["corn tortilla"], "pantry", "ea", Decimal("0.20")), + ("33333333-0000-0000-0000-000000000008", "Diced Tomatoes, Canned", ["canned tomato"], "pantry", "can", Decimal("1.49")), + ("33333333-0000-0000-0000-000000000009", "Chicken Broth", ["chicken stock"], "pantry", "cup", Decimal("0.30")), + ("33333333-0000-0000-0000-00000000000a", "Coconut Milk, Canned", [], "pantry", "can", Decimal("2.49")), + ("33333333-0000-0000-0000-00000000000b", "Salt, Kosher", ["kosher salt"], "pantry", "tsp", Decimal("0.01")), + ("33333333-0000-0000-0000-00000000000c", "Black Pepper", ["pepper"], "pantry", "tsp", Decimal("0.02")), + ("33333333-0000-0000-0000-00000000000d", "Cumin, Ground", ["cumin"], "pantry", "tsp", Decimal("0.10")), + ("33333333-0000-0000-0000-00000000000e", "Paprika, Smoked", ["smoked paprika"], "pantry", "tsp", Decimal("0.10")), + ("33333333-0000-0000-0000-00000000000f", "Italian Seasoning", [], "pantry", "tsp", Decimal("0.10")), + ("33333333-0000-0000-0000-000000000010", "Curry Powder", [], "pantry", "tsp", Decimal("0.10")), + ("33333333-0000-0000-0000-000000000011", "Ginger, Fresh", ["ginger root"], "produce", "tbsp", Decimal("0.30")), + + ("44444444-0000-0000-0000-000000000001", "Cheddar Cheese, Sharp", ["cheddar"], "dairy", "oz", Decimal("0.40")), + ("44444444-0000-0000-0000-000000000002", "Mozzarella, Shredded", ["mozzarella"], "dairy", "oz", Decimal("0.45")), + ("44444444-0000-0000-0000-000000000003", "Parmesan, Grated", ["parm"], "dairy", "tbsp", Decimal("0.20")), + ("44444444-0000-0000-0000-000000000004", "Sour Cream", [], "dairy", "tbsp", Decimal("0.10")), + ("44444444-0000-0000-0000-000000000005", "Greek Yogurt, Plain", ["yogurt"], "dairy", "cup", Decimal("1.50")), + ("44444444-0000-0000-0000-000000000006", "Butter, Unsalted", ["butter"], "dairy", "tbsp", Decimal("0.20")), + ("44444444-0000-0000-0000-000000000007", "Milk, Whole", ["milk"], "dairy", "cup", Decimal("0.30")), + + ("55555555-0000-0000-0000-000000000001", "Avocado", [], "produce", "ea", Decimal("1.49")), + ("55555555-0000-0000-0000-000000000002", "Salsa, Jarred", ["salsa"], "pantry", "tbsp", Decimal("0.15")), +] + + +def _insert_ingredients() -> None: + bind = op.get_bind() + for ing_id, name, aliases, aisle, unit, typical_price in INGREDIENTS: + bind.execute( + sa.text( + """ + INSERT INTO ingredient (id, name, name_lower, aliases, aisle, unit, typical_price) + VALUES (:id, :name, :name_lower, :aliases, :aisle, :unit, :typical_price) + ON CONFLICT (name_lower) DO NOTHING + """ + ), + { + "id": ing_id, + "name": name, + "name_lower": name.lower(), + "aliases": aliases, + "aisle": aisle, + "unit": unit, + "typical_price": typical_price, + }, + ) + + +def _insert_recipes() -> None: + """Populated in Task 14.""" + pass + + +def _delete_ingredients() -> None: + bind = op.get_bind() + ids = [row[0] for row in INGREDIENTS] + bind.execute( + sa.text("DELETE FROM ingredient WHERE id = ANY(:ids)"), + {"ids": ids}, + ) + + +def _delete_recipes() -> None: + """Populated in Task 14.""" + pass + + +def upgrade() -> None: + _insert_ingredients() + _insert_recipes() + + +def downgrade() -> None: + _delete_recipes() + _delete_ingredients() +``` + +- [ ] **Step 2: Apply the migration** + +```bash +docker compose --env-file .env.test exec backend alembic upgrade head +``` + +Expected: success. + +- [ ] **Step 3: Verify ingredient rows landed** + +```bash +docker compose --env-file .env.test exec backend python -c " +from app.database import SessionLocal +from app.models import Ingredient +s = SessionLocal() +print('count:', s.query(Ingredient).count()) +print('chicken thighs:', s.query(Ingredient).filter(Ingredient.name=='Chicken Thighs, Boneless Skinless').first().aliases) +" +``` + +Expected: `count: 50` (or similar) and a non-empty `aliases` list. + +- [ ] **Step 4: Round-trip migration** + +```bash +docker compose --env-file .env.test exec backend alembic downgrade -1 +docker compose --env-file .env.test exec backend alembic upgrade head +``` + +Expected: both succeed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/alembic/versions/0007_seed_recipes.py +git commit -m "feat: migration 0007 - seed canonical ingredients (recipes follow in next commit)" +``` + +--- + +## Task 14: Migration 0007 — populate 30 starter recipes + +**Files:** +- Modify: `backend/alembic/versions/0007_seed_recipes.py` + +We extend the same migration file rather than creating a new one — they're tightly coupled (recipes reference ingredient UUIDs). + +- [ ] **Step 1: Add 30 recipes to the migration** + +Replace the `_insert_recipes()` and `_delete_recipes()` stubs in `0007_seed_recipes.py` with: + +```python +RECIPES = [ + # Each: (uuid, name, prep_min, cook_min, servings, cuisine_tags, dietary_tags, + # protein_type, calories_per_serving, ingredients [list of (ingredient_uuid, qty, unit)], + # instructions [list of strings]) + ( + "aaaaaaaa-0000-0000-0000-000000000001", + "Sheet-Pan Chicken Thighs with Roasted Vegetables", + 10, 30, 4, ["american"], [], "chicken", 520, + [ + ("11111111-0000-0000-0000-000000000001", 2.0, "lb"), + ("33333333-0000-0000-0000-000000000001", 2.0, "tbsp"), + ("22222222-0000-0000-0000-00000000000d", 1.0, "lb"), + ("22222222-0000-0000-0000-00000000000f", 2.0, "ea"), + ("33333333-0000-0000-0000-00000000000b", 1.0, "tsp"), + ], + [ + "Preheat oven to 425°F.", + "Toss chicken and vegetables with olive oil and seasoning on a sheet pan.", + "Roast 30 minutes until chicken reads 165°F internal.", + ], + ), + # 29 more recipes follow — the implementer composes them per the spec below. +] +``` + +The full 30-recipe list is large but mechanical. Implementers should produce a balanced mix: +- 8 chicken (e.g., sheet-pan thighs, lemon-garlic chicken, chicken curry, chicken stir-fry, chicken parmesan, chicken tacos, chicken fried rice, chicken broth soup) +- 4 beef (e.g., taco night, spaghetti bolognese, beef stir-fry, sloppy joes) +- 3 turkey (e.g., turkey chili, turkey burgers, turkey meatloaf) +- 3 pork (e.g., pork chops, pork carnitas tacos, pork stir-fry) +- 3 fish/seafood (e.g., baked salmon, shrimp scampi, fish tacos) +- 5 vegetarian (e.g., chickpea curry, black bean tacos, pasta primavera, frittata, sweet potato bowl) +- 4 mixed/other (e.g., breakfast-for-dinner, fried rice, soup, pasta) + +Across the 30, vary cuisine_tags: american, italian, mexican, indian, thai, mediterranean. Keep `prep_time_minutes + cook_time_minutes` ≤ 45 for at least 25 of the 30 (so they survive constraint #5 by default). All recipes: `calories_per_serving` populated; `is_manually_added=False` to mark seeded. + +Add to `_insert_recipes()`: + +```python +def _insert_recipes() -> None: + bind = op.get_bind() + for ( + rid, name, prep, cook, servings, cuisine_tags, dietary_tags, + protein, calories, ingredients, instructions, + ) in RECIPES: + ingredient_json = json.dumps( + [ + {"ingredient_id": ing_id, "qty": qty, "unit": unit} + for ing_id, qty, unit in ingredients + ] + ) + bind.execute( + sa.text( + """ + INSERT INTO recipe ( + id, name, prep_time_minutes, cook_time_minutes, servings, + cuisine_tags, dietary_tags, protein_type, calories_per_serving, + ingredients, instructions, is_manually_added + ) + VALUES ( + :id, :name, :prep, :cook, :servings, + :cuisine_tags, :dietary_tags, :protein, :calories, + CAST(:ingredients AS jsonb), :instructions, false + ) + ON CONFLICT (id) DO NOTHING + """ + ), + { + "id": rid, + "name": name, + "prep": prep, + "cook": cook, + "servings": servings, + "cuisine_tags": cuisine_tags, + "dietary_tags": dietary_tags, + "protein": protein, + "calories": calories, + "ingredients": ingredient_json, + "instructions": instructions, + }, + ) +``` + +And `_delete_recipes()`: + +```python +def _delete_recipes() -> None: + bind = op.get_bind() + ids = [row[0] for row in RECIPES] + bind.execute(sa.text("DELETE FROM recipe WHERE id = ANY(:ids)"), {"ids": ids}) +``` + +- [ ] **Step 2: Apply migration** + +```bash +docker compose --env-file .env.test exec backend alembic downgrade -1 +docker compose --env-file .env.test exec backend alembic upgrade head +``` + +Expected: success; no FK errors (every ingredient_id in RECIPES must exist in INGREDIENTS). + +- [ ] **Step 3: Smoke-test recipe count** + +```bash +docker compose --env-file .env.test exec backend python -c " +from app.database import SessionLocal +from app.models import Recipe +s = SessionLocal() +print('recipe count:', s.query(Recipe).count()) +" +``` + +Expected: 30. + +- [ ] **Step 4: Smoke-test API can serve them** + +```bash +docker compose --env-file .env.test exec backend curl -s http://localhost:8000/api/recipes | python -c "import sys, json; d = json.load(sys.stdin); print(len(d), 'recipes'); print([r['name'] for r in d[:3]])" +``` + +Expected: 30 recipes; first three names print. + +- [ ] **Step 5: Commit** + +```bash +git add backend/alembic/versions/0007_seed_recipes.py +git commit -m "feat: seed 30 starter recipes spanning chicken/beef/pork/fish/veg" +``` + +--- + +## Task 15: End-to-end smoke test + docs refresh + +**Files:** +- Create: `backend/tests/test_thin_phase4_smoke.py` +- Modify: `docs/ORIENTATION.md` +- Modify: `docs/HANDOFF.md` + +- [ ] **Step 1: Write the smoke test** + +Create `backend/tests/test_thin_phase4_smoke.py`: + +```python +"""End-to-end: ingredient + recipe + match all wired together.""" +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def test_seed_data_present_and_resolvable(client): + r = client.get("/api/recipes") + assert r.status_code == 200 + recipes = r.json() + assert len(recipes) >= 30, f"expected >=30 seeded recipes, got {len(recipes)}" + + +def test_resolve_against_seeded_ingredients(client): + r = client.post( + "/api/admin/recipes/resolve-ingredient", + json={"text": "1 lb chicken thighs"}, + headers=_admin(), + ) + assert r.status_code == 200 + candidates = r.json()["candidates"] + assert candidates, "expected at least one candidate" + assert "chicken" in candidates[0]["name"].lower() + + +def test_match_job_runs_against_seeded_data(db_session): + from app.services.matcher import run_match_job + + written = run_match_job(db_session) + assert written >= 0 +``` + +- [ ] **Step 2: Run all tests** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/ -v +``` + +Expected: 50+ passed (everything green), no failures. + +- [ ] **Step 3: Update ORIENTATION.md phase table** + +Open `docs/ORIENTATION.md`. Find the phase status table. Update row 4: + +```markdown +| 4 | Recipe engine (CRUD, search, tagging, never-suggest filter) | **Thin slice complete** — recipe + ingredient CRUD, match layer, NeverSuggest, 30-recipe seed. Ingestion source decision deferred (see spec). | +``` + +- [ ] **Step 4: Update HANDOFF.md** + +Open `docs/HANDOFF.md`. Add to the "What is real (verified)" section: + +```markdown +- Thin Phase 4: ingredient + recipe CRUD endpoints, NeverSuggest blocklist, ingredient↔grocery_item match layer (rapidfuzz, top-3, manual override), 30 seeded recipes spanning chicken/beef/pork/fish/vegetarian. Match job hooks the scrape success path; matcher failures do not flip the scrape to FAILED. +``` + +Update the "What is stubbed or missing" section by removing the Phase 4 line (it's now thin-complete) and adding: + +```markdown +- 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. +``` + +- [ ] **Step 5: Run all tests one more time** + +```bash +docker compose --env-file .env.test exec backend pytest -q tests/ -v +docker compose --env-file .env.test exec backend alembic upgrade head +docker compose --env-file .env.test exec backend alembic downgrade base +docker compose --env-file .env.test exec backend alembic upgrade head +``` + +Expected: all green, both round-trip directions clean. + +- [ ] **Step 6: Commit** + +```bash +git add backend/tests/test_thin_phase4_smoke.py docs/ORIENTATION.md docs/HANDOFF.md +git commit -m "docs: thin phase 4 complete; refresh ORIENTATION + HANDOFF" +``` + +--- + +## Verification gate + +Before this plan is considered done: + +- [ ] `pytest -q tests/` green (no skips other than `requires_postgres` when run without `TEST_DATABASE_URL`) +- [ ] `alembic upgrade head` clean from a fresh DB +- [ ] `alembic downgrade base` then `alembic upgrade head` clean +- [ ] `GET /api/recipes` returns 30 seeded recipes +- [ ] `POST /api/admin/recipes/resolve-ingredient` with `"1 lb chicken thighs"` returns "Chicken Thighs, Boneless Skinless" as the top candidate +- [ ] After a successful scrape, `ingredient_grocery_match` table has rows with `source='auto'` and `confidence >= 0.75` +- [ ] Manually pinning a match via the API survives subsequent matcher runs (still `source='manual'`) +- [ ] `docker compose --env-file .env.test up` starts cleanly with all services green + +--- + +## Out of scope (Phase 9 plan, written separately) + +- The planner algorithm (filter, score, set-select, generate endpoint) +- Per-meal cost estimation against `ingredient_grocery_match` +- Recipe ingestion from external sources (Spoonacular, TheMealDB, scrape) +- Frontend UI for recipe/ingredient management +- Per-member never-suggest preferences (household-level only here)