Files
Meal-Planner/docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md
adminandClaude Opus 4.7 0f08966583 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) <noreply@anthropic.com>
2026-05-05 20:15:12 -07:00

79 KiB

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:

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:

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
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:

"""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
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
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
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:

    aliases = Column(ARRAY(Text), nullable=False, server_default="{}")
  • Step 2: Add calories_per_serving to Recipe

In the Recipe class, after spice_level:

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

class IngredientMatchSource(enum.Enum):
    AUTO = "auto"
    MANUAL = "manual"

Then append at the bottom of the file (after EmailLog):

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:

    matches = relationship("IngredientGroceryMatch", back_populates="ingredient", cascade="all, delete-orphan")

Then update IngredientGroceryMatch.ingredient to use back_populates="matches" instead of bare relationship:

    ingredient = relationship("Ingredient", back_populates="matches")
  • Step 5: Smoke-test imports
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
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:

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
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:

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
docker compose --env-file .env.test run --rm backend pytest -q tests/test_ingredient_schema.py -v

Expected: 2 passed.

  • Step 5: Commit
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:

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
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:

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
docker compose --env-file .env.test run --rm backend pytest -q tests/test_recipe_schema.py -v

Expected: 3 passed.

  • Step 5: Commit
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:

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
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:

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:

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
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
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:

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
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:

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:

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
docker compose --env-file .env.test exec backend pytest -q tests/test_recipe_api.py -v

Expected: 3 passed.

  • Step 6: Commit
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:

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
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:

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<qty>\d+(?:\.\d+)?(?:/\d+)?)\s*(?P<unit>[a-zA-Z]+)?\s+(?P<rest>.+)$"
)


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
docker compose --env-file .env.test exec backend pytest -q tests/test_resolve_ingredient.py -v

Expected: 2 passed.

  • Step 5: Commit
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:

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
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:

"""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
docker compose --env-file .env.test exec backend pytest -q tests/test_matcher.py -v

Expected: 3 passed.

  • Step 5: Commit
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:

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
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:

        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
docker compose --env-file .env.test exec backend pytest -q tests/ -v

Expected: 35+ passed (no regressions).

  • Step 5: Commit
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:

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
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:

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

@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:

app.include_router(ingredients_api._match_admin_router)
  • Step 4: Run tests
docker compose --env-file .env.test exec backend pytest -q tests/test_ingredient_api.py -v

Expected: 8 passed.

  • Step 5: Commit
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:

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
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:

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:

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
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
docker compose --env-file .env.test exec backend pytest -q tests/test_never_suggest_api.py -v

Expected: 3 passed.

  • Step 7: Commit
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:

"""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
docker compose --env-file .env.test exec backend alembic upgrade head

Expected: success.

  • Step 3: Verify ingredient rows landed
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
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
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:

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():

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():

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
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
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
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
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:

"""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
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:

| 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:

- 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:

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