feat: remove assembly results from suggest() -- moved to Build Your Own tab

This commit is contained in:
pyr0ball 2026-04-14 11:39:57 -07:00
parent da940ebaec
commit c02e538cb2
2 changed files with 43 additions and 36 deletions

View file

@ -21,7 +21,6 @@ if TYPE_CHECKING:
from app.db.store import Store
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
from app.services.recipe.assembly_recipes import match_assembly_templates
from app.services.recipe.element_classifier import ElementClassifier
from app.services.recipe.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine
@ -517,13 +516,6 @@ def _build_source_url(row: dict) -> str | None:
return None
_ASSEMBLY_TIER_LIMITS: dict[str, int] = {
"free": 2,
"paid": 4,
"premium": 6,
}
# Method complexity classification patterns
_EASY_METHODS = re.compile(
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
@ -637,6 +629,11 @@ class RecipeEngine:
return gen.generate(req, profiles, gaps)
# Level 1 & 2: deterministic path
# L1 ("Use What I Have") applies strict quality gates:
# - exclude_generic: filter catch-all recipes at the DB level
# - effective_max_missing: default to 2 when user hasn't set a cap
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
_l1 = req.level == 1 and not req.shopping_mode
nf = req.nutrition_filters
rows = self._store.search_recipes_by_ingredients(
req.pantry_items,
@ -647,7 +644,16 @@ class RecipeEngine:
max_carbs_g=nf.max_carbs_g,
max_sodium_mg=nf.max_sodium_mg,
excluded_ids=req.excluded_ids or [],
exclude_generic=_l1,
)
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
_L1_MAX_MISSING_DEFAULT = 2
_L1_MIN_MATCH_RATIO = 0.6
effective_max_missing = req.max_missing
if _l1 and effective_max_missing is None:
effective_max_missing = _L1_MAX_MISSING_DEFAULT
suggestions = []
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
@ -690,9 +696,17 @@ class RecipeEngine:
missing.append(n)
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
continue
# L1 match ratio gate: drop results where less than 60% of the recipe's
# ingredients are in the pantry. Prevents low-signal results like a
# 10-ingredient recipe matching on only one common item.
if _l1 and ingredient_names:
match_ratio = len(matched) / len(ingredient_names)
if match_ratio < _L1_MIN_MATCH_RATIO:
continue
# Filter and tier-rank by hard_day_mode
if req.hard_day_mode:
directions: list[str] = row.get("directions") or []
@ -761,39 +775,17 @@ class RecipeEngine:
source_url=_build_source_url(row),
))
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
# Expiry boost: when expiry_first, the pantry_items list is already sorted
# by expiry urgency — treat the first slice as the "expiring" set so templates
# that use those items bubble up in the merged ranking.
expiring_set: set[str] = set()
if req.expiry_first:
expiring_set = _expand_pantry_set(req.pantry_items[:10])
assembly = match_assembly_templates(
pantry_items=req.pantry_items,
pantry_set=pantry_set,
excluded_ids=req.excluded_ids or [],
expiring_set=expiring_set,
)
# Cap by tier — lifted in shopping mode since missing-ingredient templates
# are desirable there (each fires an affiliate link opportunity).
if not req.shopping_mode:
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
assembly = assembly[:assembly_limit]
# Interleave: sort templates and corpus recipes together.
# Sort corpus results — assembly templates are now served from a dedicated tab.
# 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.
# then by match_count within each tier.
# Normal mode: sort by match_count descending.
if req.hard_day_mode and hard_day_tier_map:
suggestions = sorted(
assembly + suggestions,
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(suggestions, key=lambda s: -s.match_count)
# Build grocery list — deduplicated union of all missing ingredients
seen: set[str] = set()

View file

@ -119,3 +119,18 @@ def test_grocery_links_free_tier(store_with_recipes):
assert hasattr(link, "ingredient")
assert hasattr(link, "retailer")
assert hasattr(link, "url")
def test_suggest_returns_no_assembly_results(store_with_recipes):
"""Assembly templates (negative IDs) must no longer appear in suggest() output."""
from app.services.recipe.recipe_engine import RecipeEngine
from app.models.schemas.recipe import RecipeRequest
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(
pantry_items=["flour tortilla", "chicken", "salsa", "rice"],
level=1,
constraints=[],
)
result = engine.suggest(req)
assembly_ids = [s.id for s in result.suggestions if s.id < 0]
assert assembly_ids == [], f"Found assembly results in suggest(): {assembly_ids}"