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:
parent
a523cb094e
commit
6da86dd0a7
1 changed files with 56 additions and 4 deletions
|
|
@ -532,6 +532,43 @@ _INVOLVED_METHODS = re.compile(
|
||||||
r"\b(braise|roast|knead|deep.?fry|fry|sauté|saute|bake|boil)\b", re.IGNORECASE
|
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(
|
def _classify_method_complexity(
|
||||||
directions: list[str],
|
directions: list[str],
|
||||||
|
|
@ -612,6 +649,7 @@ class RecipeEngine:
|
||||||
excluded_ids=req.excluded_ids or [],
|
excluded_ids=req.excluded_ids or [],
|
||||||
)
|
)
|
||||||
suggestions = []
|
suggestions = []
|
||||||
|
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
ingredient_names: list[str] = row.get("ingredient_names") or []
|
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:
|
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter by hard_day_mode
|
# Filter and tier-rank by hard_day_mode
|
||||||
if req.hard_day_mode:
|
if req.hard_day_mode:
|
||||||
directions: list[str] = row.get("directions") or []
|
directions: list[str] = row.get("directions") or []
|
||||||
if isinstance(directions, str):
|
if isinstance(directions, str):
|
||||||
|
|
@ -666,6 +704,11 @@ class RecipeEngine:
|
||||||
complexity = _classify_method_complexity(directions, available_equipment)
|
complexity = _classify_method_complexity(directions, available_equipment)
|
||||||
if complexity == "involved":
|
if complexity == "involved":
|
||||||
continue
|
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
|
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||||
if req.level == 2 and req.constraints:
|
if req.level == 2 and req.constraints:
|
||||||
|
|
@ -739,8 +782,17 @@ class RecipeEngine:
|
||||||
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
|
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
|
||||||
assembly = assembly[:assembly_limit]
|
assembly = assembly[:assembly_limit]
|
||||||
|
|
||||||
# Interleave: sort templates and corpus recipes together by match_count so
|
# Interleave: sort templates and corpus recipes together.
|
||||||
# assembly dishes earn their position rather than always winning position 0-N.
|
# 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)
|
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
|
||||||
|
|
||||||
# Build grocery list — deduplicated union of all missing ingredients
|
# Build grocery list — deduplicated union of all missing ingredients
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue