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
This commit is contained in:
MealPlanner
2026-06-05 10:24:35 -07:00
parent 09c7525a12
commit efd1fc695f
15 changed files with 1063 additions and 96 deletions
+14 -3
View File
@@ -281,9 +281,20 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f'{ing_block}'
f'{instructions_block}'
f'{cost_block}'
f'<a href="{vote_url}" style="display:inline-block;margin-top:8px;padding:8px 16px;'
f'background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Vote on this meal</a>'
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;'
f'background:#16a34a;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Approve</a>'
f'<a href="{vote_url}&amp;scope=this_week" style="display:inline-block;padding:8px 14px;'
f'background:#dc2626;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Deny this week</a>'
f'<a href="{vote_url}&amp;scope=never_again" style="display:inline-block;padding:8px 14px;'
f'background:#7f1d1d;color:white;text-decoration:none;border-radius:6px;font-size:14px;'
f'border:1px dashed #fca5a5">'
f'Never again</a>'
f'</div>'
f'<div style="font-size:11px;color:#888;margin-top:4px">'
f'<a href="{vote_url}" style="color:#2563eb">Open vote page (all 3 options)</a></div>'
f'</div>'
)
+52 -4
View File
@@ -6,6 +6,7 @@ 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 (
@@ -58,7 +59,19 @@ def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
def _load_blocklists(
db: Session, family_id: UUID
) -> tuple[Set[UUID], Set[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():
@@ -66,7 +79,25 @@ def _load_blocklists(
blocked_ingredients.add(row.ingredient_id)
if row.recipe_id is not None:
blocked_recipes.add(row.recipe_id)
return blocked_ingredients, blocked_recipes
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]:
@@ -147,9 +178,17 @@ def generate_meal_plan(
match_index = _load_match_index(db)
pantry_ids = _load_pantry(db, family_id)
blocked_ings, blocked_recipes = _load_blocklists(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"],
@@ -166,7 +205,7 @@ def generate_meal_plan(
recipe_ingredient_ids=recipe_ingredient_ids,
recipe_costs=recipe_costs,
blocked_ingredient_ids=blocked_ings,
blocked_recipe_ids=blocked_recipes,
blocked_recipe_ids=all_blocked_recipes,
last_cooked_at=last_cooked,
family_calorie_target=family.calorie_target,
config=effective_config,
@@ -216,6 +255,15 @@ def generate_meal_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,