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.store import Store
|
||||
from app.models.schemas.recipe import (
|
||||
AskRequest,
|
||||
AskResponse,
|
||||
AskRecipeHit,
|
||||
AssemblyTemplateOut,
|
||||
BuildRequest,
|
||||
LeftoversResponse,
|
||||
|
|
@ -597,6 +600,137 @@ async def build_recipe(
|
|||
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}")
|
||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||
def _get(db_path: Path, rid: int) -> dict | None:
|
||||
|
|
|
|||
|
|
@ -206,3 +206,24 @@ class StreamTokenResponse(BaseModel):
|
|||
stream_url: str
|
||||
token: str
|
||||
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"
|
||||
/>
|
||||
|
||||
<!-- 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) -->
|
||||
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
|
||||
<!-- Controls Panel -->
|
||||
|
|
@ -147,6 +259,19 @@
|
|||
Tap "Find recipes" again to apply.
|
||||
</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 -->
|
||||
<div class="form-group time-bucket-group">
|
||||
<!-- Hands-on / active time row -->
|
||||
|
|
@ -400,6 +525,8 @@
|
|||
</div>
|
||||
</details>
|
||||
|
||||
</div><!-- end #refine-panel -->
|
||||
|
||||
<!-- Suggest Button -->
|
||||
<div class="suggest-row">
|
||||
<button
|
||||
|
|
@ -445,12 +572,50 @@
|
|||
|
||||
<!-- Streaming recipe generation 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>
|
||||
Generating recipe…
|
||||
</div>
|
||||
<div v-if="streamError" class="stream-error text-sm">{{ streamError }}</div>
|
||||
<pre v-if="streamChunks" class="stream-output">{{ streamChunks }}</pre>
|
||||
<div v-if="streamError" class="stream-error text-sm" role="alert">{{ streamError }}</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Screen reader announcement for loading + results -->
|
||||
|
|
@ -726,7 +891,7 @@ import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
|||
import OrchUsagePill from './OrchUsagePill.vue'
|
||||
import RecipeScanModal from './RecipeScanModal.vue'
|
||||
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'
|
||||
|
||||
// ── Scan modal ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -745,25 +910,62 @@ const isStreaming = ref(false)
|
|||
const streamChunks = ref('')
|
||||
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 inventoryStore = useInventoryStore()
|
||||
|
||||
// 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 }> = [
|
||||
{ id: 'saved', label: 'Saved' },
|
||||
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'find', label: 'Find' },
|
||||
{ id: 'browse', label: 'Browse' },
|
||||
{ id: 'ask', label: 'Ask' },
|
||||
]
|
||||
const activeTab = ref<TabId>('saved')
|
||||
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 askPanelRef = ref<HTMLElement | null>(null)
|
||||
|
||||
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)
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
|
|
@ -783,6 +985,8 @@ async function activateTab(tab: TabId) {
|
|||
// components so we locate their panel via querySelector.
|
||||
if (tab === 'find' && findPanelRef.value) {
|
||||
findPanelRef.value.focus()
|
||||
} else if (tab === 'ask' && askPanelRef.value) {
|
||||
askPanelRef.value.focus()
|
||||
} else {
|
||||
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
|
||||
panel?.focus()
|
||||
|
|
@ -985,6 +1189,14 @@ const advancedActive = computed(() =>
|
|||
!!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(() => {
|
||||
let n = 0
|
||||
if (recipesStore.constraints.length > 0) n++
|
||||
|
|
@ -1021,6 +1233,92 @@ function clearAllFindFilters() {
|
|||
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
|
||||
const activeNutritionFilterCount = computed(() =>
|
||||
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
|
||||
|
|
@ -1054,33 +1352,6 @@ const cuisineStyles = [
|
|||
{ 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
|
||||
function addConstraint(value: string) {
|
||||
const tag = value.trim().toLowerCase()
|
||||
|
|
@ -1218,6 +1489,51 @@ async function handleLoadMore() {
|
|||
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 () => {
|
||||
if (inventoryStore.items.length === 0) {
|
||||
await inventoryStore.fetchItems()
|
||||
|
|
@ -1452,6 +1768,35 @@ watch(
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1929,13 +2274,125 @@ details[open] .collapsible-summary::before {
|
|||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-output {
|
||||
font-family: inherit;
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
/* ── Stream recipe card — skeleton + progressive reveal ─────────────────── */
|
||||
.stream-recipe-card {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stream-section-label {
|
||||
display: block;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -671,6 +671,21 @@ export interface BuildRequest {
|
|||
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 ==========
|
||||
|
||||
export const recipesAPI = {
|
||||
|
|
@ -738,6 +753,12 @@ export const recipesAPI = {
|
|||
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. */
|
||||
async suggestRecipeStream(
|
||||
req: RecipeRequest,
|
||||
|
|
|
|||
Loading…
Reference in a new issue