Compare commits

..

No commits in common. "e7ba305e63eb49f13731e53fe4c9330a868ec825" and "22a3da61c309f50faff439dd4f680c89ba3e5e3d" have entirely different histories.

12 changed files with 79 additions and 627 deletions

View file

@ -28,12 +28,9 @@ from app.services.recipe.assembly_recipes import (
)
from app.services.recipe.browser_domains import (
DOMAINS,
category_has_subcategories,
get_category_names,
get_domain_labels,
get_keywords_for_category,
get_keywords_for_subcategory,
get_subcategory_names,
)
from app.services.recipe.recipe_engine import RecipeEngine
from app.services.heimdall_orch import check_orch_budget
@ -118,42 +115,15 @@ async def list_browse_categories(
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
cat_names = get_category_names(domain)
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
def _get(db_path: Path) -> list[dict]:
store = Store(db_path)
try:
return store.get_browser_categories(domain, keywords_by_category, has_subs)
finally:
store.close()
return await asyncio.to_thread(_get, session.db)
@router.get("/browse/{domain}/{category}/subcategories")
async def list_browse_subcategories(
domain: str,
category: str,
session: CloudUser = Depends(get_session),
) -> list[dict]:
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
if not category_has_subcategories(domain, category):
return []
subcat_names = get_subcategory_names(domain, category)
keywords_by_subcat = {
sub: get_keywords_for_subcategory(domain, category, sub)
for sub in subcat_names
keywords_by_category = {
cat: get_keywords_for_category(domain, cat)
for cat in get_category_names(domain)
}
def _get(db_path: Path) -> list[dict]:
store = Store(db_path)
try:
return store.get_browser_subcategories(domain, keywords_by_subcat)
return store.get_browser_categories(domain, keywords_by_category)
finally:
store.close()
@ -167,33 +137,22 @@ async def browse_recipes(
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
pantry_items: Annotated[str | None, Query()] = None,
subcategory: Annotated[str | None, Query()] = None,
session: CloudUser = Depends(get_session),
) -> dict:
"""Return a paginated list of recipes for a domain/category.
Pass pantry_items as a comma-separated string to receive match_pct badges.
Pass subcategory to narrow within a category that has subcategories.
Pass pantry_items as a comma-separated string to receive match_pct
badges on each result.
"""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
if category == "_all":
keywords = None # unfiltered browse
elif subcategory:
keywords = get_keywords_for_subcategory(domain, category, subcategory)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
)
else:
keywords = get_keywords_for_category(domain, category)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown category '{category}' in domain '{domain}'.",
)
keywords = get_keywords_for_category(domain, category)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown category '{category}' in domain '{domain}'.",
)
pantry_list = (
[p.strip() for p in pantry_items.split(",") if p.strip()]

View file

@ -10,7 +10,6 @@ import logging
from fastapi import APIRouter, Depends
from app.cloud_session import CloudUser, _auth_label, get_session
from app.core.config import settings
router = APIRouter()
log = logging.getLogger(__name__)
@ -23,13 +22,8 @@ def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
Expected log output:
INFO:app.api.endpoints.session: session auth=authed tier=paid
INFO:app.api.endpoints.session: session auth=anon tier=free
E2E test sessions (E2E_TEST_USER_ID) are logged at DEBUG so they don't
pollute analytics counts while still being visible when DEBUG=true.
"""
is_test = bool(settings.E2E_TEST_USER_ID and session.user_id == settings.E2E_TEST_USER_ID)
logger = log.debug if is_test else log.info
logger("session auth=%s tier=%s%s", _auth_label(session.user_id), session.tier, " e2e=true" if is_test else "")
log.info("session auth=%s tier=%s", _auth_label(session.user_id), session.tier)
return {
"auth": _auth_label(session.user_id),
"tier": session.tier,

View file

@ -60,10 +60,6 @@ class Settings:
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
# E2E test account — analytics logging is suppressed for this user_id so test
# runs don't pollute session counts. Set to the Directus UUID of the test user.
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
# Feature flags
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")

View file

@ -1051,38 +1051,17 @@ class Store:
# ── recipe browser ────────────────────────────────────────────────────
def get_browser_categories(
self,
domain: str,
keywords_by_category: dict[str, list[str]],
has_subcategories_by_category: dict[str, bool] | None = None,
self, domain: str, keywords_by_category: dict[str, list[str]]
) -> list[dict]:
"""Return [{category, recipe_count, has_subcategories}] for each category.
"""Return [{category, recipe_count}] for each category in the domain.
keywords_by_category maps category name keyword list for counting.
has_subcategories_by_category maps category name bool (optional;
defaults to False for all categories when omitted).
keywords_by_category maps category name to the keyword list used to
match against recipes.category and recipes.keywords.
"""
results = []
for category, keywords in keywords_by_category.items():
count = self._count_recipes_for_keywords(keywords)
results.append({
"category": category,
"recipe_count": count,
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
})
return results
def get_browser_subcategories(
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
) -> list[dict]:
"""Return [{subcategory, recipe_count}] for each subcategory.
Mirrors get_browser_categories but for the second level.
"""
results = []
for subcat, keywords in keywords_by_subcategory.items():
count = self._count_recipes_for_keywords(keywords)
results.append({"subcategory": subcat, "recipe_count": count})
results.append({"category": category, "recipe_count": count})
return results
@staticmethod
@ -1112,55 +1091,41 @@ class Store:
def browse_recipes(
self,
keywords: list[str] | None,
keywords: list[str],
page: int,
page_size: int,
pantry_items: list[str] | None = None,
) -> dict:
"""Return a page of recipes matching the keyword set.
Pass keywords=None to browse all recipes without category filtering.
Each recipe row includes match_pct (float | None) when pantry_items
is provided. match_pct is the fraction of ingredient_names covered by
the pantry set computed deterministically, no LLM needed.
"""
if keywords is not None and not keywords:
if not keywords:
return {"recipes": [], "total": 0, "page": page}
match_expr = self._browser_fts_query(keywords)
offset = (page - 1) * page_size
c = self._cp
if keywords is None:
# "All" browse — unfiltered paginated scan.
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg
FROM {c}recipes
ORDER BY id ASC
LIMIT ? OFFSET ?
""",
(page_size, offset),
)
else:
match_expr = self._browser_fts_query(keywords)
# Reuse cached count — avoids a second index scan on every page turn.
total = self._count_recipes_for_keywords(keywords)
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg
FROM {c}recipes
WHERE id IN (
SELECT rowid FROM {c}recipe_browser_fts
WHERE recipe_browser_fts MATCH ?
)
ORDER BY id ASC
LIMIT ? OFFSET ?
""",
(match_expr, page_size, offset),
# Reuse cached count — avoids a second index scan on every page turn.
total = self._count_recipes_for_keywords(keywords)
c = self._cp
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg
FROM {c}recipes
WHERE id IN (
SELECT rowid FROM {c}recipe_browser_fts
WHERE recipe_browser_fts MATCH ?
)
ORDER BY id ASC
LIMIT ? OFFSET ?
""",
(match_expr, page_size, offset),
)
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
recipes = []

View file

@ -71,10 +71,6 @@ class NutritionFilters(BaseModel):
class RecipeRequest(BaseModel):
pantry_items: list[str]
# Maps product name → secondary state label for items past nominal expiry
# but still within their secondary use window (e.g. {"Bread": "stale"}).
# Used by the recipe engine to boost recipes suited to those specific states.
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
level: int = Field(default=1, ge=1, le=4)
constraints: list[str] = Field(default_factory=list)
expiry_first: bool = False

View file

@ -194,18 +194,6 @@ class ExpirationPredictor:
'uses': ['broth', 'soups', 'risotto', 'gratins'],
'warning': None,
},
'rice': {
'window_days': 2,
'label': 'day-old',
'uses': ['fried rice', 'rice bowls', 'rice porridge'],
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
},
'tortillas': {
'window_days': 5,
'label': 'stale',
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
'warning': None,
},
}
def days_after_opening(self, category: str | None) -> int | None:

View file

@ -5,12 +5,6 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
Keyword matching is case-insensitive against the recipes.category column and the
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
Category values are either:
- list[str] flat keyword list (no subcategories)
- dict {"keywords": list[str], "subcategories": {name: list[str]}}
keywords covers the whole category (used for "All X" browse);
subcategories each have their own narrower keyword list.
These are starter mappings based on the food.com dataset structure. Run:
SELECT category, count(*) FROM recipes
@ -25,213 +19,26 @@ DOMAINS: dict[str, dict] = {
"cuisine": {
"label": "Cuisine",
"categories": {
"Italian": {
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
"subcategories": {
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
"involtini", "cannoli"],
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
"sfogliatelle", "ragù"],
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
"pappardelle", "crostini"],
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
"amatriciana", "gricia", "supplì"],
"Venetian": ["venetian", "venice", "risotto", "bigoli",
"baccalà", "sarde in saor"],
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
"trofie", "farinata"],
},
},
"Mexican": {
"keywords": ["mexican", "tex-mex", "taco", "enchilada", "burrito",
"salsa", "guacamole"],
"subcategories": {
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
"chapulines", "mezcal"],
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
"sopa de lima", "panuchos"],
"Veracruz": ["veracruz", "huachinango", "picadas", "enfrijoladas"],
"Street Food": ["taco", "elote", "tlacoyos", "torta",
"tamale", "quesadilla"],
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
"mole poblano"],
},
},
"Asian": {
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
"stir fry", "stir-fry", "ramen", "sushi"],
"subcategories": {
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
"doenjang", "gochujang"],
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
"teriyaki", "udon", "soba", "bento", "yakitori"],
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
"spring roll", "szechuan", "sichuan", "cantonese",
"chow mein", "mapo", "lo mein"],
"Thai": ["thai", "pad thai", "green curry", "red curry",
"coconut milk", "lemongrass", "satay", "tom yum"],
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
"vermicelli", "nuoc cham", "bun bo"],
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
"kare-kare", "lechon"],
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
"tempeh", "sambal"],
},
},
"Indian": {
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
"biryani", "naan", "chutney"],
"subcategories": {
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
"naan", "tandoori", "butter chicken", "palak"],
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
"sambar", "rasam", "coconut chutney"],
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe"],
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu"],
},
},
"Mediterranean": {
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
"moroccan", "lebanese"],
"subcategories": {
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
"souvlaki", "dolmades"],
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
"lahmacun"],
"Moroccan": ["moroccan", "tagine", "couscous", "harissa",
"chermoula", "preserved lemon"],
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
"tabbouleh", "kibbeh", "fattoush"],
"Israeli": ["israeli", "shakshuka", "sabich", "za'atar",
"tahini"],
},
},
"American": {
"keywords": ["american", "southern", "bbq", "barbecue", "comfort food",
"cajun", "creole"],
"subcategories": {
"Southern": ["southern", "soul food", "fried chicken",
"collard greens", "cornbread", "biscuits and gravy"],
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
"jambalaya", "etouffee", "dirty rice"],
"BBQ": ["bbq", "barbecue", "smoked", "brisket", "pulled pork",
"ribs", "pit"],
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
"queso"],
"New England": ["new england", "chowder", "lobster", "clam",
"maple", "yankee"],
},
},
"European": {
"keywords": ["french", "german", "spanish", "british", "irish",
"scandinavian"],
"subcategories": {
"French": ["french", "provencal", "beurre", "crepe",
"ratatouille", "cassoulet", "bouillabaisse"],
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
"tortilla espanola", "chorizo"],
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
"pretzel", "strudel"],
"British/Irish": ["british", "irish", "english", "pub food",
"shepherd's pie", "bangers", "scones"],
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
"danish", "gravlax", "meatballs"],
},
},
"Latin American": {
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
"cuban", "caribbean", "brazilian"],
"subcategories": {
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
"aji amarillo"],
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
"brigadeiro"],
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
"sancocho"],
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
"picadillo", "mojito"],
"Caribbean": ["caribbean", "jamaican", "jerk", "trinidadian",
"plantain", "roti"],
},
},
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
},
},
"meal_type": {
"label": "Meal Type",
"categories": {
"Breakfast": {
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
"oatmeal", "muffin"],
"subcategories": {
"Eggs": ["egg", "omelette", "frittata", "quiche",
"scrambled", "benedict", "shakshuka"],
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
"coffee cake", "danish"],
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
"overnight oats"],
},
},
"Lunch": {
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
"subcategories": {
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
"grilled cheese", "blt"],
"Salads": ["salad", "grain bowl", "chopped", "caesar",
"niçoise", "cobb"],
"Soups": ["soup", "bisque", "chowder", "gazpacho",
"minestrone", "lentil soup"],
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
"quesadilla"],
},
},
"Dinner": {
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
"subcategories": {
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
"sheperd's pie", "pot pie"],
"Stews": ["stew", "braise", "slow cooker", "pot roast",
"daube", "ragù"],
"Grilled": ["grilled", "grill", "barbecue", "charred",
"kebab", "skewer"],
"Stir-Fries": ["stir fry", "stir-fry", "wok", "sauté",
"sauteed"],
"Roasts": ["roast", "roasted", "oven", "baked chicken",
"pot roast"],
},
},
"Snack": {
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
"starter"],
"subcategories": {
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
"salsa", "pate"],
"Finger Foods": ["finger food", "bite", "skewer", "slider",
"wing", "nugget"],
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
"pretzel"],
},
},
"Dessert": {
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
"ice cream", "brownie"],
"subcategories": {
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
"cheesecake", "torte"],
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
"biscotti", "shortbread"],
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
"crumble"],
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
"popsicle", "granita"],
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
"flan", "creme brulee"],
"Candy": ["candy", "fudge", "truffle", "brittle",
"caramel", "toffee"],
},
},
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
},
},
"dietary": {
@ -249,128 +56,33 @@ DOMAINS: dict[str, dict] = {
"main_ingredient": {
"label": "Main Ingredient",
"categories": {
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
"Chicken": {
"keywords": ["main:Chicken"],
"subcategories": {
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
"chicken bake"],
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
"chicken skewer"],
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
"crispy chicken"],
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
"chicken curry", "chicken braise"],
},
},
"Beef": {
"keywords": ["main:Beef"],
"subcategories": {
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
"bolognese", "burger"],
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
"filet mignon", "t-bone"],
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
"chuck roast"],
"Stews": ["beef stew", "beef braise", "beef bourguignon",
"short ribs"],
},
},
"Pork": {
"keywords": ["main:Pork"],
"subcategories": {
"Chops": ["pork chop", "pork loin", "pork cutlet"],
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
"carnitas", "slow cooker pork"],
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
"Italian sausage"],
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
"pork belly"],
},
},
"Fish": {
"keywords": ["main:Fish"],
"subcategories": {
"Salmon": ["salmon", "smoked salmon", "gravlax"],
"Tuna": ["tuna", "albacore", "ahi"],
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
"flounder", "bass"],
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
"mussel", "clam", "oyster"],
},
},
# These values match the inferred_tags written by tag_inferrer._MAIN_INGREDIENT_SIGNALS
# and indexed into recipe_browser_fts — use exact tag strings.
"Chicken": ["main:Chicken"],
"Beef": ["main:Beef"],
"Pork": ["main:Pork"],
"Fish": ["main:Fish"],
"Pasta": ["main:Pasta"],
"Vegetables": {
"keywords": ["main:Vegetables"],
"subcategories": {
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
"parsnip", "turnip"],
"Leafy": ["spinach", "kale", "chard", "arugula",
"collard greens", "lettuce"],
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
"cabbage", "bok choy"],
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
"squash"],
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
"chanterelle"],
},
},
"Eggs": ["main:Eggs"],
"Legumes": ["main:Legumes"],
"Grains": ["main:Grains"],
"Cheese": ["main:Cheese"],
"Vegetables": ["main:Vegetables"],
"Eggs": ["main:Eggs"],
"Legumes": ["main:Legumes"],
"Grains": ["main:Grains"],
"Cheese": ["main:Cheese"],
},
},
}
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
"""Return the raw category definition, or None if not found."""
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
def get_domain_labels() -> list[dict]:
"""Return [{id, label}] for all available domains."""
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
def get_keywords_for_category(domain: str, category: str) -> list[str]:
"""Return the keyword list for the category (top-level, covers all subcategories).
For flat categories returns the list directly.
For nested categories returns the 'keywords' key.
Returns [] if category or domain not found.
"""
cat_def = _get_category_def(domain, category)
if cat_def is None:
return []
if isinstance(cat_def, list):
return cat_def
return cat_def.get("keywords", [])
def category_has_subcategories(domain: str, category: str) -> bool:
"""Return True when a category has a subcategory level."""
cat_def = _get_category_def(domain, category)
if not isinstance(cat_def, dict):
return False
return bool(cat_def.get("subcategories"))
def get_subcategory_names(domain: str, category: str) -> list[str]:
"""Return subcategory names for a category, or [] if none exist."""
cat_def = _get_category_def(domain, category)
if not isinstance(cat_def, dict):
return []
return list(cat_def.get("subcategories", {}).keys())
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
"""Return keyword list for a specific subcategory, or [] if not found."""
cat_def = _get_category_def(domain, category)
if not isinstance(cat_def, dict):
return []
return cat_def.get("subcategories", {}).get(subcategory, [])
"""Return the keyword list for a domain/category pair, or [] if not found."""
domain_data = DOMAINS.get(domain, {})
categories = domain_data.get("categories", {})
return categories.get(category, [])
def get_category_names(domain: str) -> list[str]:

View file

@ -155,24 +155,6 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
}
# When a pantry item is in a secondary state (e.g. bread → "stale"), expand
# the pantry set with terms that recipe ingredients commonly use to describe
# that state. This lets "stale bread" in a recipe ingredient match a pantry
# entry that is simply called "Bread" but is past its nominal use-by date.
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
# Values are additional strings added to the pantry set for FTS coverage.
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"],
("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"],
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"],
("dairy", "sour"): ["sour milk", "slightly sour milk"],
("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"],
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"],
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
}
# Matches leading quantity/unit prefixes in recipe ingredient strings,
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
# "3 oz. butter" → "butter"
@ -302,24 +284,14 @@ def _prep_note_for(ingredient: str) -> str | None:
return template.format(ingredient=ingredient_name)
def _expand_pantry_set(
pantry_items: list[str],
secondary_pantry_items: dict[str, str] | None = None,
) -> set[str]:
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
and adds the canonical form. This lets single-word recipe ingredients
("hamburger", "chicken") match product-label pantry entries
("burger patties", "rotisserie chicken").
If secondary_pantry_items is provided (product_name state label), items
in a secondary state also receive state-specific synonym expansion so that
recipe ingredients like "stale bread" or "day-old rice" are matched.
"""
from app.services.expiration_predictor import ExpirationPredictor
_predictor = ExpirationPredictor()
expanded: set[str] = set()
for item in pantry_items:
lower = item.lower().strip()
@ -327,15 +299,6 @@ def _expand_pantry_set(
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
if pattern in lower:
expanded.add(canonical)
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
if secondary_pantry_items and item in secondary_pantry_items:
state_label = secondary_pantry_items[item]
category = _predictor.get_category_from_product(item)
if category:
synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), [])
expanded.update(synonyms)
return expanded
@ -671,7 +634,7 @@ class RecipeEngine:
profiles = self._classifier.classify_batch(req.pantry_items)
gaps = self._classifier.identify_gaps(profiles)
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
pantry_set = _expand_pantry_set(req.pantry_items)
if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator

View file

@ -21,13 +21,7 @@
</div>
<!-- Category list + Surprise Me -->
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
<button
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
@click="selectCategory('_all')"
>
All
</button>
<div v-else class="category-list mb-md flex flex-wrap gap-xs">
<button
v-for="cat in categories"
:key="cat.category"
@ -36,7 +30,6 @@
>
{{ cat.category }}
<span class="cat-count">{{ cat.recipe_count }}</span>
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories"></span>
</button>
<button
v-if="categories.length > 1"
@ -48,31 +41,6 @@
</button>
</div>
<!-- Subcategory row shown when the active category has subcategories -->
<div
v-if="activeCategoryHasSubs && (subcategories.length > 0 || loadingSubcategories)"
class="subcategory-list mb-md flex flex-wrap gap-xs"
>
<span v-if="loadingSubcategories" class="text-secondary text-xs">Loading</span>
<template v-else>
<button
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
@click="selectSubcategory(null)"
>
All {{ activeCategory }}
</button>
<button
v-for="sub in subcategories"
:key="sub.subcategory"
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
@click="selectSubcategory(sub.subcategory)"
>
{{ sub.subcategory }}
<span class="cat-count">{{ sub.recipe_count }}</span>
</button>
</template>
</div>
<!-- Recipe grid -->
<template v-if="activeCategory">
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes</div>
@ -157,7 +125,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useInventoryStore } from '../stores/inventory'
import SaveRecipeModal from './SaveRecipeModal.vue'
@ -173,9 +141,6 @@ const domains = ref<BrowserDomain[]>([])
const activeDomain = ref<string | null>(null)
const categories = ref<BrowserCategory[]>([])
const activeCategory = ref<string | null>(null)
const subcategories = ref<BrowserSubcategory[]>([])
const activeSubcategory = ref<string | null>(null)
const loadingSubcategories = ref(false)
const recipes = ref<BrowserRecipe[]>([])
const total = ref(0)
const page = ref(1)
@ -188,10 +153,6 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
const allCountsZero = computed(() =>
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
)
const activeCategoryHasSubs = computed(() => {
if (!activeCategory.value || activeCategory.value === '_all') return false
return categories.value.find(c => c.category === activeCategory.value)?.has_subcategories ?? false
})
const pantryItems = computed(() =>
inventoryStore.items
@ -244,25 +205,6 @@ function surpriseMe() {
async function selectCategory(category: string) {
activeCategory.value = category
activeSubcategory.value = null
subcategories.value = []
page.value = 1
// Fetch subcategories in the background when the category supports them,
// then immediately start loading recipes at the full-category level.
const catMeta = categories.value.find(c => c.category === category)
if (catMeta?.has_subcategories) {
loadingSubcategories.value = true
browserAPI.listSubcategories(activeDomain.value!, category)
.then(subs => { subcategories.value = subs })
.finally(() => { loadingSubcategories.value = false })
}
await loadRecipes()
}
async function selectSubcategory(subcat: string | null) {
activeSubcategory.value = subcat
page.value = 1
await loadRecipes()
}
@ -285,7 +227,6 @@ async function loadRecipes() {
pantry_items: pantryItems.value.length > 0
? pantryItems.value.join(',')
: undefined,
subcategory: activeSubcategory.value ?? undefined,
}
)
recipes.value = result.recipes
@ -348,36 +289,6 @@ async function doUnsave(recipeId: number) {
opacity: 1;
}
.cat-drill-indicator {
margin-left: var(--spacing-xs);
opacity: 0.5;
font-size: var(--font-size-sm);
}
.subcategory-list {
padding-left: var(--spacing-sm);
border-left: 2px solid var(--color-border);
margin-left: var(--spacing-xs);
}
.subcat-btn {
font-size: var(--font-size-xs, 0.78rem);
padding: var(--spacing-xs) var(--spacing-sm);
opacity: 0.9;
}
.subcat-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
opacity: 1;
}
.subcat-btn.active .cat-count {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.recipe-grid {
display: flex;
flex-direction: column;

View file

@ -951,19 +951,6 @@ const pantryItems = computed(() => {
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
})
// Secondary-state items: expired but still usable in specific recipes.
// Maps product_name secondary_state label (e.g. "Bread" "stale").
// Sent alongside pantry_items so the recipe engine can boost relevant recipes.
const secondaryPantryItems = computed<Record<string, string>>(() => {
const result: Record<string, string> = {}
for (const item of inventoryStore.items) {
if (item.secondary_state && item.product_name) {
result[item.product_name] = item.secondary_state
}
}
return result
})
// Grocery links relevant to a specific recipe's missing ingredients
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
if (!recipesStore.result) return []
@ -1038,12 +1025,12 @@ function onNutritionInput(key: NutritionKey, e: Event) {
// Suggest handler
async function handleSuggest() {
isLoadingMore.value = false
await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value)
await recipesStore.suggest(pantryItems.value)
}
async function handleLoadMore() {
isLoadingMore.value = true
await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value)
await recipesStore.loadMore(pantryItems.value)
isLoadingMore.value = false
}

View file

@ -526,7 +526,6 @@ export interface RecipeResult {
export interface RecipeRequest {
pantry_items: string[]
secondary_pantry_items: Record<string, string>
level: number
constraints: string[]
allergies: string[]
@ -881,12 +880,6 @@ export interface BrowserDomain {
export interface BrowserCategory {
category: string
recipe_count: number
has_subcategories: boolean
}
export interface BrowserSubcategory {
subcategory: string
recipe_count: number
}
export interface BrowserRecipe {
@ -913,17 +906,10 @@ export const browserAPI = {
const response = await api.get(`/recipes/browse/${domain}`)
return response.data
},
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
const response = await api.get(
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
)
return response.data
},
async browse(domain: string, category: string, params?: {
page?: number
page_size?: number
pantry_items?: string
subcategory?: string
}): Promise<BrowserResult> {
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
return response.data

View file

@ -163,15 +163,10 @@ export const useRecipesStore = defineStore('recipes', () => {
const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(
pantryItems: string[],
secondaryPantryItems: Record<string, string> = {},
extraExcluded: number[] = [],
): RecipeRequest {
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
return {
pantry_items: pantryItems,
secondary_pantry_items: secondaryPantryItems,
level: level.value,
constraints: constraints.value,
allergies: allergies.value,
@ -196,13 +191,13 @@ export const useRecipesStore = defineStore('recipes', () => {
}
}
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
async function suggest(pantryItems: string[]) {
loading.value = true
error.value = null
seenIds.value = new Set()
try {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
_trackSeen(result.value.suggestions)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
@ -211,14 +206,14 @@ export const useRecipesStore = defineStore('recipes', () => {
}
}
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
async function loadMore(pantryItems: string[]) {
if (!result.value || loading.value) return
loading.value = true
error.value = null
try {
// Exclude everything already shown (dismissed + all seen this session)
const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value]))
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
if (more.suggestions.length === 0) {
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
} else {