Compare commits

...

10 commits

Author SHA1 Message Date
afec0c1dae fix: docuvision fast-path falls through when parse yields no items
_parse_json_from_text always returns a dict (never None), so the
previous `if parsed is not None` guard was permanently true — garbled
docuvision output would return an empty skeleton instead of falling
through to the local VLM. Replace the check with a meaningful-content
test (items or merchant present). Add two tests: one that asserts the
fallthrough behavior on an empty parse, one that confirms the fast path
is taken when parsing succeeds.
2026-04-02 13:49:38 -07:00
a18b2d2ffe fix: address recipe/OCR quality issues from review 2026-04-02 12:41:59 -07:00
7aebe96675 feat: add DocuvisionClient + cf-docuvision fast-path for OCR
Introduces a thin HTTP client for the cf-docuvision service and wires it
as a fast path in VisionLanguageOCR.extract_receipt_data(). When CF_ORCH_URL
is set, the pipeline attempts docuvision allocation via CFOrchClient before
loading the heavy local VLM; falls back gracefully if unavailable.
2026-04-02 12:33:05 -07:00
362b7ad148 feat(frontend): warm organic design overhaul — Fraunces/DM fonts, saffron accent, compact inventory shelf view
- EditItemModal: replace all hardcoded colors (#eee, #f5f5f5, #2196F3, etc.) with CSS variable tokens; restyle modal header with display font, blur backdrop, and theme-aware form elements
- ReceiptsView: replace emoji headings, hardcoded spinner, and non-theme .button class with themed equivalents; all colors through var(--color-*) tokens
- RecipesView: fix broken --color-warning-rgb / --color-primary-rgb references (not defined in theme); use --color-warning-bg and --color-info-bg instead; apply section-title to heading
- SettingsView: apply section-title display-font class to heading for consistency
- InventoryList: remove three dead functions (formatDate, getDaysUntilExpiry, getExpiryClass) that caused TS6133 build errors
2026-04-01 22:29:55 -07:00
cfd6ef88cc fix: align frontend InventoryItem type with actual API response
InventoryItemResponse returns flat fields (product_name, barcode, category)
not a nested product object. Frontend interface and templates were using
item.product.name / item.product.brand which threw TypeError on render,
blanking the inventory tab.

- InventoryItem: remove product:Product, add product_name/barcode/category
- InventoryStats: fix total_products→available_items, expired→expired_items,
  items_by_location→locations
- item IDs are int not string — update deleteItem/updateItem/consumeItem sigs
- EditItemModal, RecipesView, InventoryList: fix all item.product.xxx refs
2026-04-01 17:30:21 -07:00
addcd88625 fix: nginx /kiwi/ alias for direct port access
Vite builds with VITE_BASE_URL=/kiwi so assets are referenced as
/kiwi/assets/... in index.html. When accessed via Caddy at the /kiwi
path, Caddy strips the prefix and nginx gets /assets/... correctly.
When accessed directly at localhost:8515, nginx had no /kiwi/ route
so the JS/CSS 404'd and the SPA never booted (blank page on hard refresh).

Add location ^~ /kiwi/ { alias ...; } — ^~ prevents the regex
\.(js|css|...)$ location from intercepting /kiwi/ paths first.
2026-04-01 17:06:59 -07:00
9705a43b92 feat: cloud auth bypass, VRAM leasing, barcode EXIF fix, pipeline improvements
- cloud_session.py: CLOUD_AUTH_BYPASS_IPS with CIDR support; X-Real-IP for
  Docker bridge NAT-aware client IP resolution; local-dev DB path under
  CLOUD_DATA_ROOT for bypass sessions
- compose.cloud.yml: thread CLOUD_AUTH_BYPASS_IPS from shell env; document
  Docker bridge CIDR requirement in .env.example
- nginx.cloud.conf + nginx.conf: client_max_body_size 20m for barcode uploads
- barcode_scanner.py: EXIF orientation correction (PIL ImageOps.exif_transpose)
  before cv2 decode; rotation coverage extended to [90, 180, 270, 45, 135]
  to catch sideways barcodes the 270° case was missing
- llm_recipe.py: CF-core VRAM lease acquire/release wrapping LLMRouter calls
- tasks/runner.py + config.py: COORDINATOR_URL + recipe_llm VRAM budget (4GB)
- recipes.py: per-request Store creation inside asyncio.to_thread worker to
  avoid SQLite check_same_thread violations
- download_datasets.py: HF_PARQUET_FILES strategy for repos without dataset
  builders (lishuyang/recipepairs direct parquet download)
- derive_substitutions.py: use recipepairs_recipes.parquet for ingredient
  lookup; numpy array detection; JSON category parsing
- test_build_flavorgraph_index.py: rewritten for CSV-based index format
- pyproject.toml: add Pillow>=10.0 for EXIF rotation support
2026-04-01 16:06:23 -07:00
31063a9cfc fix: data pipeline — R-vector parser, allrecipes dataset, unique recipe index
- build_recipe_index.py: add _parse_r_vector() for food.com R format, add
  _parse_allrecipes_text() for corbt/all-recipes text format, _row_to_fields()
  dispatcher handles both columnar (food.com) and single-text (all-recipes)
- build_flavorgraph_index.py: switch from graph.json to nodes/edges CSVs
  matching actual FlavorGraph repo structure
- download_datasets.py: switch recipe source to corbt/all-recipes (2.1M
  recipes, 807MB) replacing near-empty AkashPS11/recipes_data_food.com
- 007_recipe_corpus.sql: add UNIQUE constraint on external_id to prevent
  duplicate inserts on pipeline reruns
2026-03-31 21:36:13 -07:00
0b67f66fca Merge branch 'feature/recipe-ui'
Recipe and Settings tabs complete. 96-module clean build.
2026-03-31 19:20:20 -07:00
0da1d97a60 feat: recipe + settings frontend — Recipes and Settings tabs
- RecipesView: level selector (1-4), constraints/allergies tag inputs,
  hard day mode toggle, max missing input, expiry-first pantry extraction,
  recipe cards with collapsible swaps/directions, grocery links, rate
  limit banner
- SettingsView: cooking equipment tag input with quick-add chips, save
  with confirmation feedback
- stores/recipes.ts: Pinia store for recipe state + suggest() action
- stores/settings.ts: Pinia store for cooking_equipment persistence
- api.ts: RecipeRequest/Result/Suggestion types + recipesAPI + settingsAPI
- App.vue: two new tabs (Recipes, Settings), lazy inventory load on tab switch
2026-03-31 19:20:13 -07:00
32 changed files with 2836 additions and 1091 deletions

View file

@ -11,6 +11,14 @@ DATA_DIR=./data
# Database (defaults to DATA_DIR/kiwi.db) # Database (defaults to DATA_DIR/kiwi.db)
# DB_PATH=./data/kiwi.db # DB_PATH=./data/kiwi.db
# Pipeline data directory for downloaded parquets (used by download_datasets.py)
# Override to store large datasets on a separate drive or NAS
# KIWI_PIPELINE_DATA_DIR=./data/pipeline
# CF-core resource coordinator (VRAM lease management)
# Set to the coordinator URL when running alongside cf-core orchestration
# COORDINATOR_URL=http://localhost:7700
# Processing # Processing
USE_GPU=true USE_GPU=true
GPU_MEMORY_LIMIT=6144 GPU_MEMORY_LIMIT=6144
@ -28,6 +36,14 @@ DEMO_MODE=false
# Cloud mode (set in compose.cloud.yml; also set here for reference) # Cloud mode (set in compose.cloud.yml; also set here for reference)
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data # CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
# KIWI_DB=data/kiwi.db # local-mode DB path override # KIWI_DB=data/kiwi.db # local-mode DB path override
# DEV ONLY: bypass JWT auth for these IPs/CIDRs (LAN testing without Caddy in the path).
# NEVER set in production.
# IMPORTANT: Docker port mapping NATs source IPs to the bridge gateway. When hitting
# localhost:8515 (host → Docker → nginx → API), nginx sees 192.168.80.1, not 127.0.0.1.
# Include the Docker bridge CIDR to allow localhost and LAN access through nginx.
# Run: docker network inspect kiwi-cloud_kiwi-cloud-net | grep Subnet
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1,::1,192.168.80.0/20
# CLOUD_AUTH_BYPASS_IPS=
# Heimdall license server (required for cloud tier resolution) # Heimdall license server (required for cloud tier resolution)
# HEIMDALL_URL=https://license.circuitforge.tech # HEIMDALL_URL=https://license.circuitforge.tech

View file

@ -2,11 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, get_session from app.cloud_session import CloudUser, get_session
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 RecipeRequest, RecipeResult from app.models.schemas.recipe import RecipeRequest, RecipeResult
from app.services.recipe.recipe_engine import RecipeEngine from app.services.recipe.recipe_engine import RecipeEngine
@ -15,11 +15,25 @@ from app.tiers import can_use
router = APIRouter() router = APIRouter()
def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
"""Run recipe suggestion in a worker thread with its own Store connection.
SQLite connections cannot be shared across threads. This function creates
a fresh Store (and therefore a fresh sqlite3.Connection) in the same thread
where it will be used, avoiding ProgrammingError: SQLite objects created in
a thread can only be used in that same thread.
"""
store = Store(db_path)
try:
return RecipeEngine(store).suggest(req)
finally:
store.close()
@router.post("/suggest", response_model=RecipeResult) @router.post("/suggest", response_model=RecipeResult)
async def suggest_recipes( async def suggest_recipes(
req: RecipeRequest, req: RecipeRequest,
session: CloudUser = Depends(get_session), session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> RecipeResult: ) -> RecipeResult:
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored. # Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok}) req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
@ -35,13 +49,19 @@ async def suggest_recipes(
) )
if req.style_id and not can_use("style_picker", req.tier): if req.style_id and not can_use("style_picker", req.tier):
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.") raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
engine = RecipeEngine(store) return await asyncio.to_thread(_suggest_in_thread, session.db, req)
return await asyncio.to_thread(engine.suggest, req)
@router.get("/{recipe_id}") @router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, store: Store = Depends(get_store)) -> dict: async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
recipe = await asyncio.to_thread(store.get_recipe, recipe_id) def _get(db_path: Path, rid: int) -> dict | None:
store = Store(db_path)
try:
return store.get_recipe(rid)
finally:
store.close()
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
if not recipe: if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found.") raise HTTPException(status_code=404, detail="Recipe not found.")
return recipe return recipe

View file

@ -37,6 +37,43 @@ DIRECTUS_JWT_SECRET: str = os.environ.get("DIRECTUS_JWT_SECRET", "")
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech") HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "") HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
# Dev bypass: comma-separated IPs or CIDR ranges that skip JWT auth.
# NEVER set this in production. Intended only for LAN developer testing when
# the request doesn't pass through Caddy (which normally injects X-CF-Session).
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1
import ipaddress as _ipaddress
_BYPASS_RAW: list[str] = [
e.strip()
for e in os.environ.get("CLOUD_AUTH_BYPASS_IPS", "").split(",")
if e.strip()
]
_BYPASS_NETS: list[_ipaddress.IPv4Network | _ipaddress.IPv6Network] = []
_BYPASS_IPS: frozenset[str] = frozenset()
if _BYPASS_RAW:
_nets, _ips = [], set()
for entry in _BYPASS_RAW:
try:
_nets.append(_ipaddress.ip_network(entry, strict=False))
except ValueError:
_ips.add(entry) # treat non-parseable entries as bare IPs
_BYPASS_NETS = _nets
_BYPASS_IPS = frozenset(_ips)
def _is_bypass_ip(ip: str) -> bool:
if not ip:
return False
if ip in _BYPASS_IPS:
return True
try:
addr = _ipaddress.ip_address(ip)
return any(addr in net for net in _BYPASS_NETS)
except ValueError:
return False
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db")) _LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
_TIER_CACHE: dict[str, tuple[str, float]] = {} _TIER_CACHE: dict[str, tuple[str, float]] = {}
@ -153,12 +190,28 @@ def get_session(request: Request) -> CloudUser:
Local mode: fully-privileged "local" user pointing at local DB. Local mode: fully-privileged "local" user pointing at local DB.
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier. Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
returns a "local" session without JWT validation (dev/LAN use only).
""" """
has_byok = _detect_byok() has_byok = _detect_byok()
if not CLOUD_MODE: if not CLOUD_MODE:
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok) return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
# Prefer X-Real-IP (set by nginx from the actual client address) over the
# TCP peer address (which is nginx's container IP when behind the proxy).
# Prefer X-Real-IP (set by nginx from the actual client address) over the
# TCP peer address (which is nginx's container IP when behind the proxy).
client_ip = (
request.headers.get("x-real-ip", "")
or (request.client.host if request.client else "")
)
if (_BYPASS_IPS or _BYPASS_NETS) and _is_bypass_ip(client_ip):
log.debug("CLOUD_AUTH_BYPASS_IPS match for %s — returning local session", client_ip)
# Use a dev DB under CLOUD_DATA_ROOT so the container has a writable path.
dev_db = _user_db_path("local-dev")
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
raw_header = ( raw_header = (
request.headers.get("x-cf-session", "") request.headers.get("x-cf-session", "")
or request.headers.get("cookie", "") or request.headers.get("cookie", "")

View file

@ -43,6 +43,9 @@ class Settings:
# Quality # Quality
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0")) MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
# CF-core resource coordinator (VRAM lease management)
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
# Feature flags # Feature flags
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes") ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")

View file

@ -21,4 +21,4 @@ CREATE TABLE recipes (
CREATE INDEX idx_recipes_title ON recipes (title); CREATE INDEX idx_recipes_title ON recipes (title);
CREATE INDEX idx_recipes_category ON recipes (category); CREATE INDEX idx_recipes_category ON recipes (category);
CREATE INDEX idx_recipes_external_id ON recipes (external_id); CREATE UNIQUE INDEX idx_recipes_external_id ON recipes (external_id);

View file

@ -5,6 +5,8 @@ This module provides functionality to detect and decode barcodes
from images (UPC, EAN, QR codes, etc.). from images (UPC, EAN, QR codes, etc.).
""" """
import io
import cv2 import cv2
import numpy as np import numpy as np
from pyzbar import pyzbar from pyzbar import pyzbar
@ -12,6 +14,12 @@ from pathlib import Path
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import logging import logging
try:
from PIL import Image as _PILImage
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -76,9 +84,7 @@ class BarcodeScanner:
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes) # 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
if not barcodes: if not barcodes:
logger.info("No barcodes found in standard orientation, trying rotations...") logger.info("No barcodes found in standard orientation, trying rotations...")
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range) for angle in [90, 180, 270, 45, 135]:
# 0° already tried, 180° is functionally same as 0°, 90°/270° are same axis
for angle in [30, 60, 90]:
rotated_gray = self._rotate_image(gray, angle) rotated_gray = self._rotate_image(gray, angle)
rotated_color = self._rotate_image(image, angle) rotated_color = self._rotate_image(image, angle)
detected = self._detect_barcodes(rotated_gray, rotated_color) detected = self._detect_barcodes(rotated_gray, rotated_color)
@ -264,6 +270,26 @@ class BarcodeScanner:
return list(seen.values()) return list(seen.values())
def _fix_exif_orientation(self, image_bytes: bytes) -> bytes:
"""Apply EXIF orientation correction so cv2 sees an upright image.
Phone cameras embed rotation in EXIF; cv2.imdecode ignores it,
so a photo taken in portrait may arrive physically sideways in memory.
"""
if not _HAS_PIL:
return image_bytes
try:
pil = _PILImage.open(io.BytesIO(image_bytes))
pil = _PILImage.fromarray(np.array(pil)) # strips EXIF but applies orientation via PIL
# Use ImageOps.exif_transpose for proper EXIF-aware rotation
import PIL.ImageOps
pil = PIL.ImageOps.exif_transpose(pil)
buf = io.BytesIO()
pil.save(buf, format="JPEG")
return buf.getvalue()
except Exception:
return image_bytes
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]: def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
""" """
Scan barcodes from image bytes (uploaded file). Scan barcodes from image bytes (uploaded file).
@ -275,6 +301,10 @@ class BarcodeScanner:
List of detected barcodes List of detected barcodes
""" """
try: try:
# Apply EXIF orientation correction first (phone cameras embed rotation in EXIF;
# cv2.imdecode ignores it, causing sideways barcodes to appear rotated in memory).
image_bytes = self._fix_exif_orientation(image_bytes)
# Convert bytes to numpy array # Convert bytes to numpy array
nparr = np.frombuffer(image_bytes, np.uint8) nparr = np.frombuffer(image_bytes, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
@ -300,11 +330,12 @@ class BarcodeScanner:
) )
barcodes.extend(self._detect_barcodes(thresh, image)) barcodes.extend(self._detect_barcodes(thresh, image))
# 3. Try rotations if still no barcodes found # 3. Try all 90° rotations + common tilt angles
# 90/270 catches truly sideways barcodes; 180 catches upside-down;
# 45/135 catches tilted barcodes on flat surfaces.
if not barcodes: if not barcodes:
logger.info("No barcodes found in uploaded image, trying rotations...") logger.info("No barcodes found in uploaded image, trying rotations...")
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range) for angle in [90, 180, 270, 45, 135]:
for angle in [30, 60, 90]:
rotated_gray = self._rotate_image(gray, angle) rotated_gray = self._rotate_image(gray, angle)
rotated_color = self._rotate_image(image, angle) rotated_color = self._rotate_image(image, angle)
detected = self._detect_barcodes(rotated_gray, rotated_color) detected = self._detect_barcodes(rotated_gray, rotated_color)

View file

@ -0,0 +1,60 @@
"""Thin HTTP client for the cf-docuvision document vision service."""
from __future__ import annotations
import base64
from dataclasses import dataclass
from pathlib import Path
import httpx
@dataclass
class DocuvisionResult:
text: str
confidence: float | None = None
raw: dict | None = None
class DocuvisionClient:
"""Thin client for the cf-docuvision service."""
def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")
def extract_text(self, image_path: str | Path) -> DocuvisionResult:
"""Send an image to docuvision and return extracted text."""
image_bytes = Path(image_path).read_bytes()
b64 = base64.b64encode(image_bytes).decode()
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{self._base_url}/extract",
json={"image": b64},
)
resp.raise_for_status()
data = resp.json()
return DocuvisionResult(
text=data.get("text", ""),
confidence=data.get("confidence"),
raw=data,
)
async def extract_text_async(self, image_path: str | Path) -> DocuvisionResult:
"""Async version."""
image_bytes = Path(image_path).read_bytes()
b64 = base64.b64encode(image_bytes).decode()
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{self._base_url}/extract",
json={"image": b64},
)
resp.raise_for_status()
data = resp.json()
return DocuvisionResult(
text=data.get("text", ""),
confidence=data.get("confidence"),
raw=data,
)

View file

@ -8,6 +8,7 @@ OCR with understanding of receipt structure to extract structured JSON data.
import json import json
import logging import logging
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
@ -26,6 +27,32 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _try_docuvision(image_path: str | Path) -> str | None:
"""Try to extract text via cf-docuvision. Returns None if unavailable."""
cf_orch_url = os.environ.get("CF_ORCH_URL")
if not cf_orch_url:
return None
try:
from circuitforge_core.resources import CFOrchClient
from app.services.ocr.docuvision_client import DocuvisionClient
client = CFOrchClient(cf_orch_url)
with client.allocate(
service="cf-docuvision",
model_candidates=["cf-docuvision"],
ttl_s=60.0,
caller="kiwi-ocr",
) as alloc:
if alloc is None:
return None
doc_client = DocuvisionClient(alloc.url)
result = doc_client.extract_text(image_path)
return result.text if result.text else None
except Exception as exc:
logger.debug("cf-docuvision fast-path failed, falling back: %s", exc)
return None
class VisionLanguageOCR: class VisionLanguageOCR:
"""Vision-Language Model for receipt OCR and structured extraction.""" """Vision-Language Model for receipt OCR and structured extraction."""
@ -40,7 +67,7 @@ class VisionLanguageOCR:
self.processor = None self.processor = None
self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu" self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu"
self.use_quantization = use_quantization self.use_quantization = use_quantization
self.model_name = "Qwen/Qwen2-VL-2B-Instruct" self.model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
logger.info(f"Initializing VisionLanguageOCR with device: {self.device}") logger.info(f"Initializing VisionLanguageOCR with device: {self.device}")
@ -112,6 +139,18 @@ class VisionLanguageOCR:
"warnings": [...] "warnings": [...]
} }
""" """
# Try docuvision fast path first (skips heavy local VLM if available)
docuvision_text = _try_docuvision(image_path)
if docuvision_text is not None:
parsed = self._parse_json_from_text(docuvision_text)
# Only accept the docuvision result if it yielded meaningful content;
# an empty-skeleton dict (no items, no merchant) means the text was
# garbled and we should fall through to the local VLM instead.
if parsed.get("items") or parsed.get("merchant"):
parsed["raw_text"] = docuvision_text
return self._validate_result(parsed)
# Parsed result has no meaningful content — fall through to local VLM
self._load_model() self._load_model()
try: try:

View file

@ -2,8 +2,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from contextlib import nullcontext
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from openai import OpenAI
if TYPE_CHECKING: if TYPE_CHECKING:
from app.db.store import Store from app.db.store import Store
@ -113,9 +117,51 @@ class LLMRecipeGenerator:
return "\n".join(lines) return "\n".join(lines)
def _call_llm(self, prompt: str) -> str: _MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
"""Call the LLM router and return the response text."""
def _get_llm_context(self):
"""Return a sync context manager that yields an Allocation or None.
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
when the env var is absent or CFOrchClient raises on construction.
"""
cf_orch_url = os.environ.get("CF_ORCH_URL")
if cf_orch_url:
try: try:
from circuitforge_core.resources import CFOrchClient
client = CFOrchClient(cf_orch_url)
return client.allocate(
service="vllm",
model_candidates=self._MODEL_CANDIDATES,
ttl_s=300.0,
caller="kiwi-recipe",
)
except Exception as exc:
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
return nullcontext(None)
def _call_llm(self, prompt: str) -> str:
"""Call the LLM, using CFOrchClient allocation when CF_ORCH_URL is set.
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
calls the OpenAI-compatible API directly against the allocated service URL.
Without CF_ORCH_URL: falls back to LLMRouter using its configured backends.
"""
try:
with self._get_llm_context() as alloc:
if alloc is not None:
base_url = alloc.url.rstrip("/") + "/v1"
client = OpenAI(base_url=base_url, api_key="any")
model = alloc.model or "__auto__"
if model == "__auto__":
model = client.models.list().data[0].id
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content or ""
else:
from circuitforge_core.llm.router import LLMRouter from circuitforge_core.llm.router import LLMRouter
router = LLMRouter() router = LLMRouter()
return router.complete(prompt) return router.complete(prompt)
@ -192,12 +238,16 @@ class LLMRecipeGenerator:
raw_notes = parsed.get("notes", "") raw_notes = parsed.get("notes", "")
notes_str: str = raw_notes if isinstance(raw_notes, str) else "" notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
all_ingredients: list[str] = list(parsed.get("ingredients", []))
pantry_set = {item.lower() for item in (req.pantry_items or [])}
missing = [i for i in all_ingredients if i.lower() not in pantry_set]
suggestion = RecipeSuggestion( suggestion = RecipeSuggestion(
id=0, id=0,
title=parsed.get("title") or "LLM Recipe", title=parsed.get("title") or "LLM Recipe",
match_count=len(req.pantry_items), match_count=len(req.pantry_items),
element_coverage={}, element_coverage={},
missing_ingredients=list(parsed.get("ingredients", [])), missing_ingredients=missing,
directions=directions_list, directions=directions_list,
notes=notes_str, notes=notes_str,
level=req.level, level=req.level,

View file

@ -27,6 +27,9 @@ LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback"})
VRAM_BUDGETS: dict[str, float] = { VRAM_BUDGETS: dict[str, float] = {
# ExpirationPredictor uses a small LLM (16 tokens out, single pass). # ExpirationPredictor uses a small LLM (16 tokens out, single pass).
"expiry_llm_fallback": 2.0, "expiry_llm_fallback": 2.0,
# Recipe LLM (levels 3-4): full recipe generation, ~200-500 tokens out.
# Budget assumes a quantized 7B-class model.
"recipe_llm": 4.0,
} }

View file

@ -14,6 +14,9 @@ services:
CLOUD_MODE: "true" CLOUD_MODE: "true"
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env # DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
# Production deployments must NOT set this. Leave blank or omit entirely.
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
volumes: volumes:
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data - /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
# LLM config — shared with other CF products; read-only in container # LLM config — shared with other CF products; read-only in container

View file

@ -14,6 +14,17 @@ server {
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# Forward the session header injected by Caddy from cf_session cookie. # Forward the session header injected by Caddy from cf_session cookie.
proxy_set_header X-CF-Session $http_x_cf_session; proxy_set_header X-CF-Session $http_x_cf_session;
# Allow image uploads (barcode/receipt photos from phone cameras).
client_max_body_size 20m;
}
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
# Vite's /kiwi base URL means assets are requested at /kiwi/assets/... but stored
# at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works.
# ^~ prevents regex locations from overriding this prefix match for /kiwi/ paths.
location ^~ /kiwi/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html;
} }
location = /index.html { location = /index.html {

View file

@ -9,6 +9,8 @@ server {
proxy_pass http://172.17.0.1:8512; proxy_pass http://172.17.0.1:8512;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# Allow image uploads (barcode/receipt photos from phone cameras).
client_max_body_size 20m;
} }
location = /index.html { location = /index.html {

View file

@ -23,6 +23,18 @@
> >
🧾 Receipts 🧾 Receipts
</button> </button>
<button
:class="['tab', { active: currentTab === 'recipes' }]"
@click="switchTab('recipes')"
>
🍳 Recipes
</button>
<button
:class="['tab', { active: currentTab === 'settings' }]"
@click="switchTab('settings')"
>
Settings
</button>
</div> </div>
<!-- Tab Content --> <!-- Tab Content -->
@ -33,6 +45,14 @@
<div v-show="currentTab === 'receipts'" class="tab-content"> <div v-show="currentTab === 'receipts'" class="tab-content">
<ReceiptsView /> <ReceiptsView />
</div> </div>
<div v-show="currentTab === 'recipes'" class="tab-content">
<RecipesView />
</div>
<div v-show="currentTab === 'settings'" class="tab-content">
<SettingsView />
</div>
</div> </div>
</main> </main>
@ -48,11 +68,20 @@
import { ref } from 'vue' import { ref } from 'vue'
import InventoryList from './components/InventoryList.vue' import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue' import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import { useInventoryStore } from './stores/inventory'
const currentTab = ref<'inventory' | 'receipts'>('inventory') type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
function switchTab(tab: 'inventory' | 'receipts') { const currentTab = ref<Tab>('inventory')
const inventoryStore = useInventoryStore()
async function switchTab(tab: Tab) {
currentTab.value = tab currentTab.value = tab
if (tab === 'recipes' && inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
} }
</script> </script>

View file

@ -10,8 +10,8 @@
<div class="form-group"> <div class="form-group">
<label>Product</label> <label>Product</label>
<div class="product-info"> <div class="product-info">
<strong>{{ item.product.name }}</strong> <strong>{{ item.product_name || 'Unknown Product' }}</strong>
<span v-if="item.product.brand" class="brand">({{ item.product.brand }})</span> <span v-if="item.category" class="brand">{{ item.category }}</span>
</div> </div>
</div> </div>
@ -228,160 +228,183 @@ function getExpiryHint(): string {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(4px);
} }
.modal-content { .modal-content {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius-lg); border-radius: var(--radius-xl);
width: 90%; width: 90%;
max-width: 600px; max-width: 600px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-xl);
border: 1px solid var(--color-border);
} }
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px; padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md);
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--color-border);
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
font-family: var(--font-display);
font-style: italic;
color: var(--color-text-primary);
} }
.close-btn { .close-btn {
background: none; background: none;
border: none; border: none;
font-size: 32px; font-size: 28px;
color: #999; color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 32px; width: 32px;
height: 32px; height: 32px;
line-height: 1; line-height: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
transition: color 0.18s, background 0.18s;
} }
.close-btn:hover { .close-btn:hover {
color: var(--color-text-primary); color: var(--color-text-primary);
background: var(--color-bg-elevated);
} }
.edit-form { .edit-form {
padding: 20px; padding: var(--spacing-lg);
} }
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: var(--spacing-md);
} }
/* Using .form-row from theme.css */ /* Using .form-row from theme.css */
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: var(--spacing-xs);
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-secondary);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
} }
.form-input { .form-input {
width: 100%; width: 100%;
padding: 10px; padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
background: var(--color-bg-input);
color: var(--color-text-primary);
font-family: var(--font-body);
transition: border-color 0.18s, box-shadow 0.18s;
box-sizing: border-box;
} }
.form-input:focus { .form-input:focus {
outline: none; outline: none;
border-color: #2196F3; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1); box-shadow: 0 0 0 3px var(--color-warning-bg);
} }
.form-input.expiry-expired { .form-input.expiry-expired {
border-color: #f44336; border-color: var(--color-error);
} }
.form-input.expiry-soon { .form-input.expiry-soon {
border-color: #ff5722; border-color: var(--color-error-light);
} }
.form-input.expiry-warning { .form-input.expiry-warning {
border-color: #ff9800; border-color: var(--color-warning);
} }
.form-input.expiry-good { .form-input.expiry-good {
border-color: #4CAF50; border-color: var(--color-success);
} }
textarea.form-input { textarea.form-input {
resize: vertical; resize: vertical;
font-family: inherit; font-family: var(--font-body);
} }
.product-info { .product-info {
padding: 10px; padding: var(--spacing-sm) var(--spacing-md);
background: #f5f5f5; background: var(--color-bg-secondary);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
} }
.product-info .brand { .product-info .brand {
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin-left: 8px; margin-left: var(--spacing-sm);
} }
.expiry-hint { .expiry-hint {
display: block; display: block;
margin-top: 5px; margin-top: var(--spacing-xs);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.error-message { .error-message {
background: #ffebee; background: var(--color-error-bg);
color: #c62828; color: var(--color-error-light);
padding: 12px; border: 1px solid var(--color-error-border);
border-radius: var(--radius-sm); padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: 15px; border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.form-actions { .form-actions {
display: flex; display: flex;
gap: 10px; gap: var(--spacing-sm);
justify-content: flex-end; justify-content: flex-end;
margin-top: 25px; margin-top: var(--spacing-lg);
padding-top: 20px; padding-top: var(--spacing-md);
border-top: 1px solid #eee; border-top: 1px solid var(--color-border);
} }
.btn-cancel, .btn-cancel,
.btn-save { .btn-save {
padding: 10px 24px; padding: var(--spacing-sm) var(--spacing-lg);
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600; font-weight: 600;
font-family: var(--font-body);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: all 0.18s;
} }
.btn-cancel { .btn-cancel {
background: #f5f5f5; background: var(--color-bg-elevated);
color: var(--color-text-primary); color: var(--color-text-secondary);
border: 1px solid var(--color-border);
} }
.btn-cancel:hover { .btn-cancel:hover {
background: #e0e0e0; background: var(--color-bg-primary);
color: var(--color-text-primary);
} }
.btn-save { .btn-save {
@ -394,7 +417,7 @@ textarea.form-input {
} }
.btn-save:disabled { .btn-save:disabled {
background: var(--color-text-muted); opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
@ -408,7 +431,7 @@ textarea.form-input {
} }
.modal-header { .modal-header {
padding: 15px; padding: var(--spacing-md);
} }
.modal-header h2 { .modal-header h2 {
@ -416,23 +439,24 @@ textarea.form-input {
} }
.edit-form { .edit-form {
padding: 15px; padding: var(--spacing-md);
} }
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: var(--spacing-sm);
} }
/* Form actions stack on very small screens */ /* Form actions stack on very small screens */
.form-actions { .form-actions {
flex-direction: column-reverse; flex-direction: column-reverse;
gap: 10px; gap: var(--spacing-sm);
} }
.btn-cancel, .btn-cancel,
.btn-save { .btn-save {
width: 100%; width: 100%;
padding: 12px; padding: var(--spacing-md);
text-align: center;
} }
} }
@ -440,13 +464,5 @@ textarea.form-input {
.modal-content { .modal-content {
width: 92%; width: 92%;
} }
.modal-header {
padding: 18px;
}
.edit-form {
padding: 18px;
}
} }
</style> </style>

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
<div class="receipts-view"> <div class="receipts-view">
<!-- Upload Section --> <!-- Upload Section -->
<div class="card"> <div class="card">
<h2>📸 Upload Receipt</h2> <h2 class="section-title mb-md">Upload Receipt</h2>
<div <div
class="upload-area" class="upload-area"
@click="triggerFileInput" @click="triggerFileInput"
@ -21,9 +21,9 @@
@change="handleFileSelect" @change="handleFileSelect"
/> />
<div v-if="uploading" class="loading"> <div v-if="uploading" class="loading-inline mt-md">
<div class="spinner"></div> <div class="spinner"></div>
<p>Processing receipt...</p> <span class="text-sm text-muted">Processing receipt</span>
</div> </div>
<div v-if="uploadResults.length > 0" class="results"> <div v-if="uploadResults.length > 0" class="results">
@ -39,8 +39,8 @@
<!-- Receipts List Section --> <!-- Receipts List Section -->
<div class="card"> <div class="card">
<h2>📋 Recent Receipts</h2> <h2 class="section-title mb-md">Recent Receipts</h2>
<div v-if="receipts.length === 0" style="text-align: center; color: var(--color-text-secondary)"> <div v-if="receipts.length === 0" class="text-center text-secondary p-lg">
<p>No receipts yet. Upload one above!</p> <p>No receipts yet. Upload one above!</p>
</div> </div>
<div v-else> <div v-else>
@ -89,9 +89,9 @@
</div> </div>
</div> </div>
<div style="margin-top: 20px"> <div class="flex gap-sm mt-md">
<button class="button" @click="exportCSV">📊 Download CSV</button> <button class="btn btn-secondary" @click="exportCSV">Download CSV</button>
<button class="button" @click="exportExcel">📈 Download Excel</button> <button class="btn btn-secondary" @click="exportExcel">Download Excel</button>
</div> </div>
</div> </div>
</div> </div>
@ -225,157 +225,117 @@ onMounted(() => {
.receipts-view { .receipts-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: var(--spacing-md);
}
.card {
background: var(--color-bg-card);
border-radius: var(--radius-xl);
padding: 30px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.card h2 {
margin-bottom: 20px;
color: var(--color-text-primary);
} }
.upload-area { .upload-area {
border: 3px dashed var(--color-primary); border: 2px dashed var(--color-border-focus);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 40px; padding: var(--spacing-xl) var(--spacing-lg);
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.2s ease;
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
} }
.upload-area:hover { .upload-area:hover {
border-color: var(--color-secondary); border-color: var(--color-primary);
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
} }
.upload-icon { .upload-icon {
font-size: 48px; font-size: 40px;
margin-bottom: 20px; margin-bottom: var(--spacing-md);
line-height: 1;
} }
.upload-text { .upload-text {
font-size: var(--font-size-lg); font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
margin-bottom: 10px; margin-bottom: var(--spacing-xs);
} }
.upload-hint { .upload-hint {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-secondary); color: var(--color-text-muted);
} }
.loading { .loading-inline {
text-align: center; display: flex;
padding: 20px; align-items: center;
margin-top: 20px; gap: var(--spacing-sm);
} padding: var(--spacing-sm) 0;
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.results { .results {
margin-top: 20px; margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
} }
.result-item { .result-item {
padding: 15px; padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: 10px; font-size: var(--font-size-sm);
} }
.result-success { .result-success {
background: var(--color-success-bg); background: var(--color-success-bg);
color: var(--color-success-dark); color: var(--color-success-light);
border: 1px solid var(--color-success-border); border: 1px solid var(--color-success-border);
} }
.result-error { .result-error {
background: var(--color-error-bg); background: var(--color-error-bg);
color: var(--color-error-dark); color: var(--color-error-light);
border: 1px solid var(--color-error-border); border: 1px solid var(--color-error-border);
} }
.result-info { .result-info {
background: var(--color-info-bg); background: var(--color-info-bg);
color: var(--color-info-dark); color: var(--color-info-light);
border: 1px solid var(--color-info-border); border: 1px solid var(--color-info-border);
} }
/* Using .grid-stats from theme.css */ /* Stat cards */
.stat-card { .stat-card {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
padding: 20px; padding: var(--spacing-md);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
text-align: center; text-align: center;
border: 1px solid var(--color-border);
} }
.stat-value { .stat-value {
font-family: var(--font-mono);
font-size: var(--font-size-2xl); font-size: var(--font-size-2xl);
font-weight: bold; font-weight: 500;
color: var(--color-primary); color: var(--color-primary);
margin-bottom: 5px; margin-bottom: var(--spacing-xs);
line-height: 1.1;
} }
.stat-label { .stat-label {
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
color: var(--color-text-secondary); color: var(--color-text-muted);
} text-transform: uppercase;
letter-spacing: 0.05em;
.button {
background: var(--gradient-primary);
color: white;
border: none;
padding: 12px 30px;
font-size: var(--font-size-base);
border-radius: var(--radius-md);
cursor: pointer;
transition: transform 0.2s;
margin-right: 10px;
}
.button:hover {
transform: translateY(-2px);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
} }
.receipts-list { .receipts-list {
margin-top: 20px; margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
} }
.receipt-item { .receipt-item {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
padding: 15px; padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: 10px; border: 1px solid var(--color-border);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -388,7 +348,7 @@ onMounted(() => {
.receipt-merchant { .receipt-merchant {
font-weight: 600; font-weight: 600;
font-size: var(--font-size-base); font-size: var(--font-size-base);
margin-bottom: 5px; margin-bottom: var(--spacing-xs);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@ -396,7 +356,7 @@ onMounted(() => {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-secondary); color: var(--color-text-secondary);
display: flex; display: flex;
gap: 15px; gap: var(--spacing-md);
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -419,20 +379,17 @@ onMounted(() => {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
/* Mobile Responsive - Handled by theme.css /* Mobile */
Component-specific overrides only below */
@media (max-width: 480px) { @media (max-width: 480px) {
.stat-card { .stat-card {
padding: 15px; padding: var(--spacing-sm);
} }
/* Receipt items stack content vertically */
.receipt-item { .receipt-item {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: var(--spacing-sm);
padding: 12px; padding: var(--spacing-sm);
} }
.receipt-info { .receipt-info {
@ -440,15 +397,8 @@ onMounted(() => {
} }
.receipt-details { .receipt-details {
gap: 10px; gap: var(--spacing-sm);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
} }
/* Buttons full width on mobile */
.button {
width: 100%;
margin-right: 0;
margin-bottom: 10px;
}
} }
</style> </style>

View file

@ -0,0 +1,669 @@
<template>
<div class="recipes-view">
<!-- Controls Panel -->
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
<!-- Level Selector -->
<div class="form-group">
<label class="form-label">Creativity Level</label>
<div class="flex flex-wrap gap-sm">
<button
v-for="lvl in levels"
:key="lvl.value"
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
@click="recipesStore.level = lvl.value"
>
{{ lvl.label }}
</button>
</div>
</div>
<!-- Wildcard warning -->
<div v-if="recipesStore.level === 4" class="status-badge status-warning wildcard-warning">
Wildcard mode uses LLM to generate creative recipes with whatever you have. Results may be
unusual.
<label class="flex-start gap-sm mt-xs">
<input type="checkbox" v-model="recipesStore.wildcardConfirmed" />
<span>I understand, go for it</span>
</label>
</div>
<!-- Dietary Constraints Tags -->
<div class="form-group">
<label class="form-label">Dietary Constraints</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.constraints"
:key="tag"
class="tag-chip status-badge status-info"
>
{{ tag }}
<button class="chip-remove" @click="removeConstraint(tag)" aria-label="Remove">×</button>
</span>
</div>
<input
class="form-input"
v-model="constraintInput"
placeholder="e.g. vegetarian, vegan, gluten-free — press Enter or comma"
@keydown="onConstraintKey"
@blur="commitConstraintInput"
/>
</div>
<!-- Allergies Tags -->
<div class="form-group">
<label class="form-label">Allergies (hard exclusions)</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.allergies"
:key="tag"
class="tag-chip status-badge status-error"
>
{{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" aria-label="Remove">×</button>
</span>
</div>
<input
class="form-input"
v-model="allergyInput"
placeholder="e.g. peanuts, shellfish, dairy — press Enter or comma"
@keydown="onAllergyKey"
@blur="commitAllergyInput"
/>
</div>
<!-- Hard Day Mode -->
<div class="form-group">
<label class="flex-start gap-sm hard-day-toggle">
<input type="checkbox" v-model="recipesStore.hardDayMode" />
<span class="form-label" style="margin-bottom: 0;">Hard Day Mode</span>
</label>
<p v-if="recipesStore.hardDayMode" class="text-sm text-secondary mt-xs">
Only suggests quick, simple recipes based on your saved equipment.
</p>
</div>
<!-- Max Missing -->
<div class="form-group">
<label class="form-label">Max Missing Ingredients (optional)</label>
<input
type="number"
class="form-input"
min="0"
max="5"
placeholder="Leave blank for no limit"
:value="recipesStore.maxMissing ?? ''"
@input="onMaxMissingInput"
/>
</div>
<!-- Nutrition Filters -->
<details class="collapsible form-group">
<summary class="form-label collapsible-summary nutrition-summary">
Nutrition Filters <span class="text-muted text-xs">(per recipe, optional)</span>
</summary>
<div class="nutrition-filters-grid mt-xs">
<div class="form-group">
<label class="form-label">Max Calories</label>
<input
type="number"
class="form-input"
min="0"
placeholder="e.g. 600"
:value="recipesStore.nutritionFilters.max_calories ?? ''"
@input="onNutritionInput('max_calories', $event)"
/>
</div>
<div class="form-group">
<label class="form-label">Max Sugar (g)</label>
<input
type="number"
class="form-input"
min="0"
placeholder="e.g. 10"
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''"
@input="onNutritionInput('max_sugar_g', $event)"
/>
</div>
<div class="form-group">
<label class="form-label">Max Carbs (g)</label>
<input
type="number"
class="form-input"
min="0"
placeholder="e.g. 50"
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''"
@input="onNutritionInput('max_carbs_g', $event)"
/>
</div>
<div class="form-group">
<label class="form-label">Max Sodium (mg)</label>
<input
type="number"
class="form-input"
min="0"
placeholder="e.g. 800"
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
@input="onNutritionInput('max_sodium_mg', $event)"
/>
</div>
</div>
<p class="text-xs text-muted mt-xs">
Recipes without nutrition data always appear. Filters apply to food.com and estimated values.
</p>
</details>
<!-- Suggest Button -->
<button
class="btn btn-primary btn-lg w-full"
:disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)"
@click="handleSuggest"
>
<span v-if="recipesStore.loading">
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes
</span>
<span v-else>Suggest Recipes</span>
</button>
<!-- Empty pantry nudge -->
<p v-if="pantryItems.length === 0 && !recipesStore.loading" class="text-sm text-muted text-center mt-xs">
Add items to your pantry first, then tap Suggest to find recipes.
</p>
</div>
<!-- Error -->
<div v-if="recipesStore.error" class="status-badge status-error mb-md">
{{ recipesStore.error }}
</div>
<!-- Results -->
<div v-if="recipesStore.result" class="results-section fade-in">
<!-- Rate limit warning -->
<div
v-if="recipesStore.result.rate_limited"
class="status-badge status-warning rate-limit-banner mb-md"
>
You've used your {{ recipesStore.result.rate_limit_count }} free suggestions today. Upgrade for
unlimited.
</div>
<!-- Element gaps -->
<div v-if="recipesStore.result.element_gaps.length > 0" class="card card-warning mb-md">
<p class="text-sm font-semibold">Your pantry is missing some flavor elements:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="gap in recipesStore.result.element_gaps"
:key="gap"
class="status-badge status-warning"
>{{ gap }}</span>
</div>
</div>
<!-- No suggestions -->
<div
v-if="recipesStore.result.suggestions.length === 0"
class="card text-center text-muted"
>
<p>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
</div>
<!-- Recipe Cards -->
<div class="grid-auto mb-md">
<div
v-for="recipe in recipesStore.result.suggestions"
:key="recipe.id"
class="card slide-up"
>
<!-- Header row -->
<div class="flex-between mb-sm">
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
</div>
</div>
<!-- Notes -->
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p>
<!-- Nutrition chips -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
🔥 {{ Math.round(recipe.nutrition.calories) }} kcal
</span>
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">
🧈 {{ recipe.nutrition.fat_g.toFixed(1) }}g fat
</span>
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">
💪 {{ recipe.nutrition.protein_g.toFixed(1) }}g protein
</span>
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">
🌾 {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
</span>
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">
🌿 {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
</span>
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">
🍬 {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
</span>
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">
🧂 {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
</span>
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
🍽 {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
</span>
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">
~ estimated
</span>
</div>
<!-- Missing ingredients -->
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm">
<p class="text-sm font-semibold text-warning">You'd need:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.missing_ingredients"
:key="ing"
class="status-badge status-warning"
>{{ ing }}</span>
</div>
</div>
<!-- Grocery links for this recipe's missing ingredients -->
<div v-if="groceryLinksForRecipe(recipe).length > 0" class="mb-sm">
<p class="text-sm font-semibold">Buy online:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<a
v-for="link in groceryLinksForRecipe(recipe)"
:key="link.ingredient + link.retailer"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="grocery-link status-badge status-info"
>
{{ link.ingredient }} @ {{ link.retailer }}
</a>
</div>
</div>
<!-- Swap candidates collapsible -->
<details v-if="recipe.swap_candidates.length > 0" class="collapsible mb-sm">
<summary class="text-sm font-semibold collapsible-summary">
Possible swaps ({{ recipe.swap_candidates.length }})
</summary>
<div class="card-secondary mt-xs">
<div
v-for="swap in recipe.swap_candidates"
:key="swap.original_name + swap.substitute_name"
class="swap-row text-sm"
>
<span class="font-semibold">{{ swap.original_name }}</span>
<span class="text-muted"> </span>
<span class="font-semibold">{{ swap.substitute_name }}</span>
<span v-if="swap.constraint_label" class="status-badge status-info ml-xs">{{ swap.constraint_label }}</span>
<p v-if="swap.explanation" class="text-muted mt-xs">{{ swap.explanation }}</p>
</div>
</div>
</details>
<!-- Directions collapsible -->
<details v-if="recipe.directions.length > 0" class="collapsible">
<summary class="text-sm font-semibold collapsible-summary">
Directions ({{ recipe.directions.length }} steps)
</summary>
<ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }}
</li>
</ol>
</details>
</div>
</div>
<!-- Grocery list summary -->
<div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info">
<h3 class="text-lg font-bold mb-sm">Shopping List</h3>
<ul class="grocery-list">
<li
v-for="item in recipesStore.result.grocery_list"
:key="item"
class="text-sm grocery-item"
>
{{ item }}
</li>
</ul>
</div>
</div>
<!-- Empty state when no results yet and pantry has items -->
<div
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
class="card text-center text-muted"
>
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;color:var(--color-text-muted);margin-bottom:var(--spacing-sm)">
<path d="M12 8c0 0 4-4 12-4s12 4 12 4v8H12V8z"/>
<path d="M10 16h28v4l-2 20H12L10 20v-4z"/>
<line x1="20" y1="24" x2="28" y2="24"/>
</svg>
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
const levels = [
{ value: 1, label: '1 — From Pantry' },
{ value: 2, label: '2 — Creative Swaps' },
{ value: 3, label: '3 — AI Scaffold' },
{ value: 4, label: '4 — Wildcard 🎲' },
]
// 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[]
})
// Grocery links relevant to a specific recipe's missing ingredients
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
if (!recipesStore.result) return []
return recipesStore.result.grocery_links.filter((link) =>
recipe.missing_ingredients.includes(link.ingredient)
)
}
// Tag input helpers constraints
function addConstraint(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.constraints.includes(tag)) {
recipesStore.constraints = [...recipesStore.constraints, tag]
}
constraintInput.value = ''
}
function removeConstraint(tag: string) {
recipesStore.constraints = recipesStore.constraints.filter((c) => c !== tag)
}
function onConstraintKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addConstraint(constraintInput.value)
}
}
function commitConstraintInput() {
if (constraintInput.value.trim()) {
addConstraint(constraintInput.value)
}
}
// Tag input helpers allergies
function addAllergy(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.allergies.includes(tag)) {
recipesStore.allergies = [...recipesStore.allergies, tag]
}
allergyInput.value = ''
}
function removeAllergy(tag: string) {
recipesStore.allergies = recipesStore.allergies.filter((a) => a !== tag)
}
function onAllergyKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addAllergy(allergyInput.value)
}
}
function commitAllergyInput() {
if (allergyInput.value.trim()) {
addAllergy(allergyInput.value)
}
}
// Max missing number input
function onMaxMissingInput(e: Event) {
const target = e.target as HTMLInputElement
const val = parseInt(target.value)
recipesStore.maxMissing = isNaN(val) ? null : val
}
// Nutrition filter inputs
type NutritionKey = 'max_calories' | 'max_sugar_g' | 'max_carbs_g' | 'max_sodium_mg'
function onNutritionInput(key: NutritionKey, e: Event) {
const target = e.target as HTMLInputElement
const val = parseFloat(target.value)
recipesStore.nutritionFilters[key] = isNaN(val) ? null : val
}
// Suggest handler
async function handleSuggest() {
await recipesStore.suggest(pantryItems.value)
}
onMounted(async () => {
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
})
</script>
<style scoped>
.mb-controls {
margin-bottom: var(--spacing-md);
}
.mb-md {
margin-bottom: var(--spacing-md);
}
.mb-sm {
margin-bottom: var(--spacing-sm);
}
.mt-xs {
margin-top: var(--spacing-xs);
}
.ml-xs {
margin-left: var(--spacing-xs);
}
.wildcard-warning {
display: block;
margin-bottom: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
}
.hard-day-toggle {
cursor: pointer;
user-select: none;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.chip-remove {
background: transparent;
border: none;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
color: inherit;
opacity: 0.7;
transition: opacity 0.15s;
}
.chip-remove:hover {
opacity: 1;
transform: none;
}
.inline-spinner {
display: inline-block;
vertical-align: middle;
margin-right: var(--spacing-xs);
}
.rate-limit-banner {
display: block;
padding: var(--spacing-sm) var(--spacing-md);
}
.recipe-title {
flex: 1;
margin-right: var(--spacing-sm);
}
.collapsible {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
}
.collapsible-summary {
cursor: pointer;
list-style: none;
padding: var(--spacing-xs) 0;
color: var(--color-primary);
}
.collapsible-summary::-webkit-details-marker {
display: none;
}
.collapsible-summary::before {
content: '▶ ';
font-size: 10px;
}
details[open] .collapsible-summary::before {
content: '▼ ';
}
.swap-row {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.swap-row:last-child {
border-bottom: none;
}
.directions-list {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
}
.grocery-link {
text-decoration: none;
cursor: pointer;
transition: opacity 0.2s;
}
.grocery-link:hover {
opacity: 0.8;
}
.grocery-list {
padding-left: var(--spacing-lg);
}
.grocery-item {
margin-bottom: var(--spacing-xs);
}
.results-section {
margin-top: var(--spacing-md);
}
.nutrition-summary {
cursor: pointer;
}
.nutrition-filters-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.nutrition-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.nutrition-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
background: var(--color-bg-secondary, #f5f5f5);
color: var(--color-text-secondary);
white-space: nowrap;
}
.nutrition-chip-sugar {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.nutrition-chip-servings {
background: var(--color-info-bg);
color: var(--color-info-light);
}
.nutrition-chip-estimated {
font-style: italic;
opacity: 0.7;
}
/* Mobile adjustments */
@media (max-width: 480px) {
.flex-between {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.recipe-title {
margin-right: 0;
}
.nutrition-filters-grid {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,162 @@
<template>
<div class="settings-view">
<div class="card">
<h2 class="section-title text-xl mb-md">Settings</h2>
<!-- Cooking Equipment -->
<section>
<h3 class="text-lg font-semibold mb-xs">Cooking Equipment</h3>
<p class="text-sm text-secondary mb-md">
Tell Kiwi what you have used when Hard Day Mode is on to filter out recipes requiring
equipment you don't own.
</p>
<!-- Current equipment tags -->
<div class="tags-wrap flex flex-wrap gap-xs mb-sm">
<span
v-for="item in settingsStore.cookingEquipment"
:key="item"
class="tag-chip status-badge status-info"
>
{{ item }}
<button class="chip-remove" @click="removeEquipment(item)" aria-label="Remove">×</button>
</span>
</div>
<!-- Custom input -->
<div class="form-group">
<label class="form-label">Add equipment</label>
<input
class="form-input"
v-model="equipmentInput"
placeholder="Type equipment name, press Enter or comma"
@keydown="onEquipmentKey"
@blur="commitEquipmentInput"
/>
</div>
<!-- Quick-add chips -->
<div class="form-group">
<label class="form-label">Quick-add</label>
<div class="flex flex-wrap gap-xs">
<button
v-for="eq in quickAddOptions"
:key="eq"
:class="['btn', 'btn-sm', 'btn-secondary', { active: settingsStore.cookingEquipment.includes(eq) }]"
@click="toggleEquipment(eq)"
>
{{ eq }}
</button>
</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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
const settingsStore = useSettingsStore()
const equipmentInput = ref('')
const quickAddOptions = [
'Oven',
'Stovetop',
'Microwave',
'Air Fryer',
'Instant Pot',
'Slow Cooker',
'Grill',
'Blender',
]
function addEquipment(value: string) {
const item = value.trim()
if (item && !settingsStore.cookingEquipment.includes(item)) {
settingsStore.cookingEquipment = [...settingsStore.cookingEquipment, item]
}
equipmentInput.value = ''
}
function removeEquipment(item: string) {
settingsStore.cookingEquipment = settingsStore.cookingEquipment.filter((e) => e !== item)
}
function toggleEquipment(item: string) {
if (settingsStore.cookingEquipment.includes(item)) {
removeEquipment(item)
} else {
addEquipment(item)
}
}
function onEquipmentKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addEquipment(equipmentInput.value)
}
}
function commitEquipmentInput() {
if (equipmentInput.value.trim()) {
addEquipment(equipmentInput.value)
}
}
onMounted(async () => {
await settingsStore.load()
})
</script>
<style scoped>
.mb-md {
margin-bottom: var(--spacing-md);
}
.mb-sm {
margin-bottom: var(--spacing-sm);
}
.mb-xs {
margin-bottom: var(--spacing-xs);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.chip-remove {
background: transparent;
border: none;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
color: inherit;
opacity: 0.7;
transition: opacity 0.15s;
}
.chip-remove:hover {
opacity: 1;
transform: none;
}
</style>

View file

@ -80,9 +80,11 @@ export interface Tag {
} }
export interface InventoryItem { export interface InventoryItem {
id: string id: number
product_id: string product_id: number
product: Product product_name: string | null
barcode: string | null
category: string | null
quantity: number quantity: number
unit: string unit: string
location: string location: string
@ -109,11 +111,10 @@ export interface InventoryItemUpdate {
export interface InventoryStats { export interface InventoryStats {
total_items: number total_items: number
total_products: number available_items: number
expiring_soon: number expiring_soon: number
expired: number expired_items: number
items_by_location: Record<string, number> locations: Record<string, number>
items_by_status: Record<string, number>
} }
export interface Receipt { export interface Receipt {
@ -185,7 +186,7 @@ export const inventoryAPI = {
/** /**
* Update an inventory item * Update an inventory item
*/ */
async updateItem(itemId: string, update: InventoryItemUpdate): Promise<InventoryItem> { async updateItem(itemId: number, update: InventoryItemUpdate): Promise<InventoryItem> {
const response = await api.patch(`/inventory/items/${itemId}`, update) const response = await api.patch(`/inventory/items/${itemId}`, update)
return response.data return response.data
}, },
@ -193,7 +194,7 @@ export const inventoryAPI = {
/** /**
* Delete an inventory item * Delete an inventory item
*/ */
async deleteItem(itemId: string): Promise<void> { async deleteItem(itemId: number): Promise<void> {
await api.delete(`/inventory/items/${itemId}`) await api.delete(`/inventory/items/${itemId}`)
}, },
@ -234,7 +235,7 @@ export const inventoryAPI = {
/** /**
* Mark item as consumed * Mark item as consumed
*/ */
async consumeItem(itemId: string): Promise<void> { async consumeItem(itemId: number): Promise<void> {
await api.post(`/inventory/items/${itemId}/consume`) await api.post(`/inventory/items/${itemId}/consume`)
}, },
@ -404,4 +405,94 @@ export const exportAPI = {
}, },
} }
// ========== Recipes & Settings Types ==========
export interface SwapCandidate {
original_name: string
substitute_name: string
constraint_label: string
explanation: string
compensation_hints: Record<string, string>[]
}
export interface RecipeSuggestion {
id: number
title: string
match_count: number
element_coverage: Record<string, number>
swap_candidates: SwapCandidate[]
missing_ingredients: string[]
directions: string[]
notes: string
level: number
is_wildcard: boolean
}
export interface GroceryLink {
ingredient: string
retailer: string
url: string
}
export interface RecipeResult {
suggestions: RecipeSuggestion[]
element_gaps: string[]
grocery_list: string[]
grocery_links: GroceryLink[]
rate_limited: boolean
rate_limit_count: number
}
export interface RecipeRequest {
pantry_items: string[]
level: number
constraints: string[]
allergies: string[]
expiry_first: boolean
hard_day_mode: boolean
max_missing: number | null
style_id: string | null
wildcard_confirmed: boolean
}
export interface Staple {
slug: string
name: string
category: string
dietary_tags: string[]
}
// ========== Recipes API ==========
export const recipesAPI = {
async suggest(req: RecipeRequest): Promise<RecipeResult> {
const response = await api.post('/recipes/suggest', req)
return response.data
},
async getRecipe(id: number): Promise<RecipeSuggestion> {
const response = await api.get(`/recipes/${id}`)
return response.data
},
async listStaples(dietary?: string): Promise<Staple[]> {
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
return response.data
},
}
// ========== Settings API ==========
export const settingsAPI = {
async getSetting(key: string): Promise<string | null> {
try {
const response = await api.get(`/settings/${key}`)
return response.data.value
} catch {
return null
}
},
async setSetting(key: string, value: string): Promise<void> {
await api.put(`/settings/${key}`, { value })
},
}
export default api export default api

View file

@ -76,7 +76,7 @@ export const useInventoryStore = defineStore('inventory', () => {
} }
} }
async function updateItem(itemId: string, update: InventoryItemUpdate) { async function updateItem(itemId: number, update: InventoryItemUpdate) {
loading.value = true loading.value = true
error.value = null error.value = null
@ -99,7 +99,7 @@ export const useInventoryStore = defineStore('inventory', () => {
} }
} }
async function deleteItem(itemId: string) { async function deleteItem(itemId: number) {
loading.value = true loading.value = true
error.value = null error.value = null

View file

@ -0,0 +1,78 @@
/**
* Recipes Store
*
* Manages recipe suggestion state and request parameters using Pinia.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeRequest } from '../services/api'
export const useRecipesStore = defineStore('recipes', () => {
// State
const result = ref<RecipeResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const level = ref(1)
const constraints = ref<string[]>([])
const allergies = ref<string[]>([])
const hardDayMode = ref(false)
const maxMissing = ref<number | null>(null)
const styleId = ref<string | null>(null)
const wildcardConfirmed = ref(false)
// Actions
async function suggest(pantryItems: string[]) {
loading.value = true
error.value = null
const req: RecipeRequest = {
pantry_items: pantryItems,
level: level.value,
constraints: constraints.value,
allergies: allergies.value,
expiry_first: true,
hard_day_mode: hardDayMode.value,
max_missing: maxMissing.value,
style_id: styleId.value,
wildcard_confirmed: wildcardConfirmed.value,
}
try {
result.value = await recipesAPI.suggest(req)
} catch (err: unknown) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = 'Failed to get recipe suggestions'
}
console.error('Error fetching recipe suggestions:', err)
} finally {
loading.value = false
}
}
function clearResult() {
result.value = null
error.value = null
wildcardConfirmed.value = false
}
return {
// State
result,
loading,
error,
level,
constraints,
allergies,
hardDayMode,
maxMissing,
styleId,
wildcardConfirmed,
// Actions
suggest,
clearResult,
}
})

View file

@ -0,0 +1,57 @@
/**
* Settings Store
*
* Manages user settings (cooking equipment, preferences) using Pinia.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { settingsAPI } from '../services/api'
export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([])
const loading = ref(false)
const saved = ref(false)
// Actions
async function load() {
loading.value = true
try {
const raw = await settingsAPI.getSetting('cooking_equipment')
if (raw) {
cookingEquipment.value = JSON.parse(raw)
}
} catch (err: unknown) {
console.error('Failed to load settings:', err)
} finally {
loading.value = false
}
}
async function save() {
loading.value = true
try {
await settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value))
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
} catch (err: unknown) {
console.error('Failed to save settings:', err)
} finally {
loading.value = false
}
}
return {
// State
cookingEquipment,
loading,
saved,
// Actions
load,
save,
}
})

View file

@ -18,6 +18,7 @@ dependencies = [
"opencv-python>=4.8", "opencv-python>=4.8",
"numpy>=1.25", "numpy>=1.25",
"pyzbar>=0.1.9", "pyzbar>=0.1.9",
"Pillow>=10.0",
# HTTP client # HTTP client
"httpx>=0.27", "httpx>=0.27",
# CircuitForge shared scaffold # CircuitForge shared scaffold

View file

@ -5,9 +5,9 @@ FlavorGraph GitHub: https://github.com/lamypark/FlavorGraph
Download: git clone https://github.com/lamypark/FlavorGraph /tmp/flavorgraph Download: git clone https://github.com/lamypark/FlavorGraph /tmp/flavorgraph
Usage: Usage:
conda run -n job-seeker python scripts/pipeline/build_flavorgraph_index.py \ conda run -n cf python scripts/pipeline/build_flavorgraph_index.py \
--db /path/to/kiwi.db \ --db data/kiwi.db \
--graph-json /tmp/flavorgraph/data/graph.json --flavorgraph-dir /tmp/flavorgraph/input
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@ -16,64 +16,74 @@ import sqlite3
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
import pandas as pd
def parse_ingredient_nodes(
nodes_path: Path, edges_path: Path
) -> tuple[dict[str, list[str]], dict[str, str]]:
"""Parse FlavorGraph CSVs → (ingredient→compounds, compound→name)."""
nodes = pd.read_csv(nodes_path, dtype=str).fillna("")
edges = pd.read_csv(edges_path, dtype=str).fillna("")
def parse_ingredient_nodes(graph: dict) -> dict[str, list[str]]:
"""Return {ingredient_name: [compound_id, ...]} from a FlavorGraph JSON."""
ingredient_compounds: dict[str, list[str]] = defaultdict(list)
ingredient_ids: dict[str, str] = {} # node_id -> ingredient_name ingredient_ids: dict[str, str] = {} # node_id -> ingredient_name
compound_names: dict[str, str] = {} # node_id -> compound_name
for node in graph.get("nodes", []): for _, row in nodes.iterrows():
if node.get("type") == "ingredient": nid = row["node_id"]
ingredient_ids[node["id"]] = node["name"].lower() name = row["name"].lower().replace("_", " ").strip()
if row["node_type"] == "ingredient":
ingredient_ids[nid] = name
else:
compound_names[nid] = name
for link in graph.get("links", []): ingredient_compounds: dict[str, list[str]] = defaultdict(list)
src, tgt = link.get("source", ""), link.get("target", "") for _, row in edges.iterrows():
src, tgt = row["id_1"], row["id_2"]
if src in ingredient_ids: if src in ingredient_ids:
ingredient_compounds[ingredient_ids[src]].append(tgt) ingredient_compounds[ingredient_ids[src]].append(tgt)
if tgt in ingredient_ids: if tgt in ingredient_ids:
ingredient_compounds[ingredient_ids[tgt]].append(src) ingredient_compounds[ingredient_ids[tgt]].append(src)
return dict(ingredient_compounds) return dict(ingredient_compounds), compound_names
def build(db_path: Path, graph_json_path: Path) -> None: def build(db_path: Path, flavorgraph_dir: Path) -> None:
graph = json.loads(graph_json_path.read_text()) nodes_path = flavorgraph_dir / "nodes_191120.csv"
ingredient_map = parse_ingredient_nodes(graph) edges_path = flavorgraph_dir / "edges_191120.csv"
ingredient_map, compound_names = parse_ingredient_nodes(nodes_path, edges_path)
compound_ingredients: dict[str, list[str]] = defaultdict(list) compound_ingredients: dict[str, list[str]] = defaultdict(list)
compound_names: dict[str, str] = {}
for node in graph.get("nodes", []):
if node.get("type") == "compound":
compound_names[node["id"]] = node["name"]
for ingredient, compounds in ingredient_map.items(): for ingredient, compounds in ingredient_map.items():
for cid in compounds: for cid in compounds:
compound_ingredients[cid].append(ingredient) compound_ingredients[cid].append(ingredient)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try:
for ingredient, compounds in ingredient_map.items(): for ingredient, compounds in ingredient_map.items():
conn.execute(""" conn.execute(
UPDATE ingredient_profiles "UPDATE ingredient_profiles SET flavor_molecule_ids = ? WHERE name = ?",
SET flavor_molecule_ids = ? (json.dumps(compounds), ingredient),
WHERE name = ? )
""", (json.dumps(compounds), ingredient))
for cid, ingredients in compound_ingredients.items(): for cid, ingredients in compound_ingredients.items():
conn.execute(""" conn.execute(
INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names) "INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names)"
VALUES (?, ?, ?) " VALUES (?, ?, ?)",
""", (cid, compound_names.get(cid, cid), json.dumps(ingredients))) (cid, compound_names.get(cid, cid), json.dumps(ingredients)),
)
conn.commit() conn.commit()
finally:
conn.close() conn.close()
print(f"Indexed {len(ingredient_map)} ingredients, {len(compound_ingredients)} compounds") print(f"Indexed {len(ingredient_map)} ingredients, {len(compound_ingredients)} compounds")
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--db", required=True, type=Path) parser.add_argument("--db", required=True, type=Path)
parser.add_argument("--graph-json", required=True, type=Path) parser.add_argument("--flavorgraph-dir", required=True, type=Path)
args = parser.parse_args() args = parser.parse_args()
build(args.db, args.graph_json) build(args.db, args.flavorgraph_dir)

View file

@ -25,6 +25,12 @@ _TRAILING_QUALIFIER = re.compile(
r"\s*(to taste|as needed|or more|or less|optional|if desired|if needed)\s*$", r"\s*(to taste|as needed|or more|or less|optional|if desired|if needed)\s*$",
re.IGNORECASE, re.IGNORECASE,
) )
_QUOTED = re.compile(r'"([^"]*)"')
def _parse_r_vector(s: str) -> list[str]:
"""Parse R character vector format: c("a", "b") -> ["a", "b"]."""
return _QUOTED.findall(s)
def extract_ingredient_names(raw_list: list[str]) -> list[str]: def extract_ingredient_names(raw_list: list[str]) -> list[str]:
@ -53,6 +59,55 @@ def compute_element_coverage(profiles: list[dict]) -> dict[str, float]:
return {e: round(c / len(profiles), 3) for e, c in counts.items()} return {e: round(c / len(profiles), 3) for e, c in counts.items()}
def _parse_allrecipes_text(text: str) -> tuple[str, list[str], list[str]]:
"""Parse corbt/all-recipes text format into (title, ingredients, directions)."""
lines = text.strip().split('\n')
title = lines[0].strip()
ingredients: list[str] = []
directions: list[str] = []
section: str | None = None
for line in lines[1:]:
stripped = line.strip()
if stripped.lower() == 'ingredients:':
section = 'ingredients'
elif stripped.lower() in ('directions:', 'steps:', 'instructions:'):
section = 'directions'
elif stripped.startswith('- ') and section == 'ingredients':
ingredients.append(stripped[2:].strip())
elif stripped.startswith('- ') and section == 'directions':
directions.append(stripped[2:].strip())
return title, ingredients, directions
def _row_to_fields(row: pd.Series) -> tuple[str, str, list[str], list[str]]:
"""Extract (external_id, title, raw_ingredients, directions) from a parquet row.
Handles both corbt/all-recipes (single 'input' text column) and the
food.com columnar format (RecipeId, Name, RecipeIngredientParts, ...).
"""
if "input" in row.index and pd.notna(row.get("input")):
title, raw_ingredients, directions = _parse_allrecipes_text(str(row["input"]))
external_id = f"ar_{hash(title) & 0xFFFFFFFF}"
else:
raw_parts = row.get("RecipeIngredientParts", [])
if isinstance(raw_parts, str):
parsed = _parse_r_vector(raw_parts)
raw_parts = parsed if parsed else [raw_parts]
raw_ingredients = [str(i) for i in (raw_parts or [])]
raw_dirs = row.get("RecipeInstructions", [])
if isinstance(raw_dirs, str):
parsed_dirs = _parse_r_vector(raw_dirs)
directions = parsed_dirs if parsed_dirs else [raw_dirs]
else:
directions = [str(d) for d in (raw_dirs or [])]
title = str(row.get("Name", ""))[:500]
external_id = str(row.get("RecipeId", ""))
return external_id, title, raw_ingredients, directions
def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None: def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try: try:
@ -71,13 +126,9 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
batch = [] batch = []
for _, row in df.iterrows(): for _, row in df.iterrows():
raw_ingredients = row.get("RecipeIngredientParts", []) external_id, title, raw_ingredients, directions = _row_to_fields(row)
if isinstance(raw_ingredients, str): if not title:
try: continue
raw_ingredients = json.loads(raw_ingredients)
except Exception:
raw_ingredients = [raw_ingredients]
raw_ingredients = [str(i) for i in (raw_ingredients or [])]
ingredient_names = extract_ingredient_names(raw_ingredients) ingredient_names = extract_ingredient_names(raw_ingredients)
profiles = [] profiles = []
@ -86,19 +137,12 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
profiles.append({"elements": profile_index[name]}) profiles.append({"elements": profile_index[name]})
coverage = compute_element_coverage(profiles) coverage = compute_element_coverage(profiles)
directions = row.get("RecipeInstructions", [])
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = [directions]
batch.append(( batch.append((
str(row.get("RecipeId", "")), external_id,
str(row.get("Name", ""))[:500], title,
json.dumps(raw_ingredients), json.dumps(raw_ingredients),
json.dumps(ingredient_names), json.dumps(ingredient_names),
json.dumps([str(d) for d in (directions or [])]), json.dumps(directions),
str(row.get("RecipeCategory", "") or ""), str(row.get("RecipeCategory", "") or ""),
json.dumps(list(row.get("Keywords", []) or [])), json.dumps(list(row.get("Keywords", []) or [])),
float(row.get("Calories") or 0) or None, float(row.get("Calories") or 0) or None,
@ -111,7 +155,7 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
if len(batch) >= batch_size: if len(batch) >= batch_size:
before = conn.total_changes before = conn.total_changes
conn.executemany(""" conn.executemany("""
INSERT OR IGNORE INTO recipes INSERT OR REPLACE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, (external_id, title, ingredients, ingredient_names, directions,
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage) category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
@ -124,7 +168,7 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
if batch: if batch:
before = conn.total_changes before = conn.total_changes
conn.executemany(""" conn.executemany("""
INSERT OR IGNORE INTO recipes INSERT OR REPLACE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, (external_id, title, ingredients, ingredient_names, directions,
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage) category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)

View file

@ -3,24 +3,21 @@ Derive substitution pairs by diffing lishuyang/recipepairs.
GPL-3.0 source -- derived annotations only, raw pairs not shipped. GPL-3.0 source -- derived annotations only, raw pairs not shipped.
Usage: Usage:
conda run -n job-seeker python scripts/pipeline/derive_substitutions.py \ PYTHONPATH=/path/to/kiwi conda run -n cf python scripts/pipeline/derive_substitutions.py \
--db /path/to/kiwi.db \ --db /path/to/kiwi.db \
--recipepairs data/recipepairs.parquet --recipepairs data/pipeline/recipepairs.parquet \
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import re
import sqlite3 import sqlite3
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
from scripts.pipeline.build_recipe_index import extract_ingredient_names
CONSTRAINT_COLS = ["vegan", "vegetarian", "dairy_free", "low_calorie",
"low_carb", "low_fat", "low_sodium", "gluten_free"]
def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], list[str]]: def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], list[str]]:
base_set = set(base) base_set = set(base)
@ -30,21 +27,44 @@ def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], lis
return removed, added return removed, added
def build(db_path: Path, recipepairs_path: Path) -> None: def _parse_categories(val: object) -> list[str]:
"""Parse categories field which may be a list, str-repr list, or bare string."""
if isinstance(val, list):
return [str(v) for v in val]
if isinstance(val, str):
val = val.strip()
if val.startswith("["):
# parse list repr: ['a', 'b'] — use json after converting single quotes
try:
fixed = re.sub(r"'", '"', val)
return json.loads(fixed)
except Exception:
pass
return [val] if val else []
return []
def build(db_path: Path, recipepairs_path: Path, recipes_path: Path) -> None:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try: try:
print("Loading recipe ingredient index...") # Load ingredient lists from the bundled recipepairs recipe corpus.
# This is GPL-3.0 data — we only use it for diffing; raw data is not persisted.
print("Loading recipe ingredient index from recipepairs corpus...")
recipes_df = pd.read_parquet(recipes_path, columns=["id", "ingredients"])
recipe_ingredients: dict[str, list[str]] = {} recipe_ingredients: dict[str, list[str]] = {}
for row in conn.execute("SELECT external_id, ingredient_names FROM recipes"): for _, r in recipes_df.iterrows():
recipe_ingredients[str(row[0])] = json.loads(row[1]) ings = r["ingredients"]
if ings is not None and hasattr(ings, "__iter__") and not isinstance(ings, str):
recipe_ingredients[str(int(r["id"]))] = [str(i) for i in ings]
print(f" {len(recipe_ingredients)} recipes loaded")
df = pd.read_parquet(recipepairs_path) pairs_df = pd.read_parquet(recipepairs_path)
pair_counts: dict[tuple, dict] = defaultdict(lambda: {"count": 0}) pair_counts: dict[tuple, dict] = defaultdict(lambda: {"count": 0})
print("Diffing recipe pairs...") print("Diffing recipe pairs...")
for _, row in df.iterrows(): for _, row in pairs_df.iterrows():
base_id = str(row.get("base", "")) base_id = str(int(row["base"]))
target_id = str(row.get("target", "")) target_id = str(int(row["target"]))
base_ings = recipe_ingredients.get(base_id, []) base_ings = recipe_ingredients.get(base_id, [])
target_ings = recipe_ingredients.get(target_id, []) target_ings = recipe_ingredients.get(target_id, [])
if not base_ings or not target_ings: if not base_ings or not target_ings:
@ -56,7 +76,9 @@ def build(db_path: Path, recipepairs_path: Path) -> None:
original = removed[0] original = removed[0]
substitute = added[0] substitute = added[0]
constraints = [c for c in CONSTRAINT_COLS if row.get(c, 0)] constraints = _parse_categories(row.get("categories", []))
if not constraints:
continue
for constraint in constraints: for constraint in constraints:
key = (original, substitute, constraint) key = (original, substitute, constraint)
pair_counts[key]["count"] += 1 pair_counts[key]["count"] += 1
@ -103,6 +125,10 @@ def build(db_path: Path, recipepairs_path: Path) -> None:
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--db", required=True, type=Path) parser.add_argument("--db", required=True, type=Path)
parser.add_argument("--recipepairs", required=True, type=Path) parser.add_argument("--recipepairs", required=True, type=Path,
help="pairs.parquet from lishuyang/recipepairs")
parser.add_argument("--recipepairs-recipes", required=True, type=Path,
dest="recipepairs_recipes",
help="recipes.parquet from lishuyang/recipepairs (ingredient lookup)")
args = parser.parse_args() args = parser.parse_args()
build(args.db, args.recipepairs) build(args.db, args.recipepairs, args.recipepairs_recipes)

View file

@ -2,31 +2,43 @@
Download recipe engine datasets from HuggingFace. Download recipe engine datasets from HuggingFace.
Usage: Usage:
conda run -n job-seeker python scripts/pipeline/download_datasets.py --data-dir /path/to/data conda run -n cf python scripts/pipeline/download_datasets.py --data-dir data/pipeline
Downloads: Downloads:
- AkashPS11/recipes_data_food.com (MIT) data/recipes_foodcom.parquet - corbt/all-recipes (no license) data/pipeline/recipes_allrecipes.parquet [2.1M recipes]
- omid5/usda-fdc-foods-cleaned (CC0) data/usda_fdc_cleaned.parquet - omid5/usda-fdc-foods-cleaned (CC0) data/pipeline/usda_fdc_cleaned.parquet
- jacktol/usda-branded-food-data (MIT) data/usda_branded.parquet - jacktol/usda-branded-food-data (MIT) data/pipeline/usda_branded.parquet
- lishuyang/recipepairs (GPL-3.0 ) data/recipepairs.parquet [derive only, don't ship] - lishuyang/recipepairs (GPL-3.0 ) data/pipeline/recipepairs.parquet [derive only, don't ship]
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import shutil
from pathlib import Path from pathlib import Path
from datasets import load_dataset from datasets import load_dataset
from huggingface_hub import hf_hub_download
DATASETS = [ # Standard HuggingFace datasets: (hf_path, split, output_filename)
("AkashPS11/recipes_data_food.com", "train", "recipes_foodcom.parquet"), HF_DATASETS = [
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),
("omid5/usda-fdc-foods-cleaned", "train", "usda_fdc_cleaned.parquet"), ("omid5/usda-fdc-foods-cleaned", "train", "usda_fdc_cleaned.parquet"),
("jacktol/usda-branded-food-data", "train", "usda_branded.parquet"), ("jacktol/usda-branded-food-data","train", "usda_branded.parquet"),
("lishuyang/recipepairs", "train", "recipepairs.parquet"), ]
# Datasets that expose raw parquet files directly (no HF dataset builder)
HF_PARQUET_FILES = [
# (repo_id, repo_filename, output_filename)
# lishuyang/recipepairs: GPL-3.0 ⚠ — derive only, don't ship
("lishuyang/recipepairs", "pairs.parquet", "recipepairs.parquet"),
] ]
def download_all(data_dir: Path) -> None: def download_all(data_dir: Path) -> None:
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
for hf_path, split, filename in DATASETS:
for hf_path, split, filename in HF_DATASETS:
out = data_dir / filename out = data_dir / filename
if out.exists(): if out.exists():
print(f" skip {filename} (already exists)") print(f" skip {filename} (already exists)")
@ -36,9 +48,29 @@ def download_all(data_dir: Path) -> None:
ds.to_parquet(str(out)) ds.to_parquet(str(out))
print(f" saved → {out}") print(f" saved → {out}")
for repo_id, repo_file, filename in HF_PARQUET_FILES:
out = data_dir / filename
if out.exists():
print(f" skip {filename} (already exists)")
continue
print(f" downloading {repo_id}/{repo_file} ...")
cached = hf_hub_download(repo_id=repo_id, filename=repo_file, repo_type="dataset")
shutil.copy2(cached, out)
print(f" saved → {out}")
_DEFAULT_DATA_DIR = Path(
os.environ.get("KIWI_PIPELINE_DATA_DIR", "data/pipeline")
)
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", required=True, type=Path) parser.add_argument(
"--data-dir",
type=Path,
default=_DEFAULT_DATA_DIR,
help="Directory for downloaded parquets (default: $KIWI_PIPELINE_DATA_DIR or data/pipeline)",
)
args = parser.parse_args() args = parser.parse_args()
download_all(args.data_dir) download_all(args.data_dir)

View file

@ -1,18 +1,39 @@
import csv
import tempfile
from pathlib import Path
def _write_csv(path: Path, rows: list[dict], fieldnames: list[str]) -> None:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
w.writeheader()
w.writerows(rows)
def test_parse_flavorgraph_node(): def test_parse_flavorgraph_node():
from scripts.pipeline.build_flavorgraph_index import parse_ingredient_nodes from scripts.pipeline.build_flavorgraph_index import parse_ingredient_nodes
sample = {
"nodes": [ with tempfile.TemporaryDirectory() as tmp:
{"id": "I_beef", "type": "ingredient", "name": "beef"}, nodes_path = Path(tmp) / "nodes.csv"
{"id": "C_pyrazine", "type": "compound", "name": "pyrazine"}, edges_path = Path(tmp) / "edges.csv"
{"id": "I_mushroom", "type": "ingredient", "name": "mushroom"},
], _write_csv(nodes_path, [
"links": [ {"node_id": "1", "name": "beef", "node_type": "ingredient"},
{"source": "I_beef", "target": "C_pyrazine"}, {"node_id": "2", "name": "pyrazine", "node_type": "compound"},
{"source": "I_mushroom","target": "C_pyrazine"}, {"node_id": "3", "name": "mushroom", "node_type": "ingredient"},
] ], ["node_id", "name", "node_type"])
}
result = parse_ingredient_nodes(sample) _write_csv(edges_path, [
assert "beef" in result {"id_1": "1", "id_2": "2", "score": "0.8"},
assert "C_pyrazine" in result["beef"] {"id_1": "3", "id_2": "2", "score": "0.7"},
assert "mushroom" in result ], ["id_1", "id_2", "score"])
assert "C_pyrazine" in result["mushroom"]
ingredient_to_compounds, compound_names = parse_ingredient_nodes(nodes_path, edges_path)
assert "beef" in ingredient_to_compounds
assert "mushroom" in ingredient_to_compounds
# compound node_id "2" maps to name "pyrazine"
beef_compounds = ingredient_to_compounds["beef"]
assert any(compound_names.get(c) == "pyrazine" for c in beef_compounds)
mushroom_compounds = ingredient_to_compounds["mushroom"]
assert any(compound_names.get(c) == "pyrazine" for c in mushroom_compounds)

View file

@ -1,6 +1,11 @@
"""Tests for LLMRecipeGenerator — prompt builders and allergy filtering.""" """Tests for LLMRecipeGenerator — prompt builders and allergy filtering."""
from __future__ import annotations from __future__ import annotations
import os
from contextlib import contextmanager
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
import pytest import pytest
from app.models.schemas.recipe import RecipeRequest from app.models.schemas.recipe import RecipeRequest
@ -135,7 +140,90 @@ def test_generate_returns_result_when_llm_responds(monkeypatch):
assert len(result.suggestions) == 1 assert len(result.suggestions) == 1
suggestion = result.suggestions[0] suggestion = result.suggestions[0]
assert suggestion.title == "Mushroom Butter Pasta" assert suggestion.title == "Mushroom Butter Pasta"
assert "butter" in suggestion.missing_ingredients # All LLM ingredients (butter, mushrooms, pasta) are in the pantry, so none are missing
assert suggestion.missing_ingredients == []
assert len(suggestion.directions) > 0 assert len(suggestion.directions) > 0
assert "parmesan" in suggestion.notes.lower() assert "parmesan" in suggestion.notes.lower()
assert result.element_gaps == ["Brightness"] assert result.element_gaps == ["Brightness"]
# ---------------------------------------------------------------------------
# CFOrchClient integration tests
# ---------------------------------------------------------------------------
@dataclass
class _FakeAllocation:
allocation_id: str = "alloc-test-1"
service: str = "vllm"
node_id: str = "node-1"
gpu_id: int = 0
model: str | None = "Ouro-2.6B-Thinking"
url: str = "http://test:8000"
started: bool = True
warm: bool = True
def test_recipe_gen_uses_cf_orch_when_env_set(monkeypatch):
"""When CF_ORCH_URL is set, _call_llm uses alloc.url+/v1 as the OpenAI base_url."""
from app.services.recipe.llm_recipe import LLMRecipeGenerator
store = _make_store()
gen = LLMRecipeGenerator(store)
fake_alloc = _FakeAllocation()
@contextmanager
def _fake_llm_context():
yield fake_alloc
captured = {}
# Fake OpenAI that records the base_url it was constructed with
class _FakeOpenAI:
def __init__(self, *, base_url, api_key):
captured["base_url"] = base_url
msg = MagicMock()
msg.content = "Title: Test\nIngredients: a\nDirections: do it.\nNotes: none."
choice = MagicMock()
choice.message = msg
completion = MagicMock()
completion.choices = [choice]
self.chat = MagicMock()
self.chat.completions = MagicMock()
self.chat.completions.create = MagicMock(return_value=completion)
# Patch _get_llm_context directly so no real HTTP call is made
monkeypatch.setattr(gen, "_get_llm_context", _fake_llm_context)
with patch("app.services.recipe.llm_recipe.OpenAI", _FakeOpenAI):
gen._call_llm("make me a recipe")
assert captured.get("base_url") == "http://test:8000/v1"
def test_recipe_gen_falls_back_without_cf_orch(monkeypatch):
"""When CF_ORCH_URL is not set, _call_llm falls back to LLMRouter."""
from app.services.recipe.llm_recipe import LLMRecipeGenerator
store = _make_store()
gen = LLMRecipeGenerator(store)
monkeypatch.delenv("CF_ORCH_URL", raising=False)
router_called = {}
def _fake_complete(prompt, **_kwargs):
router_called["prompt"] = prompt
return "Title: Direct\nIngredients: x\nDirections: go.\nNotes: ok."
fake_router = MagicMock()
fake_router.complete.side_effect = _fake_complete
# LLMRouter is imported locally inside _call_llm, so patch it at its source module.
# new_callable=MagicMock makes the class itself a MagicMock; set return_value so
# that LLMRouter() (instantiation) yields fake_router rather than a new MagicMock.
with patch("circuitforge_core.llm.router.LLMRouter", new_callable=MagicMock) as mock_router_cls:
mock_router_cls.return_value = fake_router
gen._call_llm("direct path prompt")
assert router_called.get("prompt") == "direct path prompt"

View file

View file

@ -0,0 +1,204 @@
"""Tests for DocuvisionClient and the _try_docuvision fast path."""
from __future__ import annotations
import base64
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
import pytest
from app.services.ocr.docuvision_client import DocuvisionClient, DocuvisionResult
# ---------------------------------------------------------------------------
# DocuvisionClient unit tests
# ---------------------------------------------------------------------------
def test_extract_text_sends_base64_image(tmp_path: Path) -> None:
"""extract_text() POSTs a base64-encoded image and returns parsed text."""
image_file = tmp_path / "test.jpg"
image_file.write_bytes(b"fake-image-bytes")
mock_response = MagicMock()
mock_response.json.return_value = {"text": "Cheerios", "confidence": 0.95}
mock_response.raise_for_status.return_value = None
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = mock_response
client = DocuvisionClient("http://docuvision:8080")
result = client.extract_text(image_file)
assert result.text == "Cheerios"
assert result.confidence == 0.95
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args
assert call_kwargs[0][0] == "http://docuvision:8080/extract"
posted_json = call_kwargs[1]["json"]
expected_b64 = base64.b64encode(b"fake-image-bytes").decode()
assert posted_json["image"] == expected_b64
def test_extract_text_raises_on_http_error(tmp_path: Path) -> None:
"""extract_text() propagates HTTP errors from the server."""
image_file = tmp_path / "test.jpg"
image_file.write_bytes(b"fake-image-bytes")
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"500 Internal Server Error",
request=MagicMock(),
response=MagicMock(),
)
with patch("httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value.__enter__.return_value = mock_client
mock_client.post.return_value = mock_response
client = DocuvisionClient("http://docuvision:8080")
with pytest.raises(httpx.HTTPStatusError):
client.extract_text(image_file)
# ---------------------------------------------------------------------------
# _try_docuvision fast-path tests
# ---------------------------------------------------------------------------
def test_try_docuvision_returns_none_without_cf_orch_url(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""_try_docuvision() returns None immediately when CF_ORCH_URL is not set."""
monkeypatch.delenv("CF_ORCH_URL", raising=False)
# Import after env manipulation so the function sees the unset var
from app.services.ocr.vl_model import _try_docuvision
with patch("httpx.Client") as mock_client_cls:
result = _try_docuvision(tmp_path / "test.jpg")
assert result is None
mock_client_cls.assert_not_called()
# ---------------------------------------------------------------------------
# extract_receipt_data docuvision fast-path fallthrough tests
# ---------------------------------------------------------------------------
def test_extract_receipt_data_falls_through_when_docuvision_yields_empty_parse(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When docuvision returns garbled text that parses to an empty structure,
extract_receipt_data must fall through to the local VLM rather than
returning an empty skeleton dict as a successful result."""
from app.services.ocr.vl_model import VisionLanguageOCR
vlm = VisionLanguageOCR()
# Simulate docuvision returning some text that cannot be meaningfully parsed
garbled_text = "not valid json at all @@##!!"
local_vlm_result = {
"merchant": {"name": "Whole Foods"},
"transaction": {},
"items": [{"name": "Milk", "quantity": 1, "unit_price": 3.99, "total_price": 3.99}],
"totals": {"total": 3.99},
"confidence": {"overall": 0.9},
"raw_text": "Whole Foods\nMilk $3.99",
}
with (
patch("app.services.ocr.vl_model._try_docuvision", return_value=garbled_text),
patch.object(vlm, "_load_model"),
patch.object(vlm, "_parse_json_from_text", wraps=vlm._parse_json_from_text) as spy_parse,
patch.object(vlm, "_validate_result", side_effect=lambda r: r) as mock_validate,
):
# Intercept the VLM path by making generate/processor unavailable
# by patching extract_receipt_data at the local-VLM branch entry.
# We do this by replacing the second call to _parse_json_from_text
# (the one from the local VLM branch) with the known good result.
call_count = {"n": 0}
original_parse = vlm._parse_json_from_text.__wrapped__ if hasattr(
vlm._parse_json_from_text, "__wrapped__"
) else None
def _fake_parse(text: str) -> dict:
call_count["n"] += 1
if call_count["n"] == 1:
# First call: docuvision path — return the real (empty) result
return vlm.__class__._parse_json_from_text(vlm, text)
# Second call: local VLM path — return populated result
return local_vlm_result
spy_parse.side_effect = _fake_parse
# Also stub the model inference bits so we don't need a real GPU
from unittest.mock import MagicMock
import torch
vlm._model_loaded = True
vlm.model = MagicMock()
vlm.processor = MagicMock()
vlm.processor.return_value = {}
vlm.processor.decode.return_value = "Whole Foods\nMilk $3.99"
vlm.processor.tokenizer = MagicMock()
vlm.model.generate.return_value = [torch.tensor([1, 2, 3])]
# Provide a minimal image file
img_path = tmp_path / "receipt.jpg"
from PIL import Image as PILImage
img = PILImage.new("RGB", (10, 10), color=(255, 255, 255))
img.save(img_path)
result = vlm.extract_receipt_data(str(img_path))
# The result must NOT be the empty skeleton — it should come from the local VLM path
assert result.get("merchant") or result.get("items"), (
"extract_receipt_data returned an empty skeleton instead of falling "
"through to the local VLM when docuvision parse yielded no content"
)
# parse was called at least twice (once for docuvision, once for local VLM)
assert call_count["n"] >= 2, (
"Expected _parse_json_from_text to be called for both the docuvision "
f"path and the local VLM path, but it was called {call_count['n']} time(s)"
)
def test_extract_receipt_data_uses_docuvision_when_parse_succeeds(
tmp_path: Path,
) -> None:
"""When docuvision returns text that yields meaningful parsed content,
extract_receipt_data must return that result and skip the local VLM."""
from app.services.ocr.vl_model import VisionLanguageOCR
vlm = VisionLanguageOCR()
populated_parse = {
"merchant": {"name": "Target"},
"transaction": {},
"items": [{"name": "Shampoo", "quantity": 1, "unit_price": 5.99, "total_price": 5.99}],
"totals": {"total": 5.99},
"confidence": {"overall": 0.88},
}
docuvision_text = '{"merchant": {"name": "Target"}, "items": [...]}'
with (
patch("app.services.ocr.vl_model._try_docuvision", return_value=docuvision_text),
patch.object(vlm, "_parse_json_from_text", return_value=populated_parse),
patch.object(vlm, "_validate_result", side_effect=lambda r: r),
patch.object(vlm, "_load_model") as mock_load,
):
result = vlm.extract_receipt_data(str(tmp_path / "receipt.jpg"))
# Local VLM should NOT have been loaded — docuvision fast path handled it
mock_load.assert_not_called()
assert result["merchant"]["name"] == "Target"
assert result["raw_text"] == docuvision_text