Compare commits

..

No commits in common. "3f4b756fc6413ae9505e4195d01469b14b97a800" and "e62d69d0990f77a992d1902251b6b7e29bc5cf25" have entirely different histories.

13 changed files with 148 additions and 840 deletions

View file

@ -478,8 +478,7 @@ async def scan_barcode_image(
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor
image_bytes = temp_file.read_bytes()
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
barcodes = await asyncio.to_thread(BarcodeScanner().scan_image, temp_file)
if not barcodes:
return BarcodeScanResponse(
success=False, barcodes_found=0, results=[],
@ -501,10 +500,9 @@ async def scan_barcode_image(
product_info = await off.lookup_product(code)
product_source = "openfoodfacts"
db_product = None
inventory_item = None
if product_info:
db_product, _ = await asyncio.to_thread(
if product_info and auto_add_to_inventory:
product, _ = await asyncio.to_thread(
store.get_or_create_product,
product_info.get("name", code),
code,
@ -514,7 +512,6 @@ async def scan_barcode_image(
source=product_source,
source_data=product_info,
)
if auto_add_to_inventory:
exp = predictor.predict_expiration(
product_info.get("category", ""),
location,
@ -526,18 +523,18 @@ async def scan_barcode_image(
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
db_product["id"], location,
product["id"], location,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)
product_found = db_product is not None
product_found = product_info is not None
needs_capture = not product_found and has_visual_capture
results.append({
"barcode": code,
"barcode_type": bc.get("type", "unknown"),
"product": ProductResponse.model_validate(db_product) if db_product else None,
"product": ProductResponse.model_validate(product_info) if product_info else None,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not None,
"needs_manual_entry": not product_found and not needs_capture,

View file

@ -6,9 +6,7 @@ import logging
from pathlib import Path
from typing import Annotated
import json as _json_mod
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.cloud_session import CloudUser, _auth_label, get_session
@ -105,39 +103,6 @@ def _build_stream_prompt(db_path: Path, level: int) -> str:
store.close()
async def _stream_recipe_sse(db_path: Path, req: RecipeRequest):
"""Async generator that yields SSE events for a streaming recipe request.
Phase 1 (thread): classify pantry items using a temporary Store.
Phase 2 (async): stream tokens from LLM via LLMRecipeGenerator.stream_generate().
"""
def _prep(db_path: Path) -> tuple[list, list[str]]:
from app.services.recipe.element_classifier import IngredientClassifier
store = Store(db_path)
try:
classifier = IngredientClassifier(store)
profiles = classifier.classify_batch(req.pantry_items)
gaps = classifier.identify_gaps(profiles)
return profiles, gaps
finally:
store.close()
try:
profiles, gaps = await asyncio.to_thread(_prep, db_path)
except Exception as exc:
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
return
from app.services.recipe.llm_recipe import LLMRecipeGenerator
gen = LLMRecipeGenerator(None)
try:
async for token in gen.stream_generate(req, profiles, gaps):
yield f"data: {_json_mod.dumps({'chunk': token})}\n\n"
yield f"data: {_json_mod.dumps({'done': True})}\n\n"
except Exception as exc:
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
"""Queue an async recipe_llm job and return 202 with job_id.
@ -179,7 +144,6 @@ async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
async def suggest_recipes(
req: RecipeRequest,
async_mode: bool = Query(default=False, alias="async"),
stream: bool = Query(default=False),
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
):
@ -215,13 +179,6 @@ async def suggest_recipes(
req = req.model_copy(update={"level": 2})
orch_fallback = True
if stream and req.level in (3, 4):
return StreamingResponse(
_stream_recipe_sse(session.db, req),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
if req.level in (3, 4) and async_mode:
return await _enqueue_recipe_job(session, req)

View file

View file

@ -1,306 +0,0 @@
"""Kiwi MCP Server — read-only corpus DB access for tag/keyword audits.
Exposes four tools to Claude:
kiwi_query_corpus run a read-only SQL query against the corpus DB
kiwi_count_fts run an FTS5 MATCH expression and return row count
kiwi_sample_tags return tag frequency distribution by prefix
kiwi_browse_preview call the browse endpoint and return first-page results
Run with:
python -m app.mcp.server
(from /Library/Development/CircuitForge/kiwi with cf conda env active)
Configure in Claude Code ~/.claude/settings.json mcpServers:
"kiwi": {
"command": "/devl/miniconda3/envs/cf/bin/python",
"args": ["-m", "app.mcp.server"],
"cwd": "/Library/Development/CircuitForge/kiwi",
"env": {
"KIWI_DB_PATH": "/Library/Development/CircuitForge/kiwi/data/kiwi.db",
"KIWI_API_URL": "http://localhost:8512"
}
}
"""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
from pathlib import Path
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
_DB_PATH = os.environ.get(
"KIWI_DB_PATH",
str(Path(__file__).parents[3] / "data" / "kiwi.db"),
)
_API_URL = os.environ.get("KIWI_API_URL", "http://localhost:8512")
_TIMEOUT = 30.0
_QUERY_ROW_LIMIT = 200
server = Server("kiwi")
def _open_ro() -> sqlite3.Connection:
"""Open the corpus DB in read-only mode."""
uri = f"file:///{Path(_DB_PATH).as_posix()}?mode=ro"
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="kiwi_query_corpus",
description=(
"Run a read-only SQL SELECT query against the Kiwi corpus DB (kiwi.db). "
"Returns up to 200 rows as a JSON array. "
"Key tables: recipes (id, title, ingredient_names, inferred_tags, source_url), "
"recipes_fts (FTS5 virtual table for full-text search), "
"ingredient_profiles (name, elements, texture_profile). "
"Use for schema exploration, spot-checking tag coverage, and counting results. "
"Read-only — any write statement will be rejected by SQLite."
),
inputSchema={
"type": "object",
"required": ["sql"],
"properties": {
"sql": {
"type": "string",
"description": (
"A SELECT statement. E.g.: "
"SELECT title, inferred_tags FROM recipes WHERE inferred_tags LIKE '%vegan%' LIMIT 10"
),
},
},
},
),
Tool(
name="kiwi_count_fts",
description=(
"Run an FTS5 MATCH expression against the recipes_fts table and return the hit count. "
"Useful for quickly auditing keyword coverage without a full query. "
"Always double-quote all terms in MATCH expressions. "
"E.g. match_expr='\"tofu\" OR \"tempeh\"' returns how many recipes include either."
),
inputSchema={
"type": "object",
"required": ["match_expr"],
"properties": {
"match_expr": {
"type": "string",
"description": (
"FTS5 MATCH expression string (without the MATCH keyword). "
'E.g. \'"lentil" OR "chickpea"\' or \'"pasta" AND "vegetarian"\''
),
},
},
},
),
Tool(
name="kiwi_sample_tags",
description=(
"Return tag frequency distribution from the corpus. "
"Queries inferred_tags column for tags matching the given prefix pattern. "
"Useful for auditing how well a category keyword set covers the corpus, "
"or discovering what tags exist under a domain (cuisine:, meal:, dietary:, texture:)."
),
inputSchema={
"type": "object",
"properties": {
"prefix": {
"type": "string",
"default": "",
"description": (
"Tag prefix to filter by. E.g. 'cuisine:' returns all cuisine tags, "
"'meal:' returns all meal type tags, '' returns all tags. "
"Returns top 50 by frequency."
),
},
"limit": {
"type": "integer",
"default": 50,
"description": "Max number of tag entries to return (default 50, max 200).",
},
},
},
),
Tool(
name="kiwi_browse_preview",
description=(
"Call the Kiwi browse endpoint and return first-page results. "
"Use to verify that a domain/category returns the expected recipes "
"after a keyword or tag change, without opening the browser. "
"Returns recipe titles, match counts, and total result count."
),
inputSchema={
"type": "object",
"required": ["domain", "category"],
"properties": {
"domain": {
"type": "string",
"description": (
"Browse domain slug. "
"Known domains: cuisine, meal_type, dietary, ingredient, occasion, texture."
),
},
"category": {
"type": "string",
"description": "Category slug within the domain, e.g. 'italian', 'breakfast', 'vegan'.",
},
"subcategory": {
"type": "string",
"default": "",
"description": "Optional subcategory slug to narrow further.",
},
"page_size": {
"type": "integer",
"default": 10,
"description": "Results per page (default 10, max 50).",
},
},
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "kiwi_query_corpus":
return await _query_corpus(arguments)
if name == "kiwi_count_fts":
return await _count_fts(arguments)
if name == "kiwi_sample_tags":
return await _sample_tags(arguments)
if name == "kiwi_browse_preview":
return await _browse_preview(arguments)
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def _query_corpus(args: dict) -> list[TextContent]:
sql = args.get("sql", "").strip()
if not sql.upper().startswith("SELECT"):
return [TextContent(type="text", text="Error: only SELECT statements are allowed.")]
def _run() -> list[dict]:
conn = _open_ro()
try:
cur = conn.execute(sql)
rows = cur.fetchmany(_QUERY_ROW_LIMIT)
return [dict(r) for r in rows]
finally:
conn.close()
try:
rows = await asyncio.get_event_loop().run_in_executor(None, _run)
return [TextContent(type="text", text=json.dumps(rows, indent=2, default=str))]
except Exception as exc:
return [TextContent(type="text", text=f"Query error: {exc}")]
async def _count_fts(args: dict) -> list[TextContent]:
match_expr = args.get("match_expr", "").strip()
if not match_expr:
return [TextContent(type="text", text="Error: match_expr is required.")]
def _run() -> int:
conn = _open_ro()
try:
cur = conn.execute(
"SELECT COUNT(*) FROM recipes_fts WHERE recipes_fts MATCH ?",
(match_expr,),
)
return cur.fetchone()[0]
finally:
conn.close()
try:
count = await asyncio.get_event_loop().run_in_executor(None, _run)
return [TextContent(type="text", text=json.dumps({"match_expr": match_expr, "count": count}))]
except Exception as exc:
return [TextContent(type="text", text=f"FTS error: {exc}")]
async def _sample_tags(args: dict) -> list[TextContent]:
prefix = args.get("prefix", "")
limit = min(int(args.get("limit", 50)), _QUERY_ROW_LIMIT)
def _run() -> list[dict]:
conn = _open_ro()
try:
# Split inferred_tags (comma or space separated) and count each tag
sql = """
WITH tag_rows AS (
SELECT trim(value) AS tag
FROM recipes, json_each('["' || replace(replace(inferred_tags, ', ', '","'), ',', '","') || '"]')
WHERE inferred_tags IS NOT NULL AND inferred_tags != ''
)
SELECT tag, COUNT(*) AS frequency
FROM tag_rows
WHERE tag LIKE ? AND tag != ''
GROUP BY tag
ORDER BY frequency DESC
LIMIT ?
"""
pattern = f"{prefix}%" if prefix else "%"
cur = conn.execute(sql, (pattern, limit))
return [{"tag": r["tag"], "frequency": r["frequency"]} for r in cur.fetchall()]
finally:
conn.close()
try:
tags = await asyncio.get_event_loop().run_in_executor(None, _run)
return [TextContent(type="text", text=json.dumps({"prefix": prefix, "tags": tags}, indent=2))]
except Exception as exc:
return [TextContent(type="text", text=f"Tag query error: {exc}")]
async def _browse_preview(args: dict) -> list[TextContent]:
domain = args.get("domain", "")
category = args.get("category", "")
subcategory = args.get("subcategory", "")
page_size = min(int(args.get("page_size", 10)), 50)
params: dict = {"page": 1, "page_size": page_size}
if subcategory:
params["subcategory"] = subcategory
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
try:
resp = await client.get(
f"{_API_URL}/api/v1/recipes/browse/{domain}/{category}",
params=params,
)
resp.raise_for_status()
except Exception as exc:
return [TextContent(type="text", text=f"Browse error: {exc}")]
data = resp.json()
summary = {
"domain": domain,
"category": category,
"subcategory": subcategory or None,
"total": data.get("total", 0),
"page_size": page_size,
"titles": [r.get("title", "") for r in data.get("recipes", [])],
}
return [TextContent(type="text", text=json.dumps(summary, indent=2))]
async def _main() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
asyncio.run(_main())

View file

@ -1,14 +1,13 @@
"""LLM-driven recipe generator for Levels 3 and 4."""
from __future__ import annotations
import asyncio
import logging
import os
import re
from contextlib import nullcontext
from typing import TYPE_CHECKING, AsyncGenerator
from typing import TYPE_CHECKING
from openai import AsyncOpenAI, OpenAI
from openai import OpenAI
if TYPE_CHECKING:
from app.db.store import Store
@ -150,8 +149,8 @@ class LLMRecipeGenerator:
return "\n".join(lines)
_SERVICE_TYPE = "cf-text"
_MODEL_CANDIDATES = ["granite-4.1-8b", "deepseek-r1-1.5b"]
_SERVICE_TYPE = "vllm"
_MODEL_CANDIDATES = ["Qwen2.5-3B-Instruct", "Phi-4-mini-instruct"]
_TTL_S = 300.0
_CALLER = "kiwi-recipe"
@ -183,12 +182,7 @@ class LLMRecipeGenerator:
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
calls the OpenAI-compatible API directly against the allocated service URL.
Falls back to LLMRouter when:
- Allocation succeeded but the service is cold (warm=False) avoids
making the user wait for model load; LLMRouter uses Ollama which is
already running.
- Allocation succeeded but the connection to the service URL fails the
agent may have registered the service but failed to start it.
Allocation failure falls through to LLMRouter rather than silently returning "".
Without CF_ORCH_URL: uses LLMRouter directly.
"""
ctx = self._get_llm_context()
@ -214,15 +208,6 @@ class LLMRecipeGenerator:
try:
if alloc is not None:
# Skip cold services — model not yet loaded means the user would
# wait 60120 s for model load before any response. Use LLMRouter
# (Ollama) instead, which is already warm on the host.
if not alloc.warm:
logger.info(
"cf-orch vllm allocated but cold (warm=False) — releasing and falling back to LLMRouter"
)
raise RuntimeError("vllm cold")
base_url = alloc.url.rstrip("/") + "/v1"
client = OpenAI(base_url=base_url, api_key="any")
model = alloc.model or "__auto__"
@ -238,20 +223,6 @@ class LLMRecipeGenerator:
return LLMRouter().complete(prompt)
except Exception as exc:
logger.error("LLM call failed: %s", exc)
# When cf-orch gave us an allocation but the service is unreachable
# (cold skip, connection refused, or other error), fall back to
# LLMRouter rather than silently returning empty.
# Skip "vllm" in the fallback order — that backend also routes through
# cf-orch, which would trigger a second (wasted) cold allocation.
if alloc is not None:
logger.info("Falling back to LLMRouter after vllm failure")
try:
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
_order = [b for b in (router.config.get("fallback_order") or []) if b != "vllm"]
return router.complete(prompt, fallback_order=_order or None)
except Exception as fallback_exc:
logger.error("LLMRouter fallback also failed: %s", fallback_exc)
return ""
finally:
if ctx is not None:
@ -388,91 +359,3 @@ class LLMRecipeGenerator:
suggestions=[suggestion],
element_gaps=gaps,
)
async def stream_generate(
self,
req: RecipeRequest,
profiles: list,
gaps: list[str],
) -> AsyncGenerator[str, None]:
"""Stream LLM tokens for L3/L4. Yields raw text chunks as they arrive.
Tries cf-orch warm vllm first; falls back to Ollama via AsyncOpenAI.
When neither is reachable, falls back to blocking _call_llm and yields
the complete response as a single chunk so the caller always gets output.
"""
if req.level == 4:
prompt = self.build_level4_prompt(req)
else:
prompt = self.build_level3_prompt(req, profiles, gaps)
# Phase 1: try cf-orch warm vllm (sync allocation, wrapped in thread)
alloc_info = await asyncio.to_thread(self._try_alloc_for_stream)
if alloc_info is not None:
alloc, ctx = alloc_info
try:
async for token in self._stream_openai_compat(
alloc.url.rstrip("/") + "/v1", "any", alloc.model or "__auto__", prompt
):
yield token
return
except Exception as exc:
logger.debug("cf-orch stream failed, falling back to Ollama: %s", exc)
finally:
await asyncio.to_thread(lambda: _safe_exit(ctx))
# Phase 2: Ollama streaming via OpenAI-compat API
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
ollama = router.config.get("backends", {}).get("ollama")
if ollama and ollama.get("enabled", True):
base_url = ollama["base_url"]
model = ollama.get("model", "llama3")
try:
async for token in self._stream_openai_compat(base_url, "any", model, prompt):
yield token
return
except Exception as exc:
logger.warning("Ollama streaming failed, falling back to blocking: %s", exc)
# Phase 3: blocking fallback — yields full response at once
result = await asyncio.to_thread(self._call_llm, prompt)
if result:
yield result
def _try_alloc_for_stream(self):
"""Attempt cf-orch allocation synchronously; return (alloc, ctx) or None."""
ctx = self._get_llm_context()
try:
alloc = ctx.__enter__()
if alloc is not None and alloc.warm:
return alloc, ctx
# Not warm — release and signal fallback
_safe_exit(ctx)
except Exception as exc:
logger.debug("cf-orch alloc for stream failed: %s", exc)
return None
@staticmethod
async def _stream_openai_compat(
base_url: str, api_key: str, model: str, prompt: str
) -> AsyncGenerator[str, None]:
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
if model == "__auto__":
models = await client.models.list()
model = models.data[0].id
stream = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
)
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
def _safe_exit(ctx) -> None:
try:
ctx.__exit__(None, None, None)
except Exception:
pass

View file

@ -18,10 +18,6 @@ server {
proxy_set_header X-CF-Session $http_x_cf_session;
# Allow image uploads (barcode/receipt photos from phone cameras).
client_max_body_size 20m;
# LLM inference (recipe suggestions, expiry fallback) can take 60-120s.
# Default proxy_read_timeout is 60s which causes 504s on full recipe generation.
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
@ -38,8 +34,6 @@ server {
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-CF-Session $http_x_cf_session;
client_max_body_size 20m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),

View file

@ -6,7 +6,6 @@
v-for="domain in domains"
:key="domain.id"
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
:aria-pressed="activeDomain === domain.id"
@click="selectDomain(domain.id)"
>
{{ domain.label }}
@ -25,7 +24,6 @@
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
<button
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
:aria-pressed="activeCategory === '_all'"
@click="selectCategory('_all')"
>
All
@ -34,7 +32,6 @@
v-for="cat in categories"
:key="cat.category"
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
:aria-pressed="activeCategory === cat.category"
@click="selectCategory(cat.category)"
>
{{ cat.category }}
@ -60,7 +57,6 @@
<template v-else>
<button
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
:aria-pressed="activeSubcategory === null"
@click="selectSubcategory(null)"
>
All {{ activeCategory }}
@ -69,7 +65,6 @@
v-for="sub in subcategories"
:key="sub.subcategory"
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
:aria-pressed="activeSubcategory === sub.subcategory"
@click="selectSubcategory(sub.subcategory)"
>
{{ sub.subcategory }}
@ -84,25 +79,6 @@
</template>
</div>
<!-- Browse breadcrumb shows current position in domain > category > subcategory hierarchy -->
<nav v-if="activeDomain && activeCategory" class="browse-breadcrumb" aria-label="Browse location">
<button
class="crumb-btn"
@click="selectDomain(activeDomain)"
:aria-current="!activeCategory ? 'page' : undefined"
>{{ domains.find(d => d.id === activeDomain)?.label ?? activeDomain }}</button>
<span class="crumb-sep" aria-hidden="true"></span>
<button
class="crumb-btn"
@click="selectCategory(activeCategory)"
:aria-current="!activeSubcategory ? 'page' : undefined"
>{{ activeCategory === '_all' ? 'All' : activeCategory }}</button>
<template v-if="activeSubcategory">
<span class="crumb-sep" aria-hidden="true"></span>
<span class="crumb-current" aria-current="page">{{ activeSubcategory }}</span>
</template>
</nav>
<!-- Recipe grid -->
<template v-if="activeCategory">
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes</div>
@ -129,25 +105,21 @@
<div class="sort-btns flex gap-xs">
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
:aria-pressed="sortOrder === 'default'"
@click="setSort('default')"
title="Corpus order"
>Default</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
:aria-pressed="sortOrder === 'alpha'"
@click="setSort('alpha')"
title="Alphabetical A→Z"
>AZ</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
:aria-pressed="sortOrder === 'alpha_desc'"
@click="setSort('alpha_desc')"
title="Alphabetical Z→A"
>ZA</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
:aria-pressed="sortOrder === 'match'"
:disabled="pantryCount === 0"
@click="setSort('match')"
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
@ -156,11 +128,7 @@
</div>
<div class="results-header flex-between mb-sm">
<span
class="text-sm text-secondary"
aria-live="polite"
aria-atomic="true"
>
<span class="text-sm text-secondary">
{{ total }} recipes
<span v-if="pantryCount > 0"> pantry match shown</span>
<span v-if="requiredIngredient.trim()"> must include "{{ requiredIngredient.trim() }}"</span>
@ -169,14 +137,12 @@
<button
class="btn btn-secondary btn-xs"
:disabled="page <= 1"
aria-label="Previous page"
@click="changePage(page - 1)"
> Prev</button>
<span class="text-sm text-secondary page-indicator" aria-live="polite">{{ page }} / {{ totalPages }}</span>
<span class="text-sm text-secondary page-indicator">{{ page }} / {{ totalPages }}</span>
<button
class="btn btn-secondary btn-xs"
:disabled="page >= totalPages"
aria-label="Next page"
@click="changePage(page + 1)"
>Next </button>
</div>
@ -888,40 +854,4 @@ async function submitTag() {
font-size: 0.875rem;
margin-left: 0.5rem;
}
/* ── Browse breadcrumb ───────────────────────────────────────────────────── */
.browse-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-xs, 0.78rem);
color: var(--color-text-secondary);
}
.crumb-btn {
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
color: var(--color-primary);
font-size: inherit;
border-radius: var(--radius-sm);
}
.crumb-btn:hover {
text-decoration: underline;
}
.crumb-sep {
opacity: 0.5;
padding: 0 2px;
}
.crumb-current {
padding: 2px 4px;
color: var(--color-text);
font-weight: 500;
}
</style>

View file

@ -95,14 +95,6 @@
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
<!-- Active-filter summary bar -->
<div v-if="activeFilterCount > 0" class="active-filter-bar" role="status">
<span class="text-sm">{{ activeFilterCount }} filter{{ activeFilterCount !== 1 ? 's' : '' }} active</span>
<button class="btn btn-xs btn-secondary" @click="clearAllFindFilters" aria-label="Clear all active filters">
Clear all
</button>
</div>
<!-- Level Selector -->
<div class="form-group">
<label class="form-label">How far should we stretch?</label>
@ -147,8 +139,9 @@
Tap "Find recipes" again to apply.
</p>
<!-- Time Budget selector always visible; closes #131 -->
<div class="form-group time-bucket-group">
<!-- Time Budget selector (kiwi#52) -->
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
<!-- Hands-on / active time row -->
<div class="time-row">
<span class="time-row-label">Hands-on time</span>
@ -162,12 +155,6 @@
:aria-pressed="recipesStore.maxActiveMin === bucket.value"
:title="'Max ' + bucket.label + ' of active cooking'"
>{{ bucket.label }}</button>
<button
v-if="recipesStore.maxActiveMin !== null"
class="btn btn-sm btn-secondary time-bucket-clear"
@click="recipesStore.maxActiveMin = null"
aria-label="Clear hands-on time limit"
>No limit</button>
</div>
</div>
@ -184,12 +171,6 @@
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
:title="'Max ' + bucket.label + ' start to finish'"
>{{ bucket.label }}</button>
<button
v-if="recipesStore.maxTotalMin !== null"
class="btn btn-sm btn-secondary time-bucket-clear"
@click="recipesStore.maxTotalMin = null"
aria-label="Clear total time limit"
>No limit</button>
</div>
</div>
@ -265,9 +246,9 @@
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div>
<!-- Not Today ingredient exclusions, persisted to localStorage -->
<!-- Not Today temporary per-session ingredient exclusions -->
<div class="form-group">
<label class="form-label">Not today <span class="text-muted text-xs">(saved between visits)</span></label>
<label class="form-label">Not today <span class="text-muted text-xs">(skip these ingredients this session)</span></label>
<div v-if="recipesStore.excludeIngredients.length > 0" class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.excludeIngredients"
@ -987,42 +968,6 @@ const advancedActive = computed(() =>
!!recipesStore.styleId
)
const activeFilterCount = computed(() => {
let n = 0
if (recipesStore.constraints.length > 0) n++
if (recipesStore.allergies.length > 0) n++
if (recipesStore.excludeIngredients.length > 0) n++
if (recipesStore.shoppingMode) n++
if (recipesStore.pantryMatchOnly) n++
if (recipesStore.hardDayMode) n++
if (recipesStore.maxActiveMin !== null) n++
if (recipesStore.maxTotalMin !== null) n++
if (recipesStore.maxMissing !== null) n++
if (recipesStore.styleId !== null) n++
if (recipesStore.category !== null) n++
n += Object.values(recipesStore.nutritionFilters).filter((v) => v !== null).length
return n
})
function clearAllFindFilters() {
recipesStore.clearConstraints()
recipesStore.clearAllergies()
recipesStore.clearExcludeIngredients()
recipesStore.shoppingMode = false
recipesStore.pantryMatchOnly = false
recipesStore.hardDayMode = false
recipesStore.maxActiveMin = null
recipesStore.maxTotalMin = null
recipesStore.maxMissing = null
recipesStore.styleId = null
recipesStore.category = null
recipesStore.nutritionFilters = { max_calories: null, max_sugar_g: null, max_carbs_g: null, max_sodium_mg: null }
constraintInput.value = ''
allergyInput.value = ''
excludeIngredientInput.value = ''
categoryInput.value = ''
}
// #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
@ -1177,35 +1122,41 @@ async function streamRecipe(level: 3 | 4, wildcardConfirmed = false) {
streamChunks.value = ''
streamError.value = null
// Try cf-orch warm vllm path first (returns a direct stream URL)
let tokenData: StreamTokenResponse | null = null
let tokenData: StreamTokenResponse
try {
tokenData = await recipesAPI.getRecipeStreamToken({ level, wildcard_confirmed: wildcardConfirmed })
} catch { /* cf-orch unavailable — fall through to native SSE */ }
if (tokenData) {
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
const es = new EventSource(url)
es.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (data.done) { es.close(); isStreaming.value = false }
else if (data.error) { es.close(); isStreaming.value = false; streamError.value = data.error }
else if (data.chunk) { streamChunks.value += data.chunk }
} catch { /* ignore malformed events */ }
}
es.onerror = () => { es.close(); isStreaming.value = false; streamError.value = 'Stream connection lost' }
} catch (err: unknown) {
isStreaming.value = false
streamError.value = err instanceof Error ? err.message : 'Failed to start stream'
return
}
// Native SSE fallback: Kiwi backend streams directly from Ollama
await recipesStore.streamSuggest(
pantryItems.value,
secondaryPantryItems.value,
(chunk) => { streamChunks.value += chunk },
() => { isStreaming.value = false },
(err) => { isStreaming.value = false; streamError.value = err },
)
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
const es = new EventSource(url)
es.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (data.done) {
es.close()
isStreaming.value = false
} else if (data.error) {
es.close()
isStreaming.value = false
streamError.value = data.error
} else if (data.chunk) {
streamChunks.value += data.chunk
}
} catch {
// ignore malformed events
}
}
es.onerror = () => {
es.close()
isStreaming.value = false
streamError.value = 'Stream connection lost'
}
}
// Suggest handler
@ -1442,18 +1393,6 @@ watch(
}
/* Filter bar */
.active-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
}
.filter-bar {
display: flex;
flex-direction: column;
@ -1649,15 +1588,6 @@ details[open] .collapsible-summary::before {
border-color: var(--color-primary, #1a6b4a);
}
.time-bucket-clear {
font-style: italic;
opacity: 0.7;
}
.time-bucket-clear:hover {
opacity: 1;
}
@media (max-width: 480px) {
.time-row {
flex-direction: column;

View file

@ -2,7 +2,6 @@
<div class="settings-view">
<div class="card">
<h2 class="section-title text-xl mb-md">Settings</h2>
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
<!-- Cooking Equipment -->
<section>
@ -20,7 +19,7 @@
class="tag-chip status-badge status-info"
>
{{ item }}
<button class="chip-remove" @click="removeEquipment(item)" :aria-label="'Remove equipment: ' + item">×</button>
<button class="chip-remove" @click="removeEquipment(item)" aria-label="Remove">×</button>
</span>
</div>
@ -51,6 +50,18 @@
</div>
</div>
<!-- Save button -->
<div class="flex-start gap-sm">
<button
class="btn btn-primary"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save Settings</span>
</button>
</div>
</section>
<!-- Sensory Preferences -->
@ -123,6 +134,17 @@
</p>
</div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.saveSensory()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved">Saved!</span>
<span v-else>Save sensory preferences</span>
</button>
</div>
</section>
<!-- Units -->
@ -147,6 +169,17 @@
Imperial (oz, cups, °F)
</button>
</div>
<div class="flex-start gap-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Shopping Locale -->
@ -187,6 +220,17 @@
<option value="br">Brazil (BRL R$)</option>
</optgroup>
</select>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Time-First Layout -->
@ -214,6 +258,17 @@
</span>
</label>
</div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Data Sharing (cloud only) -->
@ -338,12 +393,6 @@
</template>
</div>
</div>
<Transition name="autosave-fade">
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
Saved
</div>
</Transition>
</template>
<script setup lang="ts">
@ -822,32 +871,4 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
border-color: var(--color-border, #e0e0e0);
color: var(--color-text-secondary, #888);
}
/* ── Autosave toast ──────────────────────────────────────────────────────── */
.autosave-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 0.5rem);
padding: 0.4rem 0.9rem;
font-size: var(--font-size-sm);
color: var(--color-success, #4a8c40);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
z-index: 500;
pointer-events: none;
}
.autosave-fade-enter-active,
.autosave-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.autosave-fade-enter-from,
.autosave-fade-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style>

View file

@ -737,54 +737,6 @@ export const recipesAPI = {
})
return response.data
},
/** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
async suggestRecipeStream(
req: RecipeRequest,
onChunk: (chunk: string) => void,
onDone: () => void,
onError: (err: string) => void,
): Promise<void> {
const baseUrl = (api.defaults.baseURL ?? '') as string
let response: Response
try {
response = await fetch(`${baseUrl}/recipes/suggest?stream=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
} catch (err: unknown) {
onError(err instanceof Error ? err.message : 'Network error')
return
}
if (!response.ok) {
onError(`HTTP ${response.status}`)
return
}
const reader = response.body?.getReader()
if (!reader) { onError('No response body'); return }
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) { onDone(); break }
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const part of parts) {
if (!part.startsWith('data: ')) continue
try {
const data = JSON.parse(part.slice(6))
if (data.done) { onDone(); return }
else if (data.error) { onError(data.error); return }
else if (data.chunk) { onChunk(data.chunk) }
} catch { /* ignore malformed events */ }
}
}
},
}
// ========== Settings API ==========

View file

@ -379,17 +379,6 @@ export const useRecipesStore = defineStore('recipes', () => {
wildcardConfirmed.value = false
}
async function streamSuggest(
pantryItems: string[],
secondaryPantryItems: Record<string, string>,
onChunk: (chunk: string) => void,
onDone: () => void,
onError: (err: string) => void,
): Promise<void> {
const req = _buildRequest(pantryItems, secondaryPantryItems)
await recipesAPI.suggestRecipeStream(req, onChunk, onDone, onError)
}
return {
result,
loading,
@ -427,7 +416,6 @@ export const useRecipesStore = defineStore('recipes', () => {
missingIngredientMode,
builderFilterMode,
suggest,
streamSuggest,
loadMore,
dismiss,
undismiss,

View file

@ -1,5 +1,11 @@
/**
* Settings Store
*
* Manages user settings (cooking equipment, preferences) using Pinia.
*/
import { defineStore } from 'pinia'
import { ref, watch, nextTick } from 'vue'
import { ref } from 'vue'
import { settingsAPI } from '../services/api'
import type { UnitSystem } from '../utils/units'
import type { SensoryPreferences } from '../services/api'
@ -7,12 +13,8 @@ import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
function debounce(fn: () => void, ms: number): () => void {
let t: ReturnType<typeof setTimeout>
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
}
export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([])
const unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('us')
@ -21,40 +23,7 @@ export const useSettingsStore = defineStore('settings', () => {
const loading = ref(false)
const saved = ref(false)
// Prevents autosave watchers from firing during initial load hydration.
// Set to true after nextTick() at the end of load() — by that point all
// watcher jobs queued by the hydration assignments have already flushed.
let _hydrated = false
function _flash() {
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
async function _saveKey(key: string, value: string): Promise<void> {
if (!_hydrated) return
try {
await settingsAPI.setSetting(key, value)
_flash()
} catch (err: unknown) {
console.error('Autosave failed for key:', key, err)
}
}
const _autosave = {
equipment: debounce(() => _saveKey('cooking_equipment', JSON.stringify(cookingEquipment.value)), 600),
unit: debounce(() => _saveKey('unit_system', unitSystem.value), 600),
locale: debounce(() => _saveKey('shopping_locale', shoppingLocale.value), 600),
sensory: debounce(() => _saveKey('sensory_preferences', JSON.stringify(sensoryPreferences.value)), 600),
layout: debounce(() => _saveKey('time_first_layout', timeFirstLayout.value), 600),
}
watch(cookingEquipment, _autosave.equipment, { deep: true })
watch(unitSystem, _autosave.unit)
watch(shoppingLocale, _autosave.locale)
watch(sensoryPreferences, _autosave.sensory, { deep: true })
watch(timeFirstLayout, _autosave.layout)
// Actions
async function load() {
loading.value = true
try {
@ -89,15 +58,8 @@ export const useSettingsStore = defineStore('settings', () => {
} finally {
loading.value = false
}
// Yield past the watcher flush triggered by hydration assignments above.
// After nextTick, any pending watcher jobs from this load() have already
// run (and been ignored by _hydrated guard), so user-driven changes from
// here forward will correctly trigger autosave.
await nextTick()
_hydrated = true
}
// Kept for explicit full-save scenarios (e.g. fallback, tests).
async function save() {
loading.value = true
try {
@ -108,7 +70,10 @@ export const useSettingsStore = defineStore('settings', () => {
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
])
_flash()
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} catch (err: unknown) {
console.error('Failed to save settings:', err)
} finally {
@ -116,17 +81,24 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// Kept for backward compat; autosave handles sensory changes now.
async function saveSensory() {
loading.value = true
try {
await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
_flash()
await settingsAPI.setSetting(
'sensory_preferences',
JSON.stringify(sensoryPreferences.value),
)
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
} catch (err: unknown) {
console.error('Failed to save sensory preferences:', err)
} finally {
loading.value = false
}
}
return {
// State
cookingEquipment,
unitSystem,
shoppingLocale,
@ -134,6 +106,8 @@ export const useSettingsStore = defineStore('settings', () => {
timeFirstLayout,
loading,
saved,
// Actions
load,
save,
saveSensory,

View file

@ -14,8 +14,8 @@ OVERRIDE_FLAG=""
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update"
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build|cloud-update}"
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
echo ""
echo "Dev:"
echo " start Build (if needed) and start all services"
@ -26,7 +26,6 @@ usage() {
echo " open Open web UI in browser"
echo " build Rebuild Docker images without cache"
echo " test Run pytest test suite"
echo " update git pull + rebuild + restart dev stack"
echo ""
echo "Cloud (menagerie.circuitforge.tech/kiwi):"
echo " cloud-start Build cloud images and start kiwi-cloud project"
@ -35,7 +34,6 @@ usage() {
echo " cloud-status Show cloud containers"
echo " cloud-logs Follow cloud logs [api|web — defaults to all]"
echo " cloud-build Rebuild cloud images without cache"
echo " cloud-update git pull + rebuild + restart cloud stack"
exit 1
}
@ -70,11 +68,6 @@ case "$cmd" in
build)
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
;;
update)
git pull
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
echo "Kiwi updated and restarted → http://localhost:${WEB_PORT}"
;;
test)
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
conda run -n job-seeker pytest tests/ -v
@ -102,11 +95,6 @@ case "$cmd" in
cloud-build)
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" build --no-cache
;;
cloud-update)
git pull
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" up -d --build
echo "Kiwi cloud updated and restarted → https://menagerie.circuitforge.tech/kiwi"
;;
*)
usage