# 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)