Public Access
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
275 lines
9.7 KiB
Python
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,
|
|
)
|