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
This commit is contained in:
parent
1a7a94a344
commit
1ac7e3d76a
3 changed files with 126 additions and 16 deletions
|
|
@ -245,14 +245,15 @@ async def browse_recipes(
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
subcategory: Annotated[str | None, Query()] = None,
|
subcategory: Annotated[str | None, Query()] = None,
|
||||||
q: Annotated[str | None, Query(max_length=200)] = 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),
|
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 badges.
|
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||||
Pass subcategory to narrow within a category that has subcategories.
|
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:
|
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}'.")
|
||||||
|
|
|
||||||
115
app/db/store.py
115
app/db/store.py
|
|
@ -1161,21 +1161,33 @@ class Store:
|
||||||
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.
|
||||||
|
|
||||||
q: optional title substring filter (case-insensitive LIKE).
|
q: optional title substring filter (case-insensitive LIKE).
|
||||||
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A).
|
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:
|
if keywords is not None and not keywords:
|
||||||
return {"recipes": [], "total": 0, "page": page}
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
c = self._cp
|
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 = {
|
order_clause = {
|
||||||
"alpha": "ORDER BY title ASC",
|
"alpha": "ORDER BY title ASC",
|
||||||
"alpha_desc": "ORDER BY title DESC",
|
"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
|
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 = (
|
cols = (
|
||||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||||
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
|
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
|
||||||
|
|
@ -1217,26 +1229,107 @@ class Store:
|
||||||
(match_expr, page_size, offset),
|
(match_expr, page_size, offset),
|
||||||
)
|
)
|
||||||
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
|
||||||
recipes = []
|
recipes = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
entry = {
|
entry = {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"title": r["title"],
|
"title": r["title"],
|
||||||
"category": r["category"],
|
"category": r["category"],
|
||||||
"match_pct": None,
|
"match_pct": None,
|
||||||
}
|
}
|
||||||
if pantry_set:
|
if pantry_set:
|
||||||
names = r.get("ingredient_names") or []
|
names = r.get("ingredient_names") or []
|
||||||
if names:
|
if names:
|
||||||
matched = sum(
|
matched = sum(1 for n in names if n.lower() in pantry_set)
|
||||||
1 for n in names if n.lower() in pantry_set
|
|
||||||
)
|
|
||||||
entry["match_pct"] = round(matched / len(names), 3)
|
entry["match_pct"] = round(matched / len(names), 3)
|
||||||
recipes.append(entry)
|
recipes.append(entry)
|
||||||
|
|
||||||
return {"recipes": recipes, "total": total, "page": page}
|
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(
|
def log_browser_telemetry(
|
||||||
self,
|
self,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,12 @@
|
||||||
@click="setSort('alpha_desc')"
|
@click="setSort('alpha_desc')"
|
||||||
title="Alphabetical Z→A"
|
title="Alphabetical Z→A"
|
||||||
>Z→A</button>
|
>Z→A</button>
|
||||||
|
<button
|
||||||
|
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
|
||||||
|
:disabled="pantryCount === 0"
|
||||||
|
@click="setSort('match')"
|
||||||
|
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
|
||||||
|
>Best match</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -184,7 +190,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, 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'
|
||||||
|
|
@ -212,7 +218,7 @@ const loadingDomains = ref(false)
|
||||||
const loadingRecipes = ref(false)
|
const loadingRecipes = ref(false)
|
||||||
const savingRecipe = ref<BrowserRecipe | null>(null)
|
const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc'>('default')
|
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default')
|
||||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
|
|
@ -258,13 +264,23 @@ function onSearchInput() {
|
||||||
}, 350)
|
}, 350)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
|
function setSort(s: 'default' | 'alpha' | 'alpha_desc' | 'match') {
|
||||||
if (sortOrder.value === s) return
|
if (sortOrder.value === s) return
|
||||||
sortOrder.value = s
|
sortOrder.value = s
|
||||||
page.value = 1
|
page.value = 1
|
||||||
loadRecipes()
|
loadRecipes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When pantry items first become available while browsing, auto-engage match sort.
|
||||||
|
// When pantry empties out mid-session, drop back to default so the button disables cleanly.
|
||||||
|
watch(pantryCount, (newCount, oldCount) => {
|
||||||
|
if (newCount > 0 && oldCount === 0 && activeCategory.value) {
|
||||||
|
setSort('match')
|
||||||
|
} else if (newCount === 0 && sortOrder.value === 'match') {
|
||||||
|
setSort('default')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function selectDomain(domainId: string) {
|
async function selectDomain(domainId: string) {
|
||||||
activeDomain.value = domainId
|
activeDomain.value = domainId
|
||||||
activeCategory.value = null
|
activeCategory.value = null
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue