feat: add get_role_candidates() and build_from_selection() to assembly engine

Both functions are DB-free public API additions to assembly_recipes.py.
get_role_candidates() scores pantry candidates against a wizard step using
element-profile overlap with prior picks; build_from_selection() builds a
RecipeSuggestion from explicit role overrides with required-role validation.
This commit is contained in:
pyr0ball 2026-04-14 11:06:08 -07:00
parent 4f1570ee6f
commit da940ebaec
2 changed files with 220 additions and 0 deletions

View file

@ -870,3 +870,142 @@ def match_assembly_templates(
# Sort by optional coverage descending — best-matched templates first
results.sort(key=lambda s: s.match_count, reverse=True)
return results
def get_role_candidates(
template_slug: str,
role_display: str,
pantry_set: set[str],
prior_picks: list[str],
profile_index: dict[str, list[str]],
) -> dict:
"""Return ingredient candidates for one wizard step.
Splits candidates into 'compatible' (element overlap with prior picks)
and 'other' (valid for role but no overlap).
profile_index: {ingredient_name: [element_tag, ...]} -- pre-loaded from
Store.get_element_profiles() by the caller so this function stays DB-free.
Returns {"compatible": [...], "other": [...], "available_tags": [...]}
where each item is {"name": str, "in_pantry": bool, "tags": [str]}.
"""
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
if tmpl is None:
return {"compatible": [], "other": [], "available_tags": []}
# Find the AssemblyRole for this display name
target_role: AssemblyRole | None = None
for role in tmpl.required + tmpl.optional:
if role.display == role_display:
target_role = role
break
if target_role is None:
return {"compatible": [], "other": [], "available_tags": []}
# Build prior-pick element set for compatibility scoring
prior_elements: set[str] = set()
for pick in prior_picks:
prior_elements.update(profile_index.get(pick, []))
# Find pantry items that match this role
pantry_matches = _matches_role(target_role, pantry_set)
# Build keyword-based "other" candidates from role keywords not in pantry
pantry_lower = {p.lower() for p in pantry_set}
other_names: list[str] = []
for kw in target_role.keywords:
if not any(kw in item.lower() for item in pantry_lower):
if len(kw) >= 4:
other_names.append(kw.title())
def _make_item(name: str, in_pantry: bool) -> dict:
tags = profile_index.get(name, profile_index.get(name.lower(), []))
return {"name": name, "in_pantry": in_pantry, "tags": tags}
# Score: compatible if shares any element with prior picks (or no prior picks yet)
compatible: list[dict] = []
other: list[dict] = []
for name in pantry_matches:
item_elements = set(profile_index.get(name, []))
item = _make_item(name, in_pantry=True)
if not prior_elements or item_elements & prior_elements:
compatible.append(item)
else:
other.append(item)
for name in other_names:
other.append(_make_item(name, in_pantry=False))
# available_tags: union of all tags in the full candidate set
all_tags: set[str] = set()
for item in compatible + other:
all_tags.update(item["tags"])
return {
"compatible": compatible,
"other": other,
"available_tags": sorted(all_tags),
}
def build_from_selection(
template_slug: str,
role_overrides: dict[str, str],
pantry_set: set[str],
) -> "RecipeSuggestion | None":
"""Build a RecipeSuggestion from explicit role selections.
role_overrides: {role.display -> chosen pantry item name}
Returns None if template not found or any required role is uncovered.
"""
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
if tmpl is None:
return None
seed = _pantry_hash(pantry_set)
# Validate required roles: covered by override OR pantry match
matched_required: list[str] = []
for role in tmpl.required:
chosen = role_overrides.get(role.display)
if chosen:
matched_required.append(chosen)
else:
hits = _matches_role(role, pantry_set)
if not hits:
return None
matched_required.append(_pick_one(hits, seed + tmpl.id))
# Collect optional matches (override preferred, then pantry match)
matched_optional: list[str] = []
for role in tmpl.optional:
chosen = role_overrides.get(role.display)
if chosen:
matched_optional.append(chosen)
else:
hits = _matches_role(role, pantry_set)
if hits:
matched_optional.append(_pick_one(hits, seed + tmpl.id))
all_matched = matched_required + matched_optional
# Build title: prefer override items for personalisation
effective_pantry = pantry_set | set(role_overrides.values())
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
return RecipeSuggestion(
id=tmpl.id,
title=title,
match_count=len(all_matched),
element_coverage={},
swap_candidates=[],
matched_ingredients=all_matched,
missing_ingredients=[],
directions=tmpl.directions,
notes=tmpl.notes,
level=1,
is_wildcard=False,
nutrition=None,
)

View file

@ -133,3 +133,84 @@ def test_get_templates_for_api_all_have_slugs():
slugs = {t["id"] for t in templates}
assert len(slugs) == 13
assert all(isinstance(s, str) and len(s) > 3 for s in slugs)
def test_get_role_candidates_splits_compatible_other():
from app.services.recipe.assembly_recipes import get_role_candidates
profile_index = {
"rice": ["Starch", "Structure"],
"chicken": ["Protein"],
"broccoli": ["Vegetable"],
}
result = get_role_candidates(
template_slug="stir_fry",
role_display="protein",
pantry_set={"rice", "chicken", "broccoli"},
prior_picks=["rice"],
profile_index=profile_index,
)
assert isinstance(result["compatible"], list)
assert isinstance(result["other"], list)
assert isinstance(result["available_tags"], list)
all_names = [c["name"] for c in result["compatible"] + result["other"]]
assert "chicken" in all_names
def test_get_role_candidates_available_tags():
from app.services.recipe.assembly_recipes import get_role_candidates
profile_index = {
"chicken": ["Protein", "Umami"],
"tofu": ["Protein"],
}
result = get_role_candidates(
template_slug="stir_fry",
role_display="protein",
pantry_set={"chicken", "tofu"},
prior_picks=[],
profile_index=profile_index,
)
assert "Protein" in result["available_tags"]
def test_get_role_candidates_unknown_template_returns_empty():
from app.services.recipe.assembly_recipes import get_role_candidates
result = get_role_candidates(
template_slug="nonexistent_template",
role_display="protein",
pantry_set={"chicken"},
prior_picks=[],
profile_index={},
)
assert result == {"compatible": [], "other": [], "available_tags": []}
def test_build_from_selection_returns_recipe():
from app.services.recipe.assembly_recipes import build_from_selection
result = build_from_selection(
template_slug="burrito_taco",
role_overrides={"tortilla or wrap": "flour tortilla", "protein": "chicken"},
pantry_set={"flour tortilla", "chicken", "salsa"},
)
assert result is not None
assert len(result.directions) > 0
assert result.id == -1
def test_build_from_selection_missing_required_role_returns_none():
from app.services.recipe.assembly_recipes import build_from_selection
result = build_from_selection(
template_slug="burrito_taco",
role_overrides={"protein": "chicken"},
pantry_set={"chicken"},
)
assert result is None
def test_build_from_selection_unknown_template_returns_none():
from app.services.recipe.assembly_recipes import build_from_selection
result = build_from_selection(
template_slug="does_not_exist",
role_overrides={},
pantry_set={"chicken"},
)
assert result is None