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)
# 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
USE_GPU=true
GPU_MEMORY_LIMIT=6144
@ -28,6 +36,14 @@ DEMO_MODE=false
# Cloud mode (set in compose.cloud.yml; also set here for reference)
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
# 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_URL=https://license.circuitforge.tech

View file

@ -2,11 +2,11 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, get_session
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult
from app.services.recipe.recipe_engine import RecipeEngine
@ -15,11 +15,25 @@ from app.tiers import can_use
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)
async def suggest_recipes(
req: RecipeRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> RecipeResult:
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
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):
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
engine = RecipeEngine(store)
return await asyncio.to_thread(engine.suggest, req)
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
@router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, store: Store = Depends(get_store)) -> dict:
recipe = await asyncio.to_thread(store.get_recipe, recipe_id)
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
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:
raise HTTPException(status_code=404, detail="Recipe not found.")
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_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"))
_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.
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()
if not CLOUD_MODE:
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 = (
request.headers.get("x-cf-session", "")
or request.headers.get("cookie", "")

View file

@ -43,6 +43,9 @@ class Settings:
# Quality
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
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_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.).
"""
import io
import cv2
import numpy as np
from pyzbar import pyzbar
@ -12,6 +14,12 @@ from pathlib import Path
from typing import List, Dict, Any, Optional
import logging
try:
from PIL import Image as _PILImage
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
logger = logging.getLogger(__name__)
@ -76,9 +84,7 @@ class BarcodeScanner:
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
if not barcodes:
logger.info("No barcodes found in standard orientation, trying rotations...")
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
# 0° already tried, 180° is functionally same as 0°, 90°/270° are same axis
for angle in [30, 60, 90]:
for angle in [90, 180, 270, 45, 135]:
rotated_gray = self._rotate_image(gray, angle)
rotated_color = self._rotate_image(image, angle)
detected = self._detect_barcodes(rotated_gray, rotated_color)
@ -264,6 +270,26 @@ class BarcodeScanner:
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]]:
"""
Scan barcodes from image bytes (uploaded file).
@ -275,6 +301,10 @@ class BarcodeScanner:
List of detected barcodes
"""
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
nparr = np.frombuffer(image_bytes, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
@ -300,11 +330,12 @@ class BarcodeScanner:
)
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:
logger.info("No barcodes found in uploaded image, trying rotations...")
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
for angle in [30, 60, 90]:
for angle in [90, 180, 270, 45, 135]:
rotated_gray = self._rotate_image(gray, angle)
rotated_color = self._rotate_image(image, angle)
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 logging
import os
import re
from pathlib import Path
from typing import Dict, Any, Optional, List
@ -26,6 +27,32 @@ from app.core.config import settings
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:
"""Vision-Language Model for receipt OCR and structured extraction."""
@ -40,7 +67,7 @@ class VisionLanguageOCR:
self.processor = None
self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu"
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}")
@ -112,6 +139,18 @@ class VisionLanguageOCR:
"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()
try:

View file

@ -2,8 +2,12 @@
from __future__ import annotations
import logging
import os
from contextlib import nullcontext
from typing import TYPE_CHECKING
from openai import OpenAI
if TYPE_CHECKING:
from app.db.store import Store
@ -113,9 +117,51 @@ class LLMRecipeGenerator:
return "\n".join(lines)
def _call_llm(self, prompt: str) -> str:
"""Call the LLM router and return the response text."""
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
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:
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
router = LLMRouter()
return router.complete(prompt)
@ -192,12 +238,16 @@ class LLMRecipeGenerator:
raw_notes = parsed.get("notes", "")
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(
id=0,
title=parsed.get("title") or "LLM Recipe",
match_count=len(req.pantry_items),
element_coverage={},
missing_ingredients=list(parsed.get("ingredients", [])),
missing_ingredients=missing,
directions=directions_list,
notes=notes_str,
level=req.level,

View file

@ -27,6 +27,9 @@ LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback"})
VRAM_BUDGETS: dict[str, float] = {
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
"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_DATA_ROOT: /devl/kiwi-cloud-data
# 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:
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
# 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;
# Forward the session header injected by Caddy from cf_session cookie.
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 {

View file

@ -9,6 +9,8 @@ server {
proxy_pass http://172.17.0.1:8512;
proxy_set_header Host $host;
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 {

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
<div class="receipts-view">
<!-- Upload Section -->
<div class="card">
<h2>📸 Upload Receipt</h2>
<h2 class="section-title mb-md">Upload Receipt</h2>
<div
class="upload-area"
@click="triggerFileInput"
@ -21,9 +21,9 @@
@change="handleFileSelect"
/>
<div v-if="uploading" class="loading">
<div v-if="uploading" class="loading-inline mt-md">
<div class="spinner"></div>
<p>Processing receipt...</p>
<span class="text-sm text-muted">Processing receipt</span>
</div>
<div v-if="uploadResults.length > 0" class="results">
@ -39,8 +39,8 @@
<!-- Receipts List Section -->
<div class="card">
<h2>📋 Recent Receipts</h2>
<div v-if="receipts.length === 0" style="text-align: center; color: var(--color-text-secondary)">
<h2 class="section-title mb-md">Recent Receipts</h2>
<div v-if="receipts.length === 0" class="text-center text-secondary p-lg">
<p>No receipts yet. Upload one above!</p>
</div>
<div v-else>
@ -89,9 +89,9 @@
</div>
</div>
<div style="margin-top: 20px">
<button class="button" @click="exportCSV">📊 Download CSV</button>
<button class="button" @click="exportExcel">📈 Download Excel</button>
<div class="flex gap-sm mt-md">
<button class="btn btn-secondary" @click="exportCSV">Download CSV</button>
<button class="btn btn-secondary" @click="exportExcel">Download Excel</button>
</div>
</div>
</div>
@ -225,157 +225,117 @@ onMounted(() => {
.receipts-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.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);
gap: var(--spacing-md);
}
.upload-area {
border: 3px dashed var(--color-primary);
border: 2px dashed var(--color-border-focus);
border-radius: var(--radius-lg);
padding: 40px;
padding: var(--spacing-xl) var(--spacing-lg);
text-align: center;
cursor: pointer;
transition: all 0.3s;
transition: all 0.2s ease;
background: var(--color-bg-secondary);
}
.upload-area:hover {
border-color: var(--color-secondary);
border-color: var(--color-primary);
background: var(--color-bg-elevated);
}
.upload-icon {
font-size: 48px;
margin-bottom: 20px;
font-size: 40px;
margin-bottom: var(--spacing-md);
line-height: 1;
}
.upload-text {
font-size: var(--font-size-lg);
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 10px;
margin-bottom: var(--spacing-xs);
}
.upload-hint {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
color: var(--color-text-muted);
}
.loading {
text-align: center;
padding: 20px;
margin-top: 20px;
}
.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);
}
.loading-inline {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) 0;
}
.results {
margin-top: 20px;
margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.result-item {
padding: 15px;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: 10px;
font-size: var(--font-size-sm);
}
.result-success {
background: var(--color-success-bg);
color: var(--color-success-dark);
color: var(--color-success-light);
border: 1px solid var(--color-success-border);
}
.result-error {
background: var(--color-error-bg);
color: var(--color-error-dark);
color: var(--color-error-light);
border: 1px solid var(--color-error-border);
}
.result-info {
background: var(--color-info-bg);
color: var(--color-info-dark);
color: var(--color-info-light);
border: 1px solid var(--color-info-border);
}
/* Using .grid-stats from theme.css */
/* Stat cards */
.stat-card {
background: var(--color-bg-secondary);
padding: 20px;
padding: var(--spacing-md);
border-radius: var(--radius-lg);
text-align: center;
border: 1px solid var(--color-border);
}
.stat-value {
font-family: var(--font-mono);
font-size: var(--font-size-2xl);
font-weight: bold;
font-weight: 500;
color: var(--color-primary);
margin-bottom: 5px;
margin-bottom: var(--spacing-xs);
line-height: 1.1;
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.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;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.receipts-list {
margin-top: 20px;
margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.receipt-item {
background: var(--color-bg-secondary);
padding: 15px;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: 10px;
border: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
@ -388,7 +348,7 @@ onMounted(() => {
.receipt-merchant {
font-weight: 600;
font-size: var(--font-size-base);
margin-bottom: 5px;
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
@ -396,7 +356,7 @@ onMounted(() => {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
display: flex;
gap: 15px;
gap: var(--spacing-md);
flex-wrap: wrap;
}
@ -419,20 +379,17 @@ onMounted(() => {
color: var(--color-text-secondary);
}
/* Mobile Responsive - Handled by theme.css
Component-specific overrides only below */
/* Mobile */
@media (max-width: 480px) {
.stat-card {
padding: 15px;
padding: var(--spacing-sm);
}
/* Receipt items stack content vertically */
.receipt-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.receipt-info {
@ -440,15 +397,8 @@ onMounted(() => {
}
.receipt-details {
gap: 10px;
gap: var(--spacing-sm);
font-size: var(--font-size-xs);
}
/* Buttons full width on mobile */
.button {
width: 100%;
margin-right: 0;
margin-bottom: 10px;
}
}
</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 {
id: string
product_id: string
product: Product
id: number
product_id: number
product_name: string | null
barcode: string | null
category: string | null
quantity: number
unit: string
location: string
@ -109,11 +111,10 @@ export interface InventoryItemUpdate {
export interface InventoryStats {
total_items: number
total_products: number
available_items: number
expiring_soon: number
expired: number
items_by_location: Record<string, number>
items_by_status: Record<string, number>
expired_items: number
locations: Record<string, number>
}
export interface Receipt {
@ -185,7 +186,7 @@ export const inventoryAPI = {
/**
* 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)
return response.data
},
@ -193,7 +194,7 @@ export const inventoryAPI = {
/**
* Delete an inventory item
*/
async deleteItem(itemId: string): Promise<void> {
async deleteItem(itemId: number): Promise<void> {
await api.delete(`/inventory/items/${itemId}`)
},
@ -234,7 +235,7 @@ export const inventoryAPI = {
/**
* Mark item as consumed
*/
async consumeItem(itemId: string): Promise<void> {
async consumeItem(itemId: number): Promise<void> {
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

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
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
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",
"numpy>=1.25",
"pyzbar>=0.1.9",
"Pillow>=10.0",
# HTTP client
"httpx>=0.27",
# 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
Usage:
conda run -n job-seeker python scripts/pipeline/build_flavorgraph_index.py \
--db /path/to/kiwi.db \
--graph-json /tmp/flavorgraph/data/graph.json
conda run -n cf python scripts/pipeline/build_flavorgraph_index.py \
--db data/kiwi.db \
--flavorgraph-dir /tmp/flavorgraph/input
"""
from __future__ import annotations
import argparse
@ -16,64 +16,74 @@ import sqlite3
from collections import defaultdict
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
compound_names: dict[str, str] = {} # node_id -> compound_name
for node in graph.get("nodes", []):
if node.get("type") == "ingredient":
ingredient_ids[node["id"]] = node["name"].lower()
for _, row in nodes.iterrows():
nid = row["node_id"]
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", []):
src, tgt = link.get("source", ""), link.get("target", "")
ingredient_compounds: dict[str, list[str]] = defaultdict(list)
for _, row in edges.iterrows():
src, tgt = row["id_1"], row["id_2"]
if src in ingredient_ids:
ingredient_compounds[ingredient_ids[src]].append(tgt)
if tgt in ingredient_ids:
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:
graph = json.loads(graph_json_path.read_text())
ingredient_map = parse_ingredient_nodes(graph)
def build(db_path: Path, flavorgraph_dir: Path) -> None:
nodes_path = flavorgraph_dir / "nodes_191120.csv"
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_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 cid in compounds:
compound_ingredients[cid].append(ingredient)
conn = sqlite3.connect(db_path)
try:
for ingredient, compounds in ingredient_map.items():
conn.execute("""
UPDATE ingredient_profiles
SET flavor_molecule_ids = ?
WHERE name = ?
""", (json.dumps(compounds), ingredient))
conn.execute(
"UPDATE ingredient_profiles SET flavor_molecule_ids = ? WHERE name = ?",
(json.dumps(compounds), ingredient),
)
for cid, ingredients in compound_ingredients.items():
conn.execute("""
INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names)
VALUES (?, ?, ?)
""", (cid, compound_names.get(cid, cid), json.dumps(ingredients)))
conn.execute(
"INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names)"
" VALUES (?, ?, ?)",
(cid, compound_names.get(cid, cid), json.dumps(ingredients)),
)
conn.commit()
finally:
conn.close()
print(f"Indexed {len(ingredient_map)} ingredients, {len(compound_ingredients)} compounds")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
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()
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*$",
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]:
@ -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()}
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:
conn = sqlite3.connect(db_path)
try:
@ -71,13 +126,9 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
batch = []
for _, row in df.iterrows():
raw_ingredients = row.get("RecipeIngredientParts", [])
if isinstance(raw_ingredients, str):
try:
raw_ingredients = json.loads(raw_ingredients)
except Exception:
raw_ingredients = [raw_ingredients]
raw_ingredients = [str(i) for i in (raw_ingredients or [])]
external_id, title, raw_ingredients, directions = _row_to_fields(row)
if not title:
continue
ingredient_names = extract_ingredient_names(raw_ingredients)
profiles = []
@ -86,19 +137,12 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
profiles.append({"elements": profile_index[name]})
coverage = compute_element_coverage(profiles)
directions = row.get("RecipeInstructions", [])
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = [directions]
batch.append((
str(row.get("RecipeId", "")),
str(row.get("Name", ""))[:500],
external_id,
title,
json.dumps(raw_ingredients),
json.dumps(ingredient_names),
json.dumps([str(d) for d in (directions or [])]),
json.dumps(directions),
str(row.get("RecipeCategory", "") or ""),
json.dumps(list(row.get("Keywords", []) or [])),
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:
before = conn.total_changes
conn.executemany("""
INSERT OR IGNORE INTO recipes
INSERT OR REPLACE INTO recipes
(external_id, title, ingredients, ingredient_names, directions,
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
@ -124,7 +168,7 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
if batch:
before = conn.total_changes
conn.executemany("""
INSERT OR IGNORE INTO recipes
INSERT OR REPLACE INTO recipes
(external_id, title, ingredients, ingredient_names, directions,
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
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.
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 \
--recipepairs data/recipepairs.parquet
--recipepairs data/pipeline/recipepairs.parquet \
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
from collections import defaultdict
from pathlib import Path
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]]:
base_set = set(base)
@ -30,21 +27,44 @@ def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], lis
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)
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]] = {}
for row in conn.execute("SELECT external_id, ingredient_names FROM recipes"):
recipe_ingredients[str(row[0])] = json.loads(row[1])
for _, r in recipes_df.iterrows():
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})
print("Diffing recipe pairs...")
for _, row in df.iterrows():
base_id = str(row.get("base", ""))
target_id = str(row.get("target", ""))
for _, row in pairs_df.iterrows():
base_id = str(int(row["base"]))
target_id = str(int(row["target"]))
base_ings = recipe_ingredients.get(base_id, [])
target_ings = recipe_ingredients.get(target_id, [])
if not base_ings or not target_ings:
@ -56,7 +76,9 @@ def build(db_path: Path, recipepairs_path: Path) -> None:
original = removed[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:
key = (original, substitute, constraint)
pair_counts[key]["count"] += 1
@ -103,6 +125,10 @@ def build(db_path: Path, recipepairs_path: Path) -> None:
if __name__ == "__main__":
parser = argparse.ArgumentParser()
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()
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.
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:
- AkashPS11/recipes_data_food.com (MIT) data/recipes_foodcom.parquet
- omid5/usda-fdc-foods-cleaned (CC0) data/usda_fdc_cleaned.parquet
- jacktol/usda-branded-food-data (MIT) data/usda_branded.parquet
- lishuyang/recipepairs (GPL-3.0 ) data/recipepairs.parquet [derive only, don't ship]
- corbt/all-recipes (no license) data/pipeline/recipes_allrecipes.parquet [2.1M recipes]
- omid5/usda-fdc-foods-cleaned (CC0) data/pipeline/usda_fdc_cleaned.parquet
- jacktol/usda-branded-food-data (MIT) data/pipeline/usda_branded.parquet
- lishuyang/recipepairs (GPL-3.0 ) data/pipeline/recipepairs.parquet [derive only, don't ship]
"""
from __future__ import annotations
import argparse
import os
import shutil
from pathlib import Path
from datasets import load_dataset
from huggingface_hub import hf_hub_download
DATASETS = [
("AkashPS11/recipes_data_food.com", "train", "recipes_foodcom.parquet"),
# Standard HuggingFace datasets: (hf_path, split, output_filename)
HF_DATASETS = [
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),
("omid5/usda-fdc-foods-cleaned", "train", "usda_fdc_cleaned.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:
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
if out.exists():
print(f" skip {filename} (already exists)")
@ -36,9 +48,29 @@ def download_all(data_dir: Path) -> None:
ds.to_parquet(str(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__":
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()
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():
from scripts.pipeline.build_flavorgraph_index import parse_ingredient_nodes
sample = {
"nodes": [
{"id": "I_beef", "type": "ingredient", "name": "beef"},
{"id": "C_pyrazine", "type": "compound", "name": "pyrazine"},
{"id": "I_mushroom", "type": "ingredient", "name": "mushroom"},
],
"links": [
{"source": "I_beef", "target": "C_pyrazine"},
{"source": "I_mushroom","target": "C_pyrazine"},
]
}
result = parse_ingredient_nodes(sample)
assert "beef" in result
assert "C_pyrazine" in result["beef"]
assert "mushroom" in result
assert "C_pyrazine" in result["mushroom"]
with tempfile.TemporaryDirectory() as tmp:
nodes_path = Path(tmp) / "nodes.csv"
edges_path = Path(tmp) / "edges.csv"
_write_csv(nodes_path, [
{"node_id": "1", "name": "beef", "node_type": "ingredient"},
{"node_id": "2", "name": "pyrazine", "node_type": "compound"},
{"node_id": "3", "name": "mushroom", "node_type": "ingredient"},
], ["node_id", "name", "node_type"])
_write_csv(edges_path, [
{"id_1": "1", "id_2": "2", "score": "0.8"},
{"id_1": "3", "id_2": "2", "score": "0.7"},
], ["id_1", "id_2", "score"])
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."""
from __future__ import annotations
import os
from contextlib import contextmanager
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
import pytest
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
suggestion = result.suggestions[0]
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 "parmesan" in suggestion.notes.lower()
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