Files
Meal-Planner/docs/superpowers/plans/2026-05-05-phase-9-planner-algorithm.md
admin de51e2e8d3 docs: mark tunable planner weights and score persistence as complete
- implementation-plan.md: check Phase 9.2 tunable weights
- phase-9-planner-algorithm.md: mark tunable weights open item as done
- Note: score persistence was completed in earlier commit e22eae1
2026-05-25 16:04:39 -07:00

1977 lines
61 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
- `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.
- (DONE) Tunable weights via family-level planner_config JSONB, accessible through /api/profile/planner-config endpoints
---
## 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)