From 1ac7e3d76a0cb0354219a59e261ceb6e7541b858 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 21 Apr 2026 15:04:34 -0700 Subject: [PATCH] feat(browse): sort recipes by pantry match percentage Adds 'Best match' sort button to the recipe browser. When selected, recipes are ordered by the fraction of their ingredients that are in the user's pantry. - store.py: _browse_by_match() pushes match_pct computation into SQL via json_each() so ORDER BY can sort the full result set before LIMIT/OFFSET pagination - recipes.py: extends sort pattern validation to accept 'match'; falls back to default when no pantry_items provided - RecipeBrowserPanel.vue: adds 'Best match' button (disabled when pantry empty); watcher auto-engages match sort when pantry goes from empty to non-empty --- app/api/endpoints/recipes.py | 5 +- app/db/store.py | 115 ++++++++++++++++-- .../src/components/RecipeBrowserPanel.vue | 22 +++- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index ee65761..20b70d5 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -245,14 +245,15 @@ async def browse_recipes( pantry_items: Annotated[str | None, Query()] = None, subcategory: Annotated[str | None, Query()] = None, q: Annotated[str | None, Query(max_length=200)] = None, - sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc)$")] = "default", + sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc|match)$")] = "default", 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 q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc). + Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc/match). + sort=match orders by pantry coverage DESC; falls back to default when no pantry_items. """ if domain not in DOMAINS: raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.") diff --git a/app/db/store.py b/app/db/store.py index d4e74fc..6066ed9 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1161,21 +1161,33 @@ class Store: is provided. match_pct is the fraction of ingredient_names covered by the pantry set — computed deterministically, no LLM needed. - q: optional title substring filter (case-insensitive LIKE). - sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A). + q: optional title substring filter (case-insensitive LIKE). + sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A) + | "match" (pantry coverage DESC — falls back to default when no pantry). """ if keywords is not None and not keywords: return {"recipes": [], "total": 0, "page": page} offset = (page - 1) * page_size c = self._cp + pantry_set = {p.lower() for p in pantry_items} if pantry_items else None + + # "match" sort requires pantry items; fall back gracefully when absent. + effective_sort = sort if (sort != "match" or pantry_set) else "default" order_clause = { "alpha": "ORDER BY title ASC", "alpha_desc": "ORDER BY title DESC", - }.get(sort, "ORDER BY id ASC") + }.get(effective_sort, "ORDER BY id ASC") q_param = f"%{q.strip()}%" if q and q.strip() else None + + # ── match sort: push match_pct computation into SQL so ORDER BY works ── + if effective_sort == "match" and pantry_set: + return self._browse_by_match( + keywords, page, page_size, offset, pantry_set, q_param, c + ) + cols = ( f"SELECT id, title, category, keywords, ingredient_names," f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes" @@ -1217,26 +1229,107 @@ class Store: (match_expr, page_size, offset), ) - pantry_set = {p.lower() for p in pantry_items} if pantry_items else None recipes = [] for r in rows: entry = { - "id": r["id"], - "title": r["title"], - "category": r["category"], - "match_pct": None, + "id": r["id"], + "title": r["title"], + "category": r["category"], + "match_pct": None, } if pantry_set: names = r.get("ingredient_names") or [] if names: - matched = sum( - 1 for n in names if n.lower() in pantry_set - ) + matched = sum(1 for n in names if n.lower() in pantry_set) entry["match_pct"] = round(matched / len(names), 3) recipes.append(entry) return {"recipes": recipes, "total": total, "page": page} + def _browse_by_match( + self, + keywords: list[str] | None, + page: int, + page_size: int, + offset: int, + pantry_set: set[str], + q_param: str | None, + c: str, + ) -> dict: + """Browse recipes sorted by pantry match percentage, computed in SQL. + + Uses json_each() to count how many of each recipe's ingredient_names + appear in the pantry set, then sorts highest-first. match_pct is + already present in the SQL result so no Python post-processing needed. + """ + pantry_list = sorted(pantry_set) + ph = ",".join("?" * len(pantry_list)) + + # Subquery computes match fraction inline so ORDER BY can use it. + match_col = ( + f"(SELECT CAST(COUNT(*) AS REAL)" + f" / NULLIF(json_array_length(r.ingredient_names), 0)" + f" FROM json_each(r.ingredient_names) AS j" + f" WHERE LOWER(j.value) IN ({ph}))" + ) + + base_cols = ( + f"SELECT r.id, r.title, r.category, r.keywords, r.ingredient_names," + f" r.calories, r.fat_g, r.protein_g, r.sodium_mg," + f" {match_col} AS match_pct" + f" FROM {c}recipes r" + ) + + if keywords is None: + fts_where = "LOWER(r.title) LIKE LOWER(?)" if q_param else "1=1" + count_params: tuple = (q_param,) if q_param else () + total = self.conn.execute( + f"SELECT COUNT(*) FROM {c}recipes r WHERE {fts_where}", count_params + ).fetchone()[0] + data_params = (*pantry_list, *(count_params), page_size, offset) + where_clause = f"WHERE {fts_where}" if fts_where != "1=1" else "" + sql = ( + f"{base_cols} {where_clause}" + f" ORDER BY match_pct DESC NULLS LAST, r.id ASC" + f" LIMIT ? OFFSET ?" + ) + else: + match_expr = self._browser_fts_query(keywords) + fts_sub = ( + f"r.id IN (SELECT rowid FROM {c}recipe_browser_fts" + f" WHERE recipe_browser_fts MATCH ?)" + ) + if q_param: + total = self.conn.execute( + f"SELECT COUNT(*) FROM {c}recipes r" + f" WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)", + (match_expr, q_param), + ).fetchone()[0] + where_clause = f"WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)" + data_params = (*pantry_list, match_expr, q_param, page_size, offset) + else: + total = self._count_recipes_for_keywords(keywords) + where_clause = f"WHERE {fts_sub}" + data_params = (*pantry_list, match_expr, page_size, offset) + sql = ( + f"{base_cols} {where_clause}" + f" ORDER BY match_pct DESC NULLS LAST, r.id ASC" + f" LIMIT ? OFFSET ?" + ) + + self.conn.row_factory = sqlite3.Row + rows = self.conn.execute(sql, data_params).fetchall() + recipes = [] + for r in rows: + row = dict(r) + recipes.append({ + "id": row["id"], + "title": row["title"], + "category": row["category"], + "match_pct": round(row["match_pct"], 3) if row["match_pct"] is not None else None, + }) + return {"recipes": recipes, "total": total, "page": page} + def log_browser_telemetry( self, domain: str, diff --git a/frontend/src/components/RecipeBrowserPanel.vue b/frontend/src/components/RecipeBrowserPanel.vue index 28aa39b..363b7e3 100644 --- a/frontend/src/components/RecipeBrowserPanel.vue +++ b/frontend/src/components/RecipeBrowserPanel.vue @@ -103,6 +103,12 @@ @click="setSort('alpha_desc')" title="Alphabetical Z→A" >Z→A + @@ -184,7 +190,7 @@