Compare commits
10 commits
ae9137585a
...
afec0c1dae
| Author | SHA1 | Date | |
|---|---|---|---|
| afec0c1dae | |||
| a18b2d2ffe | |||
| 7aebe96675 | |||
| 362b7ad148 | |||
| cfd6ef88cc | |||
| addcd88625 | |||
| 9705a43b92 | |||
| 31063a9cfc | |||
| 0b67f66fca | |||
| 0da1d97a60 |
32 changed files with 2836 additions and 1091 deletions
16
.env.example
16
.env.example
|
|
@ -11,6 +11,14 @@ DATA_DIR=./data
|
||||||
# Database (defaults to DATA_DIR/kiwi.db)
|
# Database (defaults to DATA_DIR/kiwi.db)
|
||||||
# DB_PATH=./data/kiwi.db
|
# DB_PATH=./data/kiwi.db
|
||||||
|
|
||||||
|
# Pipeline data directory for downloaded parquets (used by download_datasets.py)
|
||||||
|
# Override to store large datasets on a separate drive or NAS
|
||||||
|
# KIWI_PIPELINE_DATA_DIR=./data/pipeline
|
||||||
|
|
||||||
|
# CF-core resource coordinator (VRAM lease management)
|
||||||
|
# Set to the coordinator URL when running alongside cf-core orchestration
|
||||||
|
# COORDINATOR_URL=http://localhost:7700
|
||||||
|
|
||||||
# Processing
|
# Processing
|
||||||
USE_GPU=true
|
USE_GPU=true
|
||||||
GPU_MEMORY_LIMIT=6144
|
GPU_MEMORY_LIMIT=6144
|
||||||
|
|
@ -28,6 +36,14 @@ DEMO_MODE=false
|
||||||
# Cloud mode (set in compose.cloud.yml; also set here for reference)
|
# Cloud mode (set in compose.cloud.yml; also set here for reference)
|
||||||
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
|
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
|
||||||
# KIWI_DB=data/kiwi.db # local-mode DB path override
|
# KIWI_DB=data/kiwi.db # local-mode DB path override
|
||||||
|
# DEV ONLY: bypass JWT auth for these IPs/CIDRs (LAN testing without Caddy in the path).
|
||||||
|
# NEVER set in production.
|
||||||
|
# IMPORTANT: Docker port mapping NATs source IPs to the bridge gateway. When hitting
|
||||||
|
# localhost:8515 (host → Docker → nginx → API), nginx sees 192.168.80.1, not 127.0.0.1.
|
||||||
|
# Include the Docker bridge CIDR to allow localhost and LAN access through nginx.
|
||||||
|
# Run: docker network inspect kiwi-cloud_kiwi-cloud-net | grep Subnet
|
||||||
|
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1,::1,192.168.80.0/20
|
||||||
|
# CLOUD_AUTH_BYPASS_IPS=
|
||||||
|
|
||||||
# Heimdall license server (required for cloud tier resolution)
|
# Heimdall license server (required for cloud tier resolution)
|
||||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import RecipeRequest, RecipeResult
|
from app.models.schemas.recipe import RecipeRequest, RecipeResult
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
|
|
@ -15,11 +15,25 @@ from app.tiers import can_use
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
||||||
|
"""Run recipe suggestion in a worker thread with its own Store connection.
|
||||||
|
|
||||||
|
SQLite connections cannot be shared across threads. This function creates
|
||||||
|
a fresh Store (and therefore a fresh sqlite3.Connection) in the same thread
|
||||||
|
where it will be used, avoiding ProgrammingError: SQLite objects created in
|
||||||
|
a thread can only be used in that same thread.
|
||||||
|
"""
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
return RecipeEngine(store).suggest(req)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest", response_model=RecipeResult)
|
@router.post("/suggest", response_model=RecipeResult)
|
||||||
async def suggest_recipes(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> RecipeResult:
|
) -> RecipeResult:
|
||||||
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
||||||
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
||||||
|
|
@ -35,13 +49,19 @@ async def suggest_recipes(
|
||||||
)
|
)
|
||||||
if req.style_id and not can_use("style_picker", req.tier):
|
if req.style_id and not can_use("style_picker", req.tier):
|
||||||
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
||||||
engine = RecipeEngine(store)
|
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
||||||
return await asyncio.to_thread(engine.suggest, req)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, store: Store = Depends(get_store)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
recipe = await asyncio.to_thread(store.get_recipe, recipe_id)
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
return store.get_recipe(rid)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
||||||
if not recipe:
|
if not recipe:
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||||
return recipe
|
return recipe
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,43 @@ DIRECTUS_JWT_SECRET: str = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
||||||
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
||||||
|
|
||||||
|
# Dev bypass: comma-separated IPs or CIDR ranges that skip JWT auth.
|
||||||
|
# NEVER set this in production. Intended only for LAN developer testing when
|
||||||
|
# the request doesn't pass through Caddy (which normally injects X-CF-Session).
|
||||||
|
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1
|
||||||
|
import ipaddress as _ipaddress
|
||||||
|
|
||||||
|
_BYPASS_RAW: list[str] = [
|
||||||
|
e.strip()
|
||||||
|
for e in os.environ.get("CLOUD_AUTH_BYPASS_IPS", "").split(",")
|
||||||
|
if e.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
_BYPASS_NETS: list[_ipaddress.IPv4Network | _ipaddress.IPv6Network] = []
|
||||||
|
_BYPASS_IPS: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
if _BYPASS_RAW:
|
||||||
|
_nets, _ips = [], set()
|
||||||
|
for entry in _BYPASS_RAW:
|
||||||
|
try:
|
||||||
|
_nets.append(_ipaddress.ip_network(entry, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
_ips.add(entry) # treat non-parseable entries as bare IPs
|
||||||
|
_BYPASS_NETS = _nets
|
||||||
|
_BYPASS_IPS = frozenset(_ips)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bypass_ip(ip: str) -> bool:
|
||||||
|
if not ip:
|
||||||
|
return False
|
||||||
|
if ip in _BYPASS_IPS:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
addr = _ipaddress.ip_address(ip)
|
||||||
|
return any(addr in net for net in _BYPASS_NETS)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
||||||
|
|
||||||
_TIER_CACHE: dict[str, tuple[str, float]] = {}
|
_TIER_CACHE: dict[str, tuple[str, float]] = {}
|
||||||
|
|
@ -153,12 +190,28 @@ def get_session(request: Request) -> CloudUser:
|
||||||
|
|
||||||
Local mode: fully-privileged "local" user pointing at local DB.
|
Local mode: fully-privileged "local" user pointing at local DB.
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
||||||
|
Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
|
||||||
|
returns a "local" session without JWT validation (dev/LAN use only).
|
||||||
"""
|
"""
|
||||||
has_byok = _detect_byok()
|
has_byok = _detect_byok()
|
||||||
|
|
||||||
if not CLOUD_MODE:
|
if not CLOUD_MODE:
|
||||||
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
||||||
|
|
||||||
|
# Prefer X-Real-IP (set by nginx from the actual client address) over the
|
||||||
|
# TCP peer address (which is nginx's container IP when behind the proxy).
|
||||||
|
# Prefer X-Real-IP (set by nginx from the actual client address) over the
|
||||||
|
# TCP peer address (which is nginx's container IP when behind the proxy).
|
||||||
|
client_ip = (
|
||||||
|
request.headers.get("x-real-ip", "")
|
||||||
|
or (request.client.host if request.client else "")
|
||||||
|
)
|
||||||
|
if (_BYPASS_IPS or _BYPASS_NETS) and _is_bypass_ip(client_ip):
|
||||||
|
log.debug("CLOUD_AUTH_BYPASS_IPS match for %s — returning local session", client_ip)
|
||||||
|
# Use a dev DB under CLOUD_DATA_ROOT so the container has a writable path.
|
||||||
|
dev_db = _user_db_path("local-dev")
|
||||||
|
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
|
||||||
|
|
||||||
raw_header = (
|
raw_header = (
|
||||||
request.headers.get("x-cf-session", "")
|
request.headers.get("x-cf-session", "")
|
||||||
or request.headers.get("cookie", "")
|
or request.headers.get("cookie", "")
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ class Settings:
|
||||||
# Quality
|
# Quality
|
||||||
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
|
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
|
||||||
|
|
||||||
|
# CF-core resource coordinator (VRAM lease management)
|
||||||
|
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,4 @@ CREATE TABLE recipes (
|
||||||
|
|
||||||
CREATE INDEX idx_recipes_title ON recipes (title);
|
CREATE INDEX idx_recipes_title ON recipes (title);
|
||||||
CREATE INDEX idx_recipes_category ON recipes (category);
|
CREATE INDEX idx_recipes_category ON recipes (category);
|
||||||
CREATE INDEX idx_recipes_external_id ON recipes (external_id);
|
CREATE UNIQUE INDEX idx_recipes_external_id ON recipes (external_id);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ This module provides functionality to detect and decode barcodes
|
||||||
from images (UPC, EAN, QR codes, etc.).
|
from images (UPC, EAN, QR codes, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pyzbar import pyzbar
|
from pyzbar import pyzbar
|
||||||
|
|
@ -12,6 +14,12 @@ from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
_HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PIL = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,9 +84,7 @@ class BarcodeScanner:
|
||||||
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
|
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
logger.info("No barcodes found in standard orientation, trying rotations...")
|
logger.info("No barcodes found in standard orientation, trying rotations...")
|
||||||
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
|
for angle in [90, 180, 270, 45, 135]:
|
||||||
# 0° already tried, 180° is functionally same as 0°, 90°/270° are same axis
|
|
||||||
for angle in [30, 60, 90]:
|
|
||||||
rotated_gray = self._rotate_image(gray, angle)
|
rotated_gray = self._rotate_image(gray, angle)
|
||||||
rotated_color = self._rotate_image(image, angle)
|
rotated_color = self._rotate_image(image, angle)
|
||||||
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
||||||
|
|
@ -264,6 +270,26 @@ class BarcodeScanner:
|
||||||
|
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|
||||||
|
def _fix_exif_orientation(self, image_bytes: bytes) -> bytes:
|
||||||
|
"""Apply EXIF orientation correction so cv2 sees an upright image.
|
||||||
|
|
||||||
|
Phone cameras embed rotation in EXIF; cv2.imdecode ignores it,
|
||||||
|
so a photo taken in portrait may arrive physically sideways in memory.
|
||||||
|
"""
|
||||||
|
if not _HAS_PIL:
|
||||||
|
return image_bytes
|
||||||
|
try:
|
||||||
|
pil = _PILImage.open(io.BytesIO(image_bytes))
|
||||||
|
pil = _PILImage.fromarray(np.array(pil)) # strips EXIF but applies orientation via PIL
|
||||||
|
# Use ImageOps.exif_transpose for proper EXIF-aware rotation
|
||||||
|
import PIL.ImageOps
|
||||||
|
pil = PIL.ImageOps.exif_transpose(pil)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
pil.save(buf, format="JPEG")
|
||||||
|
return buf.getvalue()
|
||||||
|
except Exception:
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
|
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Scan barcodes from image bytes (uploaded file).
|
Scan barcodes from image bytes (uploaded file).
|
||||||
|
|
@ -275,6 +301,10 @@ class BarcodeScanner:
|
||||||
List of detected barcodes
|
List of detected barcodes
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Apply EXIF orientation correction first (phone cameras embed rotation in EXIF;
|
||||||
|
# cv2.imdecode ignores it, causing sideways barcodes to appear rotated in memory).
|
||||||
|
image_bytes = self._fix_exif_orientation(image_bytes)
|
||||||
|
|
||||||
# Convert bytes to numpy array
|
# Convert bytes to numpy array
|
||||||
nparr = np.frombuffer(image_bytes, np.uint8)
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
||||||
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
|
@ -300,11 +330,12 @@ class BarcodeScanner:
|
||||||
)
|
)
|
||||||
barcodes.extend(self._detect_barcodes(thresh, image))
|
barcodes.extend(self._detect_barcodes(thresh, image))
|
||||||
|
|
||||||
# 3. Try rotations if still no barcodes found
|
# 3. Try all 90° rotations + common tilt angles
|
||||||
|
# 90/270 catches truly sideways barcodes; 180 catches upside-down;
|
||||||
|
# 45/135 catches tilted barcodes on flat surfaces.
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
logger.info("No barcodes found in uploaded image, trying rotations...")
|
logger.info("No barcodes found in uploaded image, trying rotations...")
|
||||||
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
|
for angle in [90, 180, 270, 45, 135]:
|
||||||
for angle in [30, 60, 90]:
|
|
||||||
rotated_gray = self._rotate_image(gray, angle)
|
rotated_gray = self._rotate_image(gray, angle)
|
||||||
rotated_color = self._rotate_image(image, angle)
|
rotated_color = self._rotate_image(image, angle)
|
||||||
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
||||||
|
|
|
||||||
60
app/services/ocr/docuvision_client.py
Normal file
60
app/services/ocr/docuvision_client.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -8,6 +8,7 @@ OCR with understanding of receipt structure to extract structured JSON data.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
@ -26,6 +27,32 @@ from app.core.config import settings
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
|
"""Try to extract text via cf-docuvision. Returns None if unavailable."""
|
||||||
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
|
if not cf_orch_url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from circuitforge_core.resources import CFOrchClient
|
||||||
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
|
||||||
|
client = CFOrchClient(cf_orch_url)
|
||||||
|
with client.allocate(
|
||||||
|
service="cf-docuvision",
|
||||||
|
model_candidates=["cf-docuvision"],
|
||||||
|
ttl_s=60.0,
|
||||||
|
caller="kiwi-ocr",
|
||||||
|
) as alloc:
|
||||||
|
if alloc is None:
|
||||||
|
return None
|
||||||
|
doc_client = DocuvisionClient(alloc.url)
|
||||||
|
result = doc_client.extract_text(image_path)
|
||||||
|
return result.text if result.text else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-docuvision fast-path failed, falling back: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class VisionLanguageOCR:
|
class VisionLanguageOCR:
|
||||||
"""Vision-Language Model for receipt OCR and structured extraction."""
|
"""Vision-Language Model for receipt OCR and structured extraction."""
|
||||||
|
|
||||||
|
|
@ -40,7 +67,7 @@ class VisionLanguageOCR:
|
||||||
self.processor = None
|
self.processor = None
|
||||||
self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu"
|
self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu"
|
||||||
self.use_quantization = use_quantization
|
self.use_quantization = use_quantization
|
||||||
self.model_name = "Qwen/Qwen2-VL-2B-Instruct"
|
self.model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||||
|
|
||||||
logger.info(f"Initializing VisionLanguageOCR with device: {self.device}")
|
logger.info(f"Initializing VisionLanguageOCR with device: {self.device}")
|
||||||
|
|
||||||
|
|
@ -112,6 +139,18 @@ class VisionLanguageOCR:
|
||||||
"warnings": [...]
|
"warnings": [...]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
# Try docuvision fast path first (skips heavy local VLM if available)
|
||||||
|
docuvision_text = _try_docuvision(image_path)
|
||||||
|
if docuvision_text is not None:
|
||||||
|
parsed = self._parse_json_from_text(docuvision_text)
|
||||||
|
# Only accept the docuvision result if it yielded meaningful content;
|
||||||
|
# an empty-skeleton dict (no items, no merchant) means the text was
|
||||||
|
# garbled and we should fall through to the local VLM instead.
|
||||||
|
if parsed.get("items") or parsed.get("merchant"):
|
||||||
|
parsed["raw_text"] = docuvision_text
|
||||||
|
return self._validate_result(parsed)
|
||||||
|
# Parsed result has no meaningful content — fall through to local VLM
|
||||||
|
|
||||||
self._load_model()
|
self._load_model()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import nullcontext
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
|
|
@ -113,9 +117,51 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _call_llm(self, prompt: str) -> str:
|
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
|
||||||
"""Call the LLM router and return the response text."""
|
|
||||||
|
def _get_llm_context(self):
|
||||||
|
"""Return a sync context manager that yields an Allocation or None.
|
||||||
|
|
||||||
|
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
|
||||||
|
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
||||||
|
when the env var is absent or CFOrchClient raises on construction.
|
||||||
|
"""
|
||||||
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
|
if cf_orch_url:
|
||||||
try:
|
try:
|
||||||
|
from circuitforge_core.resources import CFOrchClient
|
||||||
|
client = CFOrchClient(cf_orch_url)
|
||||||
|
return client.allocate(
|
||||||
|
service="vllm",
|
||||||
|
model_candidates=self._MODEL_CANDIDATES,
|
||||||
|
ttl_s=300.0,
|
||||||
|
caller="kiwi-recipe",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
||||||
|
return nullcontext(None)
|
||||||
|
|
||||||
|
def _call_llm(self, prompt: str) -> str:
|
||||||
|
"""Call the LLM, using CFOrchClient allocation when CF_ORCH_URL is set.
|
||||||
|
|
||||||
|
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
||||||
|
calls the OpenAI-compatible API directly against the allocated service URL.
|
||||||
|
Without CF_ORCH_URL: falls back to LLMRouter using its configured backends.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._get_llm_context() as alloc:
|
||||||
|
if alloc is not None:
|
||||||
|
base_url = alloc.url.rstrip("/") + "/v1"
|
||||||
|
client = OpenAI(base_url=base_url, api_key="any")
|
||||||
|
model = alloc.model or "__auto__"
|
||||||
|
if model == "__auto__":
|
||||||
|
model = client.models.list().data[0].id
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
return resp.choices[0].message.content or ""
|
||||||
|
else:
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
router = LLMRouter()
|
router = LLMRouter()
|
||||||
return router.complete(prompt)
|
return router.complete(prompt)
|
||||||
|
|
@ -192,12 +238,16 @@ class LLMRecipeGenerator:
|
||||||
raw_notes = parsed.get("notes", "")
|
raw_notes = parsed.get("notes", "")
|
||||||
notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
|
notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
|
||||||
|
|
||||||
|
all_ingredients: list[str] = list(parsed.get("ingredients", []))
|
||||||
|
pantry_set = {item.lower() for item in (req.pantry_items or [])}
|
||||||
|
missing = [i for i in all_ingredients if i.lower() not in pantry_set]
|
||||||
|
|
||||||
suggestion = RecipeSuggestion(
|
suggestion = RecipeSuggestion(
|
||||||
id=0,
|
id=0,
|
||||||
title=parsed.get("title") or "LLM Recipe",
|
title=parsed.get("title") or "LLM Recipe",
|
||||||
match_count=len(req.pantry_items),
|
match_count=len(req.pantry_items),
|
||||||
element_coverage={},
|
element_coverage={},
|
||||||
missing_ingredients=list(parsed.get("ingredients", [])),
|
missing_ingredients=missing,
|
||||||
directions=directions_list,
|
directions=directions_list,
|
||||||
notes=notes_str,
|
notes=notes_str,
|
||||||
level=req.level,
|
level=req.level,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback"})
|
||||||
VRAM_BUDGETS: dict[str, float] = {
|
VRAM_BUDGETS: dict[str, float] = {
|
||||||
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
|
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
|
||||||
"expiry_llm_fallback": 2.0,
|
"expiry_llm_fallback": 2.0,
|
||||||
|
# Recipe LLM (levels 3-4): full recipe generation, ~200-500 tokens out.
|
||||||
|
# Budget assumes a quantized 7B-class model.
|
||||||
|
"recipe_llm": 4.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ services:
|
||||||
CLOUD_MODE: "true"
|
CLOUD_MODE: "true"
|
||||||
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
||||||
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
|
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
|
||||||
|
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
||||||
|
# Production deployments must NOT set this. Leave blank or omit entirely.
|
||||||
|
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
||||||
volumes:
|
volumes:
|
||||||
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
||||||
# LLM config — shared with other CF products; read-only in container
|
# LLM config — shared with other CF products; read-only in container
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,17 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
# Forward the session header injected by Caddy from cf_session cookie.
|
# Forward the session header injected by Caddy from cf_session cookie.
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
|
# Allow image uploads (barcode/receipt photos from phone cameras).
|
||||||
|
client_max_body_size 20m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
||||||
|
# Vite's /kiwi base URL means assets are requested at /kiwi/assets/... but stored
|
||||||
|
# at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works.
|
||||||
|
# ^~ prevents regex locations from overriding this prefix match for /kiwi/ paths.
|
||||||
|
location ^~ /kiwi/ {
|
||||||
|
alias /usr/share/nginx/html/;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ server {
|
||||||
proxy_pass http://172.17.0.1:8512;
|
proxy_pass http://172.17.0.1:8512;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# Allow image uploads (barcode/receipt photos from phone cameras).
|
||||||
|
client_max_body_size 20m;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,18 @@
|
||||||
>
|
>
|
||||||
🧾 Receipts
|
🧾 Receipts
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['tab', { active: currentTab === 'recipes' }]"
|
||||||
|
@click="switchTab('recipes')"
|
||||||
|
>
|
||||||
|
🍳 Recipes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['tab', { active: currentTab === 'settings' }]"
|
||||||
|
@click="switchTab('settings')"
|
||||||
|
>
|
||||||
|
⚙️ Settings
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
|
|
@ -33,6 +45,14 @@
|
||||||
<div v-show="currentTab === 'receipts'" class="tab-content">
|
<div v-show="currentTab === 'receipts'" class="tab-content">
|
||||||
<ReceiptsView />
|
<ReceiptsView />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-show="currentTab === 'recipes'" class="tab-content">
|
||||||
|
<RecipesView />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="currentTab === 'settings'" class="tab-content">
|
||||||
|
<SettingsView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -48,11 +68,20 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import InventoryList from './components/InventoryList.vue'
|
import InventoryList from './components/InventoryList.vue'
|
||||||
import ReceiptsView from './components/ReceiptsView.vue'
|
import ReceiptsView from './components/ReceiptsView.vue'
|
||||||
|
import RecipesView from './components/RecipesView.vue'
|
||||||
|
import SettingsView from './components/SettingsView.vue'
|
||||||
|
import { useInventoryStore } from './stores/inventory'
|
||||||
|
|
||||||
const currentTab = ref<'inventory' | 'receipts'>('inventory')
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
|
||||||
|
|
||||||
function switchTab(tab: 'inventory' | 'receipts') {
|
const currentTab = ref<Tab>('inventory')
|
||||||
|
const inventoryStore = useInventoryStore()
|
||||||
|
|
||||||
|
async function switchTab(tab: Tab) {
|
||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
|
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
||||||
|
await inventoryStore.fetchItems()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Product</label>
|
<label>Product</label>
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<strong>{{ item.product.name }}</strong>
|
<strong>{{ item.product_name || 'Unknown Product' }}</strong>
|
||||||
<span v-if="item.product.brand" class="brand">({{ item.product.brand }})</span>
|
<span v-if="item.category" class="brand">{{ item.category }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -228,160 +228,183 @@ function getExpiryHint(): string {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-xl);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-xl);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px;
|
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md);
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 32px;
|
font-size: 28px;
|
||||||
color: #999;
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: color 0.18s, background 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
padding: 20px;
|
padding: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Using .form-row from theme.css */
|
/* Using .form-row from theme.css */
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--spacing-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus {
|
.form-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #2196F3;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
|
box-shadow: 0 0 0 3px var(--color-warning-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-expired {
|
.form-input.expiry-expired {
|
||||||
border-color: #f44336;
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-soon {
|
.form-input.expiry-soon {
|
||||||
border-color: #ff5722;
|
border-color: var(--color-error-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-warning {
|
.form-input.expiry-warning {
|
||||||
border-color: #ff9800;
|
border-color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-good {
|
.form-input.expiry-good {
|
||||||
border-color: #4CAF50;
|
border-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.form-input {
|
textarea.form-input {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-info {
|
.product-info {
|
||||||
padding: 10px;
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
background: #f5f5f5;
|
background: var(--color-bg-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-info .brand {
|
.product-info .brand {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin-left: 8px;
|
margin-left: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.expiry-hint {
|
.expiry-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 5px;
|
margin-top: var(--spacing-xs);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
background: #ffebee;
|
background: var(--color-error-bg);
|
||||||
color: #c62828;
|
color: var(--color-error-light);
|
||||||
padding: 12px;
|
border: 1px solid var(--color-error-border);
|
||||||
border-radius: var(--radius-sm);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
margin-bottom: 15px;
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: var(--spacing-sm);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 25px;
|
margin-top: var(--spacing-lg);
|
||||||
padding-top: 20px;
|
padding-top: var(--spacing-md);
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel,
|
.btn-cancel,
|
||||||
.btn-save {
|
.btn-save {
|
||||||
padding: 10px 24px;
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-family: var(--font-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: all 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
background: #f5f5f5;
|
background: var(--color-bg-elevated);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel:hover {
|
.btn-cancel:hover {
|
||||||
background: #e0e0e0;
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save {
|
.btn-save {
|
||||||
|
|
@ -394,7 +417,7 @@ textarea.form-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save:disabled {
|
.btn-save:disabled {
|
||||||
background: var(--color-text-muted);
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,7 +431,7 @@ textarea.form-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
padding: 15px;
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
|
|
@ -416,23 +439,24 @@ textarea.form-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
padding: 15px;
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 15px;
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form actions stack on very small screens */
|
/* Form actions stack on very small screens */
|
||||||
.form-actions {
|
.form-actions {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
gap: 10px;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel,
|
.btn-cancel,
|
||||||
.btn-save {
|
.btn-save {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,13 +464,5 @@ textarea.form-input {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
width: 92%;
|
width: 92%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-form {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="receipts-view">
|
<div class="receipts-view">
|
||||||
<!-- Upload Section -->
|
<!-- Upload Section -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📸 Upload Receipt</h2>
|
<h2 class="section-title mb-md">Upload Receipt</h2>
|
||||||
<div
|
<div
|
||||||
class="upload-area"
|
class="upload-area"
|
||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="uploading" class="loading">
|
<div v-if="uploading" class="loading-inline mt-md">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Processing receipt...</p>
|
<span class="text-sm text-muted">Processing receipt…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="uploadResults.length > 0" class="results">
|
<div v-if="uploadResults.length > 0" class="results">
|
||||||
|
|
@ -39,8 +39,8 @@
|
||||||
|
|
||||||
<!-- Receipts List Section -->
|
<!-- Receipts List Section -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📋 Recent Receipts</h2>
|
<h2 class="section-title mb-md">Recent Receipts</h2>
|
||||||
<div v-if="receipts.length === 0" style="text-align: center; color: var(--color-text-secondary)">
|
<div v-if="receipts.length === 0" class="text-center text-secondary p-lg">
|
||||||
<p>No receipts yet. Upload one above!</p>
|
<p>No receipts yet. Upload one above!</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|
@ -89,9 +89,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 20px">
|
<div class="flex gap-sm mt-md">
|
||||||
<button class="button" @click="exportCSV">📊 Download CSV</button>
|
<button class="btn btn-secondary" @click="exportCSV">Download CSV</button>
|
||||||
<button class="button" @click="exportExcel">📈 Download Excel</button>
|
<button class="btn btn-secondary" @click="exportExcel">Download Excel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,157 +225,117 @@ onMounted(() => {
|
||||||
.receipts-view {
|
.receipts-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: var(--spacing-md);
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
border: 3px dashed var(--color-primary);
|
border: 2px dashed var(--color-border-focus);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 40px;
|
padding: var(--spacing-xl) var(--spacing-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.2s ease;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area:hover {
|
.upload-area:hover {
|
||||||
border-color: var(--color-secondary);
|
border-color: var(--color-primary);
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 48px;
|
font-size: 40px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--spacing-md);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-text {
|
.upload-text {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: 10px;
|
margin-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-hint {
|
.upload-hint {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading-inline {
|
||||||
text-align: center;
|
display: flex;
|
||||||
padding: 20px;
|
align-items: center;
|
||||||
margin-top: 20px;
|
gap: var(--spacing-sm);
|
||||||
}
|
padding: var(--spacing-sm) 0;
|
||||||
|
|
||||||
.spinner {
|
|
||||||
border: 4px solid #f3f3f3;
|
|
||||||
border-top: 4px solid #667eea;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
margin-top: 20px;
|
margin-top: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item {
|
.result-item {
|
||||||
padding: 15px;
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 10px;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-success {
|
.result-success {
|
||||||
background: var(--color-success-bg);
|
background: var(--color-success-bg);
|
||||||
color: var(--color-success-dark);
|
color: var(--color-success-light);
|
||||||
border: 1px solid var(--color-success-border);
|
border: 1px solid var(--color-success-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-error {
|
.result-error {
|
||||||
background: var(--color-error-bg);
|
background: var(--color-error-bg);
|
||||||
color: var(--color-error-dark);
|
color: var(--color-error-light);
|
||||||
border: 1px solid var(--color-error-border);
|
border: 1px solid var(--color-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-info {
|
.result-info {
|
||||||
background: var(--color-info-bg);
|
background: var(--color-info-bg);
|
||||||
color: var(--color-info-dark);
|
color: var(--color-info-light);
|
||||||
border: 1px solid var(--color-info-border);
|
border: 1px solid var(--color-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Using .grid-stats from theme.css */
|
/* Stat cards */
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: 20px;
|
padding: var(--spacing-md);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
font-weight: bold;
|
font-weight: 500;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
margin-bottom: 5px;
|
margin-bottom: var(--spacing-xs);
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-muted);
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
.button {
|
|
||||||
background: var(--gradient-primary);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 30px;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipts-list {
|
.receipts-list {
|
||||||
margin-top: 20px;
|
margin-top: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-item {
|
.receipt-item {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: 15px;
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 10px;
|
border: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -388,7 +348,7 @@ onMounted(() => {
|
||||||
.receipt-merchant {
|
.receipt-merchant {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
margin-bottom: 5px;
|
margin-bottom: var(--spacing-xs);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,7 +356,7 @@ onMounted(() => {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: var(--spacing-md);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,20 +379,17 @@ onMounted(() => {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive - Handled by theme.css
|
/* Mobile */
|
||||||
Component-specific overrides only below */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 15px;
|
padding: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Receipt items stack content vertically */
|
|
||||||
.receipt-item {
|
.receipt-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: var(--spacing-sm);
|
||||||
padding: 12px;
|
padding: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-info {
|
.receipt-info {
|
||||||
|
|
@ -440,15 +397,8 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-details {
|
.receipt-details {
|
||||||
gap: 10px;
|
gap: var(--spacing-sm);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons full width on mobile */
|
|
||||||
.button {
|
|
||||||
width: 100%;
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
669
frontend/src/components/RecipesView.vue
Normal file
669
frontend/src/components/RecipesView.vue
Normal 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>
|
||||||
162
frontend/src/components/SettingsView.vue
Normal file
162
frontend/src/components/SettingsView.vue
Normal 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>
|
||||||
|
|
@ -80,9 +80,11 @@ export interface Tag {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
id: string
|
id: number
|
||||||
product_id: string
|
product_id: number
|
||||||
product: Product
|
product_name: string | null
|
||||||
|
barcode: string | null
|
||||||
|
category: string | null
|
||||||
quantity: number
|
quantity: number
|
||||||
unit: string
|
unit: string
|
||||||
location: string
|
location: string
|
||||||
|
|
@ -109,11 +111,10 @@ export interface InventoryItemUpdate {
|
||||||
|
|
||||||
export interface InventoryStats {
|
export interface InventoryStats {
|
||||||
total_items: number
|
total_items: number
|
||||||
total_products: number
|
available_items: number
|
||||||
expiring_soon: number
|
expiring_soon: number
|
||||||
expired: number
|
expired_items: number
|
||||||
items_by_location: Record<string, number>
|
locations: Record<string, number>
|
||||||
items_by_status: Record<string, number>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Receipt {
|
export interface Receipt {
|
||||||
|
|
@ -185,7 +186,7 @@ export const inventoryAPI = {
|
||||||
/**
|
/**
|
||||||
* Update an inventory item
|
* Update an inventory item
|
||||||
*/
|
*/
|
||||||
async updateItem(itemId: string, update: InventoryItemUpdate): Promise<InventoryItem> {
|
async updateItem(itemId: number, update: InventoryItemUpdate): Promise<InventoryItem> {
|
||||||
const response = await api.patch(`/inventory/items/${itemId}`, update)
|
const response = await api.patch(`/inventory/items/${itemId}`, update)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
@ -193,7 +194,7 @@ export const inventoryAPI = {
|
||||||
/**
|
/**
|
||||||
* Delete an inventory item
|
* Delete an inventory item
|
||||||
*/
|
*/
|
||||||
async deleteItem(itemId: string): Promise<void> {
|
async deleteItem(itemId: number): Promise<void> {
|
||||||
await api.delete(`/inventory/items/${itemId}`)
|
await api.delete(`/inventory/items/${itemId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -234,7 +235,7 @@ export const inventoryAPI = {
|
||||||
/**
|
/**
|
||||||
* Mark item as consumed
|
* Mark item as consumed
|
||||||
*/
|
*/
|
||||||
async consumeItem(itemId: string): Promise<void> {
|
async consumeItem(itemId: number): Promise<void> {
|
||||||
await api.post(`/inventory/items/${itemId}/consume`)
|
await api.post(`/inventory/items/${itemId}/consume`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -404,4 +405,94 @@ export const exportAPI = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Recipes & Settings Types ==========
|
||||||
|
|
||||||
|
export interface SwapCandidate {
|
||||||
|
original_name: string
|
||||||
|
substitute_name: string
|
||||||
|
constraint_label: string
|
||||||
|
explanation: string
|
||||||
|
compensation_hints: Record<string, string>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeSuggestion {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
match_count: number
|
||||||
|
element_coverage: Record<string, number>
|
||||||
|
swap_candidates: SwapCandidate[]
|
||||||
|
missing_ingredients: string[]
|
||||||
|
directions: string[]
|
||||||
|
notes: string
|
||||||
|
level: number
|
||||||
|
is_wildcard: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroceryLink {
|
||||||
|
ingredient: string
|
||||||
|
retailer: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeResult {
|
||||||
|
suggestions: RecipeSuggestion[]
|
||||||
|
element_gaps: string[]
|
||||||
|
grocery_list: string[]
|
||||||
|
grocery_links: GroceryLink[]
|
||||||
|
rate_limited: boolean
|
||||||
|
rate_limit_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeRequest {
|
||||||
|
pantry_items: string[]
|
||||||
|
level: number
|
||||||
|
constraints: string[]
|
||||||
|
allergies: string[]
|
||||||
|
expiry_first: boolean
|
||||||
|
hard_day_mode: boolean
|
||||||
|
max_missing: number | null
|
||||||
|
style_id: string | null
|
||||||
|
wildcard_confirmed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Staple {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
dietary_tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Recipes API ==========
|
||||||
|
|
||||||
|
export const recipesAPI = {
|
||||||
|
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
||||||
|
const response = await api.post('/recipes/suggest', req)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
||||||
|
const response = await api.get(`/recipes/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async listStaples(dietary?: string): Promise<Staple[]> {
|
||||||
|
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Settings API ==========
|
||||||
|
|
||||||
|
export const settingsAPI = {
|
||||||
|
async getSetting(key: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/settings/${key}`)
|
||||||
|
return response.data.value
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setSetting(key: string, value: string): Promise<void> {
|
||||||
|
await api.put(`/settings/${key}`, { value })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateItem(itemId: string, update: InventoryItemUpdate) {
|
async function updateItem(itemId: number, update: InventoryItemUpdate) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItem(itemId: string) {
|
async function deleteItem(itemId: number) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
|
|
||||||
78
frontend/src/stores/recipes.ts
Normal file
78
frontend/src/stores/recipes.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
57
frontend/src/stores/settings.ts
Normal file
57
frontend/src/stores/settings.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -18,6 +18,7 @@ dependencies = [
|
||||||
"opencv-python>=4.8",
|
"opencv-python>=4.8",
|
||||||
"numpy>=1.25",
|
"numpy>=1.25",
|
||||||
"pyzbar>=0.1.9",
|
"pyzbar>=0.1.9",
|
||||||
|
"Pillow>=10.0",
|
||||||
# HTTP client
|
# HTTP client
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
# CircuitForge shared scaffold
|
# CircuitForge shared scaffold
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ FlavorGraph GitHub: https://github.com/lamypark/FlavorGraph
|
||||||
Download: git clone https://github.com/lamypark/FlavorGraph /tmp/flavorgraph
|
Download: git clone https://github.com/lamypark/FlavorGraph /tmp/flavorgraph
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
conda run -n job-seeker python scripts/pipeline/build_flavorgraph_index.py \
|
conda run -n cf python scripts/pipeline/build_flavorgraph_index.py \
|
||||||
--db /path/to/kiwi.db \
|
--db data/kiwi.db \
|
||||||
--graph-json /tmp/flavorgraph/data/graph.json
|
--flavorgraph-dir /tmp/flavorgraph/input
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -16,64 +16,74 @@ import sqlite3
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ingredient_nodes(
|
||||||
|
nodes_path: Path, edges_path: Path
|
||||||
|
) -> tuple[dict[str, list[str]], dict[str, str]]:
|
||||||
|
"""Parse FlavorGraph CSVs → (ingredient→compounds, compound→name)."""
|
||||||
|
nodes = pd.read_csv(nodes_path, dtype=str).fillna("")
|
||||||
|
edges = pd.read_csv(edges_path, dtype=str).fillna("")
|
||||||
|
|
||||||
def parse_ingredient_nodes(graph: dict) -> dict[str, list[str]]:
|
|
||||||
"""Return {ingredient_name: [compound_id, ...]} from a FlavorGraph JSON."""
|
|
||||||
ingredient_compounds: dict[str, list[str]] = defaultdict(list)
|
|
||||||
ingredient_ids: dict[str, str] = {} # node_id -> ingredient_name
|
ingredient_ids: dict[str, str] = {} # node_id -> ingredient_name
|
||||||
|
compound_names: dict[str, str] = {} # node_id -> compound_name
|
||||||
|
|
||||||
for node in graph.get("nodes", []):
|
for _, row in nodes.iterrows():
|
||||||
if node.get("type") == "ingredient":
|
nid = row["node_id"]
|
||||||
ingredient_ids[node["id"]] = node["name"].lower()
|
name = row["name"].lower().replace("_", " ").strip()
|
||||||
|
if row["node_type"] == "ingredient":
|
||||||
|
ingredient_ids[nid] = name
|
||||||
|
else:
|
||||||
|
compound_names[nid] = name
|
||||||
|
|
||||||
for link in graph.get("links", []):
|
ingredient_compounds: dict[str, list[str]] = defaultdict(list)
|
||||||
src, tgt = link.get("source", ""), link.get("target", "")
|
for _, row in edges.iterrows():
|
||||||
|
src, tgt = row["id_1"], row["id_2"]
|
||||||
if src in ingredient_ids:
|
if src in ingredient_ids:
|
||||||
ingredient_compounds[ingredient_ids[src]].append(tgt)
|
ingredient_compounds[ingredient_ids[src]].append(tgt)
|
||||||
if tgt in ingredient_ids:
|
if tgt in ingredient_ids:
|
||||||
ingredient_compounds[ingredient_ids[tgt]].append(src)
|
ingredient_compounds[ingredient_ids[tgt]].append(src)
|
||||||
|
|
||||||
return dict(ingredient_compounds)
|
return dict(ingredient_compounds), compound_names
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, graph_json_path: Path) -> None:
|
def build(db_path: Path, flavorgraph_dir: Path) -> None:
|
||||||
graph = json.loads(graph_json_path.read_text())
|
nodes_path = flavorgraph_dir / "nodes_191120.csv"
|
||||||
ingredient_map = parse_ingredient_nodes(graph)
|
edges_path = flavorgraph_dir / "edges_191120.csv"
|
||||||
|
|
||||||
|
ingredient_map, compound_names = parse_ingredient_nodes(nodes_path, edges_path)
|
||||||
|
|
||||||
compound_ingredients: dict[str, list[str]] = defaultdict(list)
|
compound_ingredients: dict[str, list[str]] = defaultdict(list)
|
||||||
compound_names: dict[str, str] = {}
|
|
||||||
|
|
||||||
for node in graph.get("nodes", []):
|
|
||||||
if node.get("type") == "compound":
|
|
||||||
compound_names[node["id"]] = node["name"]
|
|
||||||
|
|
||||||
for ingredient, compounds in ingredient_map.items():
|
for ingredient, compounds in ingredient_map.items():
|
||||||
for cid in compounds:
|
for cid in compounds:
|
||||||
compound_ingredients[cid].append(ingredient)
|
compound_ingredients[cid].append(ingredient)
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
for ingredient, compounds in ingredient_map.items():
|
for ingredient, compounds in ingredient_map.items():
|
||||||
conn.execute("""
|
conn.execute(
|
||||||
UPDATE ingredient_profiles
|
"UPDATE ingredient_profiles SET flavor_molecule_ids = ? WHERE name = ?",
|
||||||
SET flavor_molecule_ids = ?
|
(json.dumps(compounds), ingredient),
|
||||||
WHERE name = ?
|
)
|
||||||
""", (json.dumps(compounds), ingredient))
|
|
||||||
|
|
||||||
for cid, ingredients in compound_ingredients.items():
|
for cid, ingredients in compound_ingredients.items():
|
||||||
conn.execute("""
|
conn.execute(
|
||||||
INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names)
|
"INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names)"
|
||||||
VALUES (?, ?, ?)
|
" VALUES (?, ?, ?)",
|
||||||
""", (cid, compound_names.get(cid, cid), json.dumps(ingredients)))
|
(cid, compound_names.get(cid, cid), json.dumps(ingredients)),
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
print(f"Indexed {len(ingredient_map)} ingredients, {len(compound_ingredients)} compounds")
|
print(f"Indexed {len(ingredient_map)} ingredients, {len(compound_ingredients)} compounds")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--db", required=True, type=Path)
|
parser.add_argument("--db", required=True, type=Path)
|
||||||
parser.add_argument("--graph-json", required=True, type=Path)
|
parser.add_argument("--flavorgraph-dir", required=True, type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
build(args.db, args.graph_json)
|
build(args.db, args.flavorgraph_dir)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ _TRAILING_QUALIFIER = re.compile(
|
||||||
r"\s*(to taste|as needed|or more|or less|optional|if desired|if needed)\s*$",
|
r"\s*(to taste|as needed|or more|or less|optional|if desired|if needed)\s*$",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
_QUOTED = re.compile(r'"([^"]*)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_r_vector(s: str) -> list[str]:
|
||||||
|
"""Parse R character vector format: c("a", "b") -> ["a", "b"]."""
|
||||||
|
return _QUOTED.findall(s)
|
||||||
|
|
||||||
|
|
||||||
def extract_ingredient_names(raw_list: list[str]) -> list[str]:
|
def extract_ingredient_names(raw_list: list[str]) -> list[str]:
|
||||||
|
|
@ -53,6 +59,55 @@ def compute_element_coverage(profiles: list[dict]) -> dict[str, float]:
|
||||||
return {e: round(c / len(profiles), 3) for e, c in counts.items()}
|
return {e: round(c / len(profiles), 3) for e, c in counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_allrecipes_text(text: str) -> tuple[str, list[str], list[str]]:
|
||||||
|
"""Parse corbt/all-recipes text format into (title, ingredients, directions)."""
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
title = lines[0].strip()
|
||||||
|
ingredients: list[str] = []
|
||||||
|
directions: list[str] = []
|
||||||
|
section: str | None = None
|
||||||
|
for line in lines[1:]:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.lower() == 'ingredients:':
|
||||||
|
section = 'ingredients'
|
||||||
|
elif stripped.lower() in ('directions:', 'steps:', 'instructions:'):
|
||||||
|
section = 'directions'
|
||||||
|
elif stripped.startswith('- ') and section == 'ingredients':
|
||||||
|
ingredients.append(stripped[2:].strip())
|
||||||
|
elif stripped.startswith('- ') and section == 'directions':
|
||||||
|
directions.append(stripped[2:].strip())
|
||||||
|
return title, ingredients, directions
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_fields(row: pd.Series) -> tuple[str, str, list[str], list[str]]:
|
||||||
|
"""Extract (external_id, title, raw_ingredients, directions) from a parquet row.
|
||||||
|
|
||||||
|
Handles both corbt/all-recipes (single 'input' text column) and the
|
||||||
|
food.com columnar format (RecipeId, Name, RecipeIngredientParts, ...).
|
||||||
|
"""
|
||||||
|
if "input" in row.index and pd.notna(row.get("input")):
|
||||||
|
title, raw_ingredients, directions = _parse_allrecipes_text(str(row["input"]))
|
||||||
|
external_id = f"ar_{hash(title) & 0xFFFFFFFF}"
|
||||||
|
else:
|
||||||
|
raw_parts = row.get("RecipeIngredientParts", [])
|
||||||
|
if isinstance(raw_parts, str):
|
||||||
|
parsed = _parse_r_vector(raw_parts)
|
||||||
|
raw_parts = parsed if parsed else [raw_parts]
|
||||||
|
raw_ingredients = [str(i) for i in (raw_parts or [])]
|
||||||
|
|
||||||
|
raw_dirs = row.get("RecipeInstructions", [])
|
||||||
|
if isinstance(raw_dirs, str):
|
||||||
|
parsed_dirs = _parse_r_vector(raw_dirs)
|
||||||
|
directions = parsed_dirs if parsed_dirs else [raw_dirs]
|
||||||
|
else:
|
||||||
|
directions = [str(d) for d in (raw_dirs or [])]
|
||||||
|
|
||||||
|
title = str(row.get("Name", ""))[:500]
|
||||||
|
external_id = str(row.get("RecipeId", ""))
|
||||||
|
|
||||||
|
return external_id, title, raw_ingredients, directions
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
try:
|
try:
|
||||||
|
|
@ -71,13 +126,9 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
||||||
batch = []
|
batch = []
|
||||||
|
|
||||||
for _, row in df.iterrows():
|
for _, row in df.iterrows():
|
||||||
raw_ingredients = row.get("RecipeIngredientParts", [])
|
external_id, title, raw_ingredients, directions = _row_to_fields(row)
|
||||||
if isinstance(raw_ingredients, str):
|
if not title:
|
||||||
try:
|
continue
|
||||||
raw_ingredients = json.loads(raw_ingredients)
|
|
||||||
except Exception:
|
|
||||||
raw_ingredients = [raw_ingredients]
|
|
||||||
raw_ingredients = [str(i) for i in (raw_ingredients or [])]
|
|
||||||
ingredient_names = extract_ingredient_names(raw_ingredients)
|
ingredient_names = extract_ingredient_names(raw_ingredients)
|
||||||
|
|
||||||
profiles = []
|
profiles = []
|
||||||
|
|
@ -86,19 +137,12 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
||||||
profiles.append({"elements": profile_index[name]})
|
profiles.append({"elements": profile_index[name]})
|
||||||
coverage = compute_element_coverage(profiles)
|
coverage = compute_element_coverage(profiles)
|
||||||
|
|
||||||
directions = row.get("RecipeInstructions", [])
|
|
||||||
if isinstance(directions, str):
|
|
||||||
try:
|
|
||||||
directions = json.loads(directions)
|
|
||||||
except Exception:
|
|
||||||
directions = [directions]
|
|
||||||
|
|
||||||
batch.append((
|
batch.append((
|
||||||
str(row.get("RecipeId", "")),
|
external_id,
|
||||||
str(row.get("Name", ""))[:500],
|
title,
|
||||||
json.dumps(raw_ingredients),
|
json.dumps(raw_ingredients),
|
||||||
json.dumps(ingredient_names),
|
json.dumps(ingredient_names),
|
||||||
json.dumps([str(d) for d in (directions or [])]),
|
json.dumps(directions),
|
||||||
str(row.get("RecipeCategory", "") or ""),
|
str(row.get("RecipeCategory", "") or ""),
|
||||||
json.dumps(list(row.get("Keywords", []) or [])),
|
json.dumps(list(row.get("Keywords", []) or [])),
|
||||||
float(row.get("Calories") or 0) or None,
|
float(row.get("Calories") or 0) or None,
|
||||||
|
|
@ -111,7 +155,7 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
||||||
if len(batch) >= batch_size:
|
if len(batch) >= batch_size:
|
||||||
before = conn.total_changes
|
before = conn.total_changes
|
||||||
conn.executemany("""
|
conn.executemany("""
|
||||||
INSERT OR IGNORE INTO recipes
|
INSERT OR REPLACE INTO recipes
|
||||||
(external_id, title, ingredients, ingredient_names, directions,
|
(external_id, title, ingredients, ingredient_names, directions,
|
||||||
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
|
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
|
@ -124,7 +168,7 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
||||||
if batch:
|
if batch:
|
||||||
before = conn.total_changes
|
before = conn.total_changes
|
||||||
conn.executemany("""
|
conn.executemany("""
|
||||||
INSERT OR IGNORE INTO recipes
|
INSERT OR REPLACE INTO recipes
|
||||||
(external_id, title, ingredients, ingredient_names, directions,
|
(external_id, title, ingredients, ingredient_names, directions,
|
||||||
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
|
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,21 @@ Derive substitution pairs by diffing lishuyang/recipepairs.
|
||||||
GPL-3.0 source -- derived annotations only, raw pairs not shipped.
|
GPL-3.0 source -- derived annotations only, raw pairs not shipped.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
conda run -n job-seeker python scripts/pipeline/derive_substitutions.py \
|
PYTHONPATH=/path/to/kiwi conda run -n cf python scripts/pipeline/derive_substitutions.py \
|
||||||
--db /path/to/kiwi.db \
|
--db /path/to/kiwi.db \
|
||||||
--recipepairs data/recipepairs.parquet
|
--recipepairs data/pipeline/recipepairs.parquet \
|
||||||
|
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from scripts.pipeline.build_recipe_index import extract_ingredient_names
|
|
||||||
|
|
||||||
CONSTRAINT_COLS = ["vegan", "vegetarian", "dairy_free", "low_calorie",
|
|
||||||
"low_carb", "low_fat", "low_sodium", "gluten_free"]
|
|
||||||
|
|
||||||
|
|
||||||
def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], list[str]]:
|
def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], list[str]]:
|
||||||
base_set = set(base)
|
base_set = set(base)
|
||||||
|
|
@ -30,21 +27,44 @@ def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], lis
|
||||||
return removed, added
|
return removed, added
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, recipepairs_path: Path) -> None:
|
def _parse_categories(val: object) -> list[str]:
|
||||||
|
"""Parse categories field which may be a list, str-repr list, or bare string."""
|
||||||
|
if isinstance(val, list):
|
||||||
|
return [str(v) for v in val]
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = val.strip()
|
||||||
|
if val.startswith("["):
|
||||||
|
# parse list repr: ['a', 'b'] — use json after converting single quotes
|
||||||
|
try:
|
||||||
|
fixed = re.sub(r"'", '"', val)
|
||||||
|
return json.loads(fixed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [val] if val else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build(db_path: Path, recipepairs_path: Path, recipes_path: Path) -> None:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
try:
|
try:
|
||||||
print("Loading recipe ingredient index...")
|
# Load ingredient lists from the bundled recipepairs recipe corpus.
|
||||||
|
# This is GPL-3.0 data — we only use it for diffing; raw data is not persisted.
|
||||||
|
print("Loading recipe ingredient index from recipepairs corpus...")
|
||||||
|
recipes_df = pd.read_parquet(recipes_path, columns=["id", "ingredients"])
|
||||||
recipe_ingredients: dict[str, list[str]] = {}
|
recipe_ingredients: dict[str, list[str]] = {}
|
||||||
for row in conn.execute("SELECT external_id, ingredient_names FROM recipes"):
|
for _, r in recipes_df.iterrows():
|
||||||
recipe_ingredients[str(row[0])] = json.loads(row[1])
|
ings = r["ingredients"]
|
||||||
|
if ings is not None and hasattr(ings, "__iter__") and not isinstance(ings, str):
|
||||||
|
recipe_ingredients[str(int(r["id"]))] = [str(i) for i in ings]
|
||||||
|
print(f" {len(recipe_ingredients)} recipes loaded")
|
||||||
|
|
||||||
df = pd.read_parquet(recipepairs_path)
|
pairs_df = pd.read_parquet(recipepairs_path)
|
||||||
pair_counts: dict[tuple, dict] = defaultdict(lambda: {"count": 0})
|
pair_counts: dict[tuple, dict] = defaultdict(lambda: {"count": 0})
|
||||||
|
|
||||||
print("Diffing recipe pairs...")
|
print("Diffing recipe pairs...")
|
||||||
for _, row in df.iterrows():
|
for _, row in pairs_df.iterrows():
|
||||||
base_id = str(row.get("base", ""))
|
base_id = str(int(row["base"]))
|
||||||
target_id = str(row.get("target", ""))
|
target_id = str(int(row["target"]))
|
||||||
base_ings = recipe_ingredients.get(base_id, [])
|
base_ings = recipe_ingredients.get(base_id, [])
|
||||||
target_ings = recipe_ingredients.get(target_id, [])
|
target_ings = recipe_ingredients.get(target_id, [])
|
||||||
if not base_ings or not target_ings:
|
if not base_ings or not target_ings:
|
||||||
|
|
@ -56,7 +76,9 @@ def build(db_path: Path, recipepairs_path: Path) -> None:
|
||||||
|
|
||||||
original = removed[0]
|
original = removed[0]
|
||||||
substitute = added[0]
|
substitute = added[0]
|
||||||
constraints = [c for c in CONSTRAINT_COLS if row.get(c, 0)]
|
constraints = _parse_categories(row.get("categories", []))
|
||||||
|
if not constraints:
|
||||||
|
continue
|
||||||
for constraint in constraints:
|
for constraint in constraints:
|
||||||
key = (original, substitute, constraint)
|
key = (original, substitute, constraint)
|
||||||
pair_counts[key]["count"] += 1
|
pair_counts[key]["count"] += 1
|
||||||
|
|
@ -103,6 +125,10 @@ def build(db_path: Path, recipepairs_path: Path) -> None:
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--db", required=True, type=Path)
|
parser.add_argument("--db", required=True, type=Path)
|
||||||
parser.add_argument("--recipepairs", required=True, type=Path)
|
parser.add_argument("--recipepairs", required=True, type=Path,
|
||||||
|
help="pairs.parquet from lishuyang/recipepairs")
|
||||||
|
parser.add_argument("--recipepairs-recipes", required=True, type=Path,
|
||||||
|
dest="recipepairs_recipes",
|
||||||
|
help="recipes.parquet from lishuyang/recipepairs (ingredient lookup)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
build(args.db, args.recipepairs)
|
build(args.db, args.recipepairs, args.recipepairs_recipes)
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,43 @@
|
||||||
Download recipe engine datasets from HuggingFace.
|
Download recipe engine datasets from HuggingFace.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
conda run -n job-seeker python scripts/pipeline/download_datasets.py --data-dir /path/to/data
|
conda run -n cf python scripts/pipeline/download_datasets.py --data-dir data/pipeline
|
||||||
|
|
||||||
Downloads:
|
Downloads:
|
||||||
- AkashPS11/recipes_data_food.com (MIT) → data/recipes_foodcom.parquet
|
- corbt/all-recipes (no license) → data/pipeline/recipes_allrecipes.parquet [2.1M recipes]
|
||||||
- omid5/usda-fdc-foods-cleaned (CC0) → data/usda_fdc_cleaned.parquet
|
- omid5/usda-fdc-foods-cleaned (CC0) → data/pipeline/usda_fdc_cleaned.parquet
|
||||||
- jacktol/usda-branded-food-data (MIT) → data/usda_branded.parquet
|
- jacktol/usda-branded-food-data (MIT) → data/pipeline/usda_branded.parquet
|
||||||
- lishuyang/recipepairs (GPL-3.0 ⚠) → data/recipepairs.parquet [derive only, don't ship]
|
- lishuyang/recipepairs (GPL-3.0 ⚠) → data/pipeline/recipepairs.parquet [derive only, don't ship]
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from datasets import load_dataset
|
from datasets import load_dataset
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
|
||||||
|
|
||||||
DATASETS = [
|
# Standard HuggingFace datasets: (hf_path, split, output_filename)
|
||||||
("AkashPS11/recipes_data_food.com", "train", "recipes_foodcom.parquet"),
|
HF_DATASETS = [
|
||||||
|
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),
|
||||||
("omid5/usda-fdc-foods-cleaned", "train", "usda_fdc_cleaned.parquet"),
|
("omid5/usda-fdc-foods-cleaned", "train", "usda_fdc_cleaned.parquet"),
|
||||||
("jacktol/usda-branded-food-data", "train", "usda_branded.parquet"),
|
("jacktol/usda-branded-food-data","train", "usda_branded.parquet"),
|
||||||
("lishuyang/recipepairs", "train", "recipepairs.parquet"),
|
]
|
||||||
|
|
||||||
|
# Datasets that expose raw parquet files directly (no HF dataset builder)
|
||||||
|
HF_PARQUET_FILES = [
|
||||||
|
# (repo_id, repo_filename, output_filename)
|
||||||
|
# lishuyang/recipepairs: GPL-3.0 ⚠ — derive only, don't ship
|
||||||
|
("lishuyang/recipepairs", "pairs.parquet", "recipepairs.parquet"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def download_all(data_dir: Path) -> None:
|
def download_all(data_dir: Path) -> None:
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for hf_path, split, filename in DATASETS:
|
|
||||||
|
for hf_path, split, filename in HF_DATASETS:
|
||||||
out = data_dir / filename
|
out = data_dir / filename
|
||||||
if out.exists():
|
if out.exists():
|
||||||
print(f" skip {filename} (already exists)")
|
print(f" skip {filename} (already exists)")
|
||||||
|
|
@ -36,9 +48,29 @@ def download_all(data_dir: Path) -> None:
|
||||||
ds.to_parquet(str(out))
|
ds.to_parquet(str(out))
|
||||||
print(f" saved → {out}")
|
print(f" saved → {out}")
|
||||||
|
|
||||||
|
for repo_id, repo_file, filename in HF_PARQUET_FILES:
|
||||||
|
out = data_dir / filename
|
||||||
|
if out.exists():
|
||||||
|
print(f" skip {filename} (already exists)")
|
||||||
|
continue
|
||||||
|
print(f" downloading {repo_id}/{repo_file} ...")
|
||||||
|
cached = hf_hub_download(repo_id=repo_id, filename=repo_file, repo_type="dataset")
|
||||||
|
shutil.copy2(cached, out)
|
||||||
|
print(f" saved → {out}")
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_DATA_DIR = Path(
|
||||||
|
os.environ.get("KIWI_PIPELINE_DATA_DIR", "data/pipeline")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--data-dir", required=True, type=Path)
|
parser.add_argument(
|
||||||
|
"--data-dir",
|
||||||
|
type=Path,
|
||||||
|
default=_DEFAULT_DATA_DIR,
|
||||||
|
help="Directory for downloaded parquets (default: $KIWI_PIPELINE_DATA_DIR or data/pipeline)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
download_all(args.data_dir)
|
download_all(args.data_dir)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,39 @@
|
||||||
|
import csv
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(path: Path, rows: list[dict], fieldnames: list[str]) -> None:
|
||||||
|
with open(path, "w", newline="") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_flavorgraph_node():
|
def test_parse_flavorgraph_node():
|
||||||
from scripts.pipeline.build_flavorgraph_index import parse_ingredient_nodes
|
from scripts.pipeline.build_flavorgraph_index import parse_ingredient_nodes
|
||||||
sample = {
|
|
||||||
"nodes": [
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
{"id": "I_beef", "type": "ingredient", "name": "beef"},
|
nodes_path = Path(tmp) / "nodes.csv"
|
||||||
{"id": "C_pyrazine", "type": "compound", "name": "pyrazine"},
|
edges_path = Path(tmp) / "edges.csv"
|
||||||
{"id": "I_mushroom", "type": "ingredient", "name": "mushroom"},
|
|
||||||
],
|
_write_csv(nodes_path, [
|
||||||
"links": [
|
{"node_id": "1", "name": "beef", "node_type": "ingredient"},
|
||||||
{"source": "I_beef", "target": "C_pyrazine"},
|
{"node_id": "2", "name": "pyrazine", "node_type": "compound"},
|
||||||
{"source": "I_mushroom","target": "C_pyrazine"},
|
{"node_id": "3", "name": "mushroom", "node_type": "ingredient"},
|
||||||
]
|
], ["node_id", "name", "node_type"])
|
||||||
}
|
|
||||||
result = parse_ingredient_nodes(sample)
|
_write_csv(edges_path, [
|
||||||
assert "beef" in result
|
{"id_1": "1", "id_2": "2", "score": "0.8"},
|
||||||
assert "C_pyrazine" in result["beef"]
|
{"id_1": "3", "id_2": "2", "score": "0.7"},
|
||||||
assert "mushroom" in result
|
], ["id_1", "id_2", "score"])
|
||||||
assert "C_pyrazine" in result["mushroom"]
|
|
||||||
|
ingredient_to_compounds, compound_names = parse_ingredient_nodes(nodes_path, edges_path)
|
||||||
|
|
||||||
|
assert "beef" in ingredient_to_compounds
|
||||||
|
assert "mushroom" in ingredient_to_compounds
|
||||||
|
# compound node_id "2" maps to name "pyrazine"
|
||||||
|
beef_compounds = ingredient_to_compounds["beef"]
|
||||||
|
assert any(compound_names.get(c) == "pyrazine" for c in beef_compounds)
|
||||||
|
mushroom_compounds = ingredient_to_compounds["mushroom"]
|
||||||
|
assert any(compound_names.get(c) == "pyrazine" for c in mushroom_compounds)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
"""Tests for LLMRecipeGenerator — prompt builders and allergy filtering."""
|
"""Tests for LLMRecipeGenerator — prompt builders and allergy filtering."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.models.schemas.recipe import RecipeRequest
|
from app.models.schemas.recipe import RecipeRequest
|
||||||
|
|
@ -135,7 +140,90 @@ def test_generate_returns_result_when_llm_responds(monkeypatch):
|
||||||
assert len(result.suggestions) == 1
|
assert len(result.suggestions) == 1
|
||||||
suggestion = result.suggestions[0]
|
suggestion = result.suggestions[0]
|
||||||
assert suggestion.title == "Mushroom Butter Pasta"
|
assert suggestion.title == "Mushroom Butter Pasta"
|
||||||
assert "butter" in suggestion.missing_ingredients
|
# All LLM ingredients (butter, mushrooms, pasta) are in the pantry, so none are missing
|
||||||
|
assert suggestion.missing_ingredients == []
|
||||||
assert len(suggestion.directions) > 0
|
assert len(suggestion.directions) > 0
|
||||||
assert "parmesan" in suggestion.notes.lower()
|
assert "parmesan" in suggestion.notes.lower()
|
||||||
assert result.element_gaps == ["Brightness"]
|
assert result.element_gaps == ["Brightness"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CFOrchClient integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeAllocation:
|
||||||
|
allocation_id: str = "alloc-test-1"
|
||||||
|
service: str = "vllm"
|
||||||
|
node_id: str = "node-1"
|
||||||
|
gpu_id: int = 0
|
||||||
|
model: str | None = "Ouro-2.6B-Thinking"
|
||||||
|
url: str = "http://test:8000"
|
||||||
|
started: bool = True
|
||||||
|
warm: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipe_gen_uses_cf_orch_when_env_set(monkeypatch):
|
||||||
|
"""When CF_ORCH_URL is set, _call_llm uses alloc.url+/v1 as the OpenAI base_url."""
|
||||||
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
|
||||||
|
store = _make_store()
|
||||||
|
gen = LLMRecipeGenerator(store)
|
||||||
|
|
||||||
|
fake_alloc = _FakeAllocation()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _fake_llm_context():
|
||||||
|
yield fake_alloc
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
# Fake OpenAI that records the base_url it was constructed with
|
||||||
|
class _FakeOpenAI:
|
||||||
|
def __init__(self, *, base_url, api_key):
|
||||||
|
captured["base_url"] = base_url
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.content = "Title: Test\nIngredients: a\nDirections: do it.\nNotes: none."
|
||||||
|
choice = MagicMock()
|
||||||
|
choice.message = msg
|
||||||
|
completion = MagicMock()
|
||||||
|
completion.choices = [choice]
|
||||||
|
self.chat = MagicMock()
|
||||||
|
self.chat.completions = MagicMock()
|
||||||
|
self.chat.completions.create = MagicMock(return_value=completion)
|
||||||
|
|
||||||
|
# Patch _get_llm_context directly so no real HTTP call is made
|
||||||
|
monkeypatch.setattr(gen, "_get_llm_context", _fake_llm_context)
|
||||||
|
|
||||||
|
with patch("app.services.recipe.llm_recipe.OpenAI", _FakeOpenAI):
|
||||||
|
gen._call_llm("make me a recipe")
|
||||||
|
|
||||||
|
assert captured.get("base_url") == "http://test:8000/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipe_gen_falls_back_without_cf_orch(monkeypatch):
|
||||||
|
"""When CF_ORCH_URL is not set, _call_llm falls back to LLMRouter."""
|
||||||
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
|
||||||
|
store = _make_store()
|
||||||
|
gen = LLMRecipeGenerator(store)
|
||||||
|
|
||||||
|
monkeypatch.delenv("CF_ORCH_URL", raising=False)
|
||||||
|
|
||||||
|
router_called = {}
|
||||||
|
|
||||||
|
def _fake_complete(prompt, **_kwargs):
|
||||||
|
router_called["prompt"] = prompt
|
||||||
|
return "Title: Direct\nIngredients: x\nDirections: go.\nNotes: ok."
|
||||||
|
|
||||||
|
fake_router = MagicMock()
|
||||||
|
fake_router.complete.side_effect = _fake_complete
|
||||||
|
|
||||||
|
# LLMRouter is imported locally inside _call_llm, so patch it at its source module.
|
||||||
|
# new_callable=MagicMock makes the class itself a MagicMock; set return_value so
|
||||||
|
# that LLMRouter() (instantiation) yields fake_router rather than a new MagicMock.
|
||||||
|
with patch("circuitforge_core.llm.router.LLMRouter", new_callable=MagicMock) as mock_router_cls:
|
||||||
|
mock_router_cls.return_value = fake_router
|
||||||
|
gen._call_llm("direct path prompt")
|
||||||
|
|
||||||
|
assert router_called.get("prompt") == "direct path prompt"
|
||||||
|
|
|
||||||
0
tests/test_services/__init__.py
Normal file
0
tests/test_services/__init__.py
Normal file
204
tests/test_services/test_docuvision_client.py
Normal file
204
tests/test_services/test_docuvision_client.py
Normal 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
|
||||||
Loading…
Reference in a new issue