feat(hard-day): tier-sort results — premade first, simple second

Hard Day Mode now prioritises results by effort tier before match_count:
  Tier 0 (premade): frozen/instant title keywords, or ≤2 ingredients with
    heat/microwave-only steps (frozen dinner, heat-and-eat, microwave meal)
  Tier 1 (super simple): ≤3 ingredients + any easy method (quesadilla,
    cheese toast, scrambled eggs)
  Tier 2 (easy/moderate): everything else that passed the 'involved' filter

Assembly templates default to tier 1 (inherently simple). Normal mode sort
is unchanged — match_count only.
This commit is contained in:
pyr0ball 2026-04-08 23:18:29 -07:00
parent a523cb094e
commit 6da86dd0a7

View file

@ -532,6 +532,43 @@ _INVOLVED_METHODS = re.compile(
r"\b(braise|roast|knead|deep.?fry|fry|sauté|saute|bake|boil)\b", re.IGNORECASE
)
# Hard day mode sort tier patterns
_PREMADE_TITLE_RE = re.compile(
r"\b(frozen|instant|microwave|ready.?made|pre.?made|packaged|heat.?and.?eat)\b",
re.IGNORECASE,
)
_HEAT_ONLY_RE = re.compile(r"\b(microwave|heat|warm|thaw)\b", re.IGNORECASE)
def _hard_day_sort_tier(
title: str,
ingredient_names: list[str],
directions: list[str],
) -> int:
"""Return a sort priority tier for hard day mode.
0 premade / heat-only (frozen dinner, quesadilla, microwave meal)
1 super simple (3 ingredients, easy method)
2 easy/moderate (everything else that passed the 'involved' filter)
Lower tier surfaces first.
"""
dir_text = " ".join(directions)
n_ingredients = len(ingredient_names)
n_steps = len(directions)
# Tier 0: title signals premade, OR very few ingredients with heat-only steps
if _PREMADE_TITLE_RE.search(title):
return 0
if n_ingredients <= 2 and n_steps <= 3 and _HEAT_ONLY_RE.search(dir_text):
return 0
# Tier 1: ≤3 ingredients with any easy method (quesadilla, cheese toast, etc.)
if n_ingredients <= 3 and _EASY_METHODS.search(dir_text):
return 1
return 2
def _classify_method_complexity(
directions: list[str],
@ -612,6 +649,7 @@ class RecipeEngine:
excluded_ids=req.excluded_ids or [],
)
suggestions = []
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
for row in rows:
ingredient_names: list[str] = row.get("ingredient_names") or []
@ -655,7 +693,7 @@ class RecipeEngine:
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
continue
# Filter by hard_day_mode
# Filter and tier-rank by hard_day_mode
if req.hard_day_mode:
directions: list[str] = row.get("directions") or []
if isinstance(directions, str):
@ -666,6 +704,11 @@ class RecipeEngine:
complexity = _classify_method_complexity(directions, available_equipment)
if complexity == "involved":
continue
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
title=row.get("title", ""),
ingredient_names=ingredient_names,
directions=directions,
)
# Level 2: also add dietary constraint swaps from substitution_pairs
if req.level == 2 and req.constraints:
@ -739,8 +782,17 @@ class RecipeEngine:
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
assembly = assembly[:assembly_limit]
# Interleave: sort templates and corpus recipes together by match_count so
# assembly dishes earn their position rather than always winning position 0-N.
# Interleave: sort templates and corpus recipes together.
# Hard day mode: primary sort by tier (0=premade, 1=simple, 2=moderate),
# then by match_count within each tier. Assembly templates are inherently
# simple so they default to tier 1 when not in the tier map.
# Normal mode: sort by match_count only.
if req.hard_day_mode and hard_day_tier_map:
suggestions = sorted(
assembly + suggestions,
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
)
else:
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
# Build grocery list — deduplicated union of all missing ingredients