diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 9127176..f2e221d 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -28,9 +28,12 @@ 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 @@ -115,15 +118,42 @@ async def list_browse_categories( if domain not in DOMAINS: raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.") - keywords_by_category = { - cat: get_keywords_for_category(domain, cat) - for cat in get_category_names(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 } def _get(db_path: Path) -> list[dict]: store = Store(db_path) try: - return store.get_browser_categories(domain, keywords_by_category) + return store.get_browser_subcategories(domain, keywords_by_subcat) finally: store.close() @@ -137,22 +167,33 @@ 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 on each result. + Pass pantry_items as a comma-separated string to receive match_pct badges. + Pass subcategory to narrow within a category that has subcategories. """ if domain not in DOMAINS: raise HTTPException(status_code=404, detail=f"Unknown 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}'.", - ) + 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}'.", + ) pantry_list = ( [p.strip() for p in pantry_items.split(",") if p.strip()] diff --git a/app/db/store.py b/app/db/store.py index fdb0be5..d491d03 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1051,17 +1051,38 @@ class Store: # ── recipe browser ──────────────────────────────────────────────────── def get_browser_categories( - self, domain: str, keywords_by_category: dict[str, list[str]] + self, + domain: str, + keywords_by_category: dict[str, list[str]], + has_subcategories_by_category: dict[str, bool] | None = None, ) -> list[dict]: - """Return [{category, recipe_count}] for each category in the domain. + """Return [{category, recipe_count, has_subcategories}] for each category. - keywords_by_category maps category name to the keyword list used to - match against recipes.category and recipes.keywords. + 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). """ results = [] for category, keywords in keywords_by_category.items(): count = self._count_recipes_for_keywords(keywords) - results.append({"category": category, "recipe_count": count}) + 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}) return results @staticmethod @@ -1091,41 +1112,55 @@ class Store: def browse_recipes( self, - keywords: list[str], + keywords: list[str] | None, 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 not keywords: + if keywords is not None and not keywords: return {"recipes": [], "total": 0, "page": page} - match_expr = self._browser_fts_query(keywords) offset = (page - 1) * page_size - - # 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 ? + + 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), ) - 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 = [] diff --git a/app/services/recipe/browser_domains.py b/app/services/recipe/browser_domains.py index 58a5f46..4d576b6 100644 --- a/app/services/recipe/browser_domains.py +++ b/app/services/recipe/browser_domains.py @@ -5,6 +5,12 @@ 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 @@ -19,26 +25,213 @@ DOMAINS: dict[str, dict] = { "cuisine": { "label": "Cuisine", "categories": { - "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"], + "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"], + }, + }, }, }, "meal_type": { "label": "Meal Type", "categories": { - "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"], + "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"], }, }, "dietary": { @@ -56,33 +249,128 @@ DOMAINS: dict[str, dict] = { "main_ingredient": { "label": "Main Ingredient", "categories": { - # 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"], + # 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"], + }, + }, "Pasta": ["main:Pasta"], - "Vegetables": ["main:Vegetables"], - "Eggs": ["main:Eggs"], - "Legumes": ["main:Legumes"], - "Grains": ["main:Grains"], - "Cheese": ["main:Cheese"], + "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"], }, }, } +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 a domain/category pair, or [] if not found.""" - domain_data = DOMAINS.get(domain, {}) - categories = domain_data.get("categories", {}) - return categories.get(category, []) + """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, []) def get_category_names(domain: str) -> list[str]: diff --git a/frontend/src/components/RecipeBrowserPanel.vue b/frontend/src/components/RecipeBrowserPanel.vue index d838ebf..1a94160 100644 --- a/frontend/src/components/RecipeBrowserPanel.vue +++ b/frontend/src/components/RecipeBrowserPanel.vue @@ -21,7 +21,13 @@ -
+
+
+ +
+ Loading… + +
+