Compare commits
4 commits
ac4eda2047
...
59b183a898
| Author | SHA1 | Date | |
|---|---|---|---|
| 59b183a898 | |||
| b4624fba84 | |||
| 667daf939e | |||
| 4e50661483 |
4 changed files with 675 additions and 42 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
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,118 @@
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Ask tab — natural-language recipe search with optional LLM synthesis -->
|
||||||
|
<div
|
||||||
|
v-else-if="activeTab === 'ask'"
|
||||||
|
ref="askPanelRef"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-ask"
|
||||||
|
tabindex="0"
|
||||||
|
class="ask-panel"
|
||||||
|
>
|
||||||
|
<div class="card mb-md">
|
||||||
|
<h2 class="section-title text-xl mb-xs">Ask about recipes</h2>
|
||||||
|
<p class="text-sm text-secondary mb-md">Search by ingredient, dish type, or any cooking question.</p>
|
||||||
|
|
||||||
|
<!-- Question input -->
|
||||||
|
<div class="ask-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="askQuestion"
|
||||||
|
class="form-input ask-input"
|
||||||
|
placeholder="e.g. What can I make with chicken and pasta?"
|
||||||
|
aria-label="Recipe question"
|
||||||
|
@keydown="onAskKeydown"
|
||||||
|
:disabled="askLoading"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary ask-submit"
|
||||||
|
:disabled="askLoading || !askQuestion.trim()"
|
||||||
|
@click="handleAsk()"
|
||||||
|
aria-label="Search recipes"
|
||||||
|
>
|
||||||
|
<span v-if="askLoading" class="spinner spinner-sm inline-spinner"></span>
|
||||||
|
<span v-else>Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Example questions — shown before first search -->
|
||||||
|
<div v-if="!askResult && !askLoading" class="ask-examples">
|
||||||
|
<p class="text-xs text-secondary mb-xs">Try asking:</p>
|
||||||
|
<div class="flex flex-wrap gap-xs">
|
||||||
|
<button
|
||||||
|
v-for="ex in ASK_EXAMPLES"
|
||||||
|
:key="ex"
|
||||||
|
class="btn btn-xs btn-secondary ask-example-btn"
|
||||||
|
@click="handleAsk(ex)"
|
||||||
|
>{{ ex }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-if="askError" role="alert" class="status-badge status-error mt-sm">
|
||||||
|
{{ askError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result: LLM answer + recipe list -->
|
||||||
|
<div v-if="askResult">
|
||||||
|
<!-- LLM-synthesized answer (Paid tier) -->
|
||||||
|
<div v-if="askResult.answer" class="ask-answer card mb-md" role="status" aria-live="polite">
|
||||||
|
<p class="text-sm">{{ askResult.answer }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe results -->
|
||||||
|
<div v-if="askResult.recipes.length > 0" class="mb-md">
|
||||||
|
<p class="text-sm text-secondary mb-sm">
|
||||||
|
{{ askResult.recipes.length }} recipe{{ askResult.recipes.length !== 1 ? 's' : '' }} found
|
||||||
|
</p>
|
||||||
|
<div class="grid-auto">
|
||||||
|
<div
|
||||||
|
v-for="hit in askResult.recipes"
|
||||||
|
:key="hit.id"
|
||||||
|
class="card recipe-card-compact ask-hit-card slide-up"
|
||||||
|
role="article"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openRecipeById(hit.id)"
|
||||||
|
@keydown.enter="openRecipeById(hit.id)"
|
||||||
|
:aria-label="'View recipe: ' + hit.title"
|
||||||
|
>
|
||||||
|
<h3 class="text-base font-bold mb-xs recipe-title">{{ hit.title }}</h3>
|
||||||
|
<div class="flex flex-wrap gap-xs">
|
||||||
|
<span v-if="hit.category" class="status-badge status-neutral text-xs">{{ hit.category }}</span>
|
||||||
|
<span
|
||||||
|
v-if="hit.match_pct !== null"
|
||||||
|
:class="['status-badge', 'text-xs', hit.match_pct >= 0.7 ? 'status-success' : hit.match_pct >= 0.4 ? 'status-info' : 'status-neutral']"
|
||||||
|
>{{ Math.round(hit.match_pct * 100) }}% pantry match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No results -->
|
||||||
|
<div v-else class="card text-center text-muted">
|
||||||
|
<p>No recipes found for this question. Try different keywords.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session history (last 3 questions) -->
|
||||||
|
<div v-if="askHistory.length > 1" class="ask-history mt-md">
|
||||||
|
<div class="flex-between mb-xs">
|
||||||
|
<p class="text-xs text-secondary">Recent questions</p>
|
||||||
|
<button class="btn btn-xs btn-ghost" @click="askHistory = []" aria-label="Clear question history">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-xs">
|
||||||
|
<button
|
||||||
|
v-for="entry in askHistory.slice(1)"
|
||||||
|
:key="entry.question"
|
||||||
|
class="btn btn-xs btn-secondary ask-history-btn"
|
||||||
|
@click="handleAsk(entry.question)"
|
||||||
|
>{{ entry.question }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Find tab (existing search UI) -->
|
<!-- Find tab (existing search UI) -->
|
||||||
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
|
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
|
||||||
<!-- Controls Panel -->
|
<!-- Controls Panel -->
|
||||||
|
|
@ -147,6 +259,19 @@
|
||||||
Tap "Find recipes" again to apply.
|
Tap "Find recipes" again to apply.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Refine panel toggle — wraps time budget + dietary + nutrition filters -->
|
||||||
|
<button
|
||||||
|
class="refine-toggle btn btn-secondary btn-sm"
|
||||||
|
@click="toggleFilters"
|
||||||
|
:aria-expanded="filtersOpen"
|
||||||
|
aria-controls="refine-panel"
|
||||||
|
>
|
||||||
|
{{ filtersOpen ? '▲' : '▼' }} Refine
|
||||||
|
<span v-if="activeFilterCount > 0" class="refine-count">{{ activeFilterCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="refine-panel" v-show="filtersOpen" class="refine-body">
|
||||||
|
|
||||||
<!-- Time Budget selector — always visible; closes #131 -->
|
<!-- Time Budget selector — always visible; closes #131 -->
|
||||||
<div class="form-group time-bucket-group">
|
<div class="form-group time-bucket-group">
|
||||||
<!-- Hands-on / active time row -->
|
<!-- Hands-on / active time row -->
|
||||||
|
|
@ -400,6 +525,8 @@
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
</div><!-- end #refine-panel -->
|
||||||
|
|
||||||
<!-- Suggest Button -->
|
<!-- Suggest Button -->
|
||||||
<div class="suggest-row">
|
<div class="suggest-row">
|
||||||
<button
|
<button
|
||||||
|
|
@ -445,12 +572,50 @@
|
||||||
|
|
||||||
<!-- Streaming recipe generation panel -->
|
<!-- Streaming recipe generation panel -->
|
||||||
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
|
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
|
||||||
<div v-if="isStreaming" class="stream-status">
|
<div v-if="isStreaming" class="stream-status" role="status">
|
||||||
<span class="stream-dot" aria-hidden="true"></span>
|
<span class="stream-dot" aria-hidden="true"></span>
|
||||||
Generating recipe…
|
Generating recipe…
|
||||||
</div>
|
</div>
|
||||||
<div v-if="streamError" class="stream-error text-sm">{{ streamError }}</div>
|
<div v-if="streamError" class="stream-error text-sm" role="alert">{{ streamError }}</div>
|
||||||
<pre v-if="streamChunks" class="stream-output">{{ streamChunks }}</pre>
|
|
||||||
|
<!-- Progressive reveal card: skeleton while streaming, full card on complete -->
|
||||||
|
<div v-if="isStreaming || (streamChunks && !streamError)" class="stream-recipe-card card">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="stream-title mb-sm">
|
||||||
|
<h3 v-if="parsedStream.title" class="text-lg font-semibold">{{ parsedStream.title }}</h3>
|
||||||
|
<div v-else class="skeleton-line skeleton-title"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ingredients -->
|
||||||
|
<div class="stream-section mb-sm">
|
||||||
|
<strong class="stream-section-label text-sm">Ingredients</strong>
|
||||||
|
<ul v-if="parsedStream.ingredients.length > 0" class="stream-ingredients mt-xs">
|
||||||
|
<li v-for="(ing, i) in parsedStream.ingredients" :key="i" class="text-sm">{{ ing }}</li>
|
||||||
|
</ul>
|
||||||
|
<template v-else-if="isStreaming">
|
||||||
|
<div class="skeleton-line skeleton-medium mt-xs"></div>
|
||||||
|
<div class="skeleton-line skeleton-short"></div>
|
||||||
|
<div class="skeleton-line skeleton-long"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps — only show once ingredients have arrived -->
|
||||||
|
<div v-if="parsedStream.steps.length > 0 || (isStreaming && parsedStream.ingredients.length > 0)" class="stream-section mb-sm">
|
||||||
|
<strong class="stream-section-label text-sm">Directions</strong>
|
||||||
|
<ol v-if="parsedStream.steps.length > 0" class="stream-steps mt-xs">
|
||||||
|
<li v-for="(step, i) in parsedStream.steps" :key="i" class="text-sm">{{ step }}</li>
|
||||||
|
</ol>
|
||||||
|
<template v-else-if="isStreaming">
|
||||||
|
<div class="skeleton-line skeleton-long mt-xs"></div>
|
||||||
|
<div class="skeleton-line skeleton-medium"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes — only after stream complete -->
|
||||||
|
<p v-if="parsedStream.notes && !isStreaming" class="stream-notes text-sm text-muted">
|
||||||
|
<strong>Notes:</strong> {{ parsedStream.notes }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Screen reader announcement for loading + results -->
|
<!-- Screen reader announcement for loading + results -->
|
||||||
|
|
@ -726,7 +891,7 @@ import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||||
import OrchUsagePill from './OrchUsagePill.vue'
|
import OrchUsagePill from './OrchUsagePill.vue'
|
||||||
import RecipeScanModal from './RecipeScanModal.vue'
|
import RecipeScanModal from './RecipeScanModal.vue'
|
||||||
import type { ForkResult } from '../stores/community'
|
import type { ForkResult } from '../stores/community'
|
||||||
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse, AskResponse } from '../services/api'
|
||||||
import { recipesAPI } from '../services/api'
|
import { recipesAPI } from '../services/api'
|
||||||
|
|
||||||
// ── Scan modal ────────────────────────────────────────────────────────────────
|
// ── Scan modal ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -745,25 +910,62 @@ const isStreaming = ref(false)
|
||||||
const streamChunks = ref('')
|
const streamChunks = ref('')
|
||||||
const streamError = ref<string | null>(null)
|
const streamError = ref<string | null>(null)
|
||||||
|
|
||||||
|
interface ParsedStreamRecipe {
|
||||||
|
title: string | null
|
||||||
|
ingredients: string[]
|
||||||
|
steps: string[]
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedStream = computed((): ParsedStreamRecipe => {
|
||||||
|
const text = streamChunks.value
|
||||||
|
if (!text) return { title: null, ingredients: [], steps: [], notes: null }
|
||||||
|
|
||||||
|
const titleMatch = text.match(/^Title:\s*(.+)$/m)
|
||||||
|
const title = titleMatch?.[1]?.trim() ?? null
|
||||||
|
|
||||||
|
const ingMatch = text.match(/^Ingredients:\s*(.+)$/m)
|
||||||
|
const ingredients = ingMatch?.[1]
|
||||||
|
? ingMatch[1]!.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const steps: string[] = []
|
||||||
|
const dirIdx = text.indexOf('Directions:')
|
||||||
|
const notesIdx = text.indexOf('\nNotes:')
|
||||||
|
if (dirIdx !== -1) {
|
||||||
|
const dirSection = text.slice(dirIdx + 'Directions:'.length, notesIdx !== -1 ? notesIdx : undefined)
|
||||||
|
for (const m of dirSection.matchAll(/^\d+\.\s*(.+)$/mg)) {
|
||||||
|
steps.push(m[1]?.trim() ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesMatch = text.match(/^Notes:\s*([\s\S]+)$/m)
|
||||||
|
const notes = notesMatch?.[1]?.trim() ?? null
|
||||||
|
|
||||||
|
return { title, ingredients, steps, notes }
|
||||||
|
})
|
||||||
|
|
||||||
const recipesStore = useRecipesStore()
|
const recipesStore = useRecipesStore()
|
||||||
const inventoryStore = useInventoryStore()
|
const inventoryStore = useInventoryStore()
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
|
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build' | 'ask'
|
||||||
const tabs: Array<{ id: TabId; label: string; mobileLabel?: string }> = [
|
const tabs: Array<{ id: TabId; label: string; mobileLabel?: string }> = [
|
||||||
{ id: 'saved', label: 'Saved' },
|
{ id: 'saved', label: 'Saved' },
|
||||||
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
|
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
|
||||||
{ id: 'community', label: 'Community' },
|
{ id: 'community', label: 'Community' },
|
||||||
{ id: 'find', label: 'Find' },
|
{ id: 'find', label: 'Find' },
|
||||||
{ id: 'browse', label: 'Browse' },
|
{ id: 'browse', label: 'Browse' },
|
||||||
|
{ id: 'ask', label: 'Ask' },
|
||||||
]
|
]
|
||||||
const activeTab = ref<TabId>('saved')
|
const activeTab = ref<TabId>('saved')
|
||||||
const savedStore = useSavedRecipesStore()
|
const savedStore = useSavedRecipesStore()
|
||||||
// Template ref for the Find-tab panel div (used for focus management on tab switch)
|
// Template refs for panels that need explicit focus management on tab switch
|
||||||
const findPanelRef = ref<HTMLElement | null>(null)
|
const findPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
const askPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
function onTabKeydown(e: KeyboardEvent) {
|
function onTabKeydown(e: KeyboardEvent) {
|
||||||
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse']
|
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse', 'ask']
|
||||||
const current = tabIds.indexOf(activeTab.value)
|
const current = tabIds.indexOf(activeTab.value)
|
||||||
if (e.key === 'ArrowRight') {
|
if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -783,6 +985,8 @@ async function activateTab(tab: TabId) {
|
||||||
// components so we locate their panel via querySelector.
|
// components so we locate their panel via querySelector.
|
||||||
if (tab === 'find' && findPanelRef.value) {
|
if (tab === 'find' && findPanelRef.value) {
|
||||||
findPanelRef.value.focus()
|
findPanelRef.value.focus()
|
||||||
|
} else if (tab === 'ask' && askPanelRef.value) {
|
||||||
|
askPanelRef.value.focus()
|
||||||
} else {
|
} else {
|
||||||
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
|
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
|
||||||
panel?.focus()
|
panel?.focus()
|
||||||
|
|
@ -985,6 +1189,14 @@ const advancedActive = computed(() =>
|
||||||
!!recipesStore.styleId
|
!!recipesStore.styleId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const FILTERS_OPEN_KEY = 'kiwi:find_filters_open'
|
||||||
|
const filtersOpen = ref(localStorage.getItem(FILTERS_OPEN_KEY) !== 'false')
|
||||||
|
|
||||||
|
function toggleFilters() {
|
||||||
|
filtersOpen.value = !filtersOpen.value
|
||||||
|
localStorage.setItem(FILTERS_OPEN_KEY, String(filtersOpen.value))
|
||||||
|
}
|
||||||
|
|
||||||
const activeFilterCount = computed(() => {
|
const activeFilterCount = computed(() => {
|
||||||
let n = 0
|
let n = 0
|
||||||
if (recipesStore.constraints.length > 0) n++
|
if (recipesStore.constraints.length > 0) n++
|
||||||
|
|
@ -1021,6 +1233,92 @@ function clearAllFindFilters() {
|
||||||
categoryInput.value = ''
|
categoryInput.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pantry items sorted expiry-first (available items only)
|
||||||
|
const pantryItems = computed(() => {
|
||||||
|
const sorted = [...inventoryStore.items]
|
||||||
|
.filter((item) => item.status === 'available')
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.expiration_date && !b.expiration_date) return 0
|
||||||
|
if (!a.expiration_date) return 1
|
||||||
|
if (!b.expiration_date) return -1
|
||||||
|
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime()
|
||||||
|
})
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Inverted flow: auto-suggest + live re-suggest (closes #132) ─────────────
|
||||||
|
|
||||||
|
function _debounce(fn: () => void, ms: number): () => void {
|
||||||
|
let t: ReturnType<typeof setTimeout>
|
||||||
|
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable key over all filter state — one watcher instead of a dozen.
|
||||||
|
const filterKey = computed(() => JSON.stringify({
|
||||||
|
constraints: recipesStore.constraints,
|
||||||
|
allergies: recipesStore.allergies,
|
||||||
|
excludeIngredients: recipesStore.excludeIngredients,
|
||||||
|
shoppingMode: recipesStore.shoppingMode,
|
||||||
|
pantryMatchOnly: recipesStore.pantryMatchOnly,
|
||||||
|
hardDayMode: recipesStore.hardDayMode,
|
||||||
|
maxActiveMin: recipesStore.maxActiveMin,
|
||||||
|
maxTotalMin: recipesStore.maxTotalMin,
|
||||||
|
maxMissing: recipesStore.maxMissing,
|
||||||
|
styleId: recipesStore.styleId,
|
||||||
|
category: recipesStore.category,
|
||||||
|
nutritionFilters: recipesStore.nutritionFilters,
|
||||||
|
level: recipesStore.level,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Flag prevents double-firing on initial pantry load + tab switch within same render cycle.
|
||||||
|
let _autoSuggestFired = false
|
||||||
|
|
||||||
|
// Auto-suggest on Find tab activation (L1/L2 only — L3/L4 require explicit user intent).
|
||||||
|
watch(
|
||||||
|
[activeTab, pantryItems] as const,
|
||||||
|
([tab, items]) => {
|
||||||
|
if (tab !== 'find') return
|
||||||
|
if (recipesStore.level > 2) return
|
||||||
|
if (items.length === 0) return
|
||||||
|
if (recipesStore.result || recipesStore.loading) return
|
||||||
|
if (_autoSuggestFired) return
|
||||||
|
_autoSuggestFired = true
|
||||||
|
handleSuggest().then(() => {
|
||||||
|
filtersOpen.value = false
|
||||||
|
localStorage.setItem(FILTERS_OPEN_KEY, 'false')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Live re-suggest when any filter changes while on Find tab with existing results (L1/L2).
|
||||||
|
const _debouncedResuggest = _debounce(async () => {
|
||||||
|
if (activeTab.value !== 'find') return
|
||||||
|
if (recipesStore.level > 2) return
|
||||||
|
if (pantryItems.value.length === 0) return
|
||||||
|
if (!recipesStore.result) return
|
||||||
|
await handleSuggest()
|
||||||
|
}, 1200)
|
||||||
|
|
||||||
|
watch(filterKey, () => {
|
||||||
|
if (activeTab.value !== 'find') return
|
||||||
|
if (recipesStore.level > 2) return
|
||||||
|
if (!recipesStore.result) return
|
||||||
|
_debouncedResuggest()
|
||||||
|
})
|
||||||
|
|
||||||
// #46 — count of active nutrition filters so the summary is informative when collapsed
|
// #46 — count of active nutrition filters so the summary is informative when collapsed
|
||||||
const activeNutritionFilterCount = computed(() =>
|
const activeNutritionFilterCount = computed(() =>
|
||||||
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
|
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
|
||||||
|
|
@ -1054,33 +1352,6 @@ const cuisineStyles = [
|
||||||
{ id: 'eastern_european', label: 'Eastern European' },
|
{ id: 'eastern_european', label: 'Eastern European' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Pantry items sorted expiry-first (available items only)
|
|
||||||
const pantryItems = computed(() => {
|
|
||||||
const sorted = [...inventoryStore.items]
|
|
||||||
.filter((item) => item.status === 'available')
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (!a.expiration_date && !b.expiration_date) return 0
|
|
||||||
if (!a.expiration_date) return 1
|
|
||||||
if (!b.expiration_date) return -1
|
|
||||||
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime()
|
|
||||||
})
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// Tag input helpers — constraints
|
// Tag input helpers — constraints
|
||||||
function addConstraint(value: string) {
|
function addConstraint(value: string) {
|
||||||
const tag = value.trim().toLowerCase()
|
const tag = value.trim().toLowerCase()
|
||||||
|
|
@ -1218,6 +1489,51 @@ async function handleLoadMore() {
|
||||||
isLoadingMore.value = false
|
isLoadingMore.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ask tab state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AskHistoryEntry {
|
||||||
|
question: string
|
||||||
|
result: AskResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const askQuestion = ref('')
|
||||||
|
const askLoading = ref(false)
|
||||||
|
const askError = ref<string | null>(null)
|
||||||
|
const askResult = ref<AskResponse | null>(null)
|
||||||
|
const askHistory = ref<AskHistoryEntry[]>([])
|
||||||
|
|
||||||
|
const ASK_EXAMPLES = [
|
||||||
|
'What can I make with chicken and pasta?',
|
||||||
|
'Easy vegetarian dinners under 30 minutes',
|
||||||
|
'Recipes using overripe bananas',
|
||||||
|
'Quick breakfasts with eggs',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function handleAsk(question?: string) {
|
||||||
|
const q = (question ?? askQuestion.value).trim()
|
||||||
|
if (!q) return
|
||||||
|
askQuestion.value = q
|
||||||
|
askLoading.value = true
|
||||||
|
askError.value = null
|
||||||
|
askResult.value = null
|
||||||
|
try {
|
||||||
|
const result = await recipesAPI.ask(q, pantryItems.value)
|
||||||
|
askResult.value = result
|
||||||
|
askHistory.value = [{ question: q, result }, ...askHistory.value].slice(0, 3)
|
||||||
|
} catch {
|
||||||
|
askError.value = 'Could not search recipes. Please try again.'
|
||||||
|
} finally {
|
||||||
|
askLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAskKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleAsk()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (inventoryStore.items.length === 0) {
|
if (inventoryStore.items.length === 0) {
|
||||||
await inventoryStore.fetchItems()
|
await inventoryStore.fetchItems()
|
||||||
|
|
@ -1452,6 +1768,35 @@ watch(
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Refine collapsible ──────────────────────────────────────────────────── */
|
||||||
|
.refine-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refine-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: var(--font-size-xs, 0.72rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refine-body {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1929,13 +2274,125 @@ details[open] .collapsible-summary::before {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-output {
|
/* ── Stream recipe card — skeleton + progressive reveal ─────────────────── */
|
||||||
font-family: inherit;
|
.stream-recipe-card {
|
||||||
white-space: pre-wrap;
|
margin-top: var(--spacing-sm);
|
||||||
font-size: var(--font-size-sm);
|
}
|
||||||
color: var(--color-text);
|
|
||||||
margin: 0;
|
.stream-section-label {
|
||||||
max-height: 400px;
|
display: block;
|
||||||
overflow-y: auto;
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: var(--font-size-xs, 0.72rem);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-ingredients {
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
list-style: disc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-steps {
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-notes {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton shimmer ───────────────────────────────────────────────────── */
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { background-position: -200px 0; }
|
||||||
|
100% { background-position: calc(200px + 100%) 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 0.8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg-secondary, #f5f5f5) 0%,
|
||||||
|
var(--color-border, #e0e0e0) 50%,
|
||||||
|
var(--color-bg-secondary, #f5f5f5) 100%
|
||||||
|
);
|
||||||
|
background-size: 200px 100%;
|
||||||
|
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title { height: 1.2rem; width: 55%; }
|
||||||
|
.skeleton-short { width: 38%; }
|
||||||
|
.skeleton-medium { width: 65%; }
|
||||||
|
.skeleton-long { width: 88%; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton-line {
|
||||||
|
animation: none;
|
||||||
|
background: var(--color-bg-secondary, #f5f5f5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ask tab ──────────────────────────────────────────────────────────── */
|
||||||
|
.ask-panel {
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-submit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-examples {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-example-btn {
|
||||||
|
white-space: normal;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-answer {
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
background: var(--color-bg-secondary, #f8f8f8);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-hit-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-hit-card:hover,
|
||||||
|
.ask-hit-card:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-history-btn {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -671,6 +671,21 @@ export interface BuildRequest {
|
||||||
role_overrides: Record<string, string>
|
role_overrides: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ask/RAG types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AskRecipeHit {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
match_pct: number | null
|
||||||
|
category: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskResponse {
|
||||||
|
answer: string | null
|
||||||
|
recipes: AskRecipeHit[]
|
||||||
|
tier: string
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Recipes API ==========
|
// ========== Recipes API ==========
|
||||||
|
|
||||||
export const recipesAPI = {
|
export const recipesAPI = {
|
||||||
|
|
@ -738,6 +753,12 @@ export const recipesAPI = {
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Natural-language recipe search with optional LLM synthesis (Paid tier). */
|
||||||
|
async ask(question: string, pantryItems: string[] = []): Promise<AskResponse> {
|
||||||
|
const response = await api.post('/recipes/ask', { question, pantry_items: pantryItems }, { timeout: 30000 })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
/** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
|
/** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
|
||||||
async suggestRecipeStream(
|
async suggestRecipeStream(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue