Files
Meal-Planner/docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md
T
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

2526 lines
79 KiB
Markdown

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