feat(ask): add POST /recipes/ask endpoint for natural-language recipe search
Free tier: keyword extraction + FTS ingredient search + title probe search.
Paid tier / BYOK: same search, then LLM synthesis of a conversational answer
(8s timeout so an unresponsive model degrades gracefully to recipe list only).
- AskRequest / AskRecipeHit / AskResponse schemas in recipe.py
- _extract_ask_keywords(): tokenize question, strip stopwords
- _ask_in_thread(): two-pronged search (ingredient FTS + title LIKE)
merges by ID, computes pantry match_pct when pantry_items provided
- Endpoint registered before /{recipe_id} to avoid integer coercion on /ask
- LLM synthesis gated to paid/premium/ultra only (not "local" dev tier)
Closes #134 (backend)
This commit is contained in:
parent
667daf939e
commit
b4624fba84
2 changed files with 155 additions and 0 deletions
|
|
@ -16,6 +16,9 @@ log = logging.getLogger(__name__)
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import (
|
from app.models.schemas.recipe import (
|
||||||
|
AskRequest,
|
||||||
|
AskResponse,
|
||||||
|
AskRecipeHit,
|
||||||
AssemblyTemplateOut,
|
AssemblyTemplateOut,
|
||||||
BuildRequest,
|
BuildRequest,
|
||||||
LeftoversResponse,
|
LeftoversResponse,
|
||||||
|
|
@ -597,6 +600,137 @@ async def build_recipe(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_ASK_STOPWORDS: frozenset[str] = frozenset({
|
||||||
|
"what", "can", "make", "with", "have", "some", "the", "and", "for",
|
||||||
|
"that", "this", "these", "those", "how", "about", "are", "there",
|
||||||
|
"give", "show", "find", "want", "need", "like", "any", "good",
|
||||||
|
"quick", "easy", "simple", "fast", "using", "use", "from", "into",
|
||||||
|
"more", "much", "just", "only", "my", "please", "could", "would",
|
||||||
|
"should", "something", "anything", "everything", "ideas", "idea",
|
||||||
|
"suggest", "meal", "food", "dish", "dishes", "today", "tonight",
|
||||||
|
"tomorrow", "now", "here", "there", "recipes", "recipe", "dinner",
|
||||||
|
"lunch", "breakfast", "snack", "under", "minutes", "hours", "time",
|
||||||
|
"left", "over", "also", "some", "make", "cook", "made", "cooked",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ask_keywords(question: str) -> list[str]:
|
||||||
|
"""Extract food-relevant keywords from a natural language question."""
|
||||||
|
tokens = _re.findall(r"[a-zA-Z]+", question.lower())
|
||||||
|
return [t for t in tokens if len(t) > 3 and t not in _ASK_STOPWORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_in_thread(db_path: Path, question: str, pantry_items: list[str]) -> AskResponse:
|
||||||
|
"""Run Ask logic in a worker thread.
|
||||||
|
|
||||||
|
Free tier: keyword extraction + FTS ingredient search.
|
||||||
|
Paid tier path: same search, then LLM synthesis over results.
|
||||||
|
The caller handles tier gating and LLM synthesis outside this thread
|
||||||
|
to avoid importing LLMRouter in a sync context.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
keywords = _extract_ask_keywords(question)
|
||||||
|
ingredient_hits: list[dict] = []
|
||||||
|
if keywords:
|
||||||
|
ingredient_hits = store.search_recipes_by_ingredients(keywords, limit=15)
|
||||||
|
|
||||||
|
# Also search by title using the full question text as a substring hint.
|
||||||
|
# browse_recipes q= does title LIKE %q%. Extract the longest keyword
|
||||||
|
# from the question as the title probe (most likely to appear in a title).
|
||||||
|
title_hits: list[dict] = []
|
||||||
|
title_probe = max(keywords, key=len) if keywords else None
|
||||||
|
if title_probe:
|
||||||
|
browse_result = store.browse_recipes(
|
||||||
|
keywords=None,
|
||||||
|
page=1,
|
||||||
|
page_size=12,
|
||||||
|
pantry_items=pantry_items or None,
|
||||||
|
q=title_probe,
|
||||||
|
sort="match" if pantry_items else "default",
|
||||||
|
)
|
||||||
|
title_hits = browse_result.get("recipes", [])
|
||||||
|
|
||||||
|
# Merge by ID; ingredient hits come first (more semantically relevant).
|
||||||
|
seen: set[int] = set()
|
||||||
|
merged: list[dict] = []
|
||||||
|
for row in ingredient_hits + title_hits:
|
||||||
|
rid = row.get("id")
|
||||||
|
if rid is not None and rid not in seen:
|
||||||
|
seen.add(rid)
|
||||||
|
merged.append(row)
|
||||||
|
|
||||||
|
# Compute pantry match_pct if caller sent pantry items.
|
||||||
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else set()
|
||||||
|
|
||||||
|
hits: list[AskRecipeHit] = []
|
||||||
|
for row in merged[:12]:
|
||||||
|
match_pct: float | None = None
|
||||||
|
if pantry_set:
|
||||||
|
raw_names = row.get("ingredient_names") or []
|
||||||
|
if isinstance(raw_names, str):
|
||||||
|
try:
|
||||||
|
raw_names = _json.loads(raw_names)
|
||||||
|
except Exception:
|
||||||
|
raw_names = []
|
||||||
|
if raw_names:
|
||||||
|
covered = sum(
|
||||||
|
1 for n in raw_names
|
||||||
|
if any(p in n.lower() for p in pantry_set)
|
||||||
|
)
|
||||||
|
match_pct = round(covered / len(raw_names), 2)
|
||||||
|
hits.append(AskRecipeHit(
|
||||||
|
id=row["id"],
|
||||||
|
title=row.get("title", ""),
|
||||||
|
category=row.get("category"),
|
||||||
|
match_pct=match_pct,
|
||||||
|
))
|
||||||
|
|
||||||
|
return AskResponse(answer=None, recipes=hits, tier="free")
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask", response_model=AskResponse)
|
||||||
|
async def ask_recipes(
|
||||||
|
req: AskRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> AskResponse:
|
||||||
|
"""Natural-language recipe search with optional LLM synthesis.
|
||||||
|
|
||||||
|
Free tier: keyword extraction from question → FTS ingredient + title search.
|
||||||
|
Paid tier / BYOK: same search, then LLM synthesizes a short conversational answer.
|
||||||
|
"""
|
||||||
|
result = await asyncio.to_thread(_ask_in_thread, session.db, req.question, req.pantry_items)
|
||||||
|
|
||||||
|
# LLM synthesis: only for paid/premium/ultra tiers, not "local" dev tier.
|
||||||
|
# Wrapped in wait_for so an unresponsive model degrades gracefully to recipe list only.
|
||||||
|
paid_tier = session.tier in ("paid", "premium", "ultra")
|
||||||
|
if (paid_tier or session.has_byok) and result.recipes:
|
||||||
|
recipe_titles = ", ".join(r.title for r in result.recipes[:6])
|
||||||
|
prompt = (
|
||||||
|
f'You are a helpful kitchen assistant. The user asked: "{req.question}"\n\n'
|
||||||
|
f"Matching recipes: {recipe_titles}\n\n"
|
||||||
|
f"Write a brief, friendly 1–2 sentence response suggesting which of these "
|
||||||
|
f"recipes might best fit the question. Be specific and natural."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
answer = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(LLMRouter().complete, prompt),
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
|
result = result.model_copy(update={"answer": answer.strip() or None, "tier": "paid"})
|
||||||
|
except (Exception, asyncio.TimeoutError) as exc:
|
||||||
|
log.warning("Ask LLM synthesis skipped: %s", exc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
|
|
||||||
|
|
@ -206,3 +206,24 @@ class StreamTokenResponse(BaseModel):
|
||||||
stream_url: str
|
stream_url: str
|
||||||
token: str
|
token: str
|
||||||
expires_in_s: int
|
expires_in_s: int
|
||||||
|
|
||||||
|
|
||||||
|
class AskRequest(BaseModel):
|
||||||
|
"""Request body for POST /recipes/ask."""
|
||||||
|
question: str = Field(min_length=1, max_length=500)
|
||||||
|
pantry_items: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AskRecipeHit(BaseModel):
|
||||||
|
"""A single recipe result from the Ask endpoint."""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
match_pct: float | None = None
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AskResponse(BaseModel):
|
||||||
|
"""Response from POST /recipes/ask."""
|
||||||
|
answer: str | None = None # LLM-synthesized response (Paid tier only)
|
||||||
|
recipes: list[AskRecipeHit]
|
||||||
|
tier: str
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue