Compare commits
3 commits
22a3da61c3
...
e7ba305e63
| Author | SHA1 | Date | |
|---|---|---|---|
| e7ba305e63 | |||
| b2c546e86a | |||
| 8fd77bd1f2 |
12 changed files with 627 additions and 79 deletions
|
|
@ -28,9 +28,12 @@ from app.services.recipe.assembly_recipes import (
|
||||||
)
|
)
|
||||||
from app.services.recipe.browser_domains import (
|
from app.services.recipe.browser_domains import (
|
||||||
DOMAINS,
|
DOMAINS,
|
||||||
|
category_has_subcategories,
|
||||||
get_category_names,
|
get_category_names,
|
||||||
get_domain_labels,
|
get_domain_labels,
|
||||||
get_keywords_for_category,
|
get_keywords_for_category,
|
||||||
|
get_keywords_for_subcategory,
|
||||||
|
get_subcategory_names,
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
from app.services.heimdall_orch import check_orch_budget
|
from app.services.heimdall_orch import check_orch_budget
|
||||||
|
|
@ -115,15 +118,42 @@ async def list_browse_categories(
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
keywords_by_category = {
|
cat_names = get_category_names(domain)
|
||||||
cat: get_keywords_for_category(domain, cat)
|
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
||||||
for cat in get_category_names(domain)
|
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]:
|
def _get(db_path: Path) -> list[dict]:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
return store.get_browser_categories(domain, keywords_by_category)
|
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
@ -137,16 +167,27 @@ async def browse_recipes(
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
|
subcategory: Annotated[str | None, Query()] = None,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
"""Return a paginated list of recipes for a domain/category.
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct
|
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||||
badges on each result.
|
Pass subcategory to narrow within a category that has subcategories.
|
||||||
"""
|
"""
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
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)
|
keywords = get_keywords_for_category(domain, category)
|
||||||
if not keywords:
|
if not keywords:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import logging
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -22,8 +23,13 @@ def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
|
||||||
Expected log output:
|
Expected log output:
|
||||||
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
||||||
INFO:app.api.endpoints.session: session auth=anon tier=free
|
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 {
|
return {
|
||||||
"auth": _auth_label(session.user_id),
|
"auth": _auth_label(session.user_id),
|
||||||
"tier": session.tier,
|
"tier": session.tier,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ class Settings:
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
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
|
# Feature flags
|
||||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1051,17 +1051,38 @@ class Store:
|
||||||
# ── recipe browser ────────────────────────────────────────────────────
|
# ── recipe browser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_browser_categories(
|
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]:
|
) -> 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
|
keywords_by_category maps category name → keyword list for counting.
|
||||||
match against recipes.category and recipes.keywords.
|
has_subcategories_by_category maps category name → bool (optional;
|
||||||
|
defaults to False for all categories when omitted).
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for category, keywords in keywords_by_category.items():
|
for category, keywords in keywords_by_category.items():
|
||||||
count = self._count_recipes_for_keywords(keywords)
|
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
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -1091,27 +1112,41 @@ class Store:
|
||||||
|
|
||||||
def browse_recipes(
|
def browse_recipes(
|
||||||
self,
|
self,
|
||||||
keywords: list[str],
|
keywords: list[str] | None,
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
pantry_items: list[str] | None = None,
|
pantry_items: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a page of recipes matching the keyword set.
|
"""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
|
Each recipe row includes match_pct (float | None) when pantry_items
|
||||||
is provided. match_pct is the fraction of ingredient_names covered by
|
is provided. match_pct is the fraction of ingredient_names covered by
|
||||||
the pantry set — computed deterministically, no LLM needed.
|
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}
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
||||||
match_expr = self._browser_fts_query(keywords)
|
|
||||||
offset = (page - 1) * page_size
|
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.
|
# Reuse cached count — avoids a second index scan on every page turn.
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
total = self._count_recipes_for_keywords(keywords)
|
||||||
|
|
||||||
c = self._cp
|
|
||||||
rows = self._fetch_all(
|
rows = self._fetch_all(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, title, category, keywords, ingredient_names,
|
SELECT id, title, category, keywords, ingredient_names,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ class NutritionFilters(BaseModel):
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
class RecipeRequest(BaseModel):
|
||||||
pantry_items: list[str]
|
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)
|
level: int = Field(default=1, ge=1, le=4)
|
||||||
constraints: list[str] = Field(default_factory=list)
|
constraints: list[str] = Field(default_factory=list)
|
||||||
expiry_first: bool = False
|
expiry_first: bool = False
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,18 @@ class ExpirationPredictor:
|
||||||
'uses': ['broth', 'soups', 'risotto', 'gratins'],
|
'uses': ['broth', 'soups', 'risotto', 'gratins'],
|
||||||
'warning': None,
|
'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:
|
def days_after_opening(self, category: str | None) -> int | None:
|
||||||
|
|
|
||||||
|
|
@ -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
|
Keyword matching is case-insensitive against the recipes.category column and the
|
||||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
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:
|
These are starter mappings based on the food.com dataset structure. Run:
|
||||||
|
|
||||||
SELECT category, count(*) FROM recipes
|
SELECT category, count(*) FROM recipes
|
||||||
|
|
@ -19,24 +25,211 @@ DOMAINS: dict[str, dict] = {
|
||||||
"cuisine": {
|
"cuisine": {
|
||||||
"label": "Cuisine",
|
"label": "Cuisine",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
"Italian": {
|
||||||
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||||
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
"subcategories": {
|
||||||
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
||||||
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
"involtini", "cannoli"],
|
||||||
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
||||||
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
"sfogliatelle", "ragù"],
|
||||||
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
"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": {
|
"meal_type": {
|
||||||
"label": "Meal Type",
|
"label": "Meal Type",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
"Breakfast": {
|
||||||
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
|
||||||
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
"oatmeal", "muffin"],
|
||||||
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
"subcategories": {
|
||||||
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
"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"],
|
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||||
},
|
},
|
||||||
|
|
@ -56,14 +249,72 @@ DOMAINS: dict[str, dict] = {
|
||||||
"main_ingredient": {
|
"main_ingredient": {
|
||||||
"label": "Main Ingredient",
|
"label": "Main Ingredient",
|
||||||
"categories": {
|
"categories": {
|
||||||
# These values match the inferred_tags written by tag_inferrer._MAIN_INGREDIENT_SIGNALS
|
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
||||||
# and indexed into recipe_browser_fts — use exact tag strings.
|
"Chicken": {
|
||||||
"Chicken": ["main:Chicken"],
|
"keywords": ["main:Chicken"],
|
||||||
"Beef": ["main:Beef"],
|
"subcategories": {
|
||||||
"Pork": ["main:Pork"],
|
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
||||||
"Fish": ["main:Fish"],
|
"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"],
|
"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"],
|
"Eggs": ["main:Eggs"],
|
||||||
"Legumes": ["main:Legumes"],
|
"Legumes": ["main:Legumes"],
|
||||||
"Grains": ["main:Grains"],
|
"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]:
|
def get_domain_labels() -> list[dict]:
|
||||||
"""Return [{id, label}] for all available domains."""
|
"""Return [{id, label}] for all available domains."""
|
||||||
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
||||||
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
"""Return the keyword list for the category (top-level, covers all subcategories).
|
||||||
domain_data = DOMAINS.get(domain, {})
|
|
||||||
categories = domain_data.get("categories", {})
|
For flat categories returns the list directly.
|
||||||
return categories.get(category, [])
|
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]:
|
def get_category_names(domain: str) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
||||||
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
||||||
# "3 oz. butter" → "butter"
|
# "3 oz. butter" → "butter"
|
||||||
|
|
@ -284,14 +302,24 @@ def _prep_note_for(ingredient: str) -> str | None:
|
||||||
return template.format(ingredient=ingredient_name)
|
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.
|
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
||||||
|
|
||||||
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
||||||
and adds the canonical form. This lets single-word recipe ingredients
|
and adds the canonical form. This lets single-word recipe ingredients
|
||||||
("hamburger", "chicken") match product-label pantry entries
|
("hamburger", "chicken") match product-label pantry entries
|
||||||
("burger patties", "rotisserie chicken").
|
("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()
|
expanded: set[str] = set()
|
||||||
for item in pantry_items:
|
for item in pantry_items:
|
||||||
lower = item.lower().strip()
|
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():
|
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
||||||
if pattern in lower:
|
if pattern in lower:
|
||||||
expanded.add(canonical)
|
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
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -634,7 +671,7 @@ class RecipeEngine:
|
||||||
|
|
||||||
profiles = self._classifier.classify_batch(req.pantry_items)
|
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||||
gaps = self._classifier.identify_gaps(profiles)
|
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:
|
if req.level >= 3:
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category list + Surprise Me -->
|
<!-- 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
|
<button
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat.category"
|
:key="cat.category"
|
||||||
|
|
@ -30,6 +36,7 @@
|
||||||
>
|
>
|
||||||
{{ cat.category }}
|
{{ cat.category }}
|
||||||
<span class="cat-count">{{ cat.recipe_count }}</span>
|
<span class="cat-count">{{ cat.recipe_count }}</span>
|
||||||
|
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories">›</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="categories.length > 1"
|
v-if="categories.length > 1"
|
||||||
|
|
@ -41,6 +48,31 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Recipe grid -->
|
||||||
<template v-if="activeCategory">
|
<template v-if="activeCategory">
|
||||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||||
|
|
@ -125,7 +157,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
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 { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
@ -141,6 +173,9 @@ const domains = ref<BrowserDomain[]>([])
|
||||||
const activeDomain = ref<string | null>(null)
|
const activeDomain = ref<string | null>(null)
|
||||||
const categories = ref<BrowserCategory[]>([])
|
const categories = ref<BrowserCategory[]>([])
|
||||||
const activeCategory = ref<string | null>(null)
|
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 recipes = ref<BrowserRecipe[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
@ -153,6 +188,10 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
|
||||||
const allCountsZero = computed(() =>
|
const allCountsZero = computed(() =>
|
||||||
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
|
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(() =>
|
const pantryItems = computed(() =>
|
||||||
inventoryStore.items
|
inventoryStore.items
|
||||||
|
|
@ -205,6 +244,25 @@ function surpriseMe() {
|
||||||
|
|
||||||
async function selectCategory(category: string) {
|
async function selectCategory(category: string) {
|
||||||
activeCategory.value = category
|
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
|
page.value = 1
|
||||||
await loadRecipes()
|
await loadRecipes()
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +285,7 @@ async function loadRecipes() {
|
||||||
pantry_items: pantryItems.value.length > 0
|
pantry_items: pantryItems.value.length > 0
|
||||||
? pantryItems.value.join(',')
|
? pantryItems.value.join(',')
|
||||||
: undefined,
|
: undefined,
|
||||||
|
subcategory: activeSubcategory.value ?? undefined,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipes.value = result.recipes
|
recipes.value = result.recipes
|
||||||
|
|
@ -289,6 +348,36 @@ async function doUnsave(recipeId: number) {
|
||||||
opacity: 1;
|
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 {
|
.recipe-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -951,6 +951,19 @@ const pantryItems = computed(() => {
|
||||||
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
|
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
|
// Grocery links relevant to a specific recipe's missing ingredients
|
||||||
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
|
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
|
||||||
if (!recipesStore.result) return []
|
if (!recipesStore.result) return []
|
||||||
|
|
@ -1025,12 +1038,12 @@ function onNutritionInput(key: NutritionKey, e: Event) {
|
||||||
// Suggest handler
|
// Suggest handler
|
||||||
async function handleSuggest() {
|
async function handleSuggest() {
|
||||||
isLoadingMore.value = false
|
isLoadingMore.value = false
|
||||||
await recipesStore.suggest(pantryItems.value)
|
await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoadMore() {
|
async function handleLoadMore() {
|
||||||
isLoadingMore.value = true
|
isLoadingMore.value = true
|
||||||
await recipesStore.loadMore(pantryItems.value)
|
await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value)
|
||||||
isLoadingMore.value = false
|
isLoadingMore.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,7 @@ export interface RecipeResult {
|
||||||
|
|
||||||
export interface RecipeRequest {
|
export interface RecipeRequest {
|
||||||
pantry_items: string[]
|
pantry_items: string[]
|
||||||
|
secondary_pantry_items: Record<string, string>
|
||||||
level: number
|
level: number
|
||||||
constraints: string[]
|
constraints: string[]
|
||||||
allergies: string[]
|
allergies: string[]
|
||||||
|
|
@ -880,6 +881,12 @@ export interface BrowserDomain {
|
||||||
export interface BrowserCategory {
|
export interface BrowserCategory {
|
||||||
category: string
|
category: string
|
||||||
recipe_count: number
|
recipe_count: number
|
||||||
|
has_subcategories: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserSubcategory {
|
||||||
|
subcategory: string
|
||||||
|
recipe_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserRecipe {
|
export interface BrowserRecipe {
|
||||||
|
|
@ -906,10 +913,17 @@ export const browserAPI = {
|
||||||
const response = await api.get(`/recipes/browse/${domain}`)
|
const response = await api.get(`/recipes/browse/${domain}`)
|
||||||
return response.data
|
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?: {
|
async browse(domain: string, category: string, params?: {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
pantry_items?: string
|
pantry_items?: string
|
||||||
|
subcategory?: string
|
||||||
}): Promise<BrowserResult> {
|
}): Promise<BrowserResult> {
|
||||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
|
|
|
||||||
|
|
@ -163,10 +163,15 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
|
|
||||||
const dismissedCount = computed(() => dismissedIds.value.size)
|
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])
|
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
||||||
return {
|
return {
|
||||||
pantry_items: pantryItems,
|
pantry_items: pantryItems,
|
||||||
|
secondary_pantry_items: secondaryPantryItems,
|
||||||
level: level.value,
|
level: level.value,
|
||||||
constraints: constraints.value,
|
constraints: constraints.value,
|
||||||
allergies: allergies.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
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
seenIds.value = new Set()
|
seenIds.value = new Set()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
|
||||||
_trackSeen(result.value.suggestions)
|
_trackSeen(result.value.suggestions)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
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
|
if (!result.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Exclude everything already shown (dismissed + all seen this session)
|
// 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) {
|
if (more.suggestions.length === 0) {
|
||||||
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue