Compare commits

...

3 commits

Author SHA1 Message Date
e7ba305e63 feat: hierarchical subcategory navigation in recipe browser
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Adds a two-level browse tree (domain → category → subcategory) to the
recipe browser, plus an "All" unfiltered option at the top of every
domain.

browser_domains.py:
- Category values now support list[str] (flat) or dict with "keywords"
  and "subcategories" keys — backward compatible with all existing flat
  categories
- Added subcategories to: Italian (Sicilian, Neapolitan, Tuscan, Roman,
  Venetian, Ligurian), Mexican (Oaxacan, Yucatecan, Veracruz, Street
  Food, Mole), Asian (Korean, Japanese, Chinese, Thai, Vietnamese,
  Filipino, Indonesian), Indian (North, South, Bengali, Gujarati),
  Mediterranean (Greek, Turkish, Moroccan, Lebanese, Israeli), American
  (Southern, Cajun/Creole, BBQ, Tex-Mex, New England), European
  (French, Spanish, German, British/Irish, Scandinavian), Latin American
  (Peruvian, Brazilian, Colombian, Cuban, Caribbean), Dinner, Lunch,
  Breakfast, Snack, Dessert, Chicken, Beef, Pork, Fish, Vegetables
- New helpers: category_has_subcategories, get_subcategory_names,
  get_keywords_for_subcategory

store.py:
- get_browser_categories now accepts has_subcategories_by_category and
  includes has_subcategories: bool in each result row
- New get_browser_subcategories method for subcategory count queries

recipes.py endpoints:
- GET /browse/{domain}/{category}/subcategories — returns subcategory
  list with recipe counts (registered before /{subcategory} to avoid
  path collision)
- GET /browse/{domain}/{category} gains optional ?subcategory=X param
  to narrow results within a category
- GET /browse/{domain}/{category}/_all — unfiltered paginated browse
  (landed in previous commit)

api.ts: BrowserCategory adds has_subcategories; new BrowserSubcategory
type; listSubcategories() call; browse() gains subcategory param

RecipeBrowserPanel.vue:
- Category pills show a › indicator when subcategories exist
- Selecting such a category fetches subcategories in the background
  (non-blocking — recipes load immediately at the category level)
- Subcategory row appears below the category list with an
  "All [Category]" pill + one pill per subcategory with count
- Active subcategory highlighted; clicking "All [Category]" resets
  to the full category view
2026-04-18 21:07:06 -07:00
b2c546e86a feat: wire secondary-use window hints into recipe engine (#83)
Secondary-state items (stale bread, overripe bananas, day-old rice, etc.)
are now surfaced to the recipe engine so relevant recipes get matched even
when the ingredient is phrased differently in the corpus (e.g. "day-old
rice" vs. "rice").

Backend:
- Add rice and tortillas entries to SECONDARY_WINDOW in expiration_predictor
- Add secondary_pantry_items: dict[str, str] field to RecipeRequest schema
  (maps product_name → secondary_state label, e.g. {"Bread": "stale"})
- Add _SECONDARY_STATE_SYNONYMS lookup in recipe_engine — keyed by
  (category, state_label), returns corpus-matching ingredient phrases
- Update _expand_pantry_set() to accept secondary_pantry_items and inject
  synonym terms into the expanded pantry set used for FTS matching

Frontend:
- Add secondary_pantry_items to RecipeRequest interface in api.ts
- Add secondaryPantryItems param to _buildRequest / suggest / loadMore
  in the recipes store
- Add secondaryPantryItems computed to RecipesView — reads secondary_state
  from inventory items (expired but still in secondary window) and builds
  the product_name → state_label map
- Pass secondaryPantryItems into handleSuggest and handleLoadMore

Closes #83
2026-04-18 19:06:53 -07:00
8fd77bd1f2 fix: suppress E2E test sessions from log-based analytics
Add E2E_TEST_USER_ID setting (opt-in via env); session bootstrap logs
at DEBUG instead of INFO for the known test user so test runs don't
inflate session counts.  Still visible with DEBUG=true.
2026-04-18 19:06:37 -07:00
12 changed files with 627 additions and 79 deletions

View file

@ -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,16 +167,27 @@ 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}'.")
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(

View file

@ -10,6 +10,7 @@ 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__)
@ -22,8 +23,13 @@ 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.
"""
log.info("session auth=%s tier=%s", _auth_label(session.user_id), session.tier)
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 "")
return {
"auth": _auth_label(session.user_id),
"tier": session.tier,

View file

@ -60,6 +60,10 @@ 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,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,27 +1112,41 @@ 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
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)
c = self._cp
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,

View file

@ -71,6 +71,10 @@ 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,6 +194,18 @@ 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,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,24 +25,211 @@ 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"],
"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"],
},
@ -56,14 +249,72 @@ 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"],
"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"],
@ -73,16 +324,53 @@ DOMAINS: dict[str, dict] = {
}
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]:

View file

@ -155,6 +155,24 @@ _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"
@ -284,14 +302,24 @@ def _prep_note_for(ingredient: str) -> str | None:
return template.format(ingredient=ingredient_name)
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
def _expand_pantry_set(
pantry_items: list[str],
secondary_pantry_items: dict[str, str] | None = None,
) -> 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()
@ -299,6 +327,15 @@ def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
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
@ -634,7 +671,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)
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator

View file

@ -21,7 +21,13 @@
</div>
<!-- Category list + Surprise Me -->
<div v-else class="category-list mb-md flex flex-wrap gap-xs">
<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>
<button
v-for="cat in categories"
:key="cat.category"
@ -30,6 +36,7 @@
>
{{ 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"
@ -41,6 +48,31 @@
</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>
@ -125,7 +157,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useInventoryStore } from '../stores/inventory'
import SaveRecipeModal from './SaveRecipeModal.vue'
@ -141,6 +173,9 @@ 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)
@ -153,6 +188,10 @@ 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
@ -205,6 +244,25 @@ 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()
}
@ -227,6 +285,7 @@ async function loadRecipes() {
pantry_items: pantryItems.value.length > 0
? pantryItems.value.join(',')
: undefined,
subcategory: activeSubcategory.value ?? undefined,
}
)
recipes.value = result.recipes
@ -289,6 +348,36 @@ 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,6 +951,19 @@ 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 []
@ -1025,12 +1038,12 @@ function onNutritionInput(key: NutritionKey, e: Event) {
// Suggest handler
async function handleSuggest() {
isLoadingMore.value = false
await recipesStore.suggest(pantryItems.value)
await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value)
}
async function handleLoadMore() {
isLoadingMore.value = true
await recipesStore.loadMore(pantryItems.value)
await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value)
isLoadingMore.value = false
}

View file

@ -526,6 +526,7 @@ export interface RecipeResult {
export interface RecipeRequest {
pantry_items: string[]
secondary_pantry_items: Record<string, string>
level: number
constraints: string[]
allergies: string[]
@ -880,6 +881,12 @@ 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 {
@ -906,10 +913,17 @@ 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,10 +163,15 @@ export const useRecipesStore = defineStore('recipes', () => {
const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
function _buildRequest(
pantryItems: string[],
secondaryPantryItems: Record<string, 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,
@ -191,13 +196,13 @@ export const useRecipesStore = defineStore('recipes', () => {
}
}
async function suggest(pantryItems: string[]) {
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
loading.value = true
error.value = null
seenIds.value = new Set()
try {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
_trackSeen(result.value.suggestions)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
@ -206,14 +211,14 @@ export const useRecipesStore = defineStore('recipes', () => {
}
}
async function loadMore(pantryItems: string[]) {
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, 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, [...seenIds.value]))
const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value]))
if (more.suggestions.length === 0) {
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
} else {