Compare commits
No commits in common. "e7ba305e63eb49f13731e53fe4c9330a868ec825" and "22a3da61c309f50faff439dd4f680c89ba3e5e3d" have entirely different histories.
e7ba305e63
...
22a3da61c3
12 changed files with 79 additions and 627 deletions
|
|
@ -28,12 +28,9 @@ from app.services.recipe.assembly_recipes import (
|
|||
)
|
||||
from app.services.recipe.browser_domains import (
|
||||
DOMAINS,
|
||||
category_has_subcategories,
|
||||
get_category_names,
|
||||
get_domain_labels,
|
||||
get_keywords_for_category,
|
||||
get_keywords_for_subcategory,
|
||||
get_subcategory_names,
|
||||
)
|
||||
from app.services.recipe.recipe_engine import RecipeEngine
|
||||
from app.services.heimdall_orch import check_orch_budget
|
||||
|
|
@ -118,42 +115,15 @@ async def list_browse_categories(
|
|||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
|
||||
cat_names = get_category_names(domain)
|
||||
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
||||
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
|
||||
|
||||
def _get(db_path: Path) -> list[dict]:
|
||||
store = Store(db_path)
|
||||
try:
|
||||
return store.get_browser_categories(domain, keywords_by_category, has_subs)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
return await asyncio.to_thread(_get, session.db)
|
||||
|
||||
|
||||
@router.get("/browse/{domain}/{category}/subcategories")
|
||||
async def list_browse_subcategories(
|
||||
domain: str,
|
||||
category: str,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
if not category_has_subcategories(domain, category):
|
||||
return []
|
||||
|
||||
subcat_names = get_subcategory_names(domain, category)
|
||||
keywords_by_subcat = {
|
||||
sub: get_keywords_for_subcategory(domain, category, sub)
|
||||
for sub in subcat_names
|
||||
keywords_by_category = {
|
||||
cat: get_keywords_for_category(domain, cat)
|
||||
for cat in get_category_names(domain)
|
||||
}
|
||||
|
||||
def _get(db_path: Path) -> list[dict]:
|
||||
store = Store(db_path)
|
||||
try:
|
||||
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
||||
return store.get_browser_categories(domain, keywords_by_category)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
|
@ -167,33 +137,22 @@ async def browse_recipes(
|
|||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
pantry_items: Annotated[str | None, Query()] = None,
|
||||
subcategory: Annotated[str | None, Query()] = None,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Return a paginated list of recipes for a domain/category.
|
||||
|
||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||
Pass subcategory to narrow within a category that has subcategories.
|
||||
Pass pantry_items as a comma-separated string to receive match_pct
|
||||
badges on each result.
|
||||
"""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
|
||||
if category == "_all":
|
||||
keywords = None # unfiltered browse
|
||||
elif subcategory:
|
||||
keywords = get_keywords_for_subcategory(domain, category, subcategory)
|
||||
if not keywords:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
|
||||
)
|
||||
else:
|
||||
keywords = get_keywords_for_category(domain, category)
|
||||
if not keywords:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
||||
)
|
||||
keywords = get_keywords_for_category(domain, category)
|
||||
if not keywords:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
||||
)
|
||||
|
||||
pantry_list = (
|
||||
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import logging
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -23,13 +22,8 @@ def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
|
|||
Expected log output:
|
||||
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
||||
INFO:app.api.endpoints.session: session auth=anon tier=free
|
||||
|
||||
E2E test sessions (E2E_TEST_USER_ID) are logged at DEBUG so they don't
|
||||
pollute analytics counts while still being visible when DEBUG=true.
|
||||
"""
|
||||
is_test = bool(settings.E2E_TEST_USER_ID and session.user_id == settings.E2E_TEST_USER_ID)
|
||||
logger = log.debug if is_test else log.info
|
||||
logger("session auth=%s tier=%s%s", _auth_label(session.user_id), session.tier, " e2e=true" if is_test else "")
|
||||
log.info("session auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
||||
return {
|
||||
"auth": _auth_label(session.user_id),
|
||||
"tier": session.tier,
|
||||
|
|
|
|||
|
|
@ -60,10 +60,6 @@ class Settings:
|
|||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||
|
||||
# E2E test account — analytics logging is suppressed for this user_id so test
|
||||
# runs don't pollute session counts. Set to the Directus UUID of the test user.
|
||||
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
|
||||
|
||||
# Feature flags
|
||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
|
|
|||
|
|
@ -1051,38 +1051,17 @@ class Store:
|
|||
# ── recipe browser ────────────────────────────────────────────────────
|
||||
|
||||
def get_browser_categories(
|
||||
self,
|
||||
domain: str,
|
||||
keywords_by_category: dict[str, list[str]],
|
||||
has_subcategories_by_category: dict[str, bool] | None = None,
|
||||
self, domain: str, keywords_by_category: dict[str, list[str]]
|
||||
) -> list[dict]:
|
||||
"""Return [{category, recipe_count, has_subcategories}] for each category.
|
||||
"""Return [{category, recipe_count}] for each category in the domain.
|
||||
|
||||
keywords_by_category maps category name → keyword list for counting.
|
||||
has_subcategories_by_category maps category name → bool (optional;
|
||||
defaults to False for all categories when omitted).
|
||||
keywords_by_category maps category name to the keyword list used to
|
||||
match against recipes.category and recipes.keywords.
|
||||
"""
|
||||
results = []
|
||||
for category, keywords in keywords_by_category.items():
|
||||
count = self._count_recipes_for_keywords(keywords)
|
||||
results.append({
|
||||
"category": category,
|
||||
"recipe_count": count,
|
||||
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
|
||||
})
|
||||
return results
|
||||
|
||||
def get_browser_subcategories(
|
||||
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
|
||||
) -> list[dict]:
|
||||
"""Return [{subcategory, recipe_count}] for each subcategory.
|
||||
|
||||
Mirrors get_browser_categories but for the second level.
|
||||
"""
|
||||
results = []
|
||||
for subcat, keywords in keywords_by_subcategory.items():
|
||||
count = self._count_recipes_for_keywords(keywords)
|
||||
results.append({"subcategory": subcat, "recipe_count": count})
|
||||
results.append({"category": category, "recipe_count": count})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -1112,55 +1091,41 @@ class Store:
|
|||
|
||||
def browse_recipes(
|
||||
self,
|
||||
keywords: list[str] | None,
|
||||
keywords: list[str],
|
||||
page: int,
|
||||
page_size: int,
|
||||
pantry_items: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Return a page of recipes matching the keyword set.
|
||||
|
||||
Pass keywords=None to browse all recipes without category filtering.
|
||||
Each recipe row includes match_pct (float | None) when pantry_items
|
||||
is provided. match_pct is the fraction of ingredient_names covered by
|
||||
the pantry set — computed deterministically, no LLM needed.
|
||||
"""
|
||||
if keywords is not None and not keywords:
|
||||
if not keywords:
|
||||
return {"recipes": [], "total": 0, "page": page}
|
||||
|
||||
match_expr = self._browser_fts_query(keywords)
|
||||
offset = (page - 1) * page_size
|
||||
c = self._cp
|
||||
|
||||
if keywords is None:
|
||||
# "All" browse — unfiltered paginated scan.
|
||||
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
||||
rows = self._fetch_all(
|
||||
f"""
|
||||
SELECT id, title, category, keywords, ingredient_names,
|
||||
calories, fat_g, protein_g, sodium_mg
|
||||
FROM {c}recipes
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, offset),
|
||||
)
|
||||
else:
|
||||
match_expr = self._browser_fts_query(keywords)
|
||||
# Reuse cached count — avoids a second index scan on every page turn.
|
||||
total = self._count_recipes_for_keywords(keywords)
|
||||
rows = self._fetch_all(
|
||||
f"""
|
||||
SELECT id, title, category, keywords, ingredient_names,
|
||||
calories, fat_g, protein_g, sodium_mg
|
||||
FROM {c}recipes
|
||||
WHERE id IN (
|
||||
SELECT rowid FROM {c}recipe_browser_fts
|
||||
WHERE recipe_browser_fts MATCH ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(match_expr, page_size, offset),
|
||||
# Reuse cached count — avoids a second index scan on every page turn.
|
||||
total = self._count_recipes_for_keywords(keywords)
|
||||
|
||||
c = self._cp
|
||||
rows = self._fetch_all(
|
||||
f"""
|
||||
SELECT id, title, category, keywords, ingredient_names,
|
||||
calories, fat_g, protein_g, sodium_mg
|
||||
FROM {c}recipes
|
||||
WHERE id IN (
|
||||
SELECT rowid FROM {c}recipe_browser_fts
|
||||
WHERE recipe_browser_fts MATCH ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(match_expr, page_size, offset),
|
||||
)
|
||||
|
||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||
recipes = []
|
||||
|
|
|
|||
|
|
@ -71,10 +71,6 @@ class NutritionFilters(BaseModel):
|
|||
|
||||
class RecipeRequest(BaseModel):
|
||||
pantry_items: list[str]
|
||||
# Maps product name → secondary state label for items past nominal expiry
|
||||
# but still within their secondary use window (e.g. {"Bread": "stale"}).
|
||||
# Used by the recipe engine to boost recipes suited to those specific states.
|
||||
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
|
||||
level: int = Field(default=1, ge=1, le=4)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
expiry_first: bool = False
|
||||
|
|
|
|||
|
|
@ -194,18 +194,6 @@ class ExpirationPredictor:
|
|||
'uses': ['broth', 'soups', 'risotto', 'gratins'],
|
||||
'warning': None,
|
||||
},
|
||||
'rice': {
|
||||
'window_days': 2,
|
||||
'label': 'day-old',
|
||||
'uses': ['fried rice', 'rice bowls', 'rice porridge'],
|
||||
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
|
||||
},
|
||||
'tortillas': {
|
||||
'window_days': 5,
|
||||
'label': 'stale',
|
||||
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
|
||||
'warning': None,
|
||||
},
|
||||
}
|
||||
|
||||
def days_after_opening(self, category: str | None) -> int | None:
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
|
|||
Keyword matching is case-insensitive against the recipes.category column and the
|
||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||
|
||||
Category values are either:
|
||||
- list[str] — flat keyword list (no subcategories)
|
||||
- dict — {"keywords": list[str], "subcategories": {name: list[str]}}
|
||||
keywords covers the whole category (used for "All X" browse);
|
||||
subcategories each have their own narrower keyword list.
|
||||
|
||||
These are starter mappings based on the food.com dataset structure. Run:
|
||||
|
||||
SELECT category, count(*) FROM recipes
|
||||
|
|
@ -25,213 +19,26 @@ DOMAINS: dict[str, dict] = {
|
|||
"cuisine": {
|
||||
"label": "Cuisine",
|
||||
"categories": {
|
||||
"Italian": {
|
||||
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||
"subcategories": {
|
||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
||||
"involtini", "cannoli"],
|
||||
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
||||
"sfogliatelle", "ragù"],
|
||||
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
|
||||
"pappardelle", "crostini"],
|
||||
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
|
||||
"amatriciana", "gricia", "supplì"],
|
||||
"Venetian": ["venetian", "venice", "risotto", "bigoli",
|
||||
"baccalà", "sarde in saor"],
|
||||
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
|
||||
"trofie", "farinata"],
|
||||
},
|
||||
},
|
||||
"Mexican": {
|
||||
"keywords": ["mexican", "tex-mex", "taco", "enchilada", "burrito",
|
||||
"salsa", "guacamole"],
|
||||
"subcategories": {
|
||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
||||
"chapulines", "mezcal"],
|
||||
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
|
||||
"sopa de lima", "panuchos"],
|
||||
"Veracruz": ["veracruz", "huachinango", "picadas", "enfrijoladas"],
|
||||
"Street Food": ["taco", "elote", "tlacoyos", "torta",
|
||||
"tamale", "quesadilla"],
|
||||
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
|
||||
"mole poblano"],
|
||||
},
|
||||
},
|
||||
"Asian": {
|
||||
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
||||
"stir fry", "stir-fry", "ramen", "sushi"],
|
||||
"subcategories": {
|
||||
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
|
||||
"doenjang", "gochujang"],
|
||||
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
|
||||
"teriyaki", "udon", "soba", "bento", "yakitori"],
|
||||
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
|
||||
"spring roll", "szechuan", "sichuan", "cantonese",
|
||||
"chow mein", "mapo", "lo mein"],
|
||||
"Thai": ["thai", "pad thai", "green curry", "red curry",
|
||||
"coconut milk", "lemongrass", "satay", "tom yum"],
|
||||
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
|
||||
"vermicelli", "nuoc cham", "bun bo"],
|
||||
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
|
||||
"kare-kare", "lechon"],
|
||||
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
|
||||
"tempeh", "sambal"],
|
||||
},
|
||||
},
|
||||
"Indian": {
|
||||
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
|
||||
"biryani", "naan", "chutney"],
|
||||
"subcategories": {
|
||||
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
|
||||
"naan", "tandoori", "butter chicken", "palak"],
|
||||
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
|
||||
"sambar", "rasam", "coconut chutney"],
|
||||
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe"],
|
||||
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu"],
|
||||
},
|
||||
},
|
||||
"Mediterranean": {
|
||||
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
|
||||
"moroccan", "lebanese"],
|
||||
"subcategories": {
|
||||
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
|
||||
"souvlaki", "dolmades"],
|
||||
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
|
||||
"lahmacun"],
|
||||
"Moroccan": ["moroccan", "tagine", "couscous", "harissa",
|
||||
"chermoula", "preserved lemon"],
|
||||
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
|
||||
"tabbouleh", "kibbeh", "fattoush"],
|
||||
"Israeli": ["israeli", "shakshuka", "sabich", "za'atar",
|
||||
"tahini"],
|
||||
},
|
||||
},
|
||||
"American": {
|
||||
"keywords": ["american", "southern", "bbq", "barbecue", "comfort food",
|
||||
"cajun", "creole"],
|
||||
"subcategories": {
|
||||
"Southern": ["southern", "soul food", "fried chicken",
|
||||
"collard greens", "cornbread", "biscuits and gravy"],
|
||||
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
|
||||
"jambalaya", "etouffee", "dirty rice"],
|
||||
"BBQ": ["bbq", "barbecue", "smoked", "brisket", "pulled pork",
|
||||
"ribs", "pit"],
|
||||
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
|
||||
"queso"],
|
||||
"New England": ["new england", "chowder", "lobster", "clam",
|
||||
"maple", "yankee"],
|
||||
},
|
||||
},
|
||||
"European": {
|
||||
"keywords": ["french", "german", "spanish", "british", "irish",
|
||||
"scandinavian"],
|
||||
"subcategories": {
|
||||
"French": ["french", "provencal", "beurre", "crepe",
|
||||
"ratatouille", "cassoulet", "bouillabaisse"],
|
||||
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
|
||||
"tortilla espanola", "chorizo"],
|
||||
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
|
||||
"pretzel", "strudel"],
|
||||
"British/Irish": ["british", "irish", "english", "pub food",
|
||||
"shepherd's pie", "bangers", "scones"],
|
||||
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
|
||||
"danish", "gravlax", "meatballs"],
|
||||
},
|
||||
},
|
||||
"Latin American": {
|
||||
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
|
||||
"cuban", "caribbean", "brazilian"],
|
||||
"subcategories": {
|
||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
||||
"aji amarillo"],
|
||||
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
|
||||
"brigadeiro"],
|
||||
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
|
||||
"sancocho"],
|
||||
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
|
||||
"picadillo", "mojito"],
|
||||
"Caribbean": ["caribbean", "jamaican", "jerk", "trinidadian",
|
||||
"plantain", "roti"],
|
||||
},
|
||||
},
|
||||
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
||||
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
||||
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
||||
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
||||
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
||||
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
||||
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
||||
},
|
||||
},
|
||||
"meal_type": {
|
||||
"label": "Meal Type",
|
||||
"categories": {
|
||||
"Breakfast": {
|
||||
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
|
||||
"oatmeal", "muffin"],
|
||||
"subcategories": {
|
||||
"Eggs": ["egg", "omelette", "frittata", "quiche",
|
||||
"scrambled", "benedict", "shakshuka"],
|
||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
||||
"coffee cake", "danish"],
|
||||
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
|
||||
"overnight oats"],
|
||||
},
|
||||
},
|
||||
"Lunch": {
|
||||
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||
"subcategories": {
|
||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
||||
"grilled cheese", "blt"],
|
||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
||||
"niçoise", "cobb"],
|
||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
||||
"minestrone", "lentil soup"],
|
||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
||||
"quesadilla"],
|
||||
},
|
||||
},
|
||||
"Dinner": {
|
||||
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||
"subcategories": {
|
||||
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
|
||||
"sheperd's pie", "pot pie"],
|
||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
||||
"daube", "ragù"],
|
||||
"Grilled": ["grilled", "grill", "barbecue", "charred",
|
||||
"kebab", "skewer"],
|
||||
"Stir-Fries": ["stir fry", "stir-fry", "wok", "sauté",
|
||||
"sauteed"],
|
||||
"Roasts": ["roast", "roasted", "oven", "baked chicken",
|
||||
"pot roast"],
|
||||
},
|
||||
},
|
||||
"Snack": {
|
||||
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
|
||||
"starter"],
|
||||
"subcategories": {
|
||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
||||
"salsa", "pate"],
|
||||
"Finger Foods": ["finger food", "bite", "skewer", "slider",
|
||||
"wing", "nugget"],
|
||||
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
|
||||
"pretzel"],
|
||||
},
|
||||
},
|
||||
"Dessert": {
|
||||
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
|
||||
"ice cream", "brownie"],
|
||||
"subcategories": {
|
||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
||||
"cheesecake", "torte"],
|
||||
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
|
||||
"biscotti", "shortbread"],
|
||||
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
|
||||
"crumble"],
|
||||
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
|
||||
"popsicle", "granita"],
|
||||
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
|
||||
"flan", "creme brulee"],
|
||||
"Candy": ["candy", "fudge", "truffle", "brittle",
|
||||
"caramel", "toffee"],
|
||||
},
|
||||
},
|
||||
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
||||
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
||||
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
||||
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||
},
|
||||
},
|
||||
"dietary": {
|
||||
|
|
@ -249,128 +56,33 @@ DOMAINS: dict[str, dict] = {
|
|||
"main_ingredient": {
|
||||
"label": "Main Ingredient",
|
||||
"categories": {
|
||||
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
||||
"Chicken": {
|
||||
"keywords": ["main:Chicken"],
|
||||
"subcategories": {
|
||||
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
||||
"chicken bake"],
|
||||
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
|
||||
"chicken skewer"],
|
||||
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
|
||||
"crispy chicken"],
|
||||
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
|
||||
"chicken curry", "chicken braise"],
|
||||
},
|
||||
},
|
||||
"Beef": {
|
||||
"keywords": ["main:Beef"],
|
||||
"subcategories": {
|
||||
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
|
||||
"bolognese", "burger"],
|
||||
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
|
||||
"filet mignon", "t-bone"],
|
||||
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
|
||||
"chuck roast"],
|
||||
"Stews": ["beef stew", "beef braise", "beef bourguignon",
|
||||
"short ribs"],
|
||||
},
|
||||
},
|
||||
"Pork": {
|
||||
"keywords": ["main:Pork"],
|
||||
"subcategories": {
|
||||
"Chops": ["pork chop", "pork loin", "pork cutlet"],
|
||||
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
|
||||
"carnitas", "slow cooker pork"],
|
||||
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
|
||||
"Italian sausage"],
|
||||
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
|
||||
"pork belly"],
|
||||
},
|
||||
},
|
||||
"Fish": {
|
||||
"keywords": ["main:Fish"],
|
||||
"subcategories": {
|
||||
"Salmon": ["salmon", "smoked salmon", "gravlax"],
|
||||
"Tuna": ["tuna", "albacore", "ahi"],
|
||||
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
|
||||
"flounder", "bass"],
|
||||
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
|
||||
"mussel", "clam", "oyster"],
|
||||
},
|
||||
},
|
||||
# These values match the inferred_tags written by tag_inferrer._MAIN_INGREDIENT_SIGNALS
|
||||
# and indexed into recipe_browser_fts — use exact tag strings.
|
||||
"Chicken": ["main:Chicken"],
|
||||
"Beef": ["main:Beef"],
|
||||
"Pork": ["main:Pork"],
|
||||
"Fish": ["main:Fish"],
|
||||
"Pasta": ["main:Pasta"],
|
||||
"Vegetables": {
|
||||
"keywords": ["main:Vegetables"],
|
||||
"subcategories": {
|
||||
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
|
||||
"parsnip", "turnip"],
|
||||
"Leafy": ["spinach", "kale", "chard", "arugula",
|
||||
"collard greens", "lettuce"],
|
||||
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
|
||||
"cabbage", "bok choy"],
|
||||
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
|
||||
"squash"],
|
||||
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
|
||||
"chanterelle"],
|
||||
},
|
||||
},
|
||||
"Eggs": ["main:Eggs"],
|
||||
"Legumes": ["main:Legumes"],
|
||||
"Grains": ["main:Grains"],
|
||||
"Cheese": ["main:Cheese"],
|
||||
"Vegetables": ["main:Vegetables"],
|
||||
"Eggs": ["main:Eggs"],
|
||||
"Legumes": ["main:Legumes"],
|
||||
"Grains": ["main:Grains"],
|
||||
"Cheese": ["main:Cheese"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
|
||||
"""Return the raw category definition, or None if not found."""
|
||||
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
|
||||
|
||||
|
||||
def get_domain_labels() -> list[dict]:
|
||||
"""Return [{id, label}] for all available domains."""
|
||||
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
||||
|
||||
|
||||
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
||||
"""Return the keyword list for the category (top-level, covers all subcategories).
|
||||
|
||||
For flat categories returns the list directly.
|
||||
For nested categories returns the 'keywords' key.
|
||||
Returns [] if category or domain not found.
|
||||
"""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if cat_def is None:
|
||||
return []
|
||||
if isinstance(cat_def, list):
|
||||
return cat_def
|
||||
return cat_def.get("keywords", [])
|
||||
|
||||
|
||||
def category_has_subcategories(domain: str, category: str) -> bool:
|
||||
"""Return True when a category has a subcategory level."""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if not isinstance(cat_def, dict):
|
||||
return False
|
||||
return bool(cat_def.get("subcategories"))
|
||||
|
||||
|
||||
def get_subcategory_names(domain: str, category: str) -> list[str]:
|
||||
"""Return subcategory names for a category, or [] if none exist."""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if not isinstance(cat_def, dict):
|
||||
return []
|
||||
return list(cat_def.get("subcategories", {}).keys())
|
||||
|
||||
|
||||
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
|
||||
"""Return keyword list for a specific subcategory, or [] if not found."""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if not isinstance(cat_def, dict):
|
||||
return []
|
||||
return cat_def.get("subcategories", {}).get(subcategory, [])
|
||||
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
||||
domain_data = DOMAINS.get(domain, {})
|
||||
categories = domain_data.get("categories", {})
|
||||
return categories.get(category, [])
|
||||
|
||||
|
||||
def get_category_names(domain: str) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -155,24 +155,6 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
|
|||
}
|
||||
|
||||
|
||||
# When a pantry item is in a secondary state (e.g. bread → "stale"), expand
|
||||
# the pantry set with terms that recipe ingredients commonly use to describe
|
||||
# that state. This lets "stale bread" in a recipe ingredient match a pantry
|
||||
# entry that is simply called "Bread" but is past its nominal use-by date.
|
||||
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
|
||||
# Values are additional strings added to the pantry set for FTS coverage.
|
||||
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
|
||||
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
|
||||
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"],
|
||||
("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"],
|
||||
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"],
|
||||
("dairy", "sour"): ["sour milk", "slightly sour milk"],
|
||||
("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"],
|
||||
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"],
|
||||
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
|
||||
}
|
||||
|
||||
|
||||
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
||||
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
||||
# "3 oz. butter" → "butter"
|
||||
|
|
@ -302,24 +284,14 @@ def _prep_note_for(ingredient: str) -> str | None:
|
|||
return template.format(ingredient=ingredient_name)
|
||||
|
||||
|
||||
def _expand_pantry_set(
|
||||
pantry_items: list[str],
|
||||
secondary_pantry_items: dict[str, str] | None = None,
|
||||
) -> set[str]:
|
||||
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
|
||||
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
||||
|
||||
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
||||
and adds the canonical form. This lets single-word recipe ingredients
|
||||
("hamburger", "chicken") match product-label pantry entries
|
||||
("burger patties", "rotisserie chicken").
|
||||
|
||||
If secondary_pantry_items is provided (product_name → state label), items
|
||||
in a secondary state also receive state-specific synonym expansion so that
|
||||
recipe ingredients like "stale bread" or "day-old rice" are matched.
|
||||
"""
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
_predictor = ExpirationPredictor()
|
||||
|
||||
expanded: set[str] = set()
|
||||
for item in pantry_items:
|
||||
lower = item.lower().strip()
|
||||
|
|
@ -327,15 +299,6 @@ def _expand_pantry_set(
|
|||
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
||||
if pattern in lower:
|
||||
expanded.add(canonical)
|
||||
|
||||
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
|
||||
if secondary_pantry_items and item in secondary_pantry_items:
|
||||
state_label = secondary_pantry_items[item]
|
||||
category = _predictor.get_category_from_product(item)
|
||||
if category:
|
||||
synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), [])
|
||||
expanded.update(synonyms)
|
||||
|
||||
return expanded
|
||||
|
||||
|
||||
|
|
@ -671,7 +634,7 @@ class RecipeEngine:
|
|||
|
||||
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||
gaps = self._classifier.identify_gaps(profiles)
|
||||
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
|
||||
pantry_set = _expand_pantry_set(req.pantry_items)
|
||||
|
||||
if req.level >= 3:
|
||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||
|
|
|
|||
|
|
@ -21,13 +21,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Category list + Surprise Me -->
|
||||
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
|
||||
@click="selectCategory('_all')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<div v-else class="category-list mb-md flex flex-wrap gap-xs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.category"
|
||||
|
|
@ -36,7 +30,6 @@
|
|||
>
|
||||
{{ cat.category }}
|
||||
<span class="cat-count">{{ cat.recipe_count }}</span>
|
||||
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories">›</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="categories.length > 1"
|
||||
|
|
@ -48,31 +41,6 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subcategory row — shown when the active category has subcategories -->
|
||||
<div
|
||||
v-if="activeCategoryHasSubs && (subcategories.length > 0 || loadingSubcategories)"
|
||||
class="subcategory-list mb-md flex flex-wrap gap-xs"
|
||||
>
|
||||
<span v-if="loadingSubcategories" class="text-secondary text-xs">Loading…</span>
|
||||
<template v-else>
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
|
||||
@click="selectSubcategory(null)"
|
||||
>
|
||||
All {{ activeCategory }}
|
||||
</button>
|
||||
<button
|
||||
v-for="sub in subcategories"
|
||||
:key="sub.subcategory"
|
||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
|
||||
@click="selectSubcategory(sub.subcategory)"
|
||||
>
|
||||
{{ sub.subcategory }}
|
||||
<span class="cat-count">{{ sub.recipe_count }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Recipe grid -->
|
||||
<template v-if="activeCategory">
|
||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||
|
|
@ -157,7 +125,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
|
||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { useInventoryStore } from '../stores/inventory'
|
||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||
|
|
@ -173,9 +141,6 @@ const domains = ref<BrowserDomain[]>([])
|
|||
const activeDomain = ref<string | null>(null)
|
||||
const categories = ref<BrowserCategory[]>([])
|
||||
const activeCategory = ref<string | null>(null)
|
||||
const subcategories = ref<BrowserSubcategory[]>([])
|
||||
const activeSubcategory = ref<string | null>(null)
|
||||
const loadingSubcategories = ref(false)
|
||||
const recipes = ref<BrowserRecipe[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
|
@ -188,10 +153,6 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
|
|||
const allCountsZero = computed(() =>
|
||||
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
|
||||
)
|
||||
const activeCategoryHasSubs = computed(() => {
|
||||
if (!activeCategory.value || activeCategory.value === '_all') return false
|
||||
return categories.value.find(c => c.category === activeCategory.value)?.has_subcategories ?? false
|
||||
})
|
||||
|
||||
const pantryItems = computed(() =>
|
||||
inventoryStore.items
|
||||
|
|
@ -244,25 +205,6 @@ function surpriseMe() {
|
|||
|
||||
async function selectCategory(category: string) {
|
||||
activeCategory.value = category
|
||||
activeSubcategory.value = null
|
||||
subcategories.value = []
|
||||
page.value = 1
|
||||
|
||||
// Fetch subcategories in the background when the category supports them,
|
||||
// then immediately start loading recipes at the full-category level.
|
||||
const catMeta = categories.value.find(c => c.category === category)
|
||||
if (catMeta?.has_subcategories) {
|
||||
loadingSubcategories.value = true
|
||||
browserAPI.listSubcategories(activeDomain.value!, category)
|
||||
.then(subs => { subcategories.value = subs })
|
||||
.finally(() => { loadingSubcategories.value = false })
|
||||
}
|
||||
|
||||
await loadRecipes()
|
||||
}
|
||||
|
||||
async function selectSubcategory(subcat: string | null) {
|
||||
activeSubcategory.value = subcat
|
||||
page.value = 1
|
||||
await loadRecipes()
|
||||
}
|
||||
|
|
@ -285,7 +227,6 @@ async function loadRecipes() {
|
|||
pantry_items: pantryItems.value.length > 0
|
||||
? pantryItems.value.join(',')
|
||||
: undefined,
|
||||
subcategory: activeSubcategory.value ?? undefined,
|
||||
}
|
||||
)
|
||||
recipes.value = result.recipes
|
||||
|
|
@ -348,36 +289,6 @@ async function doUnsave(recipeId: number) {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.cat-drill-indicator {
|
||||
margin-left: var(--spacing-xs);
|
||||
opacity: 0.5;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.subcategory-list {
|
||||
padding-left: var(--spacing-sm);
|
||||
border-left: 2px solid var(--color-border);
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.subcat-btn {
|
||||
font-size: var(--font-size-xs, 0.78rem);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.subcat-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subcat-btn.active .cat-count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -951,19 +951,6 @@ const pantryItems = computed(() => {
|
|||
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
|
||||
})
|
||||
|
||||
// Secondary-state items: expired but still usable in specific recipes.
|
||||
// Maps product_name → secondary_state label (e.g. "Bread" → "stale").
|
||||
// Sent alongside pantry_items so the recipe engine can boost relevant recipes.
|
||||
const secondaryPantryItems = computed<Record<string, string>>(() => {
|
||||
const result: Record<string, string> = {}
|
||||
for (const item of inventoryStore.items) {
|
||||
if (item.secondary_state && item.product_name) {
|
||||
result[item.product_name] = item.secondary_state
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Grocery links relevant to a specific recipe's missing ingredients
|
||||
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
|
||||
if (!recipesStore.result) return []
|
||||
|
|
@ -1038,12 +1025,12 @@ function onNutritionInput(key: NutritionKey, e: Event) {
|
|||
// Suggest handler
|
||||
async function handleSuggest() {
|
||||
isLoadingMore.value = false
|
||||
await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value)
|
||||
await recipesStore.suggest(pantryItems.value)
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
isLoadingMore.value = true
|
||||
await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value)
|
||||
await recipesStore.loadMore(pantryItems.value)
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -526,7 +526,6 @@ export interface RecipeResult {
|
|||
|
||||
export interface RecipeRequest {
|
||||
pantry_items: string[]
|
||||
secondary_pantry_items: Record<string, string>
|
||||
level: number
|
||||
constraints: string[]
|
||||
allergies: string[]
|
||||
|
|
@ -881,12 +880,6 @@ export interface BrowserDomain {
|
|||
export interface BrowserCategory {
|
||||
category: string
|
||||
recipe_count: number
|
||||
has_subcategories: boolean
|
||||
}
|
||||
|
||||
export interface BrowserSubcategory {
|
||||
subcategory: string
|
||||
recipe_count: number
|
||||
}
|
||||
|
||||
export interface BrowserRecipe {
|
||||
|
|
@ -913,17 +906,10 @@ export const browserAPI = {
|
|||
const response = await api.get(`/recipes/browse/${domain}`)
|
||||
return response.data
|
||||
},
|
||||
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
|
||||
const response = await api.get(
|
||||
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
async browse(domain: string, category: string, params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
pantry_items?: string
|
||||
subcategory?: string
|
||||
}): Promise<BrowserResult> {
|
||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||
return response.data
|
||||
|
|
|
|||
|
|
@ -163,15 +163,10 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
|
||||
const dismissedCount = computed(() => dismissedIds.value.size)
|
||||
|
||||
function _buildRequest(
|
||||
pantryItems: string[],
|
||||
secondaryPantryItems: Record<string, string> = {},
|
||||
extraExcluded: number[] = [],
|
||||
): RecipeRequest {
|
||||
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
|
||||
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
||||
return {
|
||||
pantry_items: pantryItems,
|
||||
secondary_pantry_items: secondaryPantryItems,
|
||||
level: level.value,
|
||||
constraints: constraints.value,
|
||||
allergies: allergies.value,
|
||||
|
|
@ -196,13 +191,13 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
||||
async function suggest(pantryItems: string[]) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
seenIds.value = new Set()
|
||||
|
||||
try {
|
||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
|
||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
||||
_trackSeen(result.value.suggestions)
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
||||
|
|
@ -211,14 +206,14 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
||||
async function loadMore(pantryItems: string[]) {
|
||||
if (!result.value || loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Exclude everything already shown (dismissed + all seen this session)
|
||||
const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value]))
|
||||
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
|
||||
if (more.suggestions.length === 0) {
|
||||
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue