Public Access
8-task TDD plan covering planner package (config, types, cost, filter, score, select, generate orchestrator), generate/regenerate/ get endpoints, and end-to-end smoke test against seeded data. Builds on the thin phase 4 plan; uses NeverSuggest for blocklists, ingredient_grocery_match for sale-aware cost estimation, top-K=20 set enumeration with diversity penalty for protein/cuisine repeats. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1979 lines
62 KiB
Markdown
1979 lines
62 KiB
Markdown
# Phase 9 — Meal-Planner Algorithm 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:** Generate a 3-dinner weekly meal plan via `POST /api/admin/meal-plans/generate`. Inputs: this week's `grocery_item` sale data, the family's recipes, blocklists, calorie target, and pantry. Output: a persisted `MealPlan` with 3 `MealPlanItem` rows ready for the existing email-approval flow.
|
||
|
||
**Architecture:** Two-stage filter-then-rank.
|
||
1. **Filter** removes recipes violating any of the 6 hard constraints (NeverSuggest, recency, calories, time, cost).
|
||
2. **Score** assigns each surviving recipe an individual score from 5 weighted signals (savings $, sale coverage %, pantry hit %, time bonus, recency bonus).
|
||
3. **Select** ranks the feasible set by individual score, takes top K=20, enumerates all C(20, 3) = 1,140 three-recipe combinations, and picks the combination with the highest combined score after a diversity penalty (protein/cuisine repeats).
|
||
|
||
Each stage is its own module with its own unit tests. The orchestrator wires them together and persists the result.
|
||
|
||
**Tech Stack:** FastAPI, SQLAlchemy 2.0, Pydantic v2, PostgreSQL 15, pytest. Pure-Python algorithm — no new dependencies.
|
||
|
||
**Spec reference:** `docs/specs/2026-05-05-meal-planner-algorithm-design.md` §2, §5.
|
||
|
||
**Depends on:** `docs/superpowers/plans/2026-05-05-thin-phase-4-recipe-engine.md` (must be complete first — Phase 9 reads `ingredient_grocery_match` and the seeded recipes).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New files:**
|
||
- `backend/app/services/planner/__init__.py` — empty marker
|
||
- `backend/app/services/planner/config.py` — weights, thresholds, K
|
||
- `backend/app/services/planner/types.py` — dataclasses for ScoredRecipe, FilterResult, GenerationResult
|
||
- `backend/app/services/planner/filter.py` — hard-constraint filter
|
||
- `backend/app/services/planner/score.py` — individual recipe scoring
|
||
- `backend/app/services/planner/select.py` — top-K set enumeration with diversity penalty
|
||
- `backend/app/services/planner/cost.py` — recipe cost + savings estimation against ingredient_grocery_match
|
||
- `backend/app/services/planner/generate.py` — orchestrator
|
||
- `backend/app/schemas/meal_plan_generation.py` — Pydantic GenerateRequest/Response, RegenerateRequest, debug payload
|
||
- `backend/app/api/meal_plans.py` — generate / regenerate / get endpoints (replaces or augments existing meals.py read paths)
|
||
- `backend/tests/test_planner_cost.py`
|
||
- `backend/tests/test_planner_filter.py`
|
||
- `backend/tests/test_planner_score.py`
|
||
- `backend/tests/test_planner_select.py`
|
||
- `backend/tests/test_planner_generate.py`
|
||
- `backend/tests/test_meal_plan_generate_api.py`
|
||
|
||
**Modified files:**
|
||
- `backend/app/main.py` — wire new router
|
||
- `docs/ORIENTATION.md` — phase 9 status
|
||
- `docs/HANDOFF.md` — phase 9 status
|
||
|
||
---
|
||
|
||
## Task 1: Planner config + types
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/planner/__init__.py`
|
||
- Create: `backend/app/services/planner/config.py`
|
||
- Create: `backend/app/services/planner/types.py`
|
||
|
||
- [ ] **Step 1: Create the package marker**
|
||
|
||
Create `backend/app/services/planner/__init__.py` with a single newline (empty).
|
||
|
||
- [ ] **Step 2: Write the config module**
|
||
|
||
Create `backend/app/services/planner/config.py`:
|
||
|
||
```python
|
||
"""Planner constants. Tune here without touching algorithm code."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlannerConfig:
|
||
# Hard constraints
|
||
recency_weeks: int = 4 # constraint #3: no repeat within N weeks
|
||
calorie_tolerance_pct: int = 20 # constraint #4: ±X% of family.calorie_target
|
||
max_total_minutes: int = 45 # constraint #5: prep + cook
|
||
max_meal_cost: float = 30.00 # constraint #6: dollars per meal
|
||
|
||
# Scoring weights (must sum to 1.0)
|
||
w_savings: float = 0.30
|
||
w_coverage: float = 0.25
|
||
w_pantry: float = 0.10
|
||
w_time: float = 0.15
|
||
w_recency: float = 0.20
|
||
|
||
# Time bonus boundaries
|
||
time_ideal_minutes: int = 25 # full bonus at <= this
|
||
time_full_minutes: int = 45 # zero bonus at this; matches max_total_minutes
|
||
|
||
# Recency bonus boundary
|
||
recency_full_weeks: int = 12 # full bonus when last cooked >= this many weeks ago
|
||
|
||
# Set selection
|
||
top_k: int = 20 # how many feasible recipes to enumerate over
|
||
set_size: int = 3 # 3 dinners/week
|
||
p_protein: float = 0.15 # diversity penalty per shared-protein pair
|
||
p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair
|
||
|
||
def validate(self) -> None:
|
||
total = self.w_savings + self.w_coverage + self.w_pantry + self.w_time + self.w_recency
|
||
if abs(total - 1.0) > 1e-6:
|
||
raise ValueError(f"weights must sum to 1.0, got {total}")
|
||
|
||
|
||
DEFAULT = PlannerConfig()
|
||
DEFAULT.validate()
|
||
```
|
||
|
||
- [ ] **Step 3: Write the types module**
|
||
|
||
Create `backend/app/services/planner/types.py`:
|
||
|
||
```python
|
||
"""Shared planner data structures."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from decimal import Decimal
|
||
from typing import List, Optional
|
||
from uuid import UUID
|
||
|
||
|
||
@dataclass
|
||
class IngredientCost:
|
||
ingredient_id: UUID
|
||
qty: float
|
||
unit: Optional[str]
|
||
grocery_item_id: Optional[UUID] # None when no match found
|
||
grocery_item_name: Optional[str]
|
||
current_price: Optional[Decimal]
|
||
regular_price: Optional[Decimal]
|
||
is_on_sale: bool
|
||
estimated_cost: Decimal # current_price scaled to recipe qty (best-effort)
|
||
estimated_savings: Decimal # max(regular - current, 0) scaled to qty
|
||
matched: bool # False if no ingredient_grocery_match row found
|
||
|
||
|
||
@dataclass
|
||
class RecipeCost:
|
||
recipe_id: UUID
|
||
total_cost: Decimal
|
||
total_savings: Decimal
|
||
sale_ingredient_count: int
|
||
matched_ingredient_count: int
|
||
total_ingredient_count: int
|
||
pantry_hit_count: int
|
||
line_items: List[IngredientCost] = field(default_factory=list)
|
||
|
||
@property
|
||
def sale_coverage_pct(self) -> float:
|
||
if self.total_ingredient_count == 0:
|
||
return 0.0
|
||
return self.sale_ingredient_count / self.total_ingredient_count
|
||
|
||
@property
|
||
def pantry_hit_pct(self) -> float:
|
||
if self.total_ingredient_count == 0:
|
||
return 0.0
|
||
return self.pantry_hit_count / self.total_ingredient_count
|
||
|
||
|
||
@dataclass
|
||
class FilterResult:
|
||
feasible_recipe_ids: List[UUID]
|
||
rejected: dict # recipe_id -> reason string
|
||
|
||
|
||
@dataclass
|
||
class ScoredRecipe:
|
||
recipe_id: UUID
|
||
score: float
|
||
components: dict # signal name -> raw value (for debug payload)
|
||
cost: RecipeCost
|
||
protein: Optional[str]
|
||
cuisine: Optional[str]
|
||
|
||
|
||
@dataclass
|
||
class GenerationResult:
|
||
meal_plan_id: UUID
|
||
selected: List[ScoredRecipe]
|
||
feasible_count: int
|
||
rejected_summary: dict # reason -> count
|
||
set_score: float
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke test imports**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend python -c "
|
||
from app.services.planner.config import DEFAULT
|
||
from app.services.planner.types import RecipeCost, ScoredRecipe
|
||
print('weights ok')
|
||
"
|
||
```
|
||
|
||
Expected: `weights ok`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/planner/__init__.py backend/app/services/planner/config.py backend/app/services/planner/types.py
|
||
git commit -m "feat: planner config (weights, thresholds, K) and shared types"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Cost + savings estimator
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/planner/cost.py`
|
||
- Test: `backend/tests/test_planner_cost.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `backend/tests/test_planner_cost.py`:
|
||
|
||
```python
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
|
||
from app.services.planner.cost import compute_recipe_cost
|
||
from app.services.planner.types import RecipeCost
|
||
|
||
|
||
def _ingredient(ing_id, qty=1.0, unit=None):
|
||
return {"ingredient_id": ing_id, "qty": qty, "unit": unit}
|
||
|
||
|
||
def _match(grocery_id, name, current, regular, is_on_sale, confidence=0.9):
|
||
return {
|
||
"grocery_item_id": grocery_id,
|
||
"grocery_item_name": name,
|
||
"current_price": Decimal(str(current)),
|
||
"regular_price": Decimal(str(regular)),
|
||
"is_on_sale": is_on_sale,
|
||
"confidence": Decimal(str(confidence)),
|
||
}
|
||
|
||
|
||
def test_compute_recipe_cost_sums_ingredient_costs():
|
||
recipe_id = uuid4()
|
||
i1, i2 = uuid4(), uuid4()
|
||
g1, g2 = uuid4(), uuid4()
|
||
matches = {
|
||
i1: [_match(g1, "Chicken", current=4.00, regular=5.00, is_on_sale=True)],
|
||
i2: [_match(g2, "Olive Oil", current=8.00, regular=8.00, is_on_sale=False)],
|
||
}
|
||
pantry_ingredient_ids = set()
|
||
|
||
result = compute_recipe_cost(
|
||
recipe_id=recipe_id,
|
||
ingredients=[_ingredient(i1, qty=2.0), _ingredient(i2, qty=1.0)],
|
||
match_index=matches,
|
||
pantry_ingredient_ids=pantry_ingredient_ids,
|
||
)
|
||
|
||
assert isinstance(result, RecipeCost)
|
||
assert result.recipe_id == recipe_id
|
||
assert result.total_ingredient_count == 2
|
||
assert result.matched_ingredient_count == 2
|
||
assert result.sale_ingredient_count == 1
|
||
assert result.total_cost == Decimal("16.00") # 2*4 + 1*8
|
||
assert result.total_savings == Decimal("2.00") # 2*(5-4)
|
||
|
||
|
||
def test_unmatched_ingredient_zero_cost_and_savings():
|
||
recipe_id = uuid4()
|
||
i1 = uuid4()
|
||
result = compute_recipe_cost(
|
||
recipe_id=recipe_id,
|
||
ingredients=[_ingredient(i1, qty=1.0)],
|
||
match_index={},
|
||
pantry_ingredient_ids=set(),
|
||
)
|
||
assert result.matched_ingredient_count == 0
|
||
assert result.total_cost == Decimal("0.00")
|
||
assert result.total_savings == Decimal("0.00")
|
||
|
||
|
||
def test_pantry_hits_counted():
|
||
recipe_id = uuid4()
|
||
i1, i2 = uuid4(), uuid4()
|
||
g1, g2 = uuid4(), uuid4()
|
||
matches = {
|
||
i1: [_match(g1, "X", current=2.00, regular=2.00, is_on_sale=False)],
|
||
i2: [_match(g2, "Y", current=3.00, regular=3.00, is_on_sale=False)],
|
||
}
|
||
result = compute_recipe_cost(
|
||
recipe_id=recipe_id,
|
||
ingredients=[_ingredient(i1), _ingredient(i2)],
|
||
match_index=matches,
|
||
pantry_ingredient_ids={i1},
|
||
)
|
||
assert result.pantry_hit_count == 1
|
||
assert result.pantry_hit_pct == 0.5
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_cost.py -v
|
||
```
|
||
|
||
Expected: ImportError on `app.services.planner.cost`.
|
||
|
||
- [ ] **Step 3: Implement the cost estimator**
|
||
|
||
Create `backend/app/services/planner/cost.py`:
|
||
|
||
```python
|
||
"""Compute per-recipe cost and savings against ingredient_grocery_match.
|
||
|
||
Inputs:
|
||
ingredients: list[dict] from recipe.ingredients JSONB
|
||
match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC
|
||
pantry_ingredient_ids: set of ingredient_ids in home_pantry
|
||
|
||
Strategy:
|
||
For each ingredient in the recipe, take the top-confidence match
|
||
(or skip if none). Cost = current_price * qty (best-effort scaling
|
||
that ignores unit conversion — see Limitations below).
|
||
Savings = max(regular - current, 0) * qty.
|
||
|
||
Limitations:
|
||
Unit conversion (lb vs oz, cup vs ml) is NOT implemented in this
|
||
pass. The qty multiplier is treated as dimensionless. This produces
|
||
a biased-but-monotonic ranking signal: recipes that use more of an
|
||
expensive ingredient still rank as more expensive, which is what
|
||
the planner needs. Real dollar accuracy can come later.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
from typing import Dict, Iterable, List, Set
|
||
from uuid import UUID
|
||
|
||
from app.services.planner.types import IngredientCost, RecipeCost
|
||
|
||
|
||
def _decimal(v) -> Decimal:
|
||
if v is None:
|
||
return Decimal("0")
|
||
return v if isinstance(v, Decimal) else Decimal(str(v))
|
||
|
||
|
||
def _scale(price: Decimal, qty: float) -> Decimal:
|
||
return (price * Decimal(str(qty))).quantize(Decimal("0.01"))
|
||
|
||
|
||
def compute_recipe_cost(
|
||
*,
|
||
recipe_id: UUID,
|
||
ingredients: Iterable[dict],
|
||
match_index: Dict[UUID, List[dict]],
|
||
pantry_ingredient_ids: Set[UUID],
|
||
) -> RecipeCost:
|
||
line_items: List[IngredientCost] = []
|
||
total_cost = Decimal("0.00")
|
||
total_savings = Decimal("0.00")
|
||
sale_count = 0
|
||
matched_count = 0
|
||
pantry_hits = 0
|
||
total = 0
|
||
|
||
for raw in ingredients:
|
||
total += 1
|
||
ing_id = raw["ingredient_id"]
|
||
if isinstance(ing_id, str):
|
||
ing_id = UUID(ing_id)
|
||
qty = float(raw.get("qty") or 1.0)
|
||
unit = raw.get("unit")
|
||
|
||
if ing_id in pantry_ingredient_ids:
|
||
pantry_hits += 1
|
||
|
||
candidates = match_index.get(ing_id) or []
|
||
if not candidates:
|
||
line_items.append(
|
||
IngredientCost(
|
||
ingredient_id=ing_id,
|
||
qty=qty,
|
||
unit=unit,
|
||
grocery_item_id=None,
|
||
grocery_item_name=None,
|
||
current_price=None,
|
||
regular_price=None,
|
||
is_on_sale=False,
|
||
estimated_cost=Decimal("0.00"),
|
||
estimated_savings=Decimal("0.00"),
|
||
matched=False,
|
||
)
|
||
)
|
||
continue
|
||
|
||
best = candidates[0]
|
||
current = _decimal(best.get("current_price"))
|
||
regular = _decimal(best.get("regular_price"))
|
||
is_on_sale = bool(best.get("is_on_sale"))
|
||
line_cost = _scale(current, qty)
|
||
line_savings = _scale(max(regular - current, Decimal("0")), qty)
|
||
|
||
matched_count += 1
|
||
if is_on_sale:
|
||
sale_count += 1
|
||
total_cost += line_cost
|
||
total_savings += line_savings
|
||
|
||
line_items.append(
|
||
IngredientCost(
|
||
ingredient_id=ing_id,
|
||
qty=qty,
|
||
unit=unit,
|
||
grocery_item_id=best.get("grocery_item_id"),
|
||
grocery_item_name=best.get("grocery_item_name"),
|
||
current_price=current,
|
||
regular_price=regular,
|
||
is_on_sale=is_on_sale,
|
||
estimated_cost=line_cost,
|
||
estimated_savings=line_savings,
|
||
matched=True,
|
||
)
|
||
)
|
||
|
||
return RecipeCost(
|
||
recipe_id=recipe_id,
|
||
total_cost=total_cost.quantize(Decimal("0.01")),
|
||
total_savings=total_savings.quantize(Decimal("0.01")),
|
||
sale_ingredient_count=sale_count,
|
||
matched_ingredient_count=matched_count,
|
||
total_ingredient_count=total,
|
||
pantry_hit_count=pantry_hits,
|
||
line_items=line_items,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_cost.py -v
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/planner/cost.py backend/tests/test_planner_cost.py
|
||
git commit -m "feat: planner cost+savings estimator against ingredient_grocery_match"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Hard-constraint filter
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/planner/filter.py`
|
||
- Test: `backend/tests/test_planner_filter.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `backend/tests/test_planner_filter.py`:
|
||
|
||
```python
|
||
from datetime import date, datetime, timezone
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
|
||
from app.services.planner.config import PlannerConfig
|
||
from app.services.planner.filter import filter_recipes
|
||
from app.services.planner.types import RecipeCost
|
||
|
||
|
||
_CFG = PlannerConfig()
|
||
|
||
|
||
def _recipe(
|
||
recipe_id=None,
|
||
name="r",
|
||
prep=10,
|
||
cook=20,
|
||
calories=2000,
|
||
protein="chicken",
|
||
cuisine_tags=("american",),
|
||
):
|
||
return {
|
||
"id": recipe_id or uuid4(),
|
||
"name": name,
|
||
"prep_time_minutes": prep,
|
||
"cook_time_minutes": cook,
|
||
"calories_per_serving": calories,
|
||
"protein_type": protein,
|
||
"cuisine_tags": list(cuisine_tags),
|
||
}
|
||
|
||
|
||
def _cost(recipe_id, total_cost):
|
||
return RecipeCost(
|
||
recipe_id=recipe_id,
|
||
total_cost=Decimal(str(total_cost)),
|
||
total_savings=Decimal("0"),
|
||
sale_ingredient_count=0,
|
||
matched_ingredient_count=1,
|
||
total_ingredient_count=1,
|
||
pantry_hit_count=0,
|
||
line_items=[],
|
||
)
|
||
|
||
|
||
def test_filter_blocks_by_never_suggest_ingredient():
|
||
r1 = _recipe(name="has-mushrooms")
|
||
r2 = _recipe(name="clean")
|
||
blocked_ingredient = uuid4()
|
||
recipe_ingredient_ids = {r1["id"]: {blocked_ingredient}, r2["id"]: {uuid4()}}
|
||
|
||
result = filter_recipes(
|
||
recipes=[r1, r2],
|
||
recipe_ingredient_ids=recipe_ingredient_ids,
|
||
recipe_costs={r1["id"]: _cost(r1["id"], 10), r2["id"]: _cost(r2["id"], 10)},
|
||
blocked_ingredient_ids={blocked_ingredient},
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert r2["id"] in result.feasible_recipe_ids
|
||
assert r1["id"] not in result.feasible_recipe_ids
|
||
assert "blocked_ingredient" in result.rejected[r1["id"]]
|
||
|
||
|
||
def test_filter_blocks_by_never_suggest_recipe():
|
||
r1 = _recipe()
|
||
result = filter_recipes(
|
||
recipes=[r1],
|
||
recipe_ingredient_ids={r1["id"]: set()},
|
||
recipe_costs={r1["id"]: _cost(r1["id"], 10)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids={r1["id"]},
|
||
last_cooked_at={},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert r1["id"] not in result.feasible_recipe_ids
|
||
assert "blocked_recipe" in result.rejected[r1["id"]]
|
||
|
||
|
||
def test_filter_blocks_by_recency():
|
||
r1 = _recipe()
|
||
last_week = date(2026, 5, 5) - __import__("datetime").timedelta(days=7)
|
||
result = filter_recipes(
|
||
recipes=[r1],
|
||
recipe_ingredient_ids={r1["id"]: set()},
|
||
recipe_costs={r1["id"]: _cost(r1["id"], 10)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={r1["id"]: last_week},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert "recency" in result.rejected[r1["id"]]
|
||
|
||
|
||
def test_filter_blocks_by_calories():
|
||
over = _recipe(calories=3500) # 75% over a 2000 target
|
||
under = _recipe(calories=2200)
|
||
result = filter_recipes(
|
||
recipes=[over, under],
|
||
recipe_ingredient_ids={over["id"]: set(), under["id"]: set()},
|
||
recipe_costs={over["id"]: _cost(over["id"], 10), under["id"]: _cost(under["id"], 10)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert under["id"] in result.feasible_recipe_ids
|
||
assert "calories" in result.rejected[over["id"]]
|
||
|
||
|
||
def test_filter_blocks_by_time():
|
||
slow = _recipe(prep=30, cook=20) # 50 min total
|
||
fast = _recipe(prep=10, cook=20)
|
||
result = filter_recipes(
|
||
recipes=[slow, fast],
|
||
recipe_ingredient_ids={slow["id"]: set(), fast["id"]: set()},
|
||
recipe_costs={slow["id"]: _cost(slow["id"], 10), fast["id"]: _cost(fast["id"], 10)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert "time" in result.rejected[slow["id"]]
|
||
assert fast["id"] in result.feasible_recipe_ids
|
||
|
||
|
||
def test_filter_blocks_by_cost():
|
||
pricey = _recipe()
|
||
cheap = _recipe()
|
||
result = filter_recipes(
|
||
recipes=[pricey, cheap],
|
||
recipe_ingredient_ids={pricey["id"]: set(), cheap["id"]: set()},
|
||
recipe_costs={pricey["id"]: _cost(pricey["id"], 50), cheap["id"]: _cost(cheap["id"], 25)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert "cost" in result.rejected[pricey["id"]]
|
||
assert cheap["id"] in result.feasible_recipe_ids
|
||
|
||
|
||
def test_filter_passes_when_calorie_target_is_none():
|
||
r = _recipe(calories=9999) # absurd, but no target → can't enforce
|
||
result = filter_recipes(
|
||
recipes=[r],
|
||
recipe_ingredient_ids={r["id"]: set()},
|
||
recipe_costs={r["id"]: _cost(r["id"], 10)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={},
|
||
family_calorie_target=None,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert r["id"] in result.feasible_recipe_ids
|
||
|
||
|
||
def test_filter_blocks_recipe_with_null_calories_when_target_set():
|
||
r = _recipe(calories=None)
|
||
result = filter_recipes(
|
||
recipes=[r],
|
||
recipe_ingredient_ids={r["id"]: set()},
|
||
recipe_costs={r["id"]: _cost(r["id"], 10)},
|
||
blocked_ingredient_ids=set(),
|
||
blocked_recipe_ids=set(),
|
||
last_cooked_at={},
|
||
family_calorie_target=2000,
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert "calories" in result.rejected[r["id"]]
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_filter.py -v
|
||
```
|
||
|
||
Expected: ImportError on `app.services.planner.filter`.
|
||
|
||
- [ ] **Step 3: Implement filter**
|
||
|
||
Create `backend/app/services/planner/filter.py`:
|
||
|
||
```python
|
||
"""Hard-constraint filter (constraints #1–#6 from the spec)."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, timedelta
|
||
from typing import Dict, Iterable, List, Optional, Set
|
||
from uuid import UUID
|
||
|
||
from app.services.planner.config import PlannerConfig
|
||
from app.services.planner.types import FilterResult, RecipeCost
|
||
|
||
|
||
def _calorie_band(target: int, pct: int) -> tuple[int, int]:
|
||
delta = target * pct / 100.0
|
||
return (int(target - delta), int(target + delta))
|
||
|
||
|
||
def filter_recipes(
|
||
*,
|
||
recipes: Iterable[dict],
|
||
recipe_ingredient_ids: Dict[UUID, Set[UUID]],
|
||
recipe_costs: Dict[UUID, RecipeCost],
|
||
blocked_ingredient_ids: Set[UUID],
|
||
blocked_recipe_ids: Set[UUID],
|
||
last_cooked_at: Dict[UUID, date],
|
||
family_calorie_target: Optional[int],
|
||
config: PlannerConfig,
|
||
today: date,
|
||
) -> FilterResult:
|
||
feasible: List[UUID] = []
|
||
rejected: Dict[UUID, str] = {}
|
||
|
||
cal_band: Optional[tuple[int, int]] = None
|
||
if family_calorie_target is not None:
|
||
cal_band = _calorie_band(family_calorie_target, config.calorie_tolerance_pct)
|
||
|
||
recency_cutoff = today - timedelta(weeks=config.recency_weeks)
|
||
|
||
for r in recipes:
|
||
rid = r["id"]
|
||
if isinstance(rid, str):
|
||
rid = UUID(rid)
|
||
|
||
# #2: per-recipe blocklist
|
||
if rid in blocked_recipe_ids:
|
||
rejected[rid] = "blocked_recipe"
|
||
continue
|
||
|
||
# #1: ingredient blocklist intersection
|
||
if blocked_ingredient_ids & recipe_ingredient_ids.get(rid, set()):
|
||
rejected[rid] = "blocked_ingredient"
|
||
continue
|
||
|
||
# #3: recency
|
||
last = last_cooked_at.get(rid)
|
||
if last and last >= recency_cutoff:
|
||
rejected[rid] = "recency"
|
||
continue
|
||
|
||
# #4: calories
|
||
if cal_band is not None:
|
||
cps = r.get("calories_per_serving")
|
||
if cps is None:
|
||
rejected[rid] = "calories_missing"
|
||
continue
|
||
lo, hi = cal_band
|
||
if cps < lo or cps > hi:
|
||
rejected[rid] = "calories"
|
||
continue
|
||
|
||
# #5: time
|
||
prep = int(r.get("prep_time_minutes") or 0)
|
||
cook = int(r.get("cook_time_minutes") or 0)
|
||
if prep + cook > config.max_total_minutes:
|
||
rejected[rid] = "time"
|
||
continue
|
||
|
||
# #6: cost
|
||
cost = recipe_costs.get(rid)
|
||
if cost is None:
|
||
rejected[rid] = "no_cost"
|
||
continue
|
||
if float(cost.total_cost) > config.max_meal_cost:
|
||
rejected[rid] = "cost"
|
||
continue
|
||
|
||
feasible.append(rid)
|
||
|
||
return FilterResult(feasible_recipe_ids=feasible, rejected=rejected)
|
||
```
|
||
|
||
(Note: the test's `__import__("datetime")` workaround is unnecessary cleanup. The test should `import datetime` at the top instead. Implementer may clean up.)
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_filter.py -v
|
||
```
|
||
|
||
Expected: 8 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/planner/filter.py backend/tests/test_planner_filter.py
|
||
git commit -m "feat: planner hard-constraint filter for the 6 spec constraints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Individual scoring
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/planner/score.py`
|
||
- Test: `backend/tests/test_planner_score.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `backend/tests/test_planner_score.py`:
|
||
|
||
```python
|
||
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
from app.services.planner.config import PlannerConfig
|
||
from app.services.planner.score import (
|
||
score_recipes,
|
||
time_bonus,
|
||
recency_bonus,
|
||
normalize_savings,
|
||
)
|
||
from app.services.planner.types import RecipeCost
|
||
|
||
|
||
_CFG = PlannerConfig()
|
||
|
||
|
||
def test_time_bonus_capped_at_ideal():
|
||
assert time_bonus(20, _CFG) == 1.0
|
||
assert time_bonus(25, _CFG) == 1.0
|
||
|
||
|
||
def test_time_bonus_decays_to_zero_at_full():
|
||
assert time_bonus(45, _CFG) == 0.0
|
||
|
||
|
||
def test_time_bonus_linear_midpoint():
|
||
# 35 min is halfway between 25 and 45
|
||
assert abs(time_bonus(35, _CFG) - 0.5) < 1e-6
|
||
|
||
|
||
def test_recency_bonus_full_when_long_ago():
|
||
today = date(2026, 5, 5)
|
||
long_ago = today - timedelta(weeks=20)
|
||
assert recency_bonus(long_ago, today, _CFG) == 1.0
|
||
|
||
|
||
def test_recency_bonus_zero_when_just_eligible():
|
||
# right at recency_weeks boundary → 0
|
||
today = date(2026, 5, 5)
|
||
cutoff = today - timedelta(weeks=_CFG.recency_weeks)
|
||
assert recency_bonus(cutoff, today, _CFG) == 0.0
|
||
|
||
|
||
def test_recency_bonus_full_when_never_cooked():
|
||
assert recency_bonus(None, date(2026, 5, 5), _CFG) == 1.0
|
||
|
||
|
||
def test_normalize_savings_minmax():
|
||
out = normalize_savings([Decimal("0"), Decimal("5"), Decimal("10")])
|
||
assert out == [0.0, 0.5, 1.0]
|
||
|
||
|
||
def test_normalize_savings_uniform_returns_zeros():
|
||
out = normalize_savings([Decimal("3"), Decimal("3"), Decimal("3")])
|
||
assert out == [0.0, 0.0, 0.0]
|
||
|
||
|
||
def test_score_recipes_orders_by_combined_score():
|
||
recipes = [
|
||
{
|
||
"id": uuid4(),
|
||
"name": "low_savings",
|
||
"prep_time_minutes": 10,
|
||
"cook_time_minutes": 30,
|
||
"protein_type": "chicken",
|
||
"cuisine_tags": ["american"],
|
||
},
|
||
{
|
||
"id": uuid4(),
|
||
"name": "high_savings",
|
||
"prep_time_minutes": 10,
|
||
"cook_time_minutes": 15, # also lower time
|
||
"protein_type": "beef",
|
||
"cuisine_tags": ["mexican"],
|
||
},
|
||
]
|
||
costs = {
|
||
recipes[0]["id"]: RecipeCost(
|
||
recipe_id=recipes[0]["id"],
|
||
total_cost=Decimal("10"),
|
||
total_savings=Decimal("1"),
|
||
sale_ingredient_count=1,
|
||
matched_ingredient_count=4,
|
||
total_ingredient_count=4,
|
||
pantry_hit_count=0,
|
||
line_items=[],
|
||
),
|
||
recipes[1]["id"]: RecipeCost(
|
||
recipe_id=recipes[1]["id"],
|
||
total_cost=Decimal("12"),
|
||
total_savings=Decimal("8"),
|
||
sale_ingredient_count=3,
|
||
matched_ingredient_count=4,
|
||
total_ingredient_count=4,
|
||
pantry_hit_count=2,
|
||
line_items=[],
|
||
),
|
||
}
|
||
scored = score_recipes(
|
||
recipes=recipes,
|
||
recipe_costs=costs,
|
||
last_cooked_at={},
|
||
config=_CFG,
|
||
today=date(2026, 5, 5),
|
||
)
|
||
assert scored[0].recipe_id == recipes[1]["id"] # high_savings first
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_score.py -v
|
||
```
|
||
|
||
Expected: ImportError.
|
||
|
||
- [ ] **Step 3: Implement scoring**
|
||
|
||
Create `backend/app/services/planner/score.py`:
|
||
|
||
```python
|
||
"""Per-recipe scoring with the 5 weighted signals from the spec."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from typing import Dict, Iterable, List, Optional
|
||
from uuid import UUID
|
||
|
||
from app.services.planner.config import PlannerConfig
|
||
from app.services.planner.types import RecipeCost, ScoredRecipe
|
||
|
||
|
||
def time_bonus(total_minutes: int, config: PlannerConfig) -> float:
|
||
if total_minutes <= config.time_ideal_minutes:
|
||
return 1.0
|
||
if total_minutes >= config.time_full_minutes:
|
||
return 0.0
|
||
span = config.time_full_minutes - config.time_ideal_minutes
|
||
over = total_minutes - config.time_ideal_minutes
|
||
return max(0.0, min(1.0, 1.0 - over / span))
|
||
|
||
|
||
def recency_bonus(
|
||
last_cooked: Optional[date],
|
||
today: date,
|
||
config: PlannerConfig,
|
||
) -> float:
|
||
if last_cooked is None:
|
||
return 1.0
|
||
weeks_ago = (today - last_cooked).days / 7
|
||
if weeks_ago >= config.recency_full_weeks:
|
||
return 1.0
|
||
# Below recency_weeks the recipe wouldn't be in the feasible set, so we treat
|
||
# exactly recency_weeks as score 0 and recency_full_weeks as score 1.
|
||
if weeks_ago <= config.recency_weeks:
|
||
return 0.0
|
||
span = config.recency_full_weeks - config.recency_weeks
|
||
over = weeks_ago - config.recency_weeks
|
||
return max(0.0, min(1.0, over / span))
|
||
|
||
|
||
def normalize_savings(values: List[Decimal]) -> List[float]:
|
||
if not values:
|
||
return []
|
||
floats = [float(v) for v in values]
|
||
lo, hi = min(floats), max(floats)
|
||
if hi == lo:
|
||
return [0.0] * len(floats)
|
||
return [(v - lo) / (hi - lo) for v in floats]
|
||
|
||
|
||
def score_recipes(
|
||
*,
|
||
recipes: Iterable[dict],
|
||
recipe_costs: Dict[UUID, RecipeCost],
|
||
last_cooked_at: Dict[UUID, date],
|
||
config: PlannerConfig,
|
||
today: date,
|
||
) -> List[ScoredRecipe]:
|
||
"""Returns recipes scored DESC. Caller passes only the feasible set."""
|
||
materialized = list(recipes)
|
||
if not materialized:
|
||
return []
|
||
|
||
savings = [recipe_costs[r["id"]].total_savings for r in materialized]
|
||
norm_savings = normalize_savings(savings)
|
||
|
||
out: List[ScoredRecipe] = []
|
||
for r, ns in zip(materialized, norm_savings):
|
||
rid = r["id"]
|
||
if isinstance(rid, str):
|
||
rid = UUID(rid)
|
||
cost = recipe_costs[rid]
|
||
total_min = int(r.get("prep_time_minutes") or 0) + int(r.get("cook_time_minutes") or 0)
|
||
tb = time_bonus(total_min, config)
|
||
rb = recency_bonus(last_cooked_at.get(rid), today, config)
|
||
|
||
components = {
|
||
"savings_normalized": ns,
|
||
"sale_coverage_pct": cost.sale_coverage_pct,
|
||
"pantry_hit_pct": cost.pantry_hit_pct,
|
||
"time_bonus": tb,
|
||
"recency_bonus": rb,
|
||
"savings_dollars": float(cost.total_savings),
|
||
}
|
||
|
||
score = (
|
||
config.w_savings * ns
|
||
+ config.w_coverage * cost.sale_coverage_pct
|
||
+ config.w_pantry * cost.pantry_hit_pct
|
||
+ config.w_time * tb
|
||
+ config.w_recency * rb
|
||
)
|
||
|
||
cuisine_tags = r.get("cuisine_tags") or []
|
||
primary_cuisine = cuisine_tags[0] if cuisine_tags else None
|
||
|
||
out.append(
|
||
ScoredRecipe(
|
||
recipe_id=rid,
|
||
score=score,
|
||
components=components,
|
||
cost=cost,
|
||
protein=r.get("protein_type"),
|
||
cuisine=primary_cuisine,
|
||
)
|
||
)
|
||
|
||
out.sort(key=lambda s: s.score, reverse=True)
|
||
return out
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_score.py -v
|
||
```
|
||
|
||
Expected: 9 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/planner/score.py backend/tests/test_planner_score.py
|
||
git commit -m "feat: planner per-recipe scoring with 5 weighted signals"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Top-K set selection with diversity
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/planner/select.py`
|
||
- Test: `backend/tests/test_planner_select.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `backend/tests/test_planner_select.py`:
|
||
|
||
```python
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
from app.services.planner.config import PlannerConfig
|
||
from app.services.planner.select import select_set, set_diversity_penalty
|
||
from app.services.planner.types import RecipeCost, ScoredRecipe
|
||
|
||
|
||
_CFG = PlannerConfig()
|
||
|
||
|
||
def _mk(score, protein, cuisine):
|
||
rid = uuid4()
|
||
return ScoredRecipe(
|
||
recipe_id=rid,
|
||
score=score,
|
||
components={},
|
||
cost=RecipeCost(
|
||
recipe_id=rid,
|
||
total_cost=Decimal("10"),
|
||
total_savings=Decimal("1"),
|
||
sale_ingredient_count=1,
|
||
matched_ingredient_count=1,
|
||
total_ingredient_count=1,
|
||
pantry_hit_count=0,
|
||
line_items=[],
|
||
),
|
||
protein=protein,
|
||
cuisine=cuisine,
|
||
)
|
||
|
||
|
||
def test_diversity_penalty_zero_when_all_unique():
|
||
a = _mk(0.5, "chicken", "american")
|
||
b = _mk(0.5, "beef", "mexican")
|
||
c = _mk(0.5, "fish", "italian")
|
||
assert set_diversity_penalty([a, b, c], _CFG) == 0.0
|
||
|
||
|
||
def test_diversity_penalty_three_chickens():
|
||
a = _mk(0.5, "chicken", "american")
|
||
b = _mk(0.5, "chicken", "italian")
|
||
c = _mk(0.5, "chicken", "mexican")
|
||
# 3 protein pairs * 0.15 = 0.45, no cuisine pairs
|
||
p = set_diversity_penalty([a, b, c], _CFG)
|
||
assert abs(p - 0.45) < 1e-6
|
||
|
||
|
||
def test_select_set_picks_diverse_over_homogeneous():
|
||
"""Three chicken/american (each 0.95) lose to mixed (each 0.90)
|
||
once protein and cuisine penalties apply."""
|
||
high1 = _mk(0.95, "chicken", "american")
|
||
high2 = _mk(0.95, "chicken", "american")
|
||
high3 = _mk(0.95, "chicken", "american")
|
||
mix1 = _mk(0.90, "chicken", "american")
|
||
mix2 = _mk(0.90, "beef", "mexican")
|
||
mix3 = _mk(0.90, "fish", "italian")
|
||
|
||
chosen, set_score = select_set([high1, high2, high3, mix1, mix2, mix3], _CFG)
|
||
chosen_ids = {s.recipe_id for s in chosen}
|
||
assert chosen_ids == {mix1.recipe_id, mix2.recipe_id, mix3.recipe_id}
|
||
|
||
|
||
def test_select_set_handles_too_few():
|
||
a = _mk(0.5, "x", "y")
|
||
b = _mk(0.5, "x", "y")
|
||
chosen, _ = select_set([a, b], _CFG)
|
||
assert len(chosen) == 2 # less than set_size returns what we have
|
||
|
||
|
||
def test_select_set_empty_input():
|
||
chosen, score = select_set([], _CFG)
|
||
assert chosen == []
|
||
assert score == 0.0
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_select.py -v
|
||
```
|
||
|
||
Expected: ImportError.
|
||
|
||
- [ ] **Step 3: Implement set selection**
|
||
|
||
Create `backend/app/services/planner/select.py`:
|
||
|
||
```python
|
||
"""Top-K set enumeration with diversity penalty.
|
||
|
||
Takes the top K (=20) scored recipes, enumerates all C(K, set_size)
|
||
combinations, applies a pairwise diversity penalty for shared
|
||
protein and cuisine, and returns the highest-scoring combination.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from itertools import combinations
|
||
from typing import Iterable, List, Tuple
|
||
|
||
from app.services.planner.config import PlannerConfig
|
||
from app.services.planner.types import ScoredRecipe
|
||
|
||
|
||
def set_diversity_penalty(
|
||
chosen: List[ScoredRecipe],
|
||
config: PlannerConfig,
|
||
) -> float:
|
||
penalty = 0.0
|
||
for a, b in combinations(chosen, 2):
|
||
if a.protein and b.protein and a.protein == b.protein:
|
||
penalty += config.p_protein
|
||
if a.cuisine and b.cuisine and a.cuisine == b.cuisine:
|
||
penalty += config.p_cuisine
|
||
return penalty
|
||
|
||
|
||
def _set_score(chosen: List[ScoredRecipe], config: PlannerConfig) -> float:
|
||
return sum(s.score for s in chosen) - set_diversity_penalty(chosen, config)
|
||
|
||
|
||
def select_set(
|
||
scored: Iterable[ScoredRecipe],
|
||
config: PlannerConfig,
|
||
) -> Tuple[List[ScoredRecipe], float]:
|
||
pool = list(scored)
|
||
if not pool:
|
||
return [], 0.0
|
||
if len(pool) <= config.set_size:
|
||
return pool, _set_score(pool, config)
|
||
|
||
pool.sort(key=lambda s: s.score, reverse=True)
|
||
candidate_pool = pool[: config.top_k]
|
||
|
||
best: List[ScoredRecipe] = []
|
||
best_score = float("-inf")
|
||
for combo in combinations(candidate_pool, config.set_size):
|
||
s = _set_score(list(combo), config)
|
||
if s > best_score:
|
||
best_score = s
|
||
best = list(combo)
|
||
return best, best_score
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, expect pass**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_select.py -v
|
||
```
|
||
|
||
Expected: 5 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/planner/select.py backend/tests/test_planner_select.py
|
||
git commit -m "feat: planner top-K set enumeration with protein/cuisine diversity penalty"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Generation orchestrator
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/planner/generate.py`
|
||
- Test: `backend/tests/test_planner_generate.py`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Create `backend/tests/test_planner_generate.py`:
|
||
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from uuid import UUID, uuid4
|
||
|
||
import pytest
|
||
|
||
pytestmark = pytest.mark.requires_postgres
|
||
|
||
|
||
def test_generate_meal_plan_against_seeded_data():
|
||
"""Smoke test: with the seeded 30 recipes and a seeded grocery scrape,
|
||
generate produces a MealPlan with 3 items.
|
||
"""
|
||
from datetime import datetime, timezone
|
||
|
||
from app.database import SessionLocal
|
||
from app.models import (
|
||
FamilyProfile,
|
||
GroceryItem,
|
||
Ingredient,
|
||
IngredientGroceryMatch,
|
||
IngredientMatchSource,
|
||
MealPlan,
|
||
MealPlanItem,
|
||
)
|
||
from app.services.matcher import run_match_job
|
||
from app.services.planner.generate import generate_meal_plan
|
||
|
||
setup = SessionLocal()
|
||
try:
|
||
family = FamilyProfile(
|
||
id=uuid4(),
|
||
name="Generate Smoke Family",
|
||
household_size=4,
|
||
adult_count=2,
|
||
child_count=2,
|
||
calorie_target=2400,
|
||
)
|
||
setup.add(family)
|
||
setup.commit()
|
||
family_id = family.id
|
||
|
||
# Seed at least one grocery_item per ingredient that the recipes use
|
||
# so cost matching can find prices. Pick a handful to keep runtime low.
|
||
for ing in setup.query(Ingredient).limit(20).all():
|
||
setup.add(
|
||
GroceryItem(
|
||
id=uuid4(),
|
||
name=ing.name,
|
||
source="lucky_california",
|
||
external_id=f"ext-test-{ing.id}",
|
||
current_price=Decimal("3.99"),
|
||
regular_price=Decimal("4.99"),
|
||
is_on_sale=True,
|
||
scraped_at=datetime.now(timezone.utc),
|
||
)
|
||
)
|
||
setup.commit()
|
||
finally:
|
||
setup.close()
|
||
|
||
work = SessionLocal()
|
||
try:
|
||
run_match_job(work)
|
||
result = generate_meal_plan(work, family_id=family_id, week_start_date=date(2026, 5, 11))
|
||
assert result.meal_plan_id is not None
|
||
assert 1 <= len(result.selected) <= 3 # at least 1, at most 3
|
||
plan = work.query(MealPlan).filter(MealPlan.id == result.meal_plan_id).first()
|
||
assert plan is not None
|
||
items = work.query(MealPlanItem).filter(MealPlanItem.meal_plan_id == plan.id).all()
|
||
assert len(items) == len(result.selected)
|
||
finally:
|
||
cleanup = SessionLocal()
|
||
try:
|
||
cleanup.query(MealPlanItem).filter(
|
||
MealPlanItem.meal_plan_id.in_(
|
||
[r[0] for r in cleanup.query(MealPlan.id).filter(MealPlan.family_profile_id == family_id).all()]
|
||
)
|
||
).delete(synchronize_session=False)
|
||
cleanup.query(MealPlan).filter(MealPlan.family_profile_id == family_id).delete(synchronize_session=False)
|
||
cleanup.query(FamilyProfile).filter(FamilyProfile.id == family_id).delete(synchronize_session=False)
|
||
cleanup.query(GroceryItem).filter(GroceryItem.external_id.like("ext-test-%")).delete(synchronize_session=False)
|
||
cleanup.query(IngredientGroceryMatch).filter(
|
||
IngredientGroceryMatch.source == IngredientMatchSource.AUTO
|
||
).delete(synchronize_session=False)
|
||
cleanup.commit()
|
||
finally:
|
||
cleanup.close()
|
||
work.close()
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_generate.py -v
|
||
```
|
||
|
||
Expected: ImportError on `app.services.planner.generate`.
|
||
|
||
- [ ] **Step 3: Implement orchestrator**
|
||
|
||
Create `backend/app/services/planner/generate.py`:
|
||
|
||
```python
|
||
"""End-to-end planner orchestration: load → filter → score → select → persist."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, timezone
|
||
from decimal import Decimal
|
||
from typing import Dict, List, Optional, Set
|
||
from uuid import UUID
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import (
|
||
FamilyProfile,
|
||
GroceryItem,
|
||
HomePantry,
|
||
Ingredient,
|
||
IngredientGroceryMatch,
|
||
IngredientMatchSource,
|
||
MealPlan,
|
||
MealPlanItem,
|
||
MealPlanItemStatus,
|
||
MealPlanStatus,
|
||
MealType,
|
||
NeverSuggest,
|
||
Recipe,
|
||
)
|
||
from app.services.planner.config import DEFAULT, PlannerConfig
|
||
from app.services.planner.cost import compute_recipe_cost
|
||
from app.services.planner.filter import filter_recipes
|
||
from app.services.planner.score import score_recipes
|
||
from app.services.planner.select import select_set
|
||
from app.services.planner.types import GenerationResult
|
||
|
||
|
||
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
|
||
"""ingredient_id → list of match dicts ordered by confidence DESC."""
|
||
rows = (
|
||
db.query(IngredientGroceryMatch, GroceryItem)
|
||
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
|
||
.order_by(IngredientGroceryMatch.confidence.desc())
|
||
.all()
|
||
)
|
||
index: Dict[UUID, List[dict]] = {}
|
||
for match, grocery in rows:
|
||
index.setdefault(match.ingredient_id, []).append(
|
||
{
|
||
"grocery_item_id": grocery.id,
|
||
"grocery_item_name": grocery.name,
|
||
"current_price": grocery.current_price,
|
||
"regular_price": grocery.regular_price,
|
||
"is_on_sale": bool(grocery.is_on_sale),
|
||
"confidence": match.confidence,
|
||
}
|
||
)
|
||
return index
|
||
|
||
|
||
def _load_blocklists(
|
||
db: Session, family_id: UUID
|
||
) -> tuple[Set[UUID], Set[UUID]]:
|
||
blocked_ingredients: Set[UUID] = set()
|
||
blocked_recipes: Set[UUID] = set()
|
||
for row in db.query(NeverSuggest).filter(NeverSuggest.family_profile_id == family_id).all():
|
||
if row.ingredient_id is not None:
|
||
blocked_ingredients.add(row.ingredient_id)
|
||
if row.recipe_id is not None:
|
||
blocked_recipes.add(row.recipe_id)
|
||
return blocked_ingredients, blocked_recipes
|
||
|
||
|
||
def _load_pantry(db: Session, family_id: UUID) -> Set[UUID]:
|
||
return {
|
||
row.ingredient_id
|
||
for row in db.query(HomePantry).filter(HomePantry.family_profile_id == family_id).all()
|
||
if row.ingredient_id is not None
|
||
}
|
||
|
||
|
||
def _load_last_cooked(db: Session, family_id: UUID) -> Dict[UUID, date]:
|
||
rows = (
|
||
db.query(MealPlanItem, MealPlan)
|
||
.join(MealPlan, MealPlan.id == MealPlanItem.meal_plan_id)
|
||
.filter(MealPlan.family_profile_id == family_id)
|
||
.all()
|
||
)
|
||
last: Dict[UUID, date] = {}
|
||
for item, plan in rows:
|
||
if item.recipe_id is None:
|
||
continue
|
||
if item.recipe_id not in last or plan.week_start_date > last[item.recipe_id]:
|
||
last[item.recipe_id] = plan.week_start_date
|
||
return last
|
||
|
||
|
||
def generate_meal_plan(
|
||
db: Session,
|
||
*,
|
||
family_id: UUID,
|
||
week_start_date: date,
|
||
config: PlannerConfig = DEFAULT,
|
||
today: Optional[date] = None,
|
||
) -> GenerationResult:
|
||
today = today or date.today()
|
||
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first()
|
||
if family is None:
|
||
raise ValueError(f"family_profile {family_id} not found")
|
||
|
||
recipes = db.query(Recipe).all()
|
||
recipe_dicts = [
|
||
{
|
||
"id": r.id,
|
||
"name": r.name,
|
||
"prep_time_minutes": r.prep_time_minutes,
|
||
"cook_time_minutes": r.cook_time_minutes,
|
||
"calories_per_serving": r.calories_per_serving,
|
||
"protein_type": r.protein_type,
|
||
"cuisine_tags": list(r.cuisine_tags or []),
|
||
"ingredients": list(r.ingredients or []),
|
||
}
|
||
for r in recipes
|
||
]
|
||
|
||
recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {}
|
||
for r in recipe_dicts:
|
||
ids: Set[UUID] = set()
|
||
for line in r["ingredients"]:
|
||
ing_id = line.get("ingredient_id")
|
||
if isinstance(ing_id, str):
|
||
ing_id = UUID(ing_id)
|
||
if ing_id is not None:
|
||
ids.add(ing_id)
|
||
recipe_ingredient_ids[r["id"]] = ids
|
||
|
||
match_index = _load_match_index(db)
|
||
pantry_ids = _load_pantry(db, family_id)
|
||
blocked_ings, blocked_recipes = _load_blocklists(db, family_id)
|
||
last_cooked = _load_last_cooked(db, family_id)
|
||
|
||
recipe_costs = {
|
||
r["id"]: compute_recipe_cost(
|
||
recipe_id=r["id"],
|
||
ingredients=r["ingredients"],
|
||
match_index=match_index,
|
||
pantry_ingredient_ids=pantry_ids,
|
||
)
|
||
for r in recipe_dicts
|
||
}
|
||
|
||
filtered = filter_recipes(
|
||
recipes=recipe_dicts,
|
||
recipe_ingredient_ids=recipe_ingredient_ids,
|
||
recipe_costs=recipe_costs,
|
||
blocked_ingredient_ids=blocked_ings,
|
||
blocked_recipe_ids=blocked_recipes,
|
||
last_cooked_at=last_cooked,
|
||
family_calorie_target=family.calorie_target,
|
||
config=config,
|
||
today=today,
|
||
)
|
||
|
||
feasible_recipes = [r for r in recipe_dicts if r["id"] in filtered.feasible_recipe_ids]
|
||
scored = score_recipes(
|
||
recipes=feasible_recipes,
|
||
recipe_costs=recipe_costs,
|
||
last_cooked_at=last_cooked,
|
||
config=config,
|
||
today=today,
|
||
)
|
||
chosen, set_score = select_set(scored, config)
|
||
|
||
plan = MealPlan(
|
||
family_profile_id=family_id,
|
||
week_start_date=week_start_date,
|
||
status=MealPlanStatus.DRAFT,
|
||
total_estimated_cost=sum(
|
||
(s.cost.total_cost for s in chosen), Decimal("0.00")
|
||
),
|
||
)
|
||
db.add(plan)
|
||
db.flush()
|
||
|
||
for index, scored_recipe in enumerate(chosen):
|
||
item = MealPlanItem(
|
||
meal_plan_id=plan.id,
|
||
recipe_id=scored_recipe.recipe_id,
|
||
day_of_week=index + 1, # Mon=1, Tue=2, Wed=3 by default
|
||
meal_type=MealType.DINNER,
|
||
approval_status=MealPlanItemStatus.PENDING,
|
||
estimated_cost=scored_recipe.cost.total_cost,
|
||
)
|
||
db.add(item)
|
||
|
||
db.commit()
|
||
db.refresh(plan)
|
||
|
||
rejected_summary: Dict[str, int] = {}
|
||
for reason in filtered.rejected.values():
|
||
rejected_summary[reason] = rejected_summary.get(reason, 0) + 1
|
||
|
||
return GenerationResult(
|
||
meal_plan_id=plan.id,
|
||
selected=chosen,
|
||
feasible_count=len(filtered.feasible_recipe_ids),
|
||
rejected_summary=rejected_summary,
|
||
set_score=set_score,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run integration test, expect pass**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_planner_generate.py -v
|
||
```
|
||
|
||
Expected: 1 passed.
|
||
|
||
- [ ] **Step 5: Run all tests for regressions**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/ -v
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/planner/generate.py backend/tests/test_planner_generate.py
|
||
git commit -m "feat: planner orchestrator - load, filter, score, select, persist"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Generate API endpoint
|
||
|
||
**Files:**
|
||
- Create: `backend/app/schemas/meal_plan_generation.py`
|
||
- Create: `backend/app/api/meal_plans.py`
|
||
- Modify: `backend/app/main.py`
|
||
- Test: `backend/tests/test_meal_plan_generate_api.py`
|
||
|
||
- [ ] **Step 1: Write the schemas**
|
||
|
||
Create `backend/app/schemas/meal_plan_generation.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from typing import Dict, List, Optional
|
||
from uuid import UUID
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class GenerateRequest(BaseModel):
|
||
family_profile_id: UUID
|
||
week_start_date: date
|
||
|
||
|
||
class RegenerateRequest(BaseModel):
|
||
family_profile_id: UUID
|
||
week_start_date: date
|
||
exclude_recipe_ids: List[UUID] = Field(default_factory=list)
|
||
relax_time_max_minutes: Optional[int] = Field(default=None, ge=0)
|
||
relax_calorie_pct: Optional[int] = Field(default=None, ge=0, le=100)
|
||
relax_max_meal_cost: Optional[float] = Field(default=None, ge=0)
|
||
|
||
|
||
class GenerationItem(BaseModel):
|
||
recipe_id: UUID
|
||
day_of_week: int
|
||
estimated_cost: Decimal
|
||
score: float
|
||
components: Dict[str, float]
|
||
|
||
|
||
class GenerationDebug(BaseModel):
|
||
feasible_count: int
|
||
rejected_summary: Dict[str, int]
|
||
set_score: float
|
||
|
||
|
||
class GenerationResponse(BaseModel):
|
||
meal_plan_id: UUID
|
||
week_start_date: date
|
||
items: List[GenerationItem]
|
||
debug: GenerationDebug
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing API tests**
|
||
|
||
Create `backend/tests/test_meal_plan_generate_api.py`:
|
||
|
||
```python
|
||
from datetime import datetime, timezone
|
||
from decimal import Decimal
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
|
||
pytestmark = pytest.mark.requires_postgres
|
||
|
||
|
||
def _admin() -> dict:
|
||
return {"Authorization": "Bearer test-admin-token"}
|
||
|
||
|
||
@pytest.fixture
|
||
def family_with_groceries(db_session):
|
||
from app.models import FamilyProfile, GroceryItem, Ingredient
|
||
|
||
family = FamilyProfile(
|
||
id=uuid4(),
|
||
name="API Smoke Family",
|
||
household_size=4,
|
||
adult_count=2,
|
||
child_count=2,
|
||
calorie_target=2400,
|
||
)
|
||
db_session.add(family)
|
||
db_session.commit()
|
||
|
||
for ing in db_session.query(Ingredient).limit(20).all():
|
||
db_session.add(
|
||
GroceryItem(
|
||
id=uuid4(),
|
||
name=ing.name,
|
||
source="lucky_california",
|
||
external_id=f"ext-api-{ing.id}",
|
||
current_price=Decimal("3.99"),
|
||
regular_price=Decimal("4.99"),
|
||
is_on_sale=True,
|
||
scraped_at=datetime.now(timezone.utc),
|
||
)
|
||
)
|
||
db_session.commit()
|
||
|
||
from app.services.matcher import run_match_job
|
||
|
||
run_match_job(db_session)
|
||
return family
|
||
|
||
|
||
def test_generate_endpoint_returns_meal_plan(client, family_with_groceries):
|
||
body = {
|
||
"family_profile_id": str(family_with_groceries.id),
|
||
"week_start_date": "2026-05-11",
|
||
}
|
||
r = client.post("/api/admin/meal-plans/generate", json=body, headers=_admin())
|
||
assert r.status_code == 201, r.text
|
||
data = r.json()
|
||
assert "meal_plan_id" in data
|
||
assert 1 <= len(data["items"]) <= 3
|
||
assert "debug" in data
|
||
assert data["debug"]["feasible_count"] >= 1
|
||
|
||
|
||
def test_generate_endpoint_requires_admin_token(client, family_with_groceries):
|
||
body = {
|
||
"family_profile_id": str(family_with_groceries.id),
|
||
"week_start_date": "2026-05-18",
|
||
}
|
||
r = client.post("/api/admin/meal-plans/generate", json=body)
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_generate_endpoint_returns_404_for_unknown_family(client):
|
||
body = {
|
||
"family_profile_id": str(uuid4()),
|
||
"week_start_date": "2026-05-11",
|
||
}
|
||
r = client.post("/api/admin/meal-plans/generate", json=body, headers=_admin())
|
||
assert r.status_code == 404
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests, expect failure**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_meal_plan_generate_api.py -v
|
||
```
|
||
|
||
Expected: 404 — endpoint missing.
|
||
|
||
- [ ] **Step 4: Implement the router**
|
||
|
||
Create `backend/app/api/meal_plans.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import replace
|
||
from typing import List
|
||
from uuid import UUID
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.database import get_db
|
||
from app.models import FamilyProfile, MealPlan, MealPlanItem
|
||
from app.schemas.meal_plan_generation import (
|
||
GenerateRequest,
|
||
GenerationDebug,
|
||
GenerationItem,
|
||
GenerationResponse,
|
||
RegenerateRequest,
|
||
)
|
||
from app.security import require_admin
|
||
from app.services.planner.config import DEFAULT
|
||
from app.services.planner.generate import generate_meal_plan
|
||
from app.services.planner.types import GenerationResult
|
||
|
||
|
||
admin_router = APIRouter(
|
||
prefix="/api/admin/meal-plans",
|
||
tags=["meal-plans-admin"],
|
||
dependencies=[Depends(require_admin)],
|
||
)
|
||
public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
||
|
||
|
||
def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse:
|
||
item_payloads: List[GenerationItem] = []
|
||
score_by_recipe = {s.recipe_id: s for s in result.selected}
|
||
for it in items:
|
||
scored = score_by_recipe.get(it.recipe_id)
|
||
item_payloads.append(
|
||
GenerationItem(
|
||
recipe_id=it.recipe_id,
|
||
day_of_week=it.day_of_week,
|
||
estimated_cost=it.estimated_cost or 0,
|
||
score=scored.score if scored else 0.0,
|
||
components={k: float(v) for k, v in (scored.components.items() if scored else [])},
|
||
)
|
||
)
|
||
return GenerationResponse(
|
||
meal_plan_id=plan_id,
|
||
week_start_date=week_start,
|
||
items=item_payloads,
|
||
debug=GenerationDebug(
|
||
feasible_count=result.feasible_count,
|
||
rejected_summary=result.rejected_summary,
|
||
set_score=result.set_score,
|
||
),
|
||
)
|
||
|
||
|
||
@admin_router.post("/generate", response_model=GenerationResponse, status_code=status.HTTP_201_CREATED)
|
||
def generate(payload: GenerateRequest, db: Session = Depends(get_db)) -> GenerationResponse:
|
||
family = db.query(FamilyProfile).filter(FamilyProfile.id == payload.family_profile_id).first()
|
||
if family is None:
|
||
raise HTTPException(status_code=404, detail="family_profile not found")
|
||
try:
|
||
result = generate_meal_plan(
|
||
db,
|
||
family_id=payload.family_profile_id,
|
||
week_start_date=payload.week_start_date,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
items = (
|
||
db.query(MealPlanItem)
|
||
.filter(MealPlanItem.meal_plan_id == result.meal_plan_id)
|
||
.order_by(MealPlanItem.day_of_week)
|
||
.all()
|
||
)
|
||
return _to_response(payload.week_start_date, result.meal_plan_id, items, result)
|
||
|
||
|
||
@admin_router.post("/regenerate", response_model=GenerationResponse, status_code=status.HTTP_201_CREATED)
|
||
def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> GenerationResponse:
|
||
family = db.query(FamilyProfile).filter(FamilyProfile.id == payload.family_profile_id).first()
|
||
if family is None:
|
||
raise HTTPException(status_code=404, detail="family_profile not found")
|
||
|
||
config = DEFAULT
|
||
if payload.relax_time_max_minutes is not None:
|
||
config = replace(config, max_total_minutes=payload.relax_time_max_minutes)
|
||
if payload.relax_calorie_pct is not None:
|
||
config = replace(config, calorie_tolerance_pct=payload.relax_calorie_pct)
|
||
if payload.relax_max_meal_cost is not None:
|
||
config = replace(config, max_meal_cost=payload.relax_max_meal_cost)
|
||
|
||
if payload.exclude_recipe_ids:
|
||
# Add the excluded recipes to the family's NeverSuggest for THIS run only?
|
||
# Simpler: pass them through by extending blocked_recipe_ids inside generate.
|
||
# The current orchestrator doesn't accept exclude_recipe_ids; for v1 we
|
||
# delete any prior plan for the same week and rerun without exclusion
|
||
# support. The TODO is captured in §Open items; the endpoint accepts the
|
||
# field for forward compatibility.
|
||
pass
|
||
|
||
db.query(MealPlan).filter(
|
||
MealPlan.family_profile_id == payload.family_profile_id,
|
||
MealPlan.week_start_date == payload.week_start_date,
|
||
).delete(synchronize_session=False)
|
||
db.commit()
|
||
|
||
result = generate_meal_plan(
|
||
db,
|
||
family_id=payload.family_profile_id,
|
||
week_start_date=payload.week_start_date,
|
||
config=config,
|
||
)
|
||
items = (
|
||
db.query(MealPlanItem)
|
||
.filter(MealPlanItem.meal_plan_id == result.meal_plan_id)
|
||
.order_by(MealPlanItem.day_of_week)
|
||
.all()
|
||
)
|
||
return _to_response(payload.week_start_date, result.meal_plan_id, items, result)
|
||
|
||
|
||
@public_router.get("/{plan_id}", response_model=GenerationResponse)
|
||
def get_plan(plan_id: UUID, db: Session = Depends(get_db)) -> GenerationResponse:
|
||
plan = db.query(MealPlan).filter(MealPlan.id == plan_id).first()
|
||
if plan is None:
|
||
raise HTTPException(status_code=404, detail="meal_plan not found")
|
||
items = (
|
||
db.query(MealPlanItem)
|
||
.filter(MealPlanItem.meal_plan_id == plan.id)
|
||
.order_by(MealPlanItem.day_of_week)
|
||
.all()
|
||
)
|
||
# When read after the fact we don't have the GenerationResult, so the
|
||
# debug payload returns zeros and items have score=0. The generate
|
||
# response is still authoritative immediately after creation.
|
||
return GenerationResponse(
|
||
meal_plan_id=plan.id,
|
||
week_start_date=plan.week_start_date,
|
||
items=[
|
||
GenerationItem(
|
||
recipe_id=it.recipe_id,
|
||
day_of_week=it.day_of_week,
|
||
estimated_cost=it.estimated_cost or 0,
|
||
score=0.0,
|
||
components={},
|
||
)
|
||
for it in items
|
||
],
|
||
debug=GenerationDebug(feasible_count=0, rejected_summary={}, set_score=0.0),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Wire the routers**
|
||
|
||
In `backend/app/main.py`:
|
||
|
||
```python
|
||
from app.api import meal_plans as meal_plans_api
|
||
|
||
app.include_router(meal_plans_api.admin_router)
|
||
app.include_router(meal_plans_api.public_router)
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests, expect pass**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/test_meal_plan_generate_api.py -v
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/app/api/meal_plans.py backend/app/schemas/meal_plan_generation.py backend/app/main.py backend/tests/test_meal_plan_generate_api.py
|
||
git commit -m "feat: POST /api/admin/meal-plans/generate + regenerate + get endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Final regression sweep + docs refresh
|
||
|
||
**Files:**
|
||
- Modify: `docs/ORIENTATION.md`
|
||
- Modify: `docs/HANDOFF.md`
|
||
|
||
- [ ] **Step 1: Run full pytest + alembic round-trip**
|
||
|
||
```bash
|
||
docker compose --env-file .env.test exec backend pytest -q tests/ -v
|
||
docker compose --env-file .env.test exec backend alembic downgrade base
|
||
docker compose --env-file .env.test exec backend alembic upgrade head
|
||
docker compose --env-file .env.test exec backend pytest -q tests/ -v
|
||
```
|
||
|
||
Expected: all green both runs; alembic round-trip clean.
|
||
|
||
- [ ] **Step 2: Update ORIENTATION.md**
|
||
|
||
Open `docs/ORIENTATION.md`. In the phase status table:
|
||
|
||
```markdown
|
||
| 9 | Meal-planner generation algorithm | **Complete** — POST /api/admin/meal-plans/generate produces 3-dinner plans against seeded recipes + matched grocery prices. Filter, score (5 signals), top-K=20 set enumeration with diversity penalty. |
|
||
```
|
||
|
||
- [ ] **Step 3: Update HANDOFF.md**
|
||
|
||
In the "What is real (verified)" section, append:
|
||
|
||
```markdown
|
||
- Phase 9: meal-plan generation. POST /api/admin/meal-plans/generate runs the full filter→score→set-select pipeline against seeded recipes and produces a persisted MealPlan with 3 MealPlanItem dinners. Regenerate endpoint accepts relaxed constraint overrides for the same week (deletes prior plan first). Per-meal cost matched against ingredient_grocery_match using the top-confidence grocery row.
|
||
```
|
||
|
||
In the "What is stubbed or missing" section, remove the Phase 9 bullet. Note the carry-over:
|
||
|
||
```markdown
|
||
- regenerate endpoint accepts `exclude_recipe_ids` for forward compatibility but does not yet apply them; the orchestrator only honors the family's NeverSuggest blocklist. Tracked as a follow-up.
|
||
- GET /api/meal-plans/{id} returns persisted items but with empty score/components/debug — those are only available in the immediate `generate` response. Acceptable for the email-approval flow which uses the generate response directly.
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/ORIENTATION.md docs/HANDOFF.md
|
||
git commit -m "docs: phase 9 complete - planner algorithm shipped"
|
||
```
|
||
|
||
---
|
||
|
||
## Verification gate
|
||
|
||
- [ ] `pytest -q tests/` green (60+ tests, no failures)
|
||
- [ ] Alembic round-trip clean (downgrade base → upgrade head)
|
||
- [ ] `POST /api/admin/meal-plans/generate` returns 201 with 3 items against seeded data
|
||
- [ ] `POST /api/admin/meal-plans/regenerate` for the same `(family, week_start_date)` deletes prior plan and produces a new one
|
||
- [ ] `GET /api/meal-plans/{id}` returns 200 with items
|
||
- [ ] Hard-constraint enforcement verified by unit tests on every constraint
|
||
- [ ] Diversity penalty verified by unit test (mixed 0.90 wins over homogeneous 0.95)
|
||
- [ ] No regression in prior 50+ tests
|
||
|
||
---
|
||
|
||
## Open items (deferred, tracked here)
|
||
|
||
- `regenerate.exclude_recipe_ids` — accepted by the API for forward compat but not yet applied by the orchestrator. Add an `exclude_recipe_ids` parameter to `generate_meal_plan()` and merge it into `blocked_recipe_ids`. ~30-line follow-up.
|
||
- Unit conversion in cost estimation. Current pass treats `qty` as dimensionless. If sourcing real-dollar accuracy from external recipes, add a unit-conversion step (lb↔oz, cup↔ml, etc.) — see `docs/specs/2026-05-05-meal-planner-algorithm-design.md` §7.
|
||
- `GET /api/meal-plans/{id}` returns score/components as zeros. If the UI needs them after the generate response is gone, persist `set_score`, per-item `score`, and per-item `components` at MealPlanItem-create time. New columns; not in this plan.
|
||
- Per-member ingredient blocklists. Schema currently uses household-level NeverSuggest only.
|
||
- Tunable weights via admin UI. Today they live in `planner/config.py`.
|
||
|
||
---
|
||
|
||
## Out of scope (other phases)
|
||
|
||
- Phase 5 orchestration (chaining scrape → generate → email → vote → finalize)
|
||
- Phase 6 SendGrid implementation
|
||
- Phase 8 feedback UI
|
||
- Phase 10 image strategy
|
||
- Phase 11 polish (APScheduler, variety analytics, budget tracking)
|
||
- Frontend UI for `/api/admin/meal-plans/*` (separate spec; this plan ships API only)
|