Files
Meal-Planner/backend/app/services/planner/generate.py
T
MealPlanner efd1fc695f feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)
User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'

The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.

Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
  denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.

Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.

Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
  and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
  _has_prior_active_soft_denial) — single source of truth for the
  deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
  (default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
  Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
  one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
  plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
  is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
  / Never again) for pending items. handleDeny is scope-aware;
  toast reflects promoted_to_permanent. window.confirm on
  'Never again' prevents accidental permanent blocks.

Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
  failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
  curls + email-render procedure + rollback.

Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
  380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md

Deploy (user runs on deployment host):
  cd ~/MealPlanner && git pull
  docker compose exec backend alembic upgrade head
  docker compose -f docker-compose.yml up -d --build backend frontend
2026-06-05 10:24:35 -07:00

275 lines
9.7 KiB
Python

"""End-to-end planner orchestration: load → filter → score → select → persist."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Dict, List, Optional, Set
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models import (
FamilyProfile,
GroceryItem,
HomePantry,
Ingredient,
IngredientGroceryMatch,
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, Ingredient)
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
.join(Ingredient, Ingredient.id == IngredientGroceryMatch.ingredient_id)
.order_by(IngredientGroceryMatch.confidence.desc())
.all()
)
index: Dict[UUID, List[dict]] = {}
for match, grocery, ingredient 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,
"ingredient_name": ingredient.name.lower(),
"grocery_unit": grocery.unit,
}
)
return index
def _load_blocklists(
db: Session, family_id: UUID
) -> tuple[Set[UUID], Set[UUID], Set[UUID]]:
"""Sprint 8: returns 3 sets of UUIDs.
- blocked_ingredients: ingredient-level NeverSuggest entries
- blocked_recipes: recipe-level NeverSuggest entries (permanent, no decay)
- soft_denied_recipes: meal_plan_item rows with approval_status='denied'
and denial_expires_at > now() (decaying in DENIAL_DECAY_DAYS; auto-
promoted to blocked_recipes on the 2nd denial in the window by the
/deny API path).
Both recipe sets are hard filters (user decision: "Hard filter. If it
is denied this week twice, it should be considered denied for good.").
"""
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)
soft_denied_recipes: Set[UUID] = set()
rows = (
db.query(MealPlanItem.recipe_id)
.join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
.filter(
MealPlan.family_profile_id == family_id,
MealPlanItem.approval_status == MealPlanItemStatus.denied,
MealPlanItem.denial_expires_at.isnot(None),
MealPlanItem.denial_expires_at > func.now(),
MealPlanItem.recipe_id.isnot(None),
)
.distinct()
.all()
)
for (rid,) in rows:
soft_denied_recipes.add(rid)
return blocked_ingredients, blocked_recipes, soft_denied_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,
exclude_recipe_ids: Optional[Set[UUID]] = 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")
# Merge family-level planner overrides if present
effective_config = config
if family.planner_config:
from app.services.planner.config import PlannerConfig
try:
effective_config = config.merge(family.planner_config)
except (ValueError, TypeError) as exc:
logger.warning("Invalid planner_config for family %s: %s", family_id, exc)
recipes = db.query(Recipe).all()
exclude_set = exclude_recipe_ids or set()
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 []),
"servings": r.servings or 4,
}
for r in recipes
if r.id not in exclude_set
]
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, soft_denied_recipes = _load_blocklists(db, family_id)
last_cooked = _load_last_cooked(db, family_id)
# Sprint 8: union the soft-denied set with the permanent blocklist
# so the filter treats them identically. The `rejected[rid]` reason
# is "blocked_recipe" for both — operators reading the planner's
# `rejected_summary` see a single bucket. The soft set is also
# passed in separately so the diagnostic label could be split
# later if needed.
all_blocked_recipes = blocked_recipes | soft_denied_recipes
recipe_costs = {
r["id"]: compute_recipe_cost(
recipe_id=r["id"],
ingredients=r["ingredients"],
match_index=match_index,
pantry_ingredient_ids=pantry_ids,
servings=r["servings"],
)
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=all_blocked_recipes,
last_cooked_at=last_cooked,
family_calorie_target=family.calorie_target,
config=effective_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=effective_config,
today=today,
)
chosen, set_score = select_set(scored, effective_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()
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
for index, scored_recipe in enumerate(chosen):
day = _dinner_days[index] if index < len(_dinner_days) else index + 1
meal_type = MealType.DINNER
item = MealPlanItem(
meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id,
day_of_week=day,
meal_type=meal_type,
approval_status=MealPlanItemStatus.pending,
estimated_cost=scored_recipe.cost.total_cost,
score=scored_recipe.score,
components=scored_recipe.components,
)
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
# Sprint 8: surface how many recipes are blocked specifically because
# of soft denials (vs. permanent NeverSuggest entries). Both are
# bucketed under "blocked_recipe" in the filter; this adds a
# "soft_denied_recipe" sub-bucket for diagnostics.
if soft_denied_recipes:
# Only count those that were actually candidates (in recipe_dicts).
soft_in_pool = sum(1 for r in recipe_dicts if r["id"] in soft_denied_recipes)
if soft_in_pool > 0:
rejected_summary["soft_denied_recipe"] = soft_in_pool
return GenerationResult(
meal_plan_id=plan.id,
selected=chosen,
feasible_count=len(filtered.feasible_recipe_ids),
rejected_summary=rejected_summary,
set_score=set_score,
)