Public Access
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>
2526 lines
79 KiB
Markdown
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)
|