Compare commits
No commits in common. "main" and "fix/recipe-enrichment-and-bugfixes" have entirely different histories.
main
...
fix/recipe
139 changed files with 831 additions and 22905 deletions
19
.env.example
19
.env.example
|
|
@ -21,12 +21,10 @@ DATA_DIR=./data
|
||||||
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
||||||
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
||||||
|
|
||||||
# GPU inference server (cf-orch coordinator for recipe scan, LLM generation, etc.)
|
# CF-core hosted coordinator (managed cloud GPU inference — Paid+ tier)
|
||||||
# GPU_SERVER_URL: set to your local cf-orch coordinator (self-hosted rack).
|
# Set CF_ORCH_URL to use a hosted cf-orch coordinator instead of self-hosting.
|
||||||
# CF_ORCH_URL is the backward-compat alias — both are honoured.
|
# CF_LICENSE_KEY is read automatically by CFOrchClient for bearer auth.
|
||||||
# Paid+ default: when CF_LICENSE_KEY is present and neither URL is set,
|
# CF_ORCH_URL=https://orch.circuitforge.tech
|
||||||
# the app automatically points to https://orch.circuitforge.tech.
|
|
||||||
# GPU_SERVER_URL=http://10.1.10.71:7700
|
|
||||||
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
||||||
|
|
||||||
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
||||||
|
|
@ -53,15 +51,6 @@ ENABLE_OCR=false
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
CLOUD_MODE=false
|
CLOUD_MODE=false
|
||||||
DEMO_MODE=false
|
DEMO_MODE=false
|
||||||
# Product identifier reported in cf-orch coordinator analytics for per-app breakdown
|
|
||||||
CF_APP_NAME=kiwi
|
|
||||||
# USE_ORCH_SCHEDULER: use coordinator-aware multi-GPU scheduler instead of local FIFO.
|
|
||||||
# Unset = auto-detect: true if CLOUD_MODE or circuitforge_orch is installed (paid+ local).
|
|
||||||
# Set false to force LocalScheduler even when cf-orch is present.
|
|
||||||
# USE_ORCH_SCHEDULER=false
|
|
||||||
# GPU_SERVER_URL: cf-orch coordinator endpoint. Required for recipe scan (cf-docuvision)
|
|
||||||
# and LLM features on a self-hosted rack. CF_ORCH_URL is the backward-compat alias.
|
|
||||||
# GPU_SERVER_URL=http://10.1.10.71:7700
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,9 +23,6 @@ dist/
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Local dev database
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# Test artifacts (MagicMock sqlite files from pytest)
|
# Test artifacts (MagicMock sqlite files from pytest)
|
||||||
<MagicMock*
|
<MagicMock*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,6 @@
|
||||||
[extend]
|
[extend]
|
||||||
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
||||||
|
|
||||||
# ── Global allowlist ──────────────────────────────────────────────────────────
|
|
||||||
# Amazon grocery department IDs (rh=n:<10-digit>) false-positive as phone
|
|
||||||
# numbers. locale_config.py is a static lookup table with no secrets.
|
|
||||||
|
|
||||||
[allowlist]
|
|
||||||
# Amazon grocery dept IDs (rh=n:<digits>) false-positive as phone numbers.
|
|
||||||
regexes = [
|
|
||||||
'''rh=n:\d{8,12}''',
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
||||||
|
|
||||||
[[rules]]
|
[[rules]]
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY circuitforge-core/ ./circuitforge-core/
|
COPY circuitforge-core/ ./circuitforge-core/
|
||||||
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
|
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
|
||||||
|
|
||||||
# Install circuitforge-orch — needed for the cf-orch-agent sidecar (compose.override.yml)
|
|
||||||
COPY circuitforge-orch/ ./circuitforge-orch/
|
|
||||||
|
|
||||||
# Create kiwi conda env and install app
|
# Create kiwi conda env and install app
|
||||||
COPY kiwi/environment.yml .
|
COPY kiwi/environment.yml .
|
||||||
RUN conda env create -f environment.yml
|
RUN conda env create -f environment.yml
|
||||||
|
|
@ -25,9 +22,8 @@ COPY kiwi/ ./kiwi/
|
||||||
# they never end up in the cloud image regardless of .dockerignore placement.
|
# they never end up in the cloud image regardless of .dockerignore placement.
|
||||||
RUN rm -f /app/kiwi/.env
|
RUN rm -f /app/kiwi/.env
|
||||||
|
|
||||||
# Install cf-core and cf-orch into the kiwi env BEFORE installing kiwi
|
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
|
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-orch
|
|
||||||
WORKDIR /app/kiwi
|
WORKDIR /app/kiwi
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e .
|
RUN conda run -n kiwi pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
|
|
||||||
138
README.md
138
README.md
|
|
@ -1,58 +1,42 @@
|
||||||
<!-- Logo coming soon — replace docs/kiwi-logo.svg when final icon ships -->
|
# 🥝 Kiwi
|
||||||
<div align="center">
|
|
||||||
<img src="docs/kiwi-logo.svg" alt="Kiwi logo" width="96" height="96" />
|
|
||||||
|
|
||||||
# Kiwi
|
> *Part of the CircuitForge LLC "AI for the tasks the system made hard on purpose" suite.*
|
||||||
|
|
||||||
**Pantry tracking and recipe suggestions — with or without an LLM.**
|
**Pantry tracking and leftover recipe suggestions.**
|
||||||
|
|
||||||
[](#license)
|
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
||||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/actions)
|
|
||||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/releases)
|
|
||||||
|
|
||||||
[Documentation](https://docs.circuitforge.tech/kiwi) · [Live demo](https://menagerie.circuitforge.tech/kiwi) · [circuitforge.tech](https://circuitforge.tech)
|
**LLM support is optional.** Inventory tracking, barcode scanning, expiry alerts, CSV export, and receipt upload all work without any LLM configured. AI features (receipt OCR, recipe suggestions, meal planning) activate when a backend is available and are BYOK-unlockable at any tier.
|
||||||
|
|
||||||
*Part of the CircuitForge LLC suite — "AI for the tasks the system made hard on purpose."*
|
**Status:** Beta · CircuitForge LLC
|
||||||
</div>
|
|
||||||
|
**[Documentation](https://docs.circuitforge.tech/kiwi/)** · [circuitforge.tech](https://circuitforge.tech)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **The LLM is optional.** Barcode scanning, receipt upload, expiry alerts, the full 200k+ recipe browser, and CSV export all work with zero LLM configured. Recipe suggestions and receipt OCR activate when a backend is available, and are BYOK-unlockable at any tier. You are never forced to send your data anywhere.
|
## What it does
|
||||||
|
|
||||||
---
|
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
||||||
|
- **Expiry alerts** — know what's about to go bad
|
||||||
|
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
|
||||||
|
- **Saved recipes** — bookmark any recipe with notes, a 0–5 star rating, and free-text style tags (Free); organize into named collections (Paid)
|
||||||
|
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
|
||||||
|
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
|
||||||
|
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
|
||||||
|
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Free, 5/day; unlimited at Paid+)
|
||||||
|
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
|
||||||
|
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
|
||||||
|
|
||||||
## What Kiwi does
|
## Stack
|
||||||
|
|
||||||
| Feature | Notes |
|
- **Frontend:** Vue 3 SPA (Vite + TypeScript)
|
||||||
|---|---|
|
- **Backend:** FastAPI + SQLite (via `circuitforge-core`)
|
||||||
| **Inventory tracking** | Add items by barcode scan, receipt upload, or manually |
|
- **Auth:** CF session cookie → Directus JWT (cloud mode)
|
||||||
| **Expiry alerts** | Know what is about to go bad before it does |
|
- **Licensing:** Heimdall (free tier auto-provisioned at signup)
|
||||||
| **Recipe browser** | 200k+ recipes — filter by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline |
|
|
||||||
| **Leftover mode** | Prioritizes nearly-expired items in recipe ranking (5/day free, unlimited at Paid+) |
|
|
||||||
| **Recipe suggestions** | Four levels: direct corpus match, substitution/swap, cuisine-style adapter, full LLM generation |
|
|
||||||
| **Meal planning** | Plan meals for the week; pull from saved recipes or suggestions |
|
|
||||||
| **Saved recipes** | Bookmark any recipe with notes, 0-5 star rating, and free-text style tags; organize into named collections (Paid) |
|
|
||||||
| **Receipt OCR** | Extract line items from receipt photos automatically |
|
|
||||||
| **Dietary profiles** | Vegan, gluten-free, diabetic, and other constraints respected throughout |
|
|
||||||
| **Style auto-classifier** | LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes |
|
|
||||||
| **Community feed** | Browse and share recipes with other Kiwi users |
|
|
||||||
| **CSV export** | Full pantry export, always available, no tier gate |
|
|
||||||
|
|
||||||
---
|
## Running locally
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
**One-line install (self-hosted, Docker required):**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/raw/branch/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Or clone and run manually:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi.git
|
|
||||||
cd kiwi
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
./manage.sh build
|
./manage.sh build
|
||||||
./manage.sh start
|
./manage.sh start
|
||||||
|
|
@ -60,59 +44,37 @@ cp .env.example .env
|
||||||
# API: http://localhost:8512
|
# API: http://localhost:8512
|
||||||
```
|
```
|
||||||
|
|
||||||
**Live cloud instance** (free account required):
|
## Cloud instance
|
||||||
[menagerie.circuitforge.tech/kiwi](https://menagerie.circuitforge.tech/kiwi)
|
|
||||||
|
|
||||||
Full setup and configuration guide: [docs.circuitforge.tech/kiwi](https://docs.circuitforge.tech/kiwi)
|
```bash
|
||||||
|
./manage.sh cloud-build
|
||||||
---
|
./manage.sh cloud-start
|
||||||
|
# Served at menagerie.circuitforge.tech/kiwi (JWT-gated)
|
||||||
|
```
|
||||||
|
|
||||||
## Tiers
|
## Tiers
|
||||||
|
|
||||||
| Feature | Free | Paid | Premium |
|
| Feature | Free | Paid | Premium |
|
||||||
|---|:---:|:---:|:---:|
|
|---------|------|------|---------|
|
||||||
| Inventory CRUD | Yes | Yes | Yes |
|
| Inventory CRUD | ✓ | ✓ | ✓ |
|
||||||
| Barcode scan | Yes | Yes | Yes |
|
| Barcode scan | ✓ | ✓ | ✓ |
|
||||||
| Receipt upload | Yes | Yes | Yes |
|
| Receipt upload | ✓ | ✓ | ✓ |
|
||||||
| Expiry alerts | Yes | Yes | Yes |
|
| Expiry alerts | ✓ | ✓ | ✓ |
|
||||||
| CSV export | Yes | Yes | Yes |
|
| CSV export | ✓ | ✓ | ✓ |
|
||||||
| Recipe browser (200k+ recipes) | Yes | Yes | Yes |
|
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
|
||||||
| Save recipes + notes + star rating | Yes | Yes | Yes |
|
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
|
||||||
| Style tags (manual, free-text) | Yes | Yes | Yes |
|
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
|
||||||
| Leftover mode (5/day) | Yes | Yes | Yes |
|
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||||
| Receipt OCR | BYOK | Yes | Yes |
|
| Recipe suggestions (L1–L4) | BYOK | ✓ | ✓ |
|
||||||
| Recipe suggestions (L1–L4) | BYOK | Yes | Yes |
|
| Named recipe collections | — | ✓ | ✓ |
|
||||||
| Named recipe collections | — | Yes | Yes |
|
| LLM style auto-classifier | — | BYOK | ✓ |
|
||||||
| LLM style auto-classifier | — | BYOK | Yes |
|
| Meal planning | — | ✓ | ✓ |
|
||||||
| Meal planning | — | Yes | Yes |
|
| Multi-household | — | — | ✓ |
|
||||||
| Multi-household | — | — | Yes |
|
| Leftover mode (5/day) | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
**BYOK** = bring your own LLM backend. Configure `~/.config/circuitforge/llm.yaml` to unlock AI features at any tier without a paid subscription.
|
BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Frontend:** Vue 3 SPA (Vite + TypeScript), served on port 8511
|
|
||||||
- **Backend:** FastAPI + SQLite via `circuitforge-core`, API on port 8512
|
|
||||||
- **Auth:** CircuitForge session cookie (cloud mode); local mode requires no account
|
|
||||||
- **Licensing:** Heimdall — free tier auto-provisioned at signup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Forgejo-primary
|
|
||||||
|
|
||||||
Kiwi is developed and maintained on Forgejo at [git.opensourcesolarpunk.com/Circuit-Forge/kiwi](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi). GitHub and Codeberg are read-only mirrors. File issues and submit pull requests on Forgejo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Kiwi uses a split license:
|
Discovery/pipeline layer: MIT
|
||||||
|
AI features: BSL 1.1 (free for personal non-commercial self-hosting)
|
||||||
- **Discovery and inventory pipeline** (barcode scan, expiry tracking, pantry CRUD, CSV export, recipe browser): [MIT](LICENSE-MIT)
|
|
||||||
- **AI features** (receipt OCR, LLM recipe suggestions, style auto-classifier): [BSL 1.1](LICENSE-BSL) — free for personal non-commercial self-hosting; commercial use or SaaS re-hosting requires a paid license. Converts to MIT after 4 years.
|
|
||||||
|
|
||||||
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
|
|
||||||
|
|
||||||
Privacy · Safety · Accessibility — co-equal, non-negotiable across all CircuitForge products.
|
|
||||||
|
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
# app/api/endpoints/activitypub.py
|
|
||||||
# MIT License
|
|
||||||
#
|
|
||||||
# ActivityPub endpoints for Kiwi instances:
|
|
||||||
# GET /.well-known/webfinger — WebFinger JRD
|
|
||||||
# GET /ap/actor — Instance actor document
|
|
||||||
# POST /ap/actor/inbox — Incoming activities
|
|
||||||
# GET /ap/outbox — Outgoing activities (OrderedCollection)
|
|
||||||
# GET /ap/posts/{slug} — Individual AP Note
|
|
||||||
# GET /ap/followers — Followers collection (count only)
|
|
||||||
# GET /ap/following — Following collection (empty stub)
|
|
||||||
#
|
|
||||||
# All endpoints are no-ops / 404 when AP_ENABLED=false or actor not loaded.
|
|
||||||
# The WebFinger and well-known routes are mounted at the root app level (not
|
|
||||||
# under /api/v1) — see main.py.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Two routers: one for well-known (root mount), one for /ap prefix ─────────
|
|
||||||
|
|
||||||
webfinger_router = APIRouter(tags=["activitypub"])
|
|
||||||
ap_router = APIRouter(prefix="/ap", tags=["activitypub"])
|
|
||||||
|
|
||||||
_AP_CONTENT_TYPE = "application/activity+json"
|
|
||||||
_JRD_CONTENT_TYPE = "application/jrd+json"
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_required():
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
raise HTTPException(status_code=404, detail="ActivityPub not enabled on this instance.")
|
|
||||||
return actor
|
|
||||||
|
|
||||||
|
|
||||||
# ── WebFinger ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@webfinger_router.get("/.well-known/webfinger")
|
|
||||||
async def webfinger(resource: str | None = None):
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
raise HTTPException(status_code=404, detail="ActivityPub not enabled.")
|
|
||||||
|
|
||||||
expected = f"acct:kiwi@{settings.AP_HOST}"
|
|
||||||
if resource and resource != expected:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Resource {resource!r} not found.")
|
|
||||||
|
|
||||||
jrd = {
|
|
||||||
"subject": expected,
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"rel": "self",
|
|
||||||
"type": _AP_CONTENT_TYPE,
|
|
||||||
"href": actor.actor_id,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return Response(
|
|
||||||
content=json.dumps(jrd),
|
|
||||||
media_type=_JRD_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Actor ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/actor")
|
|
||||||
async def get_actor_doc():
|
|
||||||
actor = _actor_required()
|
|
||||||
return Response(
|
|
||||||
content=json.dumps(actor.to_ap_dict()),
|
|
||||||
media_type=_AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Inbox (mounted via make_inbox_router below) ───────────────────────────────
|
|
||||||
|
|
||||||
async def _on_follow(activity: dict, headers: dict) -> None:
|
|
||||||
"""Accept Follow: add to ap_followers, send Accept(Follow) back."""
|
|
||||||
actor_url = activity.get("actor", "")
|
|
||||||
if not actor_url:
|
|
||||||
return
|
|
||||||
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.core.config import settings as _settings
|
|
||||||
db_path = _settings.DB_PATH
|
|
||||||
|
|
||||||
inbox_url, shared_inbox = await asyncio.to_thread(_resolve_inbox, actor_url)
|
|
||||||
if inbox_url is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT OR REPLACE INTO ap_followers
|
|
||||||
(actor_id, inbox_url, shared_inbox, followed_at, active)
|
|
||||||
VALUES (?, ?, ?, ?, 1)""",
|
|
||||||
(actor_url, inbox_url, shared_inbox, datetime.now(timezone.utc).isoformat()),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
return
|
|
||||||
accept = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.actor_id}/accepts/{activity.get('id', 'unknown')}",
|
|
||||||
"type": "Accept",
|
|
||||||
"actor": actor.actor_id,
|
|
||||||
"object": activity,
|
|
||||||
}
|
|
||||||
from circuitforge_core.activitypub import deliver_activity
|
|
||||||
await asyncio.to_thread(deliver_activity, accept, inbox_url, actor, 10.0)
|
|
||||||
|
|
||||||
|
|
||||||
async def _on_undo(activity: dict, headers: dict) -> None:
|
|
||||||
"""Handle Undo(Follow): deactivate the follower row."""
|
|
||||||
inner = activity.get("object", {})
|
|
||||||
if isinstance(inner, dict) and inner.get("type") == "Follow":
|
|
||||||
actor_url = activity.get("actor", "")
|
|
||||||
if actor_url:
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(settings.DB_PATH))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE ap_followers SET active = 0 WHERE actor_id = ?", (actor_url,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def _dedup_activity(activity_id: str | None) -> bool:
|
|
||||||
"""Return True (already seen) if activity_id is in ap_received; otherwise insert it."""
|
|
||||||
if not activity_id:
|
|
||||||
return False
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(settings.DB_PATH))
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ap_received (activity_id) VALUES (?)", (activity_id,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return False
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_inbox_router():
|
|
||||||
from circuitforge_core.activitypub.inbox import make_inbox_router
|
|
||||||
|
|
||||||
async def on_follow(activity: dict, headers: dict) -> None:
|
|
||||||
if await _dedup_activity(activity.get("id")):
|
|
||||||
return
|
|
||||||
await _on_follow(activity, headers)
|
|
||||||
|
|
||||||
async def on_undo(activity: dict, headers: dict) -> None:
|
|
||||||
if await _dedup_activity(activity.get("id")):
|
|
||||||
return
|
|
||||||
await _on_undo(activity, headers)
|
|
||||||
|
|
||||||
return make_inbox_router(
|
|
||||||
handlers={"Follow": on_follow, "Undo": on_undo},
|
|
||||||
verify_key_fetcher=None, # Signature verification enabled in prod when actor is loaded
|
|
||||||
path="/inbox",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Mount inbox at /ap/actor/inbox (AP spec: inbox is a sub-resource of the actor)
|
|
||||||
try:
|
|
||||||
_inbox_sub = _build_inbox_router()
|
|
||||||
ap_router.include_router(_inbox_sub, prefix="/actor")
|
|
||||||
except Exception as _e:
|
|
||||||
logger.warning("AP inbox router not available: %s", _e)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Outbox ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/outbox")
|
|
||||||
async def get_outbox(page: int | None = None, request: Request = None):
|
|
||||||
actor = _actor_required()
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
store = _get_community_store()
|
|
||||||
base = f"https://{settings.AP_HOST}"
|
|
||||||
|
|
||||||
if store is None:
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.outbox_url}",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": 0,
|
|
||||||
"orderedItems": [],
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
PAGE_SIZE = 20
|
|
||||||
offset = ((page or 1) - 1) * PAGE_SIZE
|
|
||||||
posts = await asyncio.to_thread(store.list_posts, limit=PAGE_SIZE, offset=offset)
|
|
||||||
items = [_post_to_ap_note(p, actor, base) for p in posts]
|
|
||||||
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": actor.outbox_url + (f"?page={page}" if page else ""),
|
|
||||||
"type": "OrderedCollectionPage" if page else "OrderedCollection",
|
|
||||||
"orderedItems": items,
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Individual post ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/posts/{slug}")
|
|
||||||
async def get_ap_post(slug: str):
|
|
||||||
actor = _actor_required()
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Community DB not available.")
|
|
||||||
|
|
||||||
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
|
||||||
if post is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found.")
|
|
||||||
|
|
||||||
base = f"https://{settings.AP_HOST}"
|
|
||||||
note = _post_to_ap_note(post, actor, base)
|
|
||||||
return Response(content=json.dumps(note), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Followers / Following ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/followers")
|
|
||||||
async def get_followers():
|
|
||||||
actor = _actor_required()
|
|
||||||
import sqlite3
|
|
||||||
count = 0
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(settings.DB_PATH))
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM ap_followers WHERE active = 1").fetchone()
|
|
||||||
conn.close()
|
|
||||||
count = row[0] if row else 0
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.actor_id}/followers",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": count,
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
@ap_router.get("/following")
|
|
||||||
async def get_following():
|
|
||||||
actor = _actor_required()
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.actor_id}/following",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": 0,
|
|
||||||
"orderedItems": [],
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _post_to_ap_note(post, actor, base_url: str) -> dict:
|
|
||||||
from circuitforge_core.activitypub import make_note
|
|
||||||
from app.services.community.ap_compat import _build_content
|
|
||||||
|
|
||||||
diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or [])
|
|
||||||
hashtags = [{"type": "Hashtag", "name": "#Kiwi", "href": f"{base_url}/ap/tags/kiwi"}]
|
|
||||||
for tag in diet_tags[:4]:
|
|
||||||
ht = "".join(w.capitalize() for w in tag.replace("-", " ").split())
|
|
||||||
hashtags.append({"type": "Hashtag", "name": f"#{ht}"})
|
|
||||||
|
|
||||||
content = _build_content(
|
|
||||||
{
|
|
||||||
"title": post.title,
|
|
||||||
"description": getattr(post, "description", None),
|
|
||||||
"outcome_notes": getattr(post, "outcome_notes", None),
|
|
||||||
"dietary_tags": diet_tags,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
published = post.published
|
|
||||||
note = make_note(
|
|
||||||
actor_id=actor.actor_id,
|
|
||||||
content=content,
|
|
||||||
tag=hashtags,
|
|
||||||
published=published if isinstance(published, datetime) else None,
|
|
||||||
)
|
|
||||||
note["id"] = f"{base_url}/ap/posts/{post.slug}"
|
|
||||||
return note
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_inbox(actor_url: str) -> tuple[str | None, str | None]:
|
|
||||||
"""Fetch an AP actor document and extract inbox + sharedInbox URLs."""
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
resp = httpx.get(
|
|
||||||
actor_url,
|
|
||||||
headers={"Accept": "application/activity+json"},
|
|
||||||
timeout=8.0,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
doc = resp.json()
|
|
||||||
inbox = doc.get("inbox")
|
|
||||||
shared = doc.get("endpoints", {}).get("sharedInbox")
|
|
||||||
return inbox, shared
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Could not resolve actor %s: %s", actor_url, exc)
|
|
||||||
return None, None
|
|
||||||
|
|
@ -167,54 +167,6 @@ def _validate_publish_body(body: dict) -> None:
|
||||||
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
|
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/check-similar")
|
|
||||||
async def check_similar(body: dict, session: CloudUser = Depends(get_session)):
|
|
||||||
"""Pre-submission dedup check: return similar existing posts for the given title/recipe_id.
|
|
||||||
|
|
||||||
Safe to call with no community store configured — returns empty list rather than 503.
|
|
||||||
"""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return {"similar_posts": []}
|
|
||||||
|
|
||||||
title = (body.get("title") or "").strip()
|
|
||||||
recipe_id = body.get("recipe_id")
|
|
||||||
post_type = body.get("post_type")
|
|
||||||
|
|
||||||
if not title:
|
|
||||||
return {"similar_posts": []}
|
|
||||||
|
|
||||||
candidates = await asyncio.to_thread(
|
|
||||||
store.search_similar_posts,
|
|
||||||
title,
|
|
||||||
recipe_id,
|
|
||||||
post_type,
|
|
||||||
8,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return {"similar_posts": []}
|
|
||||||
|
|
||||||
from app.services.community.dedup import build_similar_post_result, fetch_recipe_ingredients
|
|
||||||
incoming_ingredients = await asyncio.to_thread(
|
|
||||||
fetch_recipe_ingredients, session.db, recipe_id
|
|
||||||
)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for post in candidates:
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
build_similar_post_result,
|
|
||||||
post,
|
|
||||||
recipe_id,
|
|
||||||
incoming_ingredients,
|
|
||||||
session.db,
|
|
||||||
)
|
|
||||||
if result["similarity_tier"] != "different":
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
return {"similar_posts": results[:5]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts", status_code=201)
|
@router.post("/posts", status_code=201)
|
||||||
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
@ -262,8 +214,6 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
||||||
|
|
||||||
similar_to_ref = body.get("similar_to_ref") or None
|
|
||||||
|
|
||||||
from circuitforge_core.community.models import CommunityPost
|
from circuitforge_core.community.models import CommunityPost
|
||||||
post = CommunityPost(
|
post = CommunityPost(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|
@ -291,7 +241,6 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
fat_pct=snapshot.fat_pct,
|
fat_pct=snapshot.fat_pct,
|
||||||
protein_pct=snapshot.protein_pct,
|
protein_pct=snapshot.protein_pct,
|
||||||
moisture_pct=snapshot.moisture_pct,
|
moisture_pct=snapshot.moisture_pct,
|
||||||
similar_to_ref=similar_to_ref,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -301,41 +250,7 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail="A post with this title already exists today. Try a different title.",
|
detail="A post with this title already exists today. Try a different title.",
|
||||||
) from exc
|
) from exc
|
||||||
|
return _post_to_dict(inserted)
|
||||||
post_dict = _post_to_dict(inserted)
|
|
||||||
|
|
||||||
# AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in)
|
|
||||||
from app.core.config import settings as _settings
|
|
||||||
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
|
|
||||||
from circuitforge_core.activitypub import make_create, make_note, PUBLIC
|
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
from app.services.ap.delivery import deliver_to_followers
|
|
||||||
_ap_actor = get_actor()
|
|
||||||
if _ap_actor is not None:
|
|
||||||
base = f"https://{_settings.AP_HOST}"
|
|
||||||
from app.api.endpoints.activitypub import _post_to_ap_note
|
|
||||||
_note = _post_to_ap_note(inserted, _ap_actor, base)
|
|
||||||
_activity = make_create(_ap_actor, _note)
|
|
||||||
asyncio.create_task(
|
|
||||||
asyncio.to_thread(
|
|
||||||
deliver_to_followers, inserted.slug, _activity, session.db
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mastodon post if user has connected account and opted in
|
|
||||||
if body.get("post_to_mastodon"):
|
|
||||||
from app.services.ap.mastodon import build_post_content, get_token, post_status
|
|
||||||
_masto = await asyncio.to_thread(
|
|
||||||
get_token, session.db, session.user_id, _settings.AP_TOKEN_ENCRYPTION_KEY
|
|
||||||
)
|
|
||||||
if _masto:
|
|
||||||
_masto_url, _masto_token = _masto
|
|
||||||
_content = build_post_content(post_dict)
|
|
||||||
asyncio.create_task(
|
|
||||||
asyncio.to_thread(post_status, _masto_url, _masto_token, _content)
|
|
||||||
)
|
|
||||||
|
|
||||||
return post_dict
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/posts/{slug}", status_code=204)
|
@router.delete("/posts/{slug}", status_code=204)
|
||||||
|
|
@ -436,7 +351,6 @@ def _post_to_dict(post) -> dict:
|
||||||
"fat_pct": post.fat_pct,
|
"fat_pct": post.fat_pct,
|
||||||
"protein_pct": post.protein_pct,
|
"protein_pct": post.protein_pct,
|
||||||
"moisture_pct": post.moisture_pct,
|
"moisture_pct": post.moisture_pct,
|
||||||
"similar_to_ref": getattr(post, "similar_to_ref", None),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training
|
|
||||||
from circuitforge_core.api import make_corrections_router
|
|
||||||
from app.db.session import get_db
|
|
||||||
|
|
||||||
router = make_corrections_router(get_db=get_db, product="kiwi")
|
|
||||||
|
|
@ -11,8 +11,7 @@ import sqlite3
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, get_session
|
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN, get_session
|
||||||
from app.services.heimdall_orch import HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.household import (
|
from app.models.schemas.household import (
|
||||||
HouseholdAcceptRequest,
|
HouseholdAcceptRequest,
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,14 @@ from app.models.schemas.inventory import (
|
||||||
TagCreate,
|
TagCreate,
|
||||||
TagResponse,
|
TagResponse,
|
||||||
)
|
)
|
||||||
from app.models.schemas.label_capture import LabelConfirmRequest
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _user_constraints(store) -> list[str]:
|
def _enrich_item(item: dict) -> dict:
|
||||||
"""Load active dietary constraints from user settings (comma-separated string)."""
|
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning."""
|
||||||
raw = store.get_setting("dietary_constraints") or ""
|
|
||||||
return [c.strip() for c in raw.split(",") if c.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich_item(item: dict, user_constraints: list[str] | None = None) -> dict:
|
|
||||||
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning/discard_signs."""
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
opened = item.get("opened_date")
|
opened = item.get("opened_date")
|
||||||
if opened:
|
if opened:
|
||||||
|
|
@ -65,16 +58,13 @@ def _enrich_item(item: dict, user_constraints: list[str] | None = None) -> dict:
|
||||||
if "opened_expiry_date" not in item:
|
if "opened_expiry_date" not in item:
|
||||||
item = {**item, "opened_expiry_date": None}
|
item = {**item, "opened_expiry_date": None}
|
||||||
|
|
||||||
# Secondary use window — check sell-by date (not opened expiry).
|
# Secondary use window — check sell-by date (not opened expiry)
|
||||||
# Apply dietary constraint filter (e.g. wine suppressed for halal/alcohol-free).
|
|
||||||
sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date"))
|
sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date"))
|
||||||
sec = _predictor.filter_secondary_by_constraints(sec, user_constraints or [])
|
|
||||||
item = {
|
item = {
|
||||||
**item,
|
**item,
|
||||||
"secondary_state": sec["label"] if sec else None,
|
"secondary_state": sec["label"] if sec else None,
|
||||||
"secondary_uses": sec["uses"] if sec else None,
|
"secondary_uses": sec["uses"] if sec else None,
|
||||||
"secondary_warning": sec["warning"] if sec else None,
|
"secondary_warning": sec["warning"] if sec else None,
|
||||||
"secondary_discard_signs": sec["discard_signs"] if sec else None,
|
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
@ -181,10 +171,7 @@ async def create_inventory_item(
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
source=body.source,
|
source=body.source,
|
||||||
)
|
)
|
||||||
# RETURNING * omits joined columns (product_name, barcode, category).
|
return InventoryItemResponse.model_validate(item)
|
||||||
# Re-fetch with the products JOIN so the response is fully populated (#99).
|
|
||||||
full_item = await asyncio.to_thread(store.get_inventory_item, item["id"])
|
|
||||||
return InventoryItemResponse.model_validate(full_item)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
||||||
|
|
@ -222,15 +209,13 @@ async def list_inventory_items(
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
):
|
):
|
||||||
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||||
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
||||||
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
||||||
items = await asyncio.to_thread(store.expiring_soon, days)
|
items = await asyncio.to_thread(store.expiring_soon, days)
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||||
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -238,8 +223,7 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -256,8 +240,7 @@ async def update_inventory_item(
|
||||||
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
||||||
|
|
@ -271,8 +254,7 @@ async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||||
|
|
@ -301,8 +283,7 @@ async def consume_item(
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
||||||
|
|
@ -326,8 +307,7 @@ async def discard_item(
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
@ -350,31 +330,6 @@ class BarcodeScanTextRequest(BaseModel):
|
||||||
auto_add_to_inventory: bool = True
|
auto_add_to_inventory: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _captured_to_product_info(row: dict) -> dict:
|
|
||||||
"""Convert a captured_products row to the product_info dict shape used by
|
|
||||||
the barcode scan flow (mirrors what OpenFoodFactsService returns)."""
|
|
||||||
macros: dict = {}
|
|
||||||
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
|
||||||
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
|
|
||||||
if row.get(field) is not None:
|
|
||||||
macros[field] = row[field]
|
|
||||||
return {
|
|
||||||
"name": row.get("product_name") or row.get("barcode", "Unknown Product"),
|
|
||||||
"brand": row.get("brand"),
|
|
||||||
"category": None,
|
|
||||||
"nutrition_data": macros,
|
|
||||||
"ingredient_names": row.get("ingredient_names") or [],
|
|
||||||
"allergens": row.get("allergens") or [],
|
|
||||||
"source": "visual_capture",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _gap_message(tier: str, has_visual_capture: bool) -> str:
|
|
||||||
if has_visual_capture:
|
|
||||||
return "We couldn't find this product. Photograph the nutrition label to add it."
|
|
||||||
return "Not found in any product database — add manually"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan/text", response_model=BarcodeScanResponse)
|
@router.post("/scan/text", response_model=BarcodeScanResponse)
|
||||||
async def scan_barcode_text(
|
async def scan_barcode_text(
|
||||||
body: BarcodeScanTextRequest,
|
body: BarcodeScanTextRequest,
|
||||||
|
|
@ -385,21 +340,10 @@ async def scan_barcode_text(
|
||||||
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
predictor = ExpirationPredictor()
|
|
||||||
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
|
|
||||||
|
|
||||||
# 1. Check local captured-products cache before hitting FDC/OFF
|
|
||||||
cached = await asyncio.to_thread(store.get_captured_product, body.barcode)
|
|
||||||
if cached and cached.get("confirmed_by_user"):
|
|
||||||
product_info: dict | None = _captured_to_product_info(cached)
|
|
||||||
product_source = "visual_capture"
|
|
||||||
else:
|
|
||||||
off = OpenFoodFactsService()
|
off = OpenFoodFactsService()
|
||||||
|
predictor = ExpirationPredictor()
|
||||||
product_info = await off.lookup_product(body.barcode)
|
product_info = await off.lookup_product(body.barcode)
|
||||||
product_source = "openfoodfacts"
|
|
||||||
|
|
||||||
inventory_item = None
|
inventory_item = None
|
||||||
|
|
||||||
if product_info and body.auto_add_to_inventory:
|
if product_info and body.auto_add_to_inventory:
|
||||||
|
|
@ -410,7 +354,7 @@ async def scan_barcode_text(
|
||||||
brand=product_info.get("brand"),
|
brand=product_info.get("brand"),
|
||||||
category=product_info.get("category"),
|
category=product_info.get("category"),
|
||||||
nutrition_data=product_info.get("nutrition_data", {}),
|
nutrition_data=product_info.get("nutrition_data", {}),
|
||||||
source=product_source,
|
source="openfoodfacts",
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
exp = predictor.predict_expiration(
|
exp = predictor.predict_expiration(
|
||||||
|
|
@ -436,7 +380,6 @@ async def scan_barcode_text(
|
||||||
result_product = None
|
result_product = None
|
||||||
|
|
||||||
product_found = product_info is not None
|
product_found = product_info is not None
|
||||||
needs_capture = not product_found and has_visual_capture
|
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=True,
|
success=True,
|
||||||
barcodes_found=1,
|
barcodes_found=1,
|
||||||
|
|
@ -446,9 +389,8 @@ async def scan_barcode_text(
|
||||||
"product": result_product,
|
"product": result_product,
|
||||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||||
"added_to_inventory": inventory_item is not None,
|
"added_to_inventory": inventory_item is not None,
|
||||||
"needs_manual_entry": not product_found and not needs_capture,
|
"needs_manual_entry": not product_found,
|
||||||
"needs_visual_capture": needs_capture,
|
"message": "Added to inventory" if inventory_item else "Not found in any product database — add manually",
|
||||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
|
||||||
}],
|
}],
|
||||||
message="Barcode processed",
|
message="Barcode processed",
|
||||||
)
|
)
|
||||||
|
|
@ -465,9 +407,6 @@ async def scan_barcode_image(
|
||||||
):
|
):
|
||||||
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
||||||
log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
||||||
from app.tiers import can_use
|
|
||||||
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
|
|
||||||
|
|
||||||
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
||||||
|
|
@ -478,8 +417,7 @@ async def scan_barcode_image(
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
image_bytes = temp_file.read_bytes()
|
barcodes = await asyncio.to_thread(BarcodeScanner().scan_image, temp_file)
|
||||||
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=False, barcodes_found=0, results=[],
|
success=False, barcodes_found=0, results=[],
|
||||||
|
|
@ -491,30 +429,19 @@ async def scan_barcode_image(
|
||||||
results = []
|
results = []
|
||||||
for bc in barcodes:
|
for bc in barcodes:
|
||||||
code = bc["data"]
|
code = bc["data"]
|
||||||
|
|
||||||
# Check local visual-capture cache before hitting FDC/OFF
|
|
||||||
cached = await asyncio.to_thread(store.get_captured_product, code)
|
|
||||||
if cached and cached.get("confirmed_by_user"):
|
|
||||||
product_info: dict | None = _captured_to_product_info(cached)
|
|
||||||
product_source = "visual_capture"
|
|
||||||
else:
|
|
||||||
product_info = await off.lookup_product(code)
|
product_info = await off.lookup_product(code)
|
||||||
product_source = "openfoodfacts"
|
|
||||||
|
|
||||||
db_product = None
|
|
||||||
inventory_item = None
|
inventory_item = None
|
||||||
if product_info:
|
if product_info and auto_add_to_inventory:
|
||||||
db_product, _ = await asyncio.to_thread(
|
product, _ = await asyncio.to_thread(
|
||||||
store.get_or_create_product,
|
store.get_or_create_product,
|
||||||
product_info.get("name", code),
|
product_info.get("name", code),
|
||||||
code,
|
code,
|
||||||
brand=product_info.get("brand"),
|
brand=product_info.get("brand"),
|
||||||
category=product_info.get("category"),
|
category=product_info.get("category"),
|
||||||
nutrition_data=product_info.get("nutrition_data", {}),
|
nutrition_data=product_info.get("nutrition_data", {}),
|
||||||
source=product_source,
|
source="openfoodfacts",
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
if auto_add_to_inventory:
|
|
||||||
exp = predictor.predict_expiration(
|
exp = predictor.predict_expiration(
|
||||||
product_info.get("category", ""),
|
product_info.get("category", ""),
|
||||||
location,
|
location,
|
||||||
|
|
@ -526,23 +453,19 @@ async def scan_barcode_image(
|
||||||
resolved_unit = product_info.get("pack_unit") or "count"
|
resolved_unit = product_info.get("pack_unit") or "count"
|
||||||
inventory_item = await asyncio.to_thread(
|
inventory_item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
db_product["id"], location,
|
product["id"], location,
|
||||||
quantity=resolved_qty,
|
quantity=resolved_qty,
|
||||||
unit=resolved_unit,
|
unit=resolved_unit,
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="barcode_scan",
|
source="barcode_scan",
|
||||||
)
|
)
|
||||||
product_found = db_product is not None
|
|
||||||
needs_capture = not product_found and has_visual_capture
|
|
||||||
results.append({
|
results.append({
|
||||||
"barcode": code,
|
"barcode": code,
|
||||||
"barcode_type": bc.get("type", "unknown"),
|
"barcode_type": bc.get("type", "unknown"),
|
||||||
"product": ProductResponse.model_validate(db_product) if db_product else None,
|
"product": ProductResponse.model_validate(product) if product_info else None,
|
||||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||||
"added_to_inventory": inventory_item is not None,
|
"added_to_inventory": inventory_item is not None,
|
||||||
"needs_manual_entry": not product_found and not needs_capture,
|
"message": "Added to inventory" if inventory_item else "Barcode scanned",
|
||||||
"needs_visual_capture": needs_capture,
|
|
||||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
|
||||||
})
|
})
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=True, barcodes_found=len(barcodes), results=results,
|
success=True, barcodes_found=len(barcodes), results=results,
|
||||||
|
|
@ -553,143 +476,6 @@ async def scan_barcode_image(
|
||||||
temp_file.unlink()
|
temp_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
# ── Visual label capture (kiwi#79) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/scan/label-capture")
|
|
||||||
async def capture_nutrition_label(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
barcode: str = Form(...),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Photograph a nutrition label for an unenriched product (paid tier).
|
|
||||||
|
|
||||||
Sends the image to the vision model and returns structured nutrition data
|
|
||||||
for user review. Fields extracted with confidence < 0.7 should be
|
|
||||||
highlighted in amber in the UI.
|
|
||||||
"""
|
|
||||||
from app.tiers import can_use
|
|
||||||
from app.models.schemas.label_capture import LabelCaptureResponse
|
|
||||||
from app.services.label_capture import extract_label, needs_review as _needs_review
|
|
||||||
|
|
||||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
|
||||||
log.info("label_capture tier=%s barcode=%r", session.tier, barcode)
|
|
||||||
|
|
||||||
image_bytes = await file.read()
|
|
||||||
extraction = await asyncio.to_thread(extract_label, image_bytes)
|
|
||||||
|
|
||||||
return LabelCaptureResponse(
|
|
||||||
barcode=barcode,
|
|
||||||
product_name=extraction.get("product_name"),
|
|
||||||
brand=extraction.get("brand"),
|
|
||||||
serving_size_g=extraction.get("serving_size_g"),
|
|
||||||
calories=extraction.get("calories"),
|
|
||||||
fat_g=extraction.get("fat_g"),
|
|
||||||
saturated_fat_g=extraction.get("saturated_fat_g"),
|
|
||||||
carbs_g=extraction.get("carbs_g"),
|
|
||||||
sugar_g=extraction.get("sugar_g"),
|
|
||||||
fiber_g=extraction.get("fiber_g"),
|
|
||||||
protein_g=extraction.get("protein_g"),
|
|
||||||
sodium_mg=extraction.get("sodium_mg"),
|
|
||||||
ingredient_names=extraction.get("ingredient_names") or [],
|
|
||||||
allergens=extraction.get("allergens") or [],
|
|
||||||
confidence=extraction.get("confidence", 0.0),
|
|
||||||
needs_review=_needs_review(extraction),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan/label-confirm")
|
|
||||||
async def confirm_nutrition_label(
|
|
||||||
body: LabelConfirmRequest,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Confirm and save a user-reviewed label extraction.
|
|
||||||
|
|
||||||
Saves the product to the local cache so future scans of the same barcode
|
|
||||||
resolve instantly without another capture. Optionally adds the item to
|
|
||||||
the user's inventory.
|
|
||||||
"""
|
|
||||||
from app.tiers import can_use
|
|
||||||
from app.models.schemas.label_capture import LabelConfirmResponse
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
|
|
||||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
|
||||||
log.info("label_confirm tier=%s barcode=%r", session.tier, body.barcode)
|
|
||||||
|
|
||||||
# Persist to local visual-capture cache
|
|
||||||
await asyncio.to_thread(
|
|
||||||
store.save_captured_product,
|
|
||||||
body.barcode,
|
|
||||||
product_name=body.product_name,
|
|
||||||
brand=body.brand,
|
|
||||||
serving_size_g=body.serving_size_g,
|
|
||||||
calories=body.calories,
|
|
||||||
fat_g=body.fat_g,
|
|
||||||
saturated_fat_g=body.saturated_fat_g,
|
|
||||||
carbs_g=body.carbs_g,
|
|
||||||
sugar_g=body.sugar_g,
|
|
||||||
fiber_g=body.fiber_g,
|
|
||||||
protein_g=body.protein_g,
|
|
||||||
sodium_mg=body.sodium_mg,
|
|
||||||
ingredient_names=body.ingredient_names,
|
|
||||||
allergens=body.allergens,
|
|
||||||
confidence=body.confidence,
|
|
||||||
confirmed_by_user=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
product_id: int | None = None
|
|
||||||
inventory_item_id: int | None = None
|
|
||||||
|
|
||||||
if body.auto_add:
|
|
||||||
predictor = ExpirationPredictor()
|
|
||||||
nutrition = {}
|
|
||||||
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
|
||||||
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
|
|
||||||
val = getattr(body, field, None)
|
|
||||||
if val is not None:
|
|
||||||
nutrition[field] = val
|
|
||||||
|
|
||||||
product, _ = await asyncio.to_thread(
|
|
||||||
store.get_or_create_product,
|
|
||||||
body.product_name or body.barcode,
|
|
||||||
body.barcode,
|
|
||||||
brand=body.brand,
|
|
||||||
category=None,
|
|
||||||
nutrition_data=nutrition,
|
|
||||||
source="visual_capture",
|
|
||||||
source_data={},
|
|
||||||
)
|
|
||||||
product_id = product["id"]
|
|
||||||
|
|
||||||
exp = predictor.predict_expiration(
|
|
||||||
"",
|
|
||||||
body.location,
|
|
||||||
product_name=body.product_name or body.barcode,
|
|
||||||
tier=session.tier,
|
|
||||||
has_byok=session.has_byok,
|
|
||||||
)
|
|
||||||
inv_item = await asyncio.to_thread(
|
|
||||||
store.add_inventory_item,
|
|
||||||
product_id, body.location,
|
|
||||||
quantity=body.quantity,
|
|
||||||
unit="count",
|
|
||||||
expiration_date=str(exp) if exp else None,
|
|
||||||
source="visual_capture",
|
|
||||||
)
|
|
||||||
inventory_item_id = inv_item["id"]
|
|
||||||
|
|
||||||
return LabelConfirmResponse(
|
|
||||||
ok=True,
|
|
||||||
barcode=body.barcode,
|
|
||||||
product_id=product_id,
|
|
||||||
inventory_item_id=inventory_item_id,
|
|
||||||
message="Product saved" + (" and added to inventory" if body.auto_add else ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tags ──────────────────────────────────────────────────────────────────────
|
# ── Tags ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/tags", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/tags", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# app/api/endpoints/mastodon_oauth.py
|
|
||||||
# MIT License
|
|
||||||
#
|
|
||||||
# Mastodon OAuth flow endpoints:
|
|
||||||
# POST /social/mastodon/connect — Start OAuth (dynamic app registration)
|
|
||||||
# GET /social/mastodon/callback — OAuth callback, exchange code for token
|
|
||||||
# DELETE /social/mastodon/disconnect — Revoke and remove stored token
|
|
||||||
# GET /social/mastodon/status — Check connection status
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/social/mastodon", tags=["mastodon"])
|
|
||||||
|
|
||||||
|
|
||||||
def _redirect_uri() -> str:
|
|
||||||
host = settings.AP_HOST or "localhost:8512"
|
|
||||||
return f"https://{host}/api/v1/social/mastodon/callback"
|
|
||||||
|
|
||||||
|
|
||||||
# In-memory pending state: maps state_token → {instance_url, client_id, client_secret, user_id}
|
|
||||||
# A real deployment would persist this in a short-TTL cache or DB.
|
|
||||||
_pending: dict[str, dict] = {}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/connect")
|
|
||||||
async def connect_mastodon(body: dict, session: CloudUser = Depends(get_session)):
|
|
||||||
"""Start the Mastodon OAuth flow.
|
|
||||||
|
|
||||||
Body: {"instance_url": "https://mastodon.social"}
|
|
||||||
Returns: {"authorize_url": "..."}
|
|
||||||
"""
|
|
||||||
import secrets
|
|
||||||
from app.services.ap.mastodon import build_authorize_url, register_app
|
|
||||||
|
|
||||||
instance_url = (body.get("instance_url") or "").strip().rstrip("/")
|
|
||||||
if not instance_url.startswith("https://"):
|
|
||||||
raise HTTPException(status_code=422, detail="instance_url must be an https:// URL.")
|
|
||||||
|
|
||||||
redirect_uri = _redirect_uri()
|
|
||||||
try:
|
|
||||||
app_creds = await asyncio.to_thread(register_app, instance_url, redirect_uri)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502, detail=f"Could not register with Mastodon instance: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
state = secrets.token_urlsafe(24)
|
|
||||||
_pending[state] = {
|
|
||||||
"instance_url": instance_url,
|
|
||||||
"client_id": app_creds["client_id"],
|
|
||||||
"client_secret": app_creds["client_secret"],
|
|
||||||
"user_id": session.user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
authorize_url = build_authorize_url(
|
|
||||||
instance_url=instance_url,
|
|
||||||
client_id=app_creds["client_id"],
|
|
||||||
redirect_uri=redirect_uri + f"?state={state}",
|
|
||||||
)
|
|
||||||
return {"authorize_url": authorize_url, "state": state}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callback")
|
|
||||||
async def mastodon_callback(code: str | None = None, state: str | None = None):
|
|
||||||
"""OAuth callback. Exchanges auth code for access token and stores it."""
|
|
||||||
if not code or not state:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing code or state parameter.")
|
|
||||||
|
|
||||||
pending = _pending.pop(state, None)
|
|
||||||
if pending is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Unknown or expired OAuth state.")
|
|
||||||
|
|
||||||
from app.services.ap.mastodon import exchange_code, store_token
|
|
||||||
|
|
||||||
redirect_uri = _redirect_uri() + f"?state={state}"
|
|
||||||
try:
|
|
||||||
access_token = await asyncio.to_thread(
|
|
||||||
exchange_code,
|
|
||||||
pending["instance_url"],
|
|
||||||
pending["client_id"],
|
|
||||||
pending["client_secret"],
|
|
||||||
code,
|
|
||||||
redirect_uri,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Token exchange failed: {exc}") from exc
|
|
||||||
|
|
||||||
await asyncio.to_thread(
|
|
||||||
store_token,
|
|
||||||
settings.DB_PATH,
|
|
||||||
pending["user_id"],
|
|
||||||
pending["instance_url"],
|
|
||||||
access_token,
|
|
||||||
settings.AP_TOKEN_ENCRYPTION_KEY,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Redirect to frontend settings page after successful connect
|
|
||||||
return RedirectResponse(url="/#/settings?mastodon=connected", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/disconnect", status_code=204)
|
|
||||||
async def disconnect_mastodon(session: CloudUser = Depends(get_session)):
|
|
||||||
"""Remove the stored Mastodon token."""
|
|
||||||
from app.services.ap.mastodon import delete_token
|
|
||||||
await asyncio.to_thread(delete_token, settings.DB_PATH, session.user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def mastodon_status(session: CloudUser = Depends(get_session)):
|
|
||||||
"""Return connection status and instance URL (no token value)."""
|
|
||||||
from app.services.ap.mastodon import get_token
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
get_token,
|
|
||||||
settings.DB_PATH,
|
|
||||||
session.user_id,
|
|
||||||
settings.AP_TOKEN_ENCRYPTION_KEY,
|
|
||||||
)
|
|
||||||
if result is None:
|
|
||||||
return {"connected": False, "instance_url": None}
|
|
||||||
instance_url, _ = result
|
|
||||||
return {"connected": True, "instance_url": instance_url}
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
"""Recipe scanner endpoints (kiwi#9).
|
|
||||||
|
|
||||||
POST /recipes/scan -- scan photo(s) -> structured recipe JSON (not saved)
|
|
||||||
POST /recipes/scan/save -- save a confirmed scanned recipe to user_recipes
|
|
||||||
GET /recipes/user -- list user-created recipes
|
|
||||||
GET /recipes/user/{id} -- get a single user recipe
|
|
||||||
DELETE /recipes/user/{id} -- delete a user recipe
|
|
||||||
|
|
||||||
BSL 1.1 -- recipe_scan requires Paid tier or BYOK.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json as _json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.recipe_scan import (
|
|
||||||
ScannedIngredientSchema,
|
|
||||||
ScannedRecipeResponse,
|
|
||||||
ScannedRecipeSaveRequest,
|
|
||||||
UserRecipeResponse,
|
|
||||||
)
|
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_ALLOWED_MIME_TYPES = {
|
|
||||||
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"
|
|
||||||
}
|
|
||||||
_MAX_FILE_SIZE_MB = 20
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_upload_temp(file: UploadFile) -> Path:
|
|
||||||
"""Write upload to a temp path under UPLOAD_DIR. Caller is responsible for cleanup."""
|
|
||||||
settings.ensure_dirs()
|
|
||||||
dest = settings.UPLOAD_DIR / f"scan_{uuid.uuid4()}_{file.filename}"
|
|
||||||
async with aiofiles.open(dest, "wb") as f:
|
|
||||||
await f.write(await file.read())
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def _result_to_response(result) -> ScannedRecipeResponse:
|
|
||||||
"""Convert ScannedRecipeResult (dataclass) to Pydantic response schema."""
|
|
||||||
return ScannedRecipeResponse(
|
|
||||||
title=result.title,
|
|
||||||
subtitle=result.subtitle,
|
|
||||||
servings=result.servings,
|
|
||||||
cook_time=result.cook_time,
|
|
||||||
source_note=result.source_note,
|
|
||||||
ingredients=[
|
|
||||||
ScannedIngredientSchema(
|
|
||||||
name=i.name,
|
|
||||||
qty=i.qty,
|
|
||||||
unit=i.unit,
|
|
||||||
raw=i.raw,
|
|
||||||
in_pantry=i.in_pantry,
|
|
||||||
)
|
|
||||||
for i in result.ingredients
|
|
||||||
],
|
|
||||||
steps=result.steps,
|
|
||||||
notes=result.notes,
|
|
||||||
tags=result.tags,
|
|
||||||
pantry_match_pct=result.pantry_match_pct,
|
|
||||||
confidence=result.confidence,
|
|
||||||
warnings=result.warnings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_user_recipe(row: dict) -> UserRecipeResponse:
|
|
||||||
"""Convert a store row dict to UserRecipeResponse."""
|
|
||||||
return UserRecipeResponse(
|
|
||||||
id=row["id"],
|
|
||||||
title=row["title"],
|
|
||||||
subtitle=row.get("subtitle"),
|
|
||||||
servings=row.get("servings"),
|
|
||||||
cook_time=row.get("cook_time"),
|
|
||||||
source_note=row.get("source_note"),
|
|
||||||
ingredients=[
|
|
||||||
ScannedIngredientSchema(**i) if isinstance(i, dict) else i
|
|
||||||
for i in (row.get("ingredients") or [])
|
|
||||||
],
|
|
||||||
steps=row.get("steps") or [],
|
|
||||||
notes=row.get("notes"),
|
|
||||||
tags=row.get("tags") or [],
|
|
||||||
source=row.get("source", "manual"),
|
|
||||||
pantry_match_pct=row.get("pantry_match_pct"),
|
|
||||||
created_at=row["created_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Scan endpoint ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/scan", response_model=ScannedRecipeResponse)
|
|
||||||
async def scan_recipe(
|
|
||||||
files: Annotated[list[UploadFile], File(...)],
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Scan one or more recipe photos and return a structured recipe for review.
|
|
||||||
|
|
||||||
Accepts 1-4 images. Multi-page recipes (e.g. ingredients on page 1,
|
|
||||||
directions on page 2) work best when all pages are submitted together.
|
|
||||||
|
|
||||||
The response is NOT saved automatically -- the user reviews and edits it,
|
|
||||||
then calls POST /recipes/scan/save to persist.
|
|
||||||
|
|
||||||
Tier: Paid (or BYOK).
|
|
||||||
"""
|
|
||||||
if not can_use("recipe_scan", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=(
|
|
||||||
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
|
|
||||||
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
raise HTTPException(status_code=422, detail="At least one image file is required.")
|
|
||||||
if len(files) > 4:
|
|
||||||
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
|
|
||||||
|
|
||||||
for f in files:
|
|
||||||
ct = (f.content_type or "").lower()
|
|
||||||
if ct and ct not in _ALLOWED_MIME_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save uploads to temp files
|
|
||||||
saved_paths: list[Path] = []
|
|
||||||
try:
|
|
||||||
for f in files:
|
|
||||||
saved_paths.append(await _save_upload_temp(f))
|
|
||||||
|
|
||||||
# Get pantry item names for cross-reference
|
|
||||||
inventory = await asyncio.to_thread(store.list_inventory)
|
|
||||||
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
|
|
||||||
|
|
||||||
# Run scanner (blocks on VLM -- use to_thread)
|
|
||||||
from app.services.recipe.recipe_scanner import RecipeScanner
|
|
||||||
|
|
||||||
def _run_scan():
|
|
||||||
scanner = RecipeScanner()
|
|
||||||
return scanner.scan(saved_paths, pantry_names=pantry_names)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await asyncio.to_thread(_run_scan)
|
|
||||||
except ValueError as exc:
|
|
||||||
msg = str(exc)
|
|
||||||
if "not_a_recipe" in msg:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail="The image does not appear to contain a recipe. "
|
|
||||||
"Please photograph a recipe card, cookbook page, or handwritten note.",
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=422, detail=msg)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
msg = str(exc)
|
|
||||||
logger.warning("Recipe scanner unavailable: %s", msg)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail=(
|
|
||||||
"The recipe scanner is temporarily unavailable — "
|
|
||||||
"no vision backend could be reached. "
|
|
||||||
"Try again in a few minutes, or contact support if this persists."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _result_to_response(result)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up temp files
|
|
||||||
for p in saved_paths:
|
|
||||||
try:
|
|
||||||
p.unlink(missing_ok=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── SSE scan endpoint ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _scan_recipe_sse(saved_paths: list[Path], pantry_names: list[str]):
|
|
||||||
"""Async generator yielding SSE events for a recipe scan.
|
|
||||||
|
|
||||||
Emits progress events while the vision service allocates and runs, then a
|
|
||||||
final "done" event containing the full recipe payload (same shape as the
|
|
||||||
ScannedRecipeResponse from POST /scan).
|
|
||||||
|
|
||||||
Events:
|
|
||||||
{"status": "allocating", "message": "..."}
|
|
||||||
{"status": "scanning", "message": "..."}
|
|
||||||
{"status": "structuring","message": "..."}
|
|
||||||
{"status": "done", "recipe": {...}}
|
|
||||||
{"status": "error", "message": "..."}
|
|
||||||
"""
|
|
||||||
queue: asyncio.Queue = asyncio.Queue()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
def _run() -> None:
|
|
||||||
def cb(status: str, message: str) -> None:
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": status, "message": message})
|
|
||||||
try:
|
|
||||||
from app.services.recipe.recipe_scanner import RecipeScanner
|
|
||||||
result = RecipeScanner().scan(saved_paths, pantry_names=pantry_names, progress_cb=cb)
|
|
||||||
recipe_dict = _result_to_response(result).model_dump()
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "done", "recipe": recipe_dict})
|
|
||||||
except ValueError as exc:
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
|
||||||
except RuntimeError as exc:
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unexpected error in recipe scan thread")
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})
|
|
||||||
|
|
||||||
scan_task = asyncio.ensure_future(asyncio.to_thread(_run))
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
event = await asyncio.wait_for(queue.get(), timeout=180.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
yield f"data: {_json.dumps({'status': 'error', 'message': 'Scan timed out after 3 minutes.'})}\n\n"
|
|
||||||
break
|
|
||||||
yield f"data: {_json.dumps(event)}\n\n"
|
|
||||||
if event["status"] in ("done", "error"):
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
if not scan_task.done():
|
|
||||||
scan_task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan/stream")
|
|
||||||
async def scan_recipe_stream(
|
|
||||||
files: Annotated[list[UploadFile], File(...)],
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Scan recipe photos and stream SSE progress events during model load.
|
|
||||||
|
|
||||||
Use this endpoint instead of POST /scan when you need live feedback during
|
|
||||||
cold-start model loading (first request after a GPU-idle period can take
|
|
||||||
30-60 seconds for cf-docuvision to warm up).
|
|
||||||
|
|
||||||
Tier: Paid (or BYOK) — same gate as POST /scan.
|
|
||||||
"""
|
|
||||||
if not can_use("recipe_scan", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=(
|
|
||||||
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
|
|
||||||
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
raise HTTPException(status_code=422, detail="At least one image file is required.")
|
|
||||||
if len(files) > 4:
|
|
||||||
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
|
|
||||||
|
|
||||||
for f in files:
|
|
||||||
ct = (f.content_type or "").lower()
|
|
||||||
if ct and ct not in _ALLOWED_MIME_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
|
|
||||||
)
|
|
||||||
|
|
||||||
saved_paths: list[Path] = []
|
|
||||||
for f in files:
|
|
||||||
saved_paths.append(await _save_upload_temp(f))
|
|
||||||
|
|
||||||
inventory = await asyncio.to_thread(store.list_inventory)
|
|
||||||
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
|
|
||||||
|
|
||||||
async def generate():
|
|
||||||
try:
|
|
||||||
async for chunk in _scan_recipe_sse(saved_paths, pantry_names):
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
for p in saved_paths:
|
|
||||||
try:
|
|
||||||
p.unlink(missing_ok=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Save endpoint ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/scan/save", response_model=UserRecipeResponse, status_code=201)
|
|
||||||
async def save_scanned_recipe(
|
|
||||||
body: ScannedRecipeSaveRequest,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Save a user-reviewed (possibly edited) scanned recipe.
|
|
||||||
|
|
||||||
The body is the ScannedRecipeResponse (or a user-edited version of it).
|
|
||||||
Returns the persisted UserRecipe with an assigned ID.
|
|
||||||
|
|
||||||
Tier: Free (saving your own recipe doesn't require vision access).
|
|
||||||
"""
|
|
||||||
def _save():
|
|
||||||
return store.create_user_recipe(
|
|
||||||
title=body.title,
|
|
||||||
subtitle=body.subtitle,
|
|
||||||
servings=body.servings,
|
|
||||||
cook_time=body.cook_time,
|
|
||||||
source_note=body.source_note,
|
|
||||||
ingredients=[i.model_dump() for i in body.ingredients],
|
|
||||||
steps=body.steps,
|
|
||||||
notes=body.notes,
|
|
||||||
tags=body.tags,
|
|
||||||
source=body.source,
|
|
||||||
pantry_match_pct=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
row = await asyncio.to_thread(_save)
|
|
||||||
return _row_to_user_recipe(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ── User recipe list / get / delete ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/user", response_model=list[UserRecipeResponse])
|
|
||||||
async def list_user_recipes(
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""List all user-created recipes (scanned + manually entered), newest first."""
|
|
||||||
rows = await asyncio.to_thread(store.list_user_recipes)
|
|
||||||
return [_row_to_user_recipe(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/{recipe_id}", response_model=UserRecipeResponse)
|
|
||||||
async def get_user_recipe(
|
|
||||||
recipe_id: int,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Get a single user recipe by ID."""
|
|
||||||
row = await asyncio.to_thread(store.get_user_recipe, recipe_id)
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="User recipe not found.")
|
|
||||||
return _row_to_user_recipe(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/user/{recipe_id}", status_code=204)
|
|
||||||
async def delete_user_recipe(
|
|
||||||
recipe_id: int,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Delete a user recipe by ID."""
|
|
||||||
deleted = await asyncio.to_thread(store.delete_user_recipe, recipe_id)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="User recipe not found.")
|
|
||||||
return JSONResponse(status_code=204, content=None)
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
# app/api/endpoints/recipe_tags.py
|
|
||||||
"""Community subcategory tagging for corpus recipes.
|
|
||||||
|
|
||||||
Users can tag a recipe they're viewing with a domain/category/subcategory
|
|
||||||
from the browse taxonomy. Tags require a community pseudonym and reach
|
|
||||||
public visibility once two independent users have tagged the same recipe
|
|
||||||
to the same location (upvotes >= 2).
|
|
||||||
|
|
||||||
All tiers may submit and upvote tags — community contribution is free.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
from app.api.endpoints.session import get_session
|
|
||||||
from app.cloud_session import CloudUser
|
|
||||||
from app.services.recipe.browser_domains import DOMAINS
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
ACCEPT_THRESHOLD = 2
|
|
||||||
|
|
||||||
|
|
||||||
# ── Request / response models ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TagSubmitBody(BaseModel):
|
|
||||||
recipe_id: int
|
|
||||||
domain: str
|
|
||||||
category: str
|
|
||||||
subcategory: str | None = None
|
|
||||||
pseudonym: str
|
|
||||||
|
|
||||||
|
|
||||||
class TagResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
recipe_id: int
|
|
||||||
domain: str
|
|
||||||
category: str
|
|
||||||
subcategory: str | None
|
|
||||||
pseudonym: str
|
|
||||||
upvotes: int
|
|
||||||
accepted: bool
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(row: dict) -> TagResponse:
|
|
||||||
return TagResponse(
|
|
||||||
id=row["id"],
|
|
||||||
recipe_id=int(row["recipe_ref"]),
|
|
||||||
domain=row["domain"],
|
|
||||||
category=row["category"],
|
|
||||||
subcategory=row.get("subcategory"),
|
|
||||||
pseudonym=row["pseudonym"],
|
|
||||||
upvotes=row["upvotes"],
|
|
||||||
accepted=row["upvotes"] >= ACCEPT_THRESHOLD,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_location(domain: str, category: str, subcategory: str | None) -> None:
|
|
||||||
"""Raise 422 if (domain, category, subcategory) isn't in the known taxonomy."""
|
|
||||||
if domain not in DOMAINS:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown domain '{domain}'.")
|
|
||||||
cats = DOMAINS[domain].get("categories", {})
|
|
||||||
if category not in cats:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
|
||||||
)
|
|
||||||
if subcategory is not None:
|
|
||||||
subcats = cats[category].get("subcategories", {})
|
|
||||||
if subcategory not in subcats:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unknown subcategory '{subcategory}' in '{domain}/{category}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/recipes/community-tags/{recipe_id}", response_model=list[TagResponse])
|
|
||||||
async def list_recipe_tags(
|
|
||||||
recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[TagResponse]:
|
|
||||||
"""Return all community tags for a corpus recipe, accepted ones first."""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return []
|
|
||||||
tags = store.list_tags_for_recipe(recipe_id)
|
|
||||||
return [_to_response(r) for r in tags]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recipes/community-tags", response_model=TagResponse, status_code=201)
|
|
||||||
async def submit_recipe_tag(
|
|
||||||
body: TagSubmitBody,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> TagResponse:
|
|
||||||
"""Tag a corpus recipe with a browse taxonomy location.
|
|
||||||
|
|
||||||
Requires the user to have a community pseudonym set. Returns 409 if this
|
|
||||||
user has already tagged this recipe to this exact location.
|
|
||||||
"""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Community features are not available on this instance.",
|
|
||||||
)
|
|
||||||
|
|
||||||
_validate_location(body.domain, body.category, body.subcategory)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import psycopg2.errors # type: ignore[import]
|
|
||||||
row = store.submit_recipe_tag(
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
domain=body.domain,
|
|
||||||
category=body.category,
|
|
||||||
subcategory=body.subcategory,
|
|
||||||
pseudonym=body.pseudonym,
|
|
||||||
)
|
|
||||||
return _to_response(row)
|
|
||||||
except Exception as exc:
|
|
||||||
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="You have already tagged this recipe to this location.",
|
|
||||||
)
|
|
||||||
logger.error("submit_recipe_tag failed: %s", exc)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to submit tag.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recipes/community-tags/{tag_id}/upvote", response_model=TagResponse)
|
|
||||||
async def upvote_recipe_tag(
|
|
||||||
tag_id: int,
|
|
||||||
pseudonym: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> TagResponse:
|
|
||||||
"""Upvote an existing community tag.
|
|
||||||
|
|
||||||
Returns 409 if this pseudonym has already voted on this tag.
|
|
||||||
Returns 404 if the tag doesn't exist.
|
|
||||||
"""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community features unavailable.")
|
|
||||||
|
|
||||||
tag_row = store.get_recipe_tag_by_id(tag_id)
|
|
||||||
if tag_row is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_upvotes = store.upvote_recipe_tag(tag_id, pseudonym)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
|
||||||
except Exception as exc:
|
|
||||||
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
|
||||||
raise HTTPException(status_code=409, detail="You have already voted on this tag.")
|
|
||||||
logger.error("upvote_recipe_tag failed: %s", exc)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to upvote tag.")
|
|
||||||
|
|
||||||
tag_row["upvotes"] = new_upvotes
|
|
||||||
return _to_response(tag_row)
|
|
||||||
|
|
@ -6,9 +6,7 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import json as _json_mod
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||||
|
|
||||||
|
|
@ -16,22 +14,13 @@ log = logging.getLogger(__name__)
|
||||||
from app.db.session import get_store
|
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 (
|
from app.models.schemas.recipe import (
|
||||||
AskRequest,
|
|
||||||
AskResponse,
|
|
||||||
AskRecipeHit,
|
|
||||||
AssemblyTemplateOut,
|
AssemblyTemplateOut,
|
||||||
BuildRequest,
|
BuildRequest,
|
||||||
LeftoversResponse,
|
|
||||||
RecipeJobStatus,
|
|
||||||
RecipeRequest,
|
RecipeRequest,
|
||||||
RecipeResult,
|
RecipeResult,
|
||||||
RecipeSuggestion,
|
RecipeSuggestion,
|
||||||
RoleCandidatesResponse,
|
RoleCandidatesResponse,
|
||||||
StreamTokenRequest,
|
|
||||||
StreamTokenResponse,
|
|
||||||
)
|
)
|
||||||
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
|
|
||||||
from app.api.endpoints.imitate import _build_recipe_prompt
|
|
||||||
from app.services.recipe.assembly_recipes import (
|
from app.services.recipe.assembly_recipes import (
|
||||||
build_from_selection,
|
build_from_selection,
|
||||||
get_role_candidates,
|
get_role_candidates,
|
||||||
|
|
@ -39,16 +28,11 @@ from app.services.recipe.assembly_recipes import (
|
||||||
)
|
)
|
||||||
from app.services.recipe.browser_domains import (
|
from app.services.recipe.browser_domains import (
|
||||||
DOMAINS,
|
DOMAINS,
|
||||||
category_has_subcategories,
|
|
||||||
get_category_names,
|
get_category_names,
|
||||||
get_domain_labels,
|
get_domain_labels,
|
||||||
get_keywords_for_category,
|
get_keywords_for_category,
|
||||||
get_keywords_for_subcategory,
|
|
||||||
get_subcategory_names,
|
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
from app.services.recipe.time_effort import parse_time_effort
|
|
||||||
from app.services.recipe.sensory import build_sensory_exclude
|
|
||||||
from app.services.heimdall_orch import check_orch_budget
|
from app.services.heimdall_orch import check_orch_budget
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
@ -70,122 +54,12 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
def _build_stream_prompt(db_path: Path, level: int) -> str:
|
@router.post("/suggest", response_model=RecipeResult)
|
||||||
"""Fetch pantry + user settings from DB and build the recipe prompt.
|
|
||||||
|
|
||||||
Runs in a thread (called via asyncio.to_thread) so it can use sync Store.
|
|
||||||
"""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
items = store.list_inventory(status="available")
|
|
||||||
pantry_names = [i["product_name"] for i in items if i.get("product_name")]
|
|
||||||
|
|
||||||
today = datetime.date.today()
|
|
||||||
expiring_names = [
|
|
||||||
i["product_name"]
|
|
||||||
for i in items
|
|
||||||
if i.get("product_name")
|
|
||||||
and i.get("expiry_date")
|
|
||||||
and (datetime.date.fromisoformat(i["expiry_date"]) - today).days <= 3
|
|
||||||
]
|
|
||||||
|
|
||||||
settings: dict = {}
|
|
||||||
try:
|
|
||||||
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
|
|
||||||
settings = {r["key"]: r["value"] for r in rows}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
constraints_raw = settings.get("dietary_constraints", "")
|
|
||||||
constraints = [c.strip() for c in constraints_raw.split(",") if c.strip()] if constraints_raw else []
|
|
||||||
allergies_raw = settings.get("allergies", "")
|
|
||||||
allergies = [a.strip() for a in allergies_raw.split(",") if a.strip()] if allergies_raw else []
|
|
||||||
|
|
||||||
return _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def _stream_recipe_sse(db_path: Path, req: RecipeRequest):
|
|
||||||
"""Async generator that yields SSE events for a streaming recipe request.
|
|
||||||
|
|
||||||
Phase 1 (thread): classify pantry items using a temporary Store.
|
|
||||||
Phase 2 (async): stream tokens from LLM via LLMRecipeGenerator.stream_generate().
|
|
||||||
"""
|
|
||||||
def _prep(db_path: Path) -> tuple[list, list[str]]:
|
|
||||||
from app.services.recipe.element_classifier import IngredientClassifier
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
classifier = IngredientClassifier(store)
|
|
||||||
profiles = classifier.classify_batch(req.pantry_items)
|
|
||||||
gaps = classifier.identify_gaps(profiles)
|
|
||||||
return profiles, gaps
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
profiles, gaps = await asyncio.to_thread(_prep, db_path)
|
|
||||||
except Exception as exc:
|
|
||||||
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
|
|
||||||
return
|
|
||||||
|
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
|
||||||
gen = LLMRecipeGenerator(None)
|
|
||||||
try:
|
|
||||||
async for token in gen.stream_generate(req, profiles, gaps):
|
|
||||||
yield f"data: {_json_mod.dumps({'chunk': token})}\n\n"
|
|
||||||
yield f"data: {_json_mod.dumps({'done': True})}\n\n"
|
|
||||||
except Exception as exc:
|
|
||||||
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
|
||||||
"""Queue an async recipe_llm job and return 202 with job_id.
|
|
||||||
|
|
||||||
Falls back to synchronous generation in CLOUD_MODE (scheduler polls only
|
|
||||||
the shared settings DB, not per-user DBs — see snipe#45 / kiwi backlog).
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from app.cloud_session import CLOUD_MODE
|
|
||||||
from app.tasks.runner import insert_task
|
|
||||||
|
|
||||||
if CLOUD_MODE:
|
|
||||||
log.warning("recipe_llm async jobs not supported in CLOUD_MODE — falling back to sync")
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
||||||
return result
|
|
||||||
|
|
||||||
job_id = f"rec_{uuid.uuid4().hex}"
|
|
||||||
|
|
||||||
def _create(db_path: Path) -> int:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
row = store.create_recipe_job(job_id, session.user_id, req.model_dump_json())
|
|
||||||
return row["id"]
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
int_id = await asyncio.to_thread(_create, session.db)
|
|
||||||
params_json = json.dumps({"job_id": job_id})
|
|
||||||
task_id, is_new = insert_task(session.db, "recipe_llm", int_id, params=params_json)
|
|
||||||
if is_new:
|
|
||||||
from app.tasks.scheduler import get_scheduler
|
|
||||||
get_scheduler(session.db).enqueue(task_id, "recipe_llm", int_id, params_json)
|
|
||||||
|
|
||||||
return JSONResponse(content={"job_id": job_id, "status": "queued"}, status_code=202)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest")
|
|
||||||
async def suggest_recipes(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
async_mode: bool = Query(default=False, alias="async"),
|
|
||||||
stream: bool = Query(default=False),
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
):
|
) -> RecipeResult:
|
||||||
log.info("recipes auth=%s tier=%s level=%s", _auth_label(session.user_id), session.tier, req.level)
|
log.info("recipes auth=%s tier=%s level=%s", _auth_label(session.user_id), session.tier, req.level)
|
||||||
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
||||||
# Also read stored unit_system preference; default to metric if not set.
|
# Also read stored unit_system preference; default to metric if not set.
|
||||||
|
|
@ -218,92 +92,12 @@ async def suggest_recipes(
|
||||||
req = req.model_copy(update={"level": 2})
|
req = req.model_copy(update={"level": 2})
|
||||||
orch_fallback = True
|
orch_fallback = True
|
||||||
|
|
||||||
if stream and req.level in (3, 4):
|
|
||||||
return StreamingResponse(
|
|
||||||
_stream_recipe_sse(session.db, req),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if req.level in (3, 4) and async_mode:
|
|
||||||
return await _enqueue_recipe_job(session, req)
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
||||||
if orch_fallback:
|
if orch_fallback:
|
||||||
result = result.model_copy(update={"orch_fallback": True})
|
result = result.model_copy(update={"orch_fallback": True})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stream-token", response_model=StreamTokenResponse)
|
|
||||||
async def get_stream_token(
|
|
||||||
req: StreamTokenRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> StreamTokenResponse:
|
|
||||||
"""Issue a one-time stream token for LLM recipe generation.
|
|
||||||
|
|
||||||
Tier-gated (Paid or BYOK). Builds the prompt from pantry + user settings,
|
|
||||||
then calls the cf-orch coordinator to obtain a stream URL. Returns
|
|
||||||
immediately — the frontend opens EventSource to the stream URL directly.
|
|
||||||
"""
|
|
||||||
if not can_use("recipe_suggestions", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Streaming recipe generation requires Paid tier or a configured LLM backend.",
|
|
||||||
)
|
|
||||||
if req.level == 4 and not req.wildcard_confirmed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Level 4 (Wildcard) streaming requires wildcard_confirmed=true.",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = await asyncio.to_thread(_build_stream_prompt, session.db, req.level)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await coordinator_authorize(prompt=prompt, caller="kiwi-recipe", ttl_s=300)
|
|
||||||
except CoordinatorError as exc:
|
|
||||||
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
|
||||||
|
|
||||||
return StreamTokenResponse(
|
|
||||||
stream_url=result.stream_url,
|
|
||||||
token=result.token,
|
|
||||||
expires_in_s=result.expires_in_s,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}", response_model=RecipeJobStatus)
|
|
||||||
async def get_recipe_job_status(
|
|
||||||
job_id: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> RecipeJobStatus:
|
|
||||||
"""Poll the status of an async recipe generation job.
|
|
||||||
|
|
||||||
Returns 404 when job_id is unknown or belongs to a different user.
|
|
||||||
On status='done' with suggestions=[], the LLM returned empty — client
|
|
||||||
should show a 'no recipe generated, try again' message.
|
|
||||||
"""
|
|
||||||
def _get(db_path: Path) -> dict | None:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_recipe_job(job_id, session.user_id)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
row = await asyncio.to_thread(_get, session.db)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Job not found.")
|
|
||||||
|
|
||||||
result = None
|
|
||||||
if row["status"] == "done" and row["result"]:
|
|
||||||
result = RecipeResult.model_validate_json(row["result"])
|
|
||||||
|
|
||||||
return RecipeJobStatus(
|
|
||||||
job_id=row["job_id"],
|
|
||||||
status=row["status"],
|
|
||||||
result=result,
|
|
||||||
error=row["error"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/domains")
|
@router.get("/browse/domains")
|
||||||
async def list_browse_domains(
|
async def list_browse_domains(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
|
|
@ -321,42 +115,15 @@ async def list_browse_categories(
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
cat_names = get_category_names(domain)
|
keywords_by_category = {
|
||||||
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
cat: get_keywords_for_category(domain, cat)
|
||||||
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
|
for cat in get_category_names(domain)
|
||||||
|
|
||||||
def _get(db_path: Path) -> list[dict]:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_browser_categories(domain, keywords_by_category, has_subs)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/{domain}/{category}/subcategories")
|
|
||||||
async def list_browse_subcategories(
|
|
||||||
domain: str,
|
|
||||||
category: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
|
|
||||||
if domain not in DOMAINS:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
|
||||||
if not category_has_subcategories(domain, category):
|
|
||||||
return []
|
|
||||||
|
|
||||||
subcat_names = get_subcategory_names(domain, category)
|
|
||||||
keywords_by_subcat = {
|
|
||||||
sub: get_keywords_for_subcategory(domain, category, sub)
|
|
||||||
for sub in subcat_names
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get(db_path: Path) -> list[dict]:
|
def _get(db_path: Path) -> list[dict]:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
return store.get_browser_categories(domain, keywords_by_category)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
@ -370,33 +137,16 @@ async def browse_recipes(
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
subcategory: Annotated[str | None, Query()] = None,
|
|
||||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
|
||||||
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc|match)$")] = "default",
|
|
||||||
required_ingredient: Annotated[str | None, Query(max_length=100)] = None,
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
"""Return a paginated list of recipes for a domain/category.
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
Pass pantry_items as a comma-separated string to receive match_pct
|
||||||
Pass subcategory to narrow within a category that has subcategories.
|
badges on each result.
|
||||||
Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc/match).
|
|
||||||
sort=match orders by pantry coverage DESC; falls back to default when no pantry_items.
|
|
||||||
Pass required_ingredient to restrict results to recipes that must include that ingredient.
|
|
||||||
"""
|
"""
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
if category == "_all":
|
|
||||||
keywords = None # unfiltered browse
|
|
||||||
elif subcategory:
|
|
||||||
keywords = get_keywords_for_subcategory(domain, category, subcategory)
|
|
||||||
if not keywords:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
keywords = get_keywords_for_category(domain, category)
|
keywords = get_keywords_for_category(domain, category)
|
||||||
if not keywords:
|
if not keywords:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -413,90 +163,12 @@ async def browse_recipes(
|
||||||
def _browse(db_path: Path) -> dict:
|
def _browse(db_path: Path) -> dict:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
# Load sensory preferences
|
|
||||||
sensory_prefs_json = store.get_setting("sensory_preferences")
|
|
||||||
sensory_exclude = build_sensory_exclude(sensory_prefs_json)
|
|
||||||
|
|
||||||
result = store.browse_recipes(
|
result = store.browse_recipes(
|
||||||
keywords=keywords,
|
keywords=keywords,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
pantry_items=pantry_list,
|
pantry_items=pantry_list,
|
||||||
q=q or None,
|
|
||||||
sort=sort,
|
|
||||||
sensory_exclude=sensory_exclude,
|
|
||||||
required_ingredient=required_ingredient or None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Attach time/effort signals to each browse result ────────────────
|
|
||||||
import json as _json
|
|
||||||
for recipe_row in result.get("recipes", []):
|
|
||||||
directions_raw = recipe_row.get("directions") or []
|
|
||||||
if isinstance(directions_raw, str):
|
|
||||||
try:
|
|
||||||
directions_raw = _json.loads(directions_raw)
|
|
||||||
except Exception:
|
|
||||||
directions_raw = []
|
|
||||||
if directions_raw:
|
|
||||||
_profile = parse_time_effort(
|
|
||||||
directions_raw,
|
|
||||||
ingredients=recipe_row.get("ingredients") or [],
|
|
||||||
ingredient_names=recipe_row.get("ingredient_names") or [],
|
|
||||||
)
|
|
||||||
recipe_row["active_min"] = _profile.active_min
|
|
||||||
recipe_row["passive_min"] = _profile.passive_min
|
|
||||||
else:
|
|
||||||
recipe_row["active_min"] = None
|
|
||||||
recipe_row["passive_min"] = None
|
|
||||||
# Remove directions from browse payload — not needed by the card UI
|
|
||||||
recipe_row.pop("directions", None)
|
|
||||||
|
|
||||||
# Community tag fallback: if FTS returned nothing for a subcategory,
|
|
||||||
# check whether accepted community tags exist for this location and
|
|
||||||
# fetch those corpus recipes directly by ID.
|
|
||||||
if result["total"] == 0 and subcategory and keywords:
|
|
||||||
try:
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
cs = _get_community_store()
|
|
||||||
if cs is not None:
|
|
||||||
community_ids = cs.get_accepted_recipe_ids_for_subcategory(
|
|
||||||
domain=domain,
|
|
||||||
category=category,
|
|
||||||
subcategory=subcategory,
|
|
||||||
)
|
|
||||||
if community_ids:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
paged_ids = community_ids[offset: offset + page_size]
|
|
||||||
recipes = store.fetch_recipes_by_ids(paged_ids, pantry_list)
|
|
||||||
import json as _json_c
|
|
||||||
for recipe_row in recipes:
|
|
||||||
directions_raw = recipe_row.get("directions") or []
|
|
||||||
if isinstance(directions_raw, str):
|
|
||||||
try:
|
|
||||||
directions_raw = _json_c.loads(directions_raw)
|
|
||||||
except Exception:
|
|
||||||
directions_raw = []
|
|
||||||
if directions_raw:
|
|
||||||
_profile = parse_time_effort(
|
|
||||||
directions_raw,
|
|
||||||
ingredients=recipe_row.get("ingredients") or [],
|
|
||||||
ingredient_names=recipe_row.get("ingredient_names") or [],
|
|
||||||
)
|
|
||||||
recipe_row["active_min"] = _profile.active_min
|
|
||||||
recipe_row["passive_min"] = _profile.passive_min
|
|
||||||
else:
|
|
||||||
recipe_row["active_min"] = None
|
|
||||||
recipe_row["passive_min"] = None
|
|
||||||
recipe_row.pop("directions", None)
|
|
||||||
result = {
|
|
||||||
"recipes": recipes,
|
|
||||||
"total": len(community_ids),
|
|
||||||
"page": page,
|
|
||||||
"community_tagged": True,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("community tag fallback failed: %s", exc)
|
|
||||||
|
|
||||||
store.log_browser_telemetry(
|
store.log_browser_telemetry(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
category=category,
|
category=category,
|
||||||
|
|
@ -600,137 +272,6 @@ async def build_recipe(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
_ASK_STOPWORDS: frozenset[str] = frozenset({
|
|
||||||
"what", "can", "make", "with", "have", "some", "the", "and", "for",
|
|
||||||
"that", "this", "these", "those", "how", "about", "are", "there",
|
|
||||||
"give", "show", "find", "want", "need", "like", "any", "good",
|
|
||||||
"quick", "easy", "simple", "fast", "using", "use", "from", "into",
|
|
||||||
"more", "much", "just", "only", "my", "please", "could", "would",
|
|
||||||
"should", "something", "anything", "everything", "ideas", "idea",
|
|
||||||
"suggest", "meal", "food", "dish", "dishes", "today", "tonight",
|
|
||||||
"tomorrow", "now", "here", "there", "recipes", "recipe", "dinner",
|
|
||||||
"lunch", "breakfast", "snack", "under", "minutes", "hours", "time",
|
|
||||||
"left", "over", "also", "some", "make", "cook", "made", "cooked",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
import re as _re
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_ask_keywords(question: str) -> list[str]:
|
|
||||||
"""Extract food-relevant keywords from a natural language question."""
|
|
||||||
tokens = _re.findall(r"[a-zA-Z]+", question.lower())
|
|
||||||
return [t for t in tokens if len(t) > 3 and t not in _ASK_STOPWORDS]
|
|
||||||
|
|
||||||
|
|
||||||
def _ask_in_thread(db_path: Path, question: str, pantry_items: list[str]) -> AskResponse:
|
|
||||||
"""Run Ask logic in a worker thread.
|
|
||||||
|
|
||||||
Free tier: keyword extraction + FTS ingredient search.
|
|
||||||
Paid tier path: same search, then LLM synthesis over results.
|
|
||||||
The caller handles tier gating and LLM synthesis outside this thread
|
|
||||||
to avoid importing LLMRouter in a sync context.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
keywords = _extract_ask_keywords(question)
|
|
||||||
ingredient_hits: list[dict] = []
|
|
||||||
if keywords:
|
|
||||||
ingredient_hits = store.search_recipes_by_ingredients(keywords, limit=15)
|
|
||||||
|
|
||||||
# Also search by title using the full question text as a substring hint.
|
|
||||||
# browse_recipes q= does title LIKE %q%. Extract the longest keyword
|
|
||||||
# from the question as the title probe (most likely to appear in a title).
|
|
||||||
title_hits: list[dict] = []
|
|
||||||
title_probe = max(keywords, key=len) if keywords else None
|
|
||||||
if title_probe:
|
|
||||||
browse_result = store.browse_recipes(
|
|
||||||
keywords=None,
|
|
||||||
page=1,
|
|
||||||
page_size=12,
|
|
||||||
pantry_items=pantry_items or None,
|
|
||||||
q=title_probe,
|
|
||||||
sort="match" if pantry_items else "default",
|
|
||||||
)
|
|
||||||
title_hits = browse_result.get("recipes", [])
|
|
||||||
|
|
||||||
# Merge by ID; ingredient hits come first (more semantically relevant).
|
|
||||||
seen: set[int] = set()
|
|
||||||
merged: list[dict] = []
|
|
||||||
for row in ingredient_hits + title_hits:
|
|
||||||
rid = row.get("id")
|
|
||||||
if rid is not None and rid not in seen:
|
|
||||||
seen.add(rid)
|
|
||||||
merged.append(row)
|
|
||||||
|
|
||||||
# Compute pantry match_pct if caller sent pantry items.
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else set()
|
|
||||||
|
|
||||||
hits: list[AskRecipeHit] = []
|
|
||||||
for row in merged[:12]:
|
|
||||||
match_pct: float | None = None
|
|
||||||
if pantry_set:
|
|
||||||
raw_names = row.get("ingredient_names") or []
|
|
||||||
if isinstance(raw_names, str):
|
|
||||||
try:
|
|
||||||
raw_names = _json.loads(raw_names)
|
|
||||||
except Exception:
|
|
||||||
raw_names = []
|
|
||||||
if raw_names:
|
|
||||||
covered = sum(
|
|
||||||
1 for n in raw_names
|
|
||||||
if any(p in n.lower() for p in pantry_set)
|
|
||||||
)
|
|
||||||
match_pct = round(covered / len(raw_names), 2)
|
|
||||||
hits.append(AskRecipeHit(
|
|
||||||
id=row["id"],
|
|
||||||
title=row.get("title", ""),
|
|
||||||
category=row.get("category"),
|
|
||||||
match_pct=match_pct,
|
|
||||||
))
|
|
||||||
|
|
||||||
return AskResponse(answer=None, recipes=hits, tier="free")
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ask", response_model=AskResponse)
|
|
||||||
async def ask_recipes(
|
|
||||||
req: AskRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> AskResponse:
|
|
||||||
"""Natural-language recipe search with optional LLM synthesis.
|
|
||||||
|
|
||||||
Free tier: keyword extraction from question → FTS ingredient + title search.
|
|
||||||
Paid tier / BYOK: same search, then LLM synthesizes a short conversational answer.
|
|
||||||
"""
|
|
||||||
result = await asyncio.to_thread(_ask_in_thread, session.db, req.question, req.pantry_items)
|
|
||||||
|
|
||||||
# LLM synthesis: only for paid/premium/ultra tiers, not "local" dev tier.
|
|
||||||
# Wrapped in wait_for so an unresponsive model degrades gracefully to recipe list only.
|
|
||||||
paid_tier = session.tier in ("paid", "premium", "ultra")
|
|
||||||
if (paid_tier or session.has_byok) and result.recipes:
|
|
||||||
recipe_titles = ", ".join(r.title for r in result.recipes[:6])
|
|
||||||
prompt = (
|
|
||||||
f'You are a helpful kitchen assistant. The user asked: "{req.question}"\n\n'
|
|
||||||
f"Matching recipes: {recipe_titles}\n\n"
|
|
||||||
f"Write a brief, friendly 1–2 sentence response suggesting which of these "
|
|
||||||
f"recipes might best fit the question. Be specific and natural."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
answer = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(LLMRouter().complete, prompt),
|
|
||||||
timeout=8.0,
|
|
||||||
)
|
|
||||||
result = result.model_copy(update={"answer": answer.strip() or None, "tier": "paid"})
|
|
||||||
except (Exception, asyncio.TimeoutError) as exc:
|
|
||||||
log.warning("Ask LLM synthesis skipped: %s", exc)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
|
@ -743,111 +284,4 @@ async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session))
|
||||||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
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
|
||||||
# Normalize corpus record into RecipeSuggestion shape so RecipeDetailPanel
|
|
||||||
# can render it without knowing it came from a direct DB lookup.
|
|
||||||
ingredient_names = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(ingredient_names, str):
|
|
||||||
import json as _json
|
|
||||||
try:
|
|
||||||
ingredient_names = _json.loads(ingredient_names)
|
|
||||||
except Exception:
|
|
||||||
ingredient_names = []
|
|
||||||
|
|
||||||
_directions_for_te = recipe.get("directions") or []
|
|
||||||
if isinstance(_directions_for_te, str):
|
|
||||||
import json as _json2
|
|
||||||
try:
|
|
||||||
_directions_for_te = _json2.loads(_directions_for_te)
|
|
||||||
except Exception:
|
|
||||||
_directions_for_te = []
|
|
||||||
|
|
||||||
_ingredients_for_te = recipe.get("ingredients") or []
|
|
||||||
if isinstance(_ingredients_for_te, str):
|
|
||||||
import json as _json3
|
|
||||||
try:
|
|
||||||
_ingredients_for_te = _json3.loads(_ingredients_for_te)
|
|
||||||
except Exception:
|
|
||||||
_ingredients_for_te = []
|
|
||||||
|
|
||||||
_ingredient_names_for_te = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(_ingredient_names_for_te, str):
|
|
||||||
import json as _json4
|
|
||||||
try:
|
|
||||||
_ingredient_names_for_te = _json4.loads(_ingredient_names_for_te)
|
|
||||||
except Exception:
|
|
||||||
_ingredient_names_for_te = []
|
|
||||||
|
|
||||||
if _directions_for_te:
|
|
||||||
_te = parse_time_effort(
|
|
||||||
_directions_for_te,
|
|
||||||
ingredients=_ingredients_for_te,
|
|
||||||
ingredient_names=_ingredient_names_for_te,
|
|
||||||
)
|
|
||||||
_time_effort_out: dict | None = {
|
|
||||||
"active_min": _te.active_min,
|
|
||||||
"passive_min": _te.passive_min,
|
|
||||||
"total_min": _te.total_min,
|
|
||||||
"effort_label": _te.effort_label,
|
|
||||||
"equipment": _te.equipment,
|
|
||||||
"step_analyses": [
|
|
||||||
{
|
|
||||||
"is_passive": sa.is_passive,
|
|
||||||
"detected_minutes": sa.detected_minutes,
|
|
||||||
"prep_min": sa.prep_min,
|
|
||||||
}
|
|
||||||
for sa in _te.step_analyses
|
|
||||||
],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
_time_effort_out = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": recipe.get("id"),
|
|
||||||
"title": recipe.get("title", ""),
|
|
||||||
"match_count": 0,
|
|
||||||
"matched_ingredients": ingredient_names,
|
|
||||||
"missing_ingredients": [],
|
|
||||||
"directions": recipe.get("directions") or [],
|
|
||||||
"prep_notes": [],
|
|
||||||
"swap_candidates": [],
|
|
||||||
"element_coverage": {},
|
|
||||||
"notes": recipe.get("notes") or "",
|
|
||||||
"level": 1,
|
|
||||||
"is_wildcard": False,
|
|
||||||
"nutrition": None,
|
|
||||||
"source_url": recipe.get("source_url") or None,
|
|
||||||
"complexity": None,
|
|
||||||
"estimated_time_min": None,
|
|
||||||
"time_effort": _time_effort_out,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{recipe_id}/leftovers", response_model=LeftoversResponse)
|
|
||||||
async def get_leftovers_shelf_life(
|
|
||||||
recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> LeftoversResponse:
|
|
||||||
"""Return cooked-leftover shelf-life estimate for a recipe.
|
|
||||||
|
|
||||||
Free tier: deterministic lookup (FDA/USDA table).
|
|
||||||
Deterministic path always runs; no tier gate needed.
|
|
||||||
"""
|
|
||||||
def _get(db_path: Path, rid: int) -> LeftoversResponse:
|
|
||||||
from app.services.leftovers_predictor import predict_leftovers_from_row
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
recipe = store.get_recipe(rid)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
if recipe is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
|
||||||
result = predict_leftovers_from_row(recipe)
|
|
||||||
return LeftoversResponse(
|
|
||||||
fridge_days=result.fridge_days,
|
|
||||||
freeze_days=result.freeze_days,
|
|
||||||
freeze_by_day=result.freeze_by_day,
|
|
||||||
storage_advice=result.storage_advice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db, recipe_id)
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
@ -17,13 +16,8 @@ from app.models.schemas.saved_recipe import (
|
||||||
SaveRecipeRequest,
|
SaveRecipeRequest,
|
||||||
UpdateSavedRecipeRequest,
|
UpdateSavedRecipeRequest,
|
||||||
)
|
)
|
||||||
from app.services.magpie_hook import fire_recipe_signal
|
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
||||||
class StyleClassifyResponse(BaseModel):
|
|
||||||
suggested_tags: list[str]
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,7 +35,7 @@ def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
|
||||||
return SavedRecipeSummary(
|
return SavedRecipeSummary(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
recipe_id=row["recipe_id"],
|
recipe_id=row["recipe_id"],
|
||||||
title=row.get("title") or "",
|
title=row.get("title", ""),
|
||||||
saved_at=row["saved_at"],
|
saved_at=row["saved_at"],
|
||||||
notes=row.get("notes"),
|
notes=row.get("notes"),
|
||||||
rating=row.get("rating"),
|
rating=row.get("rating"),
|
||||||
|
|
@ -61,9 +55,7 @@ async def save_recipe(
|
||||||
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
||||||
return _to_summary(row, store)
|
return _to_summary(row, store)
|
||||||
|
|
||||||
result = await asyncio.to_thread(_in_thread, session.db, _run)
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
asyncio.create_task(fire_recipe_signal(session.db, req.recipe_id, req.rating, []))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{recipe_id}", status_code=204)
|
@router.delete("/{recipe_id}", status_code=204)
|
||||||
|
|
@ -90,11 +82,7 @@ async def update_saved_recipe(
|
||||||
)
|
)
|
||||||
return _to_summary(row, store)
|
return _to_summary(row, store)
|
||||||
|
|
||||||
result = await asyncio.to_thread(_in_thread, session.db, _run)
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
asyncio.create_task(
|
|
||||||
fire_recipe_signal(session.db, recipe_id, req.rating, req.style_tags or [])
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SavedRecipeSummary])
|
@router.get("", response_model=list[SavedRecipeSummary])
|
||||||
|
|
@ -110,37 +98,14 @@ async def list_saved_recipes(
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
|
|
||||||
|
|
||||||
# ── style classifier (Paid / BYOK) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{recipe_id}/classify-style", response_model=StyleClassifyResponse)
|
|
||||||
async def classify_style(
|
|
||||||
recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> StyleClassifyResponse:
|
|
||||||
if not can_use("style_classifier", session.tier, getattr(session, "has_byok", False)):
|
|
||||||
raise HTTPException(status_code=403, detail="Style classifier requires Paid tier or BYOK.")
|
|
||||||
|
|
||||||
def _run(store: Store) -> StyleClassifyResponse:
|
|
||||||
recipe = store.get_recipe(recipe_id)
|
|
||||||
if recipe is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
|
||||||
from app.services.recipe.style_classifier import classify_style as _classify
|
|
||||||
tags = _classify(recipe)
|
|
||||||
return StyleClassifyResponse(suggested_tags=tags)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
|
|
||||||
|
|
||||||
# ── collections (Paid) ────────────────────────────────────────────────────────
|
# ── collections (Paid) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/collections", response_model=list[CollectionSummary])
|
@router.get("/collections", response_model=list[CollectionSummary])
|
||||||
async def list_collections(
|
async def list_collections(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> list[CollectionSummary]:
|
) -> list[CollectionSummary]:
|
||||||
# Free users can list (they'll always have zero — creating requires Paid).
|
|
||||||
# Returning 403 here breaks savedStore.load() via Promise.all for non-Paid users.
|
|
||||||
if not can_use("recipe_collections", session.tier):
|
if not can_use("recipe_collections", session.tier):
|
||||||
return []
|
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
||||||
rows = await asyncio.to_thread(
|
rows = await asyncio.to_thread(
|
||||||
_in_thread, session.db, lambda s: s.get_collections()
|
_in_thread, session.db, lambda s: s.get_collections()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import logging
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -23,13 +22,8 @@ def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
|
||||||
Expected log output:
|
Expected log output:
|
||||||
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
||||||
INFO:app.api.endpoints.session: session auth=anon tier=free
|
INFO:app.api.endpoints.session: session auth=anon tier=free
|
||||||
|
|
||||||
E2E test sessions (E2E_TEST_USER_ID) are logged at DEBUG so they don't
|
|
||||||
pollute analytics counts while still being visible when DEBUG=true.
|
|
||||||
"""
|
"""
|
||||||
is_test = bool(settings.E2E_TEST_USER_ID and session.user_id == settings.E2E_TEST_USER_ID)
|
log.info("session auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
||||||
logger = log.debug if is_test else log.info
|
|
||||||
logger("session auth=%s tier=%s%s", _auth_label(session.user_id), session.tier, " e2e=true" if is_test else "")
|
|
||||||
return {
|
return {
|
||||||
"auth": _auth_label(session.user_id),
|
"auth": _auth_label(session.user_id),
|
||||||
"tier": session.tier,
|
"tier": session.tier,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"})
|
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
class SettingBody(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,12 @@ def _in_thread(db_path, fn):
|
||||||
|
|
||||||
# ── List ──────────────────────────────────────────────────────────────────────
|
# ── List ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _locale_from_store(store: Store) -> str:
|
|
||||||
return store.get_setting("shopping_locale") or "us"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ShoppingItemResponse])
|
@router.get("", response_model=list[ShoppingItemResponse])
|
||||||
async def list_shopping_items(
|
async def list_shopping_items(
|
||||||
include_checked: bool = True,
|
include_checked: bool = True,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
):
|
||||||
locale = await asyncio.to_thread(_in_thread, session.db, _locale_from_store)
|
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=locale)
|
|
||||||
items = await asyncio.to_thread(
|
items = await asyncio.to_thread(
|
||||||
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
|
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
|
||||||
)
|
)
|
||||||
|
|
@ -81,9 +75,8 @@ async def list_shopping_items(
|
||||||
async def add_shopping_item(
|
async def add_shopping_item(
|
||||||
body: ShoppingItemCreate,
|
body: ShoppingItemCreate,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
):
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
_in_thread,
|
_in_thread,
|
||||||
session.db,
|
session.db,
|
||||||
|
|
@ -107,7 +100,6 @@ async def add_shopping_item(
|
||||||
async def add_from_recipe(
|
async def add_from_recipe(
|
||||||
body: BulkAddFromRecipeRequest,
|
body: BulkAddFromRecipeRequest,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
):
|
||||||
"""Add missing ingredients from a recipe to the shopping list.
|
"""Add missing ingredients from a recipe to the shopping list.
|
||||||
|
|
||||||
|
|
@ -140,7 +132,7 @@ async def add_from_recipe(
|
||||||
added.append(item)
|
added.append(item)
|
||||||
return added
|
return added
|
||||||
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||||
items = await asyncio.to_thread(_in_thread, session.db, _run)
|
items = await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
return [_enrich(i, builder) for i in items]
|
return [_enrich(i, builder) for i in items]
|
||||||
|
|
||||||
|
|
@ -152,9 +144,8 @@ async def update_shopping_item(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
body: ShoppingItemUpdate,
|
body: ShoppingItemUpdate,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
):
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
_in_thread,
|
_in_thread,
|
||||||
session.db,
|
session.db,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
||||||
from app.api.endpoints.community import router as community_router
|
from app.api.endpoints.community import router as community_router
|
||||||
from app.api.endpoints.corrections import router as corrections_router
|
|
||||||
from app.api.endpoints.mastodon_oauth import router as mastodon_router
|
|
||||||
from app.api.endpoints.recipe_scan import router as recipe_scan_router
|
|
||||||
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -15,9 +11,6 @@ api_router.include_router(ocr.router, prefix="/receipts", tags=
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
# recipe_scan_router registered BEFORE recipes.router so /recipes/scan and /recipes/user
|
|
||||||
# take priority over /recipes/{recipe_id} (which would otherwise match them as int IDs).
|
|
||||||
api_router.include_router(recipe_scan_router, prefix="/recipes", tags=["recipe-scan"])
|
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
|
|
@ -29,6 +22,3 @@ api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=
|
||||||
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
||||||
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||||
api_router.include_router(community_router)
|
api_router.include_router(community_router)
|
||||||
api_router.include_router(recipe_tags_router)
|
|
||||||
api_router.include_router(corrections_router, prefix="/corrections", tags=["corrections"])
|
|
||||||
api_router.include_router(mastodon_router)
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""Cloud session resolution for Kiwi FastAPI.
|
"""Cloud session resolution for Kiwi FastAPI.
|
||||||
|
|
||||||
Delegates JWT validation, Heimdall provisioning, tier resolution, and guest
|
Local mode (CLOUD_MODE unset/false): returns a local CloudUser with no auth
|
||||||
session management to circuitforge_core.CloudSessionFactory. Kiwi-specific
|
checks, full tier access, and DB path pointing to settings.DB_PATH.
|
||||||
CloudUser (per-user DB path, household data, BYOK flag) and DB helpers are
|
|
||||||
kept here.
|
Cloud mode (CLOUD_MODE=true): validates the cf_session JWT injected by Caddy
|
||||||
|
as X-CF-Session, resolves user_id, auto-provisions a free Heimdall license on
|
||||||
|
first visit, fetches the tier, and returns a per-user DB path.
|
||||||
|
|
||||||
FastAPI usage:
|
FastAPI usage:
|
||||||
@app.get("/api/v1/inventory/items")
|
@app.get("/api/v1/inventory/items")
|
||||||
|
|
@ -15,10 +17,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory, detect_byok
|
import uuid
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
from fastapi import Depends, HTTPException, Request, Response
|
from fastapi import Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -27,12 +35,53 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
|
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
|
||||||
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
||||||
|
DIRECTUS_JWT_SECRET: str = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
|
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
||||||
|
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
||||||
|
|
||||||
|
# Dev bypass: comma-separated IPs or CIDR ranges that skip JWT auth.
|
||||||
|
# NEVER set this in production. Intended only for LAN developer testing when
|
||||||
|
# the request doesn't pass through Caddy (which normally injects X-CF-Session).
|
||||||
|
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1
|
||||||
|
import ipaddress as _ipaddress
|
||||||
|
|
||||||
|
_BYPASS_RAW: list[str] = [
|
||||||
|
e.strip()
|
||||||
|
for e in os.environ.get("CLOUD_AUTH_BYPASS_IPS", "").split(",")
|
||||||
|
if e.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
_BYPASS_NETS: list[_ipaddress.IPv4Network | _ipaddress.IPv6Network] = []
|
||||||
|
_BYPASS_IPS: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
if _BYPASS_RAW:
|
||||||
|
_nets, _ips = [], set()
|
||||||
|
for entry in _BYPASS_RAW:
|
||||||
|
try:
|
||||||
|
_nets.append(_ipaddress.ip_network(entry, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
_ips.add(entry) # treat non-parseable entries as bare IPs
|
||||||
|
_BYPASS_NETS = _nets
|
||||||
|
_BYPASS_IPS = frozenset(_ips)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bypass_ip(ip: str) -> bool:
|
||||||
|
if not ip:
|
||||||
|
return False
|
||||||
|
if ip in _BYPASS_IPS:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
addr = _ipaddress.ip_address(ip)
|
||||||
|
return any(addr in net for net in _BYPASS_NETS)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
||||||
|
|
||||||
TIERS = ["free", "paid", "premium", "ultra"]
|
_TIER_CACHE: dict[str, tuple[dict, float]] = {}
|
||||||
|
_TIER_CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
_core = _CoreFactory(product="kiwi", byok_detector=detect_byok)
|
TIERS = ["free", "paid", "premium", "ultra"]
|
||||||
|
|
||||||
|
|
||||||
def _auth_label(user_id: str) -> str:
|
def _auth_label(user_id: str) -> str:
|
||||||
|
|
@ -57,7 +106,73 @@ class CloudUser:
|
||||||
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
||||||
|
|
||||||
|
|
||||||
# ── DB path helpers ───────────────────────────────────────────────────────────
|
# ── JWT validation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _extract_session_token(header_value: str) -> str:
|
||||||
|
m = re.search(r'(?:^|;)\s*cf_session=([^;]+)', header_value)
|
||||||
|
return m.group(1).strip() if m else header_value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_session_jwt(token: str) -> str:
|
||||||
|
"""Validate cf_session JWT and return the Directus user_id."""
|
||||||
|
try:
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
token,
|
||||||
|
DIRECTUS_JWT_SECRET,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"require": ["id", "exp"]},
|
||||||
|
)
|
||||||
|
return payload["id"]
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("JWT validation failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=401, detail="Session invalid or expired")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Heimdall integration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ensure_provisioned(user_id: str) -> None:
|
||||||
|
if not HEIMDALL_ADMIN_TOKEN:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f"{HEIMDALL_URL}/admin/provision",
|
||||||
|
json={"directus_user_id": user_id, "product": "kiwi", "tier": "free"},
|
||||||
|
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool, str | None]:
|
||||||
|
"""Returns (tier, household_id | None, is_household_owner, license_key | None)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _TIER_CACHE.get(user_id)
|
||||||
|
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
||||||
|
entry = cached[0]
|
||||||
|
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False), entry.get("license_key")
|
||||||
|
|
||||||
|
if not HEIMDALL_ADMIN_TOKEN:
|
||||||
|
return "free", None, False, None
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
||||||
|
json={"directus_user_id": user_id, "product": "kiwi"},
|
||||||
|
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
data = resp.json() if resp.ok else {}
|
||||||
|
tier = data.get("tier", "free")
|
||||||
|
household_id = data.get("household_id")
|
||||||
|
is_owner = data.get("is_household_owner", False)
|
||||||
|
license_key = data.get("key_display")
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
||||||
|
tier, household_id, is_owner, license_key = "free", None, False, None
|
||||||
|
|
||||||
|
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner, "license_key": license_key}, now)
|
||||||
|
return tier, household_id, is_owner, license_key
|
||||||
|
|
||||||
|
|
||||||
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
||||||
if household_id:
|
if household_id:
|
||||||
|
|
@ -79,45 +194,112 @@ def _anon_guest_db_path(guest_id: str) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── BYOK detection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_LLM_CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_byok(config_path: Path = _LLM_CONFIG_PATH) -> bool:
|
||||||
|
"""Return True if at least one enabled non-vision LLM backend is configured.
|
||||||
|
|
||||||
|
Reads the same llm.yaml that LLMRouter uses. Local (Ollama, vLLM) and
|
||||||
|
API-key backends both count — the policy is "user is supplying compute",
|
||||||
|
regardless of where that compute lives.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
return any(
|
||||||
|
b.get("enabled", True) and b.get("type") != "vision_service"
|
||||||
|
for b in cfg.get("backends", {}).values()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_session(request: Request, response: Response) -> CloudUser:
|
_GUEST_COOKIE = "kiwi_guest_id"
|
||||||
"""FastAPI dependency — resolves the current user from the request.
|
_GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 90 # 90 days
|
||||||
|
|
||||||
Delegates auth/tier resolution to cf-core CloudSessionFactory, then maps
|
|
||||||
the result to Kiwi's CloudUser with per-user DB path and household data.
|
|
||||||
|
|
||||||
Local mode: fully-privileged "local" user pointing at local DB.
|
def _resolve_guest_session(request: Request, response: Response, has_byok: bool) -> CloudUser:
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
"""Return a per-session anonymous CloudUser, creating a guest UUID cookie if needed."""
|
||||||
Dev bypass: CLOUD_AUTH_BYPASS_IPS match returns a "local-dev" session.
|
guest_id = request.cookies.get(_GUEST_COOKIE, "").strip()
|
||||||
Anonymous: per-session UUID cookie (cf_guest_id) isolates each guest's data.
|
is_new = not guest_id
|
||||||
"""
|
if is_new:
|
||||||
core_user = _core.resolve(request, response)
|
guest_id = str(uuid.uuid4())
|
||||||
uid, tier, has_byok = core_user.user_id, core_user.tier, core_user.has_byok
|
log.debug("New guest session assigned: anon-%s", guest_id[:8])
|
||||||
|
# Secure flag only when the request actually arrived over HTTPS
|
||||||
if not CLOUD_MODE or uid in ("local", "local-dev"):
|
# (Caddy sets X-Forwarded-Proto=https in cloud; absent on direct port access).
|
||||||
# local-dev gets a writable path under CLOUD_DATA_ROOT; local uses KIWI_DB
|
# Avoids losing the session cookie on HTTP direct-port testing of the cloud stack.
|
||||||
db = _user_db_path(uid) if uid == "local-dev" else _LOCAL_KIWI_DB
|
is_https = request.headers.get("x-forwarded-proto", "http").lower() == "https"
|
||||||
return CloudUser(user_id=uid, tier=tier, db=db, has_byok=has_byok)
|
response.set_cookie(
|
||||||
|
key=_GUEST_COOKIE,
|
||||||
if uid.startswith("anon-"):
|
value=guest_id,
|
||||||
guest_id = uid[len("anon-"):]
|
max_age=_GUEST_COOKIE_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=is_https,
|
||||||
|
)
|
||||||
return CloudUser(
|
return CloudUser(
|
||||||
user_id=uid, tier=tier,
|
user_id=f"anon-{guest_id}",
|
||||||
|
tier="free",
|
||||||
db=_anon_guest_db_path(guest_id),
|
db=_anon_guest_db_path(guest_id),
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
)
|
)
|
||||||
|
|
||||||
household_id = core_user.meta.get("household_id")
|
|
||||||
is_owner = core_user.meta.get("is_household_owner", False)
|
def get_session(request: Request, response: Response) -> CloudUser:
|
||||||
license_key = core_user.meta.get("license_key")
|
"""FastAPI dependency — resolves the current user from the request.
|
||||||
log.debug("Resolved %s session uid=%s tier=%s household=%s", _auth_label(uid), uid[:8], tier, household_id)
|
|
||||||
|
Local mode: fully-privileged "local" user pointing at local DB.
|
||||||
|
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
||||||
|
Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
|
||||||
|
returns a "local" session without JWT validation (dev/LAN use only).
|
||||||
|
Anonymous: per-session UUID cookie isolates each guest visitor's data.
|
||||||
|
"""
|
||||||
|
has_byok = _detect_byok()
|
||||||
|
|
||||||
|
if not CLOUD_MODE:
|
||||||
|
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
||||||
|
|
||||||
|
# Prefer X-Real-IP (set by Caddy 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)
|
||||||
|
|
||||||
|
# Resolve cf_session JWT: prefer the explicit header injected by Caddy, then
|
||||||
|
# fall back to the cf_session cookie value. Other cookies (e.g. kiwi_guest_id)
|
||||||
|
# must never be treated as auth tokens.
|
||||||
|
raw_session = request.headers.get("x-cf-session", "").strip()
|
||||||
|
if not raw_session:
|
||||||
|
raw_session = request.cookies.get("cf_session", "").strip()
|
||||||
|
|
||||||
|
if not raw_session:
|
||||||
|
return _resolve_guest_session(request, response, has_byok)
|
||||||
|
|
||||||
|
token = _extract_session_token(raw_session) # gitleaks:allow — function name, not a secret
|
||||||
|
if not token:
|
||||||
|
return _resolve_guest_session(request, response, has_byok)
|
||||||
|
|
||||||
|
user_id = validate_session_jwt(token)
|
||||||
|
_ensure_provisioned(user_id)
|
||||||
|
tier, household_id, is_household_owner, license_key = _fetch_cloud_tier(user_id)
|
||||||
return CloudUser(
|
return CloudUser(
|
||||||
user_id=uid, tier=tier,
|
user_id=user_id,
|
||||||
db=_user_db_path(uid, household_id=household_id),
|
tier=tier,
|
||||||
|
db=_user_db_path(user_id, household_id=household_id),
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
household_id=household_id,
|
household_id=household_id,
|
||||||
is_household_owner=is_owner,
|
is_household_owner=is_household_owner,
|
||||||
license_key=license_key,
|
license_key=license_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,18 +35,6 @@ class Settings:
|
||||||
# Database
|
# Database
|
||||||
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
||||||
|
|
||||||
# Pre-computed browse counts cache (small SQLite, separate from corpus).
|
|
||||||
# Written by the nightly refresh task and by infer_recipe_tags.py.
|
|
||||||
# Set BROWSE_COUNTS_PATH to a bind-mounted path if you want the host
|
|
||||||
# pipeline to share counts with the container without re-running FTS.
|
|
||||||
BROWSE_COUNTS_PATH: Path = Path(
|
|
||||||
os.environ.get("BROWSE_COUNTS_PATH", str(DATA_DIR / "browse_counts.db"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Magpie data flywheel — ingest endpoint for anonymized recipe signals
|
|
||||||
# Set MAGPIE_INGEST_URL to enable; leave unset (or None) to disable silently.
|
|
||||||
MAGPIE_INGEST_URL: str | None = os.environ.get("MAGPIE_INGEST_URL") or None
|
|
||||||
|
|
||||||
# Community feature settings
|
# Community feature settings
|
||||||
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
||||||
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
||||||
|
|
@ -65,52 +53,15 @@ 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 — lease broker, not inference)
|
# CF-core resource coordinator (VRAM lease management)
|
||||||
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
||||||
|
|
||||||
# GPU inference server URL
|
|
||||||
# Priority: GPU_SERVER_URL env var → CF_ORCH_URL env var (backward compat)
|
|
||||||
# → https://orch.circuitforge.tech when CF_LICENSE_KEY is present (Paid+)
|
|
||||||
# Resolved value is written back to os.environ["CF_ORCH_URL"] at startup so
|
|
||||||
# all service-layer callers that read CF_ORCH_URL directly see the right URL.
|
|
||||||
GPU_SERVER_URL: str | None = (
|
|
||||||
os.environ.get("GPU_SERVER_URL")
|
|
||||||
or os.environ.get("CF_ORCH_URL")
|
|
||||||
or (
|
|
||||||
"https://orch.circuitforge.tech"
|
|
||||||
if os.environ.get("CF_LICENSE_KEY")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||||
|
|
||||||
# E2E test account — analytics logging is suppressed for this user_id so test
|
|
||||||
# runs don't pollute session counts. Set to the Directus UUID of the test user.
|
|
||||||
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
|
|
||||||
|
|
||||||
# ActivityPub federation (optional; disabled by default)
|
|
||||||
AP_ENABLED: bool = os.environ.get("AP_ENABLED", "false").lower() in ("1", "true", "yes")
|
|
||||||
AP_HOST: str = os.environ.get("AP_HOST", "") # e.g. kiwi.circuitforge.tech
|
|
||||||
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
|
||||||
AP_KEY_PATH: Path = Path(
|
|
||||||
os.environ.get("AP_KEY_PATH", str(CLOUD_DATA_ROOT / "ap_keys" / "instance.pem"))
|
|
||||||
)
|
|
||||||
# Fernet key for Mastodon access token encryption (base64-urlsafe, 32 bytes)
|
|
||||||
# Leave unset to skip encryption (dev only)
|
|
||||||
AP_TOKEN_ENCRYPTION_KEY: str | None = os.environ.get("AP_TOKEN_ENCRYPTION_KEY") or None
|
|
||||||
|
|
||||||
# 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")
|
||||||
# Use OrchestratedScheduler (coordinator-aware, multi-GPU fan-out) instead of
|
|
||||||
# LocalScheduler. Defaults to true in CLOUD_MODE; can be set independently
|
|
||||||
# for multi-GPU local rigs that don't need full cloud auth.
|
|
||||||
USE_ORCH_SCHEDULER: bool | None = (
|
|
||||||
None if os.environ.get("USE_ORCH_SCHEDULER") is None
|
|
||||||
else os.environ.get("USE_ORCH_SCHEDULER", "").lower() in ("1", "true", "yes")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
@ -123,9 +74,3 @@ class Settings:
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# Normalise GPU_SERVER_URL into CF_ORCH_URL so every service-layer caller that
|
|
||||||
# reads os.environ.get("CF_ORCH_URL") sees the resolved value, including the
|
|
||||||
# Paid+ cloud default injected above.
|
|
||||||
if settings.GPU_SERVER_URL:
|
|
||||||
os.environ["CF_ORCH_URL"] = settings.GPU_SERVER_URL
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- Migration 034: async recipe generation job queue
|
|
||||||
CREATE TABLE IF NOT EXISTS recipe_jobs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id TEXT NOT NULL UNIQUE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
request TEXT NOT NULL,
|
|
||||||
result TEXT,
|
|
||||||
error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_job_id ON recipe_jobs (job_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_user_id ON recipe_jobs (user_id, created_at DESC);
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- Migration 035: add sensory_tags column for sensory profile filtering
|
|
||||||
--
|
|
||||||
-- sensory_tags holds a JSON object with texture, smell, and noise signals:
|
|
||||||
-- {"textures": ["mushy", "creamy"], "smell": "pungent", "noise": "moderate"}
|
|
||||||
--
|
|
||||||
-- Empty object '{}' means untagged — these recipes pass ALL sensory filters
|
|
||||||
-- (graceful degradation when tag_sensory_profiles.py has not yet been run).
|
|
||||||
--
|
|
||||||
-- Populated offline by: python scripts/tag_sensory_profiles.py [path/to/kiwi.db]
|
|
||||||
-- No FTS rebuild needed — sensory_tags is filtered in Python after candidate fetch.
|
|
||||||
|
|
||||||
ALTER TABLE recipes ADD COLUMN sensory_tags TEXT NOT NULL DEFAULT '{}';
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
-- Migration 036: captured_products local cache
|
|
||||||
-- Products captured via visual label scanning (kiwi#79).
|
|
||||||
-- Keyed by barcode; checked before FDC/OFF on future scans so each product
|
|
||||||
-- is only captured once per device.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS captured_products (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
barcode TEXT UNIQUE NOT NULL,
|
|
||||||
product_name TEXT,
|
|
||||||
brand TEXT,
|
|
||||||
serving_size_g REAL,
|
|
||||||
calories REAL,
|
|
||||||
fat_g REAL,
|
|
||||||
saturated_fat_g REAL,
|
|
||||||
carbs_g REAL,
|
|
||||||
sugar_g REAL,
|
|
||||||
fiber_g REAL,
|
|
||||||
protein_g REAL,
|
|
||||||
sodium_mg REAL,
|
|
||||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
||||||
allergens TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
||||||
confidence REAL,
|
|
||||||
source TEXT NOT NULL DEFAULT 'visual_capture',
|
|
||||||
captured_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
confirmed_by_user INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
-- Migration 037: add 'visual_capture' to products.source CHECK constraint
|
|
||||||
-- SQLite cannot ALTER a CHECK constraint, so we rebuild the table.
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = OFF;
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE products_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
barcode TEXT UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
brand TEXT,
|
|
||||||
category TEXT,
|
|
||||||
description TEXT,
|
|
||||||
image_url TEXT,
|
|
||||||
nutrition_data TEXT NOT NULL DEFAULT '{}',
|
|
||||||
source TEXT NOT NULL DEFAULT 'openfoodfacts'
|
|
||||||
CHECK (source IN ('openfoodfacts', 'manual', 'receipt_ocr', 'visual_capture')),
|
|
||||||
source_data TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO products_new
|
|
||||||
SELECT id, barcode, name, brand, category, description, image_url,
|
|
||||||
nutrition_data, source, source_data, created_at, updated_at
|
|
||||||
FROM products;
|
|
||||||
|
|
||||||
DROP TABLE products;
|
|
||||||
ALTER TABLE products_new RENAME TO products;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
-- Migration 038: add 'visual_capture' to inventory_items.source CHECK constraint
|
|
||||||
-- SQLite cannot ALTER a CHECK constraint, so we rebuild the table.
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = OFF;
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE inventory_items_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
product_id INTEGER NOT NULL
|
|
||||||
REFERENCES products (id) ON DELETE RESTRICT,
|
|
||||||
receipt_id INTEGER
|
|
||||||
REFERENCES receipts (id) ON DELETE SET NULL,
|
|
||||||
quantity REAL NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
|
||||||
unit TEXT NOT NULL DEFAULT 'count',
|
|
||||||
location TEXT NOT NULL,
|
|
||||||
sublocation TEXT,
|
|
||||||
purchase_date TEXT,
|
|
||||||
expiration_date TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'available'
|
|
||||||
CHECK (status IN ('available', 'consumed', 'expired', 'discarded')),
|
|
||||||
consumed_at TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual'
|
|
||||||
CHECK (source IN ('barcode_scan', 'manual', 'receipt', 'visual_capture')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
opened_date TEXT,
|
|
||||||
disposal_reason TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO inventory_items_new
|
|
||||||
SELECT id, product_id, receipt_id, quantity, unit, location, sublocation,
|
|
||||||
purchase_date, expiration_date, status, consumed_at, notes, source,
|
|
||||||
created_at, updated_at, opened_date, disposal_reason
|
|
||||||
FROM inventory_items;
|
|
||||||
|
|
||||||
DROP TABLE inventory_items;
|
|
||||||
ALTER TABLE inventory_items_new RENAME TO inventory_items;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
-- Migration 039: Drop FK constraint on saved_recipes.recipe_id.
|
|
||||||
--
|
|
||||||
-- In cloud mode the recipe corpus is ATTACHed as a separate database.
|
|
||||||
-- SQLite FK constraints only resolve against the `main` schema, so
|
|
||||||
-- `REFERENCES recipes(id)` was always failing for cloud saves (the
|
|
||||||
-- main.recipes table is empty; all data lives in corpus.recipes).
|
|
||||||
-- The corpus is read-only and never modified by the app, so cascade-on-delete
|
|
||||||
-- is meaningless anyway. Remove the constraint without changing any data.
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = OFF;
|
|
||||||
|
|
||||||
CREATE TABLE saved_recipes_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
recipe_id INTEGER NOT NULL,
|
|
||||||
saved_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
notes TEXT,
|
|
||||||
rating INTEGER CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
|
|
||||||
style_tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
UNIQUE (recipe_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO saved_recipes_new SELECT * FROM saved_recipes;
|
|
||||||
|
|
||||||
DROP TABLE saved_recipes;
|
|
||||||
|
|
||||||
ALTER TABLE saved_recipes_new RENAME TO saved_recipes;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_recipes_saved_at ON saved_recipes (saved_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_recipes_rating ON saved_recipes (rating);
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- 040_corrections.sql — corrections table for SFT training data
|
|
||||||
-- Schema from circuitforge_core.api.corrections.CORRECTIONS_MIGRATION_SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS corrections (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL DEFAULT '',
|
|
||||||
product TEXT NOT NULL,
|
|
||||||
correction_type TEXT NOT NULL,
|
|
||||||
input_text TEXT NOT NULL,
|
|
||||||
original_output TEXT NOT NULL,
|
|
||||||
corrected_output TEXT NOT NULL DEFAULT '',
|
|
||||||
rating TEXT NOT NULL DEFAULT 'down',
|
|
||||||
context TEXT NOT NULL DEFAULT '{}',
|
|
||||||
opted_in INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corrections_product
|
|
||||||
ON corrections (product);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corrections_opted_in
|
|
||||||
ON corrections (opted_in);
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Migration 041: user_recipes table for user-scanned and manually-entered recipes.
|
|
||||||
--
|
|
||||||
-- Separate from the food.com corpus (recipes table) -- user recipes are personal,
|
|
||||||
-- not curated, and need different fields (servings as string, cook_time as string).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_recipes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
subtitle TEXT,
|
|
||||||
servings TEXT, -- kept as string: "2", "4-6", "serves 8"
|
|
||||||
cook_time TEXT, -- kept as string: "25 min", "1 hour"
|
|
||||||
source_note TEXT, -- e.g. "Purple Carrot", "Betty Crocker"
|
|
||||||
ingredients TEXT NOT NULL DEFAULT '[]', -- JSON: [{name, qty, unit, raw}]
|
|
||||||
steps TEXT NOT NULL DEFAULT '[]', -- JSON: ["step 1", "step 2", ...]
|
|
||||||
notes TEXT,
|
|
||||||
tags TEXT DEFAULT '[]', -- JSON: ["vegan", "quick"]
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual', -- 'scan' | 'manual'
|
|
||||||
pantry_match_pct INTEGER, -- 0-100, computed at scan time; null for manual
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_recipes_created ON user_recipes (created_at DESC);
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
-- 042_activitypub.sql
|
|
||||||
-- ActivityPub federation tables: follower registry, delivery log, dedup, Mastodon tokens.
|
|
||||||
|
|
||||||
-- Follower registry: AP actors that Follow this Kiwi instance
|
|
||||||
CREATE TABLE IF NOT EXISTS ap_followers (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
actor_id TEXT NOT NULL UNIQUE, -- AP actor URL
|
|
||||||
inbox_url TEXT NOT NULL,
|
|
||||||
shared_inbox TEXT,
|
|
||||||
followed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
active INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_followers_active
|
|
||||||
ON ap_followers (active) WHERE active = 1;
|
|
||||||
|
|
||||||
-- Outgoing delivery log: one row per (post_slug, target_inbox) attempt
|
|
||||||
CREATE TABLE IF NOT EXISTS ap_deliveries (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
post_slug TEXT NOT NULL,
|
|
||||||
target_inbox TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | failed
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
delivered_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_deliveries_status
|
|
||||||
ON ap_deliveries (status) WHERE status != 'delivered';
|
|
||||||
|
|
||||||
-- Incoming activity dedup: prevents replay attacks and double-processing
|
|
||||||
CREATE TABLE IF NOT EXISTS ap_received (
|
|
||||||
activity_id TEXT PRIMARY KEY,
|
|
||||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Mastodon OAuth tokens: per-user, encrypted at rest
|
|
||||||
-- Stored in the user's local kiwi.db (CLOUD_MODE: per-user DB tree)
|
|
||||||
CREATE TABLE IF NOT EXISTS mastodon_tokens (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
directus_user_id TEXT NOT NULL UNIQUE,
|
|
||||||
instance_url TEXT NOT NULL,
|
|
||||||
access_token TEXT NOT NULL, -- Fernet-encrypted when AP_TOKEN_ENCRYPTION_KEY set
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -6,8 +6,6 @@ Cloud mode: opens a Store at the per-user DB path from the CloudUser session.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
@ -23,16 +21,3 @@ def get_store(session: CloudUser = Depends(get_session)) -> Generator[Store, Non
|
||||||
yield store
|
yield store
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
def get_db(session: CloudUser = Depends(get_session)) -> Iterator[sqlite3.Connection]:
|
|
||||||
"""FastAPI dependency — yields the raw sqlite3.Connection for the current user.
|
|
||||||
|
|
||||||
Used by make_corrections_router() from circuitforge-core, which expects a
|
|
||||||
dependency that yields a sqlite3.Connection directly.
|
|
||||||
"""
|
|
||||||
store = Store(session.db)
|
|
||||||
try:
|
|
||||||
yield store.conn
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
532
app/db/store.py
532
app/db/store.py
|
|
@ -11,7 +11,6 @@ from typing import Any
|
||||||
|
|
||||||
from circuitforge_core.db.base import get_connection
|
from circuitforge_core.db.base import get_connection
|
||||||
from circuitforge_core.db.migrations import run_migrations
|
from circuitforge_core.db.migrations import run_migrations
|
||||||
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
|
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -60,11 +59,7 @@ class Store:
|
||||||
# saved recipe columns
|
# saved recipe columns
|
||||||
"style_tags",
|
"style_tags",
|
||||||
# meal plan columns
|
# meal plan columns
|
||||||
"meal_types",
|
"meal_types"):
|
||||||
# user_recipes columns
|
|
||||||
"steps", "tags",
|
|
||||||
# captured_products columns
|
|
||||||
"allergens"):
|
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
|
|
@ -741,41 +736,6 @@ class Store:
|
||||||
row = self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
row = self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
||||||
return row
|
return row
|
||||||
|
|
||||||
# --- Async recipe jobs ---
|
|
||||||
|
|
||||||
def create_recipe_job(self, job_id: str, user_id: str, request_json: str) -> sqlite3.Row:
|
|
||||||
return self._insert_returning(
|
|
||||||
"INSERT INTO recipe_jobs (job_id, user_id, status, request) VALUES (?,?,?,?) RETURNING *",
|
|
||||||
(job_id, user_id, "queued", request_json),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_recipe_job(self, job_id: str, user_id: str) -> sqlite3.Row | None:
|
|
||||||
return self._fetch_one(
|
|
||||||
"SELECT * FROM recipe_jobs WHERE job_id=? AND user_id=?",
|
|
||||||
(job_id, user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_recipe_job_running(self, job_id: str) -> None:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE recipe_jobs SET status='running', updated_at=datetime('now') WHERE job_id=?",
|
|
||||||
(job_id,),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def complete_recipe_job(self, job_id: str, result_json: str) -> None:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE recipe_jobs SET status='done', result=?, updated_at=datetime('now') WHERE job_id=?",
|
|
||||||
(result_json, job_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def fail_recipe_job(self, job_id: str, error: str) -> None:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE recipe_jobs SET status='failed', error=?, updated_at=datetime('now') WHERE job_id=?",
|
|
||||||
(error, job_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def upsert_built_recipe(
|
def upsert_built_recipe(
|
||||||
self,
|
self,
|
||||||
external_id: str,
|
external_id: str,
|
||||||
|
|
@ -1091,38 +1051,17 @@ class Store:
|
||||||
# ── recipe browser ────────────────────────────────────────────────────
|
# ── recipe browser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_browser_categories(
|
def get_browser_categories(
|
||||||
self,
|
self, domain: str, keywords_by_category: dict[str, list[str]]
|
||||||
domain: str,
|
|
||||||
keywords_by_category: dict[str, list[str]],
|
|
||||||
has_subcategories_by_category: dict[str, bool] | None = None,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return [{category, recipe_count, has_subcategories}] for each category.
|
"""Return [{category, recipe_count}] for each category in the domain.
|
||||||
|
|
||||||
keywords_by_category maps category name → keyword list for counting.
|
keywords_by_category maps category name to the keyword list used to
|
||||||
has_subcategories_by_category maps category name → bool (optional;
|
match against recipes.category and recipes.keywords.
|
||||||
defaults to False for all categories when omitted).
|
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for category, keywords in keywords_by_category.items():
|
for category, keywords in keywords_by_category.items():
|
||||||
count = self._count_recipes_for_keywords(keywords)
|
count = self._count_recipes_for_keywords(keywords)
|
||||||
results.append({
|
results.append({"category": category, "recipe_count": count})
|
||||||
"category": category,
|
|
||||||
"recipe_count": count,
|
|
||||||
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
|
|
||||||
})
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_browser_subcategories(
|
|
||||||
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return [{subcategory, recipe_count}] for each subcategory.
|
|
||||||
|
|
||||||
Mirrors get_browser_categories but for the second level.
|
|
||||||
"""
|
|
||||||
results = []
|
|
||||||
for subcat, keywords in keywords_by_subcategory.items():
|
|
||||||
count = self._count_recipes_for_keywords(keywords)
|
|
||||||
results.append({"subcategory": subcat, "recipe_count": count})
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -1131,19 +1070,6 @@ class Store:
|
||||||
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
||||||
return " OR ".join(phrases)
|
return " OR ".join(phrases)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ingredient_fts_term(ingredient: str) -> str:
|
|
||||||
"""Build an FTS5 ingredient_names column prefix-filter.
|
|
||||||
|
|
||||||
Returns e.g. 'ingredient_names : "potato"*' which matches any recipe whose
|
|
||||||
ingredient_names column contains a token starting with that word. Prefix
|
|
||||||
matching (*) means "potato" also matches "potatoes", "sweet potato", etc.
|
|
||||||
Apostrophes are stripped because the FTS5 tokenizer drops them.
|
|
||||||
"""
|
|
||||||
cleaned = ingredient.replace("'", "").strip()
|
|
||||||
escaped = cleaned.replace('"', '""')
|
|
||||||
return f'ingredient_names : "{escaped}"*'
|
|
||||||
|
|
||||||
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
||||||
if not keywords:
|
if not keywords:
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1165,141 +1091,45 @@ class Store:
|
||||||
|
|
||||||
def browse_recipes(
|
def browse_recipes(
|
||||||
self,
|
self,
|
||||||
keywords: list[str] | None,
|
keywords: list[str],
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
pantry_items: list[str] | None = None,
|
pantry_items: list[str] | None = None,
|
||||||
q: str | None = None,
|
|
||||||
sort: str = "default",
|
|
||||||
sensory_exclude: SensoryExclude | None = None,
|
|
||||||
required_ingredient: str | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a page of recipes matching the keyword set.
|
"""Return a page of recipes matching the keyword set.
|
||||||
|
|
||||||
Pass keywords=None to browse all recipes without category filtering.
|
|
||||||
Each recipe row includes match_pct (float | None) when pantry_items
|
Each recipe row includes match_pct (float | None) when pantry_items
|
||||||
is provided. match_pct is the fraction of ingredient_names covered by
|
is provided. match_pct is the fraction of ingredient_names covered by
|
||||||
the pantry set — computed deterministically, no LLM needed.
|
the pantry set — computed deterministically, no LLM needed.
|
||||||
|
|
||||||
q: optional title substring filter (case-insensitive LIKE).
|
|
||||||
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A)
|
|
||||||
| "match" (pantry coverage DESC — falls back to default when no pantry).
|
|
||||||
required_ingredient: when set, only return recipes whose ingredient_names contain
|
|
||||||
this substring (case-insensitive). "must include" filter.
|
|
||||||
"""
|
"""
|
||||||
if keywords is not None and not keywords:
|
if not keywords:
|
||||||
return {"recipes": [], "total": 0, "page": page}
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
||||||
|
match_expr = self._browser_fts_query(keywords)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
c = self._cp
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
|
||||||
|
|
||||||
# "match" sort requires pantry items; fall back gracefully when absent.
|
|
||||||
effective_sort = sort if (sort != "match" or pantry_set) else "default"
|
|
||||||
|
|
||||||
order_clause = {
|
|
||||||
"alpha": "ORDER BY title ASC",
|
|
||||||
"alpha_desc": "ORDER BY title DESC",
|
|
||||||
}.get(effective_sort, "ORDER BY id ASC")
|
|
||||||
|
|
||||||
q_param = f"%{q.strip()}%" if q and q.strip() else None
|
|
||||||
|
|
||||||
# ── required-ingredient FTS filter (must-include) ─────────────────────
|
|
||||||
# FTS5 column prefix-filter avoids the full table scan that LIKE '%X%' would do.
|
|
||||||
req_fts_term = (
|
|
||||||
self._ingredient_fts_term(required_ingredient) if required_ingredient else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── match sort: push match_pct computation into SQL so ORDER BY works ──
|
|
||||||
if effective_sort == "match" and pantry_set:
|
|
||||||
return self._browse_by_match(
|
|
||||||
keywords, page, page_size, offset, pantry_set, q_param, c,
|
|
||||||
sensory_exclude=sensory_exclude,
|
|
||||||
required_ingredient=required_ingredient,
|
|
||||||
)
|
|
||||||
|
|
||||||
cols = (
|
|
||||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
|
||||||
f" calories, fat_g, protein_g, sodium_mg, directions, sensory_tags FROM {c}recipes"
|
|
||||||
)
|
|
||||||
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
|
||||||
|
|
||||||
if keywords is None:
|
|
||||||
if req_fts_term:
|
|
||||||
# Ingredient filter: use FTS index — much faster than LIKE on full table
|
|
||||||
if q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
|
||||||
(req_fts_term, q_param),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(req_fts_term, q_param, page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub}",
|
|
||||||
(req_fts_term,),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(req_fts_term, page_size, offset),
|
|
||||||
)
|
|
||||||
elif q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
|
||||||
(q_param,),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(q_param, page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
keywords_expr = self._browser_fts_query(keywords)
|
|
||||||
# Combine keywords + ingredient into one FTS MATCH to use a single index pass
|
|
||||||
combined_match = (
|
|
||||||
f"({keywords_expr}) AND {req_fts_term}" if req_fts_term else keywords_expr
|
|
||||||
)
|
|
||||||
if q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
|
||||||
(combined_match, q_param),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(combined_match, q_param, page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if required_ingredient:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub}",
|
|
||||||
(combined_match,),
|
|
||||||
).fetchone()[0]
|
|
||||||
else:
|
|
||||||
# Reuse cached count — avoids a second index scan on every page turn.
|
# Reuse cached count — avoids a second index scan on every page turn.
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
total = self._count_recipes_for_keywords(keywords)
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(combined_match, page_size, offset),
|
|
||||||
)
|
|
||||||
# Community tag fallback: if FTS found nothing, check whether
|
|
||||||
# community-tagged recipe IDs exist for this keyword context.
|
|
||||||
# browse_recipes doesn't know domain/category directly, so the
|
|
||||||
# fallback is triggered by the caller via community_ids= when needed.
|
|
||||||
# (See browse_recipes_with_community_fallback in the endpoint layer.)
|
|
||||||
|
|
||||||
|
c = self._cp
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"""
|
||||||
|
SELECT id, title, category, keywords, ingredient_names,
|
||||||
|
calories, fat_g, protein_g, sodium_mg
|
||||||
|
FROM {c}recipes
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT rowid FROM {c}recipe_browser_fts
|
||||||
|
WHERE recipe_browser_fts MATCH ?
|
||||||
|
)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(match_expr, page_size, offset),
|
||||||
|
)
|
||||||
|
|
||||||
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||||
recipes = []
|
recipes = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
# Apply sensory filter -- untagged recipes (empty {}) always pass
|
|
||||||
if sensory_exclude and not sensory_exclude.is_empty():
|
|
||||||
if not passes_sensory_filter(r.get("sensory_tags"), sensory_exclude):
|
|
||||||
continue
|
|
||||||
entry = {
|
entry = {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"title": r["title"],
|
"title": r["title"],
|
||||||
|
|
@ -1309,197 +1139,14 @@ class Store:
|
||||||
if pantry_set:
|
if pantry_set:
|
||||||
names = r.get("ingredient_names") or []
|
names = r.get("ingredient_names") or []
|
||||||
if names:
|
if names:
|
||||||
matched = sum(1 for n in names if n.lower() in pantry_set)
|
matched = sum(
|
||||||
|
1 for n in names if n.lower() in pantry_set
|
||||||
|
)
|
||||||
entry["match_pct"] = round(matched / len(names), 3)
|
entry["match_pct"] = round(matched / len(names), 3)
|
||||||
recipes.append(entry)
|
recipes.append(entry)
|
||||||
|
|
||||||
return {"recipes": recipes, "total": total, "page": page}
|
return {"recipes": recipes, "total": total, "page": page}
|
||||||
|
|
||||||
def fetch_recipes_by_ids(
|
|
||||||
self,
|
|
||||||
recipe_ids: list[int],
|
|
||||||
pantry_items: list[str] | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Fetch a specific set of corpus recipes by ID for community tag fallback.
|
|
||||||
|
|
||||||
Returns recipes in the same shape as browse_recipes rows, with match_pct
|
|
||||||
populated when pantry_items are provided.
|
|
||||||
"""
|
|
||||||
if not recipe_ids:
|
|
||||||
return []
|
|
||||||
c = self._cp
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
|
||||||
ph = ",".join("?" * len(recipe_ids))
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
|
||||||
f" calories, fat_g, protein_g, sodium_mg, directions"
|
|
||||||
f" FROM {c}recipes WHERE id IN ({ph}) ORDER BY id ASC",
|
|
||||||
tuple(recipe_ids),
|
|
||||||
)
|
|
||||||
result = []
|
|
||||||
for r in rows:
|
|
||||||
entry: dict = {
|
|
||||||
"id": r["id"],
|
|
||||||
"title": r["title"],
|
|
||||||
"category": r["category"],
|
|
||||||
"match_pct": None,
|
|
||||||
}
|
|
||||||
entry["directions"] = r.get("directions")
|
|
||||||
if pantry_set:
|
|
||||||
names = r.get("ingredient_names") or []
|
|
||||||
if names:
|
|
||||||
matched = sum(1 for n in names if n.lower() in pantry_set)
|
|
||||||
entry["match_pct"] = round(matched / len(names), 3)
|
|
||||||
result.append(entry)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# How many FTS candidates to fetch before Python-scoring for match sort.
|
|
||||||
# Large enough to cover several pages with good diversity; small enough
|
|
||||||
# that json-parsing + dict-lookup stays sub-second even for big categories.
|
|
||||||
_MATCH_POOL_SIZE = 800
|
|
||||||
|
|
||||||
def _browse_by_match(
|
|
||||||
self,
|
|
||||||
keywords: list[str] | None,
|
|
||||||
page: int,
|
|
||||||
page_size: int,
|
|
||||||
offset: int,
|
|
||||||
pantry_set: set[str],
|
|
||||||
q_param: str | None,
|
|
||||||
c: str,
|
|
||||||
sensory_exclude: SensoryExclude | None = None,
|
|
||||||
required_ingredient: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Browse recipes sorted by pantry match percentage.
|
|
||||||
|
|
||||||
Fetches up to _MATCH_POOL_SIZE FTS candidates, scores each against the
|
|
||||||
pantry set in Python (fast dict lookup on a bounded list), then sorts
|
|
||||||
and paginates in-memory. This avoids correlated json_each() subqueries
|
|
||||||
that are prohibitively slow over 50k+ row result sets.
|
|
||||||
|
|
||||||
The reported total is the full FTS count (from cache), not pool size.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
pantry_lower = {p.lower() for p in pantry_set}
|
|
||||||
|
|
||||||
# ── required-ingredient FTS filter (must-include) ─────────────────────
|
|
||||||
req_fts_term = (
|
|
||||||
self._ingredient_fts_term(required_ingredient) if required_ingredient else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Fetch candidate pool from FTS ────────────────────────────────────
|
|
||||||
base_cols = (
|
|
||||||
f"SELECT r.id, r.title, r.category, r.ingredient_names, r.directions, r.sensory_tags"
|
|
||||||
f" FROM {c}recipes r"
|
|
||||||
)
|
|
||||||
fts_sub = (
|
|
||||||
f"r.id IN (SELECT rowid FROM {c}recipe_browser_fts"
|
|
||||||
f" WHERE recipe_browser_fts MATCH ?)"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.conn.row_factory = sqlite3.Row
|
|
||||||
|
|
||||||
if keywords is None:
|
|
||||||
if req_fts_term:
|
|
||||||
if q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE id IN"
|
|
||||||
f" (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
|
||||||
f" AND LOWER(title) LIKE LOWER(?)",
|
|
||||||
(req_fts_term, q_param),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"{base_cols} WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)"
|
|
||||||
f" ORDER BY r.id ASC LIMIT ?",
|
|
||||||
(req_fts_term, q_param, self._MATCH_POOL_SIZE),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE id IN"
|
|
||||||
f" (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)",
|
|
||||||
(req_fts_term,),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"{base_cols} WHERE {fts_sub} ORDER BY r.id ASC LIMIT ?",
|
|
||||||
(req_fts_term, self._MATCH_POOL_SIZE),
|
|
||||||
).fetchall()
|
|
||||||
elif q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
|
||||||
(q_param,),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"{base_cols} WHERE LOWER(r.title) LIKE LOWER(?)"
|
|
||||||
f" ORDER BY r.id ASC LIMIT ?",
|
|
||||||
(q_param, self._MATCH_POOL_SIZE),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes"
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"{base_cols} ORDER BY r.id ASC LIMIT ?",
|
|
||||||
(self._MATCH_POOL_SIZE,),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
keywords_expr = self._browser_fts_query(keywords)
|
|
||||||
combined_match = (
|
|
||||||
f"({keywords_expr}) AND {req_fts_term}" if req_fts_term else keywords_expr
|
|
||||||
)
|
|
||||||
if q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes r"
|
|
||||||
f" WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)",
|
|
||||||
(combined_match, q_param),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"{base_cols} WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)"
|
|
||||||
f" ORDER BY r.id ASC LIMIT ?",
|
|
||||||
(combined_match, q_param, self._MATCH_POOL_SIZE),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
if required_ingredient:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes r WHERE {fts_sub}",
|
|
||||||
(combined_match,),
|
|
||||||
).fetchone()[0]
|
|
||||||
else:
|
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"{base_cols} WHERE {fts_sub} ORDER BY r.id ASC LIMIT ?",
|
|
||||||
(combined_match, self._MATCH_POOL_SIZE),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
# ── Score in Python, sort, paginate ──────────────────────────────────
|
|
||||||
scored = []
|
|
||||||
for r in rows:
|
|
||||||
row = dict(r)
|
|
||||||
# Sensory filter applied before scoring to keep hot path clean
|
|
||||||
if sensory_exclude and not sensory_exclude.is_empty():
|
|
||||||
if not passes_sensory_filter(row.get("sensory_tags"), sensory_exclude):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
names = _json.loads(row["ingredient_names"] or "[]")
|
|
||||||
except Exception:
|
|
||||||
names = []
|
|
||||||
if names:
|
|
||||||
matched = sum(1 for n in names if n.lower() in pantry_lower)
|
|
||||||
match_pct = round(matched / len(names), 3)
|
|
||||||
else:
|
|
||||||
match_pct = None
|
|
||||||
scored.append({
|
|
||||||
"id": row["id"],
|
|
||||||
"title": row["title"],
|
|
||||||
"category": row["category"],
|
|
||||||
"match_pct": match_pct,
|
|
||||||
"directions": row.get("directions"),
|
|
||||||
})
|
|
||||||
|
|
||||||
scored.sort(key=lambda r: (-(r["match_pct"] or 0), r["id"]))
|
|
||||||
page_slice = scored[offset: offset + page_size]
|
|
||||||
return {"recipes": page_slice, "total": total, "page": page}
|
|
||||||
|
|
||||||
def log_browser_telemetry(
|
def log_browser_telemetry(
|
||||||
self,
|
self,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
|
@ -1734,124 +1381,3 @@ class Store:
|
||||||
cur = self.conn.execute("DELETE FROM shopping_list_items")
|
cur = self.conn.execute("DELETE FROM shopping_list_items")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
# ── Captured products (visual label cache) ────────────────────────────────
|
|
||||||
|
|
||||||
def get_captured_product(self, barcode: str) -> dict | None:
|
|
||||||
"""Look up a locally-captured product by barcode.
|
|
||||||
|
|
||||||
Returns the row dict (ingredient_names and allergens already decoded as
|
|
||||||
lists) or None if the barcode has not been captured yet.
|
|
||||||
"""
|
|
||||||
return self._fetch_one(
|
|
||||||
"SELECT * FROM captured_products WHERE barcode = ?", (barcode,)
|
|
||||||
)
|
|
||||||
|
|
||||||
def save_captured_product(
|
|
||||||
self,
|
|
||||||
barcode: str,
|
|
||||||
*,
|
|
||||||
product_name: str | None = None,
|
|
||||||
brand: str | None = None,
|
|
||||||
serving_size_g: float | None = None,
|
|
||||||
calories: float | None = None,
|
|
||||||
fat_g: float | None = None,
|
|
||||||
saturated_fat_g: float | None = None,
|
|
||||||
carbs_g: float | None = None,
|
|
||||||
sugar_g: float | None = None,
|
|
||||||
fiber_g: float | None = None,
|
|
||||||
protein_g: float | None = None,
|
|
||||||
sodium_mg: float | None = None,
|
|
||||||
ingredient_names: list[str] | None = None,
|
|
||||||
allergens: list[str] | None = None,
|
|
||||||
confidence: float | None = None,
|
|
||||||
confirmed_by_user: bool = True,
|
|
||||||
source: str = "visual_capture",
|
|
||||||
) -> dict:
|
|
||||||
"""Insert or replace a captured product row, returning the saved dict."""
|
|
||||||
return self._insert_returning(
|
|
||||||
"""INSERT INTO captured_products
|
|
||||||
(barcode, product_name, brand, serving_size_g, calories,
|
|
||||||
fat_g, saturated_fat_g, carbs_g, sugar_g, fiber_g,
|
|
||||||
protein_g, sodium_mg, ingredient_names, allergens,
|
|
||||||
confidence, confirmed_by_user, source)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(barcode) DO UPDATE SET
|
|
||||||
product_name = excluded.product_name,
|
|
||||||
brand = excluded.brand,
|
|
||||||
serving_size_g = excluded.serving_size_g,
|
|
||||||
calories = excluded.calories,
|
|
||||||
fat_g = excluded.fat_g,
|
|
||||||
saturated_fat_g = excluded.saturated_fat_g,
|
|
||||||
carbs_g = excluded.carbs_g,
|
|
||||||
sugar_g = excluded.sugar_g,
|
|
||||||
fiber_g = excluded.fiber_g,
|
|
||||||
protein_g = excluded.protein_g,
|
|
||||||
sodium_mg = excluded.sodium_mg,
|
|
||||||
ingredient_names = excluded.ingredient_names,
|
|
||||||
allergens = excluded.allergens,
|
|
||||||
confidence = excluded.confidence,
|
|
||||||
confirmed_by_user = excluded.confirmed_by_user,
|
|
||||||
source = excluded.source,
|
|
||||||
captured_at = datetime('now')
|
|
||||||
RETURNING *""",
|
|
||||||
(
|
|
||||||
barcode, product_name, brand, serving_size_g, calories,
|
|
||||||
fat_g, saturated_fat_g, carbs_g, sugar_g, fiber_g,
|
|
||||||
protein_g, sodium_mg,
|
|
||||||
self._dump(ingredient_names or []),
|
|
||||||
self._dump(allergens or []),
|
|
||||||
confidence, 1 if confirmed_by_user else 0, source,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── User Recipes (kiwi#9) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def create_user_recipe(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
ingredients: list[dict],
|
|
||||||
steps: list[str],
|
|
||||||
subtitle: str | None = None,
|
|
||||||
servings: str | None = None,
|
|
||||||
cook_time: str | None = None,
|
|
||||||
source_note: str | None = None,
|
|
||||||
notes: str | None = None,
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
source: str = "manual",
|
|
||||||
pantry_match_pct: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return self._insert_returning(
|
|
||||||
"""INSERT INTO user_recipes
|
|
||||||
(title, subtitle, servings, cook_time, source_note,
|
|
||||||
ingredients, steps, notes, tags, source, pantry_match_pct)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *""",
|
|
||||||
(
|
|
||||||
title, subtitle, servings, cook_time, source_note,
|
|
||||||
self._dump(ingredients),
|
|
||||||
self._dump(steps),
|
|
||||||
notes,
|
|
||||||
self._dump(tags or []),
|
|
||||||
source,
|
|
||||||
pantry_match_pct,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_user_recipe(self, recipe_id: int) -> dict[str, Any] | None:
|
|
||||||
return self._fetch_one(
|
|
||||||
"SELECT * FROM user_recipes WHERE id = ?",
|
|
||||||
(recipe_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_user_recipes(self) -> list[dict[str, Any]]:
|
|
||||||
return self._fetch_all(
|
|
||||||
"SELECT * FROM user_recipes ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_user_recipe(self, recipe_id: int) -> bool:
|
|
||||||
cur = self.conn.execute(
|
|
||||||
"DELETE FROM user_recipes WHERE id = ?", (recipe_id,)
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
|
||||||
61
app/main.py
61
app/main.py
|
|
@ -1,9 +1,7 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# app/main.py
|
# app/main.py
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -18,36 +16,11 @@ from app.services.meal_plan.affiliates import register_kiwi_programs
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BROWSE_REFRESH_INTERVAL_H = 24
|
|
||||||
|
|
||||||
|
|
||||||
async def _browse_counts_refresh_loop(corpus_path: str) -> None:
|
|
||||||
"""Refresh browse counts every 24 h while the container is running."""
|
|
||||||
from app.db.store import _COUNT_CACHE
|
|
||||||
from app.services.recipe.browse_counts_cache import load_into_memory, refresh
|
|
||||||
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(_BROWSE_REFRESH_INTERVAL_H * 3600)
|
|
||||||
try:
|
|
||||||
logger.info("browse_counts: starting scheduled refresh...")
|
|
||||||
computed = await asyncio.to_thread(
|
|
||||||
refresh, corpus_path, settings.BROWSE_COUNTS_PATH
|
|
||||||
)
|
|
||||||
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
|
||||||
logger.info("browse_counts: scheduled refresh complete (%d sets)", computed)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: scheduled refresh failed: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting Kiwi API...")
|
logger.info("Starting Kiwi API...")
|
||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
|
|
||||||
# Run DB migrations at startup (ensures all tables exist before any request)
|
|
||||||
from app.db.store import Store
|
|
||||||
_s = Store(settings.DB_PATH)
|
|
||||||
_s.close()
|
|
||||||
register_kiwi_programs()
|
register_kiwi_programs()
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
|
|
@ -59,35 +32,6 @@ async def lifespan(app: FastAPI):
|
||||||
from app.api.endpoints.community import init_community_store
|
from app.api.endpoints.community import init_community_store
|
||||||
init_community_store(settings.COMMUNITY_DB_URL)
|
init_community_store(settings.COMMUNITY_DB_URL)
|
||||||
|
|
||||||
# Initialize ActivityPub instance actor (no-op when AP_ENABLED=false)
|
|
||||||
if settings.AP_ENABLED and settings.AP_HOST:
|
|
||||||
try:
|
|
||||||
from app.services.ap.keys import init_actor
|
|
||||||
init_actor(host=settings.AP_HOST, key_path=settings.AP_KEY_PATH)
|
|
||||||
except Exception as _ap_exc:
|
|
||||||
logger.warning("AP init failed (AP features disabled): %s", _ap_exc)
|
|
||||||
|
|
||||||
# Browse counts cache — warm in-memory cache from disk, refresh if stale.
|
|
||||||
# Uses the corpus path the store will attach to at request time.
|
|
||||||
corpus_path = os.environ.get("RECIPE_DB_PATH", str(settings.DB_PATH))
|
|
||||||
try:
|
|
||||||
from app.db.store import _COUNT_CACHE
|
|
||||||
from app.services.recipe.browse_counts_cache import (
|
|
||||||
is_stale, load_into_memory, refresh,
|
|
||||||
)
|
|
||||||
if is_stale(settings.BROWSE_COUNTS_PATH):
|
|
||||||
logger.info("browse_counts: cache stale — refreshing in background...")
|
|
||||||
asyncio.create_task(
|
|
||||||
asyncio.to_thread(refresh, corpus_path, settings.BROWSE_COUNTS_PATH)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: startup init failed (live FTS fallback active): %s", exc)
|
|
||||||
|
|
||||||
# Nightly background refresh loop
|
|
||||||
asyncio.create_task(_browse_counts_refresh_loop(corpus_path))
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Graceful scheduler shutdown
|
# Graceful scheduler shutdown
|
||||||
|
|
@ -114,11 +58,6 @@ app.add_middleware(
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.API_PREFIX)
|
app.include_router(api_router, prefix=settings.API_PREFIX)
|
||||||
|
|
||||||
# AP endpoints: WebFinger at root (not under /api/v1), AP objects under /ap
|
|
||||||
from app.api.endpoints.activitypub import ap_router, webfinger_router
|
|
||||||
app.include_router(webfinger_router)
|
|
||||||
app.include_router(ap_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
"""Kiwi MCP Server — read-only corpus DB access for tag/keyword audits.
|
|
||||||
|
|
||||||
Exposes four tools to Claude:
|
|
||||||
kiwi_query_corpus — run a read-only SQL query against the corpus DB
|
|
||||||
kiwi_count_fts — run an FTS5 MATCH expression and return row count
|
|
||||||
kiwi_sample_tags — return tag frequency distribution by prefix
|
|
||||||
kiwi_browse_preview — call the browse endpoint and return first-page results
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
python -m app.mcp.server
|
|
||||||
(from /Library/Development/CircuitForge/kiwi with cf conda env active)
|
|
||||||
|
|
||||||
Configure in Claude Code ~/.claude/settings.json mcpServers:
|
|
||||||
"kiwi": {
|
|
||||||
"command": "/devl/miniconda3/envs/cf/bin/python",
|
|
||||||
"args": ["-m", "app.mcp.server"],
|
|
||||||
"cwd": "/Library/Development/CircuitForge/kiwi",
|
|
||||||
"env": {
|
|
||||||
"KIWI_DB_PATH": "/Library/Development/CircuitForge/kiwi/data/kiwi.db",
|
|
||||||
"KIWI_API_URL": "http://localhost:8512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from mcp.server import Server
|
|
||||||
from mcp.server.stdio import stdio_server
|
|
||||||
from mcp.types import TextContent, Tool
|
|
||||||
|
|
||||||
_DB_PATH = os.environ.get(
|
|
||||||
"KIWI_DB_PATH",
|
|
||||||
str(Path(__file__).parents[3] / "data" / "kiwi.db"),
|
|
||||||
)
|
|
||||||
_API_URL = os.environ.get("KIWI_API_URL", "http://localhost:8512")
|
|
||||||
_TIMEOUT = 30.0
|
|
||||||
_QUERY_ROW_LIMIT = 200
|
|
||||||
|
|
||||||
server = Server("kiwi")
|
|
||||||
|
|
||||||
|
|
||||||
def _open_ro() -> sqlite3.Connection:
|
|
||||||
"""Open the corpus DB in read-only mode."""
|
|
||||||
uri = f"file:///{Path(_DB_PATH).as_posix()}?mode=ro"
|
|
||||||
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
@server.list_tools()
|
|
||||||
async def list_tools() -> list[Tool]:
|
|
||||||
return [
|
|
||||||
Tool(
|
|
||||||
name="kiwi_query_corpus",
|
|
||||||
description=(
|
|
||||||
"Run a read-only SQL SELECT query against the Kiwi corpus DB (kiwi.db). "
|
|
||||||
"Returns up to 200 rows as a JSON array. "
|
|
||||||
"Key tables: recipes (id, title, ingredient_names, inferred_tags, source_url), "
|
|
||||||
"recipes_fts (FTS5 virtual table for full-text search), "
|
|
||||||
"ingredient_profiles (name, elements, texture_profile). "
|
|
||||||
"Use for schema exploration, spot-checking tag coverage, and counting results. "
|
|
||||||
"Read-only — any write statement will be rejected by SQLite."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["sql"],
|
|
||||||
"properties": {
|
|
||||||
"sql": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"A SELECT statement. E.g.: "
|
|
||||||
"SELECT title, inferred_tags FROM recipes WHERE inferred_tags LIKE '%vegan%' LIMIT 10"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="kiwi_count_fts",
|
|
||||||
description=(
|
|
||||||
"Run an FTS5 MATCH expression against the recipes_fts table and return the hit count. "
|
|
||||||
"Useful for quickly auditing keyword coverage without a full query. "
|
|
||||||
"Always double-quote all terms in MATCH expressions. "
|
|
||||||
"E.g. match_expr='\"tofu\" OR \"tempeh\"' returns how many recipes include either."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["match_expr"],
|
|
||||||
"properties": {
|
|
||||||
"match_expr": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"FTS5 MATCH expression string (without the MATCH keyword). "
|
|
||||||
'E.g. \'"lentil" OR "chickpea"\' or \'"pasta" AND "vegetarian"\''
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="kiwi_sample_tags",
|
|
||||||
description=(
|
|
||||||
"Return tag frequency distribution from the corpus. "
|
|
||||||
"Queries inferred_tags column for tags matching the given prefix pattern. "
|
|
||||||
"Useful for auditing how well a category keyword set covers the corpus, "
|
|
||||||
"or discovering what tags exist under a domain (cuisine:, meal:, dietary:, texture:)."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"prefix": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": (
|
|
||||||
"Tag prefix to filter by. E.g. 'cuisine:' returns all cuisine tags, "
|
|
||||||
"'meal:' returns all meal type tags, '' returns all tags. "
|
|
||||||
"Returns top 50 by frequency."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"limit": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 50,
|
|
||||||
"description": "Max number of tag entries to return (default 50, max 200).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="kiwi_browse_preview",
|
|
||||||
description=(
|
|
||||||
"Call the Kiwi browse endpoint and return first-page results. "
|
|
||||||
"Use to verify that a domain/category returns the expected recipes "
|
|
||||||
"after a keyword or tag change, without opening the browser. "
|
|
||||||
"Returns recipe titles, match counts, and total result count."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["domain", "category"],
|
|
||||||
"properties": {
|
|
||||||
"domain": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"Browse domain slug. "
|
|
||||||
"Known domains: cuisine, meal_type, dietary, ingredient, occasion, texture."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Category slug within the domain, e.g. 'italian', 'breakfast', 'vegan'.",
|
|
||||||
},
|
|
||||||
"subcategory": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Optional subcategory slug to narrow further.",
|
|
||||||
},
|
|
||||||
"page_size": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 10,
|
|
||||||
"description": "Results per page (default 10, max 50).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@server.call_tool()
|
|
||||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
||||||
if name == "kiwi_query_corpus":
|
|
||||||
return await _query_corpus(arguments)
|
|
||||||
if name == "kiwi_count_fts":
|
|
||||||
return await _count_fts(arguments)
|
|
||||||
if name == "kiwi_sample_tags":
|
|
||||||
return await _sample_tags(arguments)
|
|
||||||
if name == "kiwi_browse_preview":
|
|
||||||
return await _browse_preview(arguments)
|
|
||||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_corpus(args: dict) -> list[TextContent]:
|
|
||||||
sql = args.get("sql", "").strip()
|
|
||||||
if not sql.upper().startswith("SELECT"):
|
|
||||||
return [TextContent(type="text", text="Error: only SELECT statements are allowed.")]
|
|
||||||
|
|
||||||
def _run() -> list[dict]:
|
|
||||||
conn = _open_ro()
|
|
||||||
try:
|
|
||||||
cur = conn.execute(sql)
|
|
||||||
rows = cur.fetchmany(_QUERY_ROW_LIMIT)
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = await asyncio.get_event_loop().run_in_executor(None, _run)
|
|
||||||
return [TextContent(type="text", text=json.dumps(rows, indent=2, default=str))]
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"Query error: {exc}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _count_fts(args: dict) -> list[TextContent]:
|
|
||||||
match_expr = args.get("match_expr", "").strip()
|
|
||||||
if not match_expr:
|
|
||||||
return [TextContent(type="text", text="Error: match_expr is required.")]
|
|
||||||
|
|
||||||
def _run() -> int:
|
|
||||||
conn = _open_ro()
|
|
||||||
try:
|
|
||||||
cur = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM recipes_fts WHERE recipes_fts MATCH ?",
|
|
||||||
(match_expr,),
|
|
||||||
)
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
count = await asyncio.get_event_loop().run_in_executor(None, _run)
|
|
||||||
return [TextContent(type="text", text=json.dumps({"match_expr": match_expr, "count": count}))]
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"FTS error: {exc}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _sample_tags(args: dict) -> list[TextContent]:
|
|
||||||
prefix = args.get("prefix", "")
|
|
||||||
limit = min(int(args.get("limit", 50)), _QUERY_ROW_LIMIT)
|
|
||||||
|
|
||||||
def _run() -> list[dict]:
|
|
||||||
conn = _open_ro()
|
|
||||||
try:
|
|
||||||
# Split inferred_tags (comma or space separated) and count each tag
|
|
||||||
sql = """
|
|
||||||
WITH tag_rows AS (
|
|
||||||
SELECT trim(value) AS tag
|
|
||||||
FROM recipes, json_each('["' || replace(replace(inferred_tags, ', ', '","'), ',', '","') || '"]')
|
|
||||||
WHERE inferred_tags IS NOT NULL AND inferred_tags != ''
|
|
||||||
)
|
|
||||||
SELECT tag, COUNT(*) AS frequency
|
|
||||||
FROM tag_rows
|
|
||||||
WHERE tag LIKE ? AND tag != ''
|
|
||||||
GROUP BY tag
|
|
||||||
ORDER BY frequency DESC
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
|
||||||
pattern = f"{prefix}%" if prefix else "%"
|
|
||||||
cur = conn.execute(sql, (pattern, limit))
|
|
||||||
return [{"tag": r["tag"], "frequency": r["frequency"]} for r in cur.fetchall()]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
tags = await asyncio.get_event_loop().run_in_executor(None, _run)
|
|
||||||
return [TextContent(type="text", text=json.dumps({"prefix": prefix, "tags": tags}, indent=2))]
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"Tag query error: {exc}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _browse_preview(args: dict) -> list[TextContent]:
|
|
||||||
domain = args.get("domain", "")
|
|
||||||
category = args.get("category", "")
|
|
||||||
subcategory = args.get("subcategory", "")
|
|
||||||
page_size = min(int(args.get("page_size", 10)), 50)
|
|
||||||
|
|
||||||
params: dict = {"page": 1, "page_size": page_size}
|
|
||||||
if subcategory:
|
|
||||||
params["subcategory"] = subcategory
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
||||||
try:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{_API_URL}/api/v1/recipes/browse/{domain}/{category}",
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"Browse error: {exc}")]
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
summary = {
|
|
||||||
"domain": domain,
|
|
||||||
"category": category,
|
|
||||||
"subcategory": subcategory or None,
|
|
||||||
"total": data.get("total", 0),
|
|
||||||
"page_size": page_size,
|
|
||||||
"titles": [r.get("title", "") for r in data.get("recipes", [])],
|
|
||||||
}
|
|
||||||
return [TextContent(type="text", text=json.dumps(summary, indent=2))]
|
|
||||||
|
|
||||||
|
|
||||||
async def _main() -> None:
|
|
||||||
async with stdio_server() as (read_stream, write_stream):
|
|
||||||
await server.run(
|
|
||||||
read_stream,
|
|
||||||
write_stream,
|
|
||||||
server.create_initialization_options(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(_main())
|
|
||||||
|
|
@ -122,7 +122,6 @@ class InventoryItemResponse(BaseModel):
|
||||||
secondary_state: Optional[str] = None
|
secondary_state: Optional[str] = None
|
||||||
secondary_uses: Optional[List[str]] = None
|
secondary_uses: Optional[List[str]] = None
|
||||||
secondary_warning: Optional[str] = None
|
secondary_warning: Optional[str] = None
|
||||||
secondary_discard_signs: Optional[str] = None
|
|
||||||
status: str
|
status: str
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
disposal_reason: Optional[str] = None
|
disposal_reason: Optional[str] = None
|
||||||
|
|
@ -142,7 +141,6 @@ class BarcodeScanResult(BaseModel):
|
||||||
inventory_item: Optional[InventoryItemResponse]
|
inventory_item: Optional[InventoryItemResponse]
|
||||||
added_to_inventory: bool
|
added_to_inventory: bool
|
||||||
needs_manual_entry: bool = False
|
needs_manual_entry: bool = False
|
||||||
needs_visual_capture: bool = False # Paid tier offer when no product data found
|
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"""Pydantic schemas for visual label capture (kiwi#79)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class LabelCaptureResponse(BaseModel):
|
|
||||||
"""Extraction result returned after the user photographs a nutrition label."""
|
|
||||||
barcode: str
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
brand: Optional[str] = None
|
|
||||||
serving_size_g: Optional[float] = None
|
|
||||||
calories: Optional[float] = None
|
|
||||||
fat_g: Optional[float] = None
|
|
||||||
saturated_fat_g: Optional[float] = None
|
|
||||||
carbs_g: Optional[float] = None
|
|
||||||
sugar_g: Optional[float] = None
|
|
||||||
fiber_g: Optional[float] = None
|
|
||||||
protein_g: Optional[float] = None
|
|
||||||
sodium_mg: Optional[float] = None
|
|
||||||
ingredient_names: List[str] = Field(default_factory=list)
|
|
||||||
allergens: List[str] = Field(default_factory=list)
|
|
||||||
confidence: float = 0.0
|
|
||||||
needs_review: bool = True # True when confidence < REVIEW_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
class LabelConfirmRequest(BaseModel):
|
|
||||||
"""User-confirmed extraction to save to the local product cache."""
|
|
||||||
barcode: str
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
brand: Optional[str] = None
|
|
||||||
serving_size_g: Optional[float] = None
|
|
||||||
calories: Optional[float] = None
|
|
||||||
fat_g: Optional[float] = None
|
|
||||||
saturated_fat_g: Optional[float] = None
|
|
||||||
carbs_g: Optional[float] = None
|
|
||||||
sugar_g: Optional[float] = None
|
|
||||||
fiber_g: Optional[float] = None
|
|
||||||
protein_g: Optional[float] = None
|
|
||||||
sodium_mg: Optional[float] = None
|
|
||||||
ingredient_names: List[str] = Field(default_factory=list)
|
|
||||||
allergens: List[str] = Field(default_factory=list)
|
|
||||||
confidence: float = 0.0
|
|
||||||
# When True the confirmed product is also added to inventory
|
|
||||||
location: str = "pantry"
|
|
||||||
quantity: float = 1.0
|
|
||||||
auto_add: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class LabelConfirmResponse(BaseModel):
|
|
||||||
"""Result of confirming a captured product."""
|
|
||||||
ok: bool
|
|
||||||
barcode: str
|
|
||||||
product_id: Optional[int] = None
|
|
||||||
inventory_item_id: Optional[int] = None
|
|
||||||
message: str
|
|
||||||
|
|
@ -4,36 +4,6 @@ from __future__ import annotations
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class LeftoversResponse(BaseModel):
|
|
||||||
"""Cooked-leftover shelf-life estimate returned by POST /recipes/{id}/leftovers."""
|
|
||||||
fridge_days: int
|
|
||||||
freeze_days: int | None = None # None = not recommended
|
|
||||||
freeze_by_day: int | None = None # day number from cook date to freeze by
|
|
||||||
storage_advice: str
|
|
||||||
|
|
||||||
|
|
||||||
class StepAnalysis(BaseModel):
|
|
||||||
"""Active/passive classification for one direction step."""
|
|
||||||
is_passive: bool
|
|
||||||
detected_minutes: int | None = None
|
|
||||||
prep_min: int | None = None # estimated physical prep time (action detection)
|
|
||||||
|
|
||||||
|
|
||||||
class TimeEffortProfile(BaseModel):
|
|
||||||
"""Parsed time and effort profile for a recipe.
|
|
||||||
|
|
||||||
Mirrors app.services.recipe.time_effort.TimeEffortProfile (dataclass).
|
|
||||||
Serialised into RecipeSuggestion so the frontend can render the effort
|
|
||||||
summary without a second round-trip.
|
|
||||||
"""
|
|
||||||
active_min: int = 0
|
|
||||||
passive_min: int = 0
|
|
||||||
total_min: int = 0
|
|
||||||
effort_label: str = "moderate" # "quick" | "moderate" | "involved"
|
|
||||||
equipment: list[str] = Field(default_factory=list)
|
|
||||||
step_analyses: list[StepAnalysis] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class SwapCandidate(BaseModel):
|
class SwapCandidate(BaseModel):
|
||||||
original_name: str
|
original_name: str
|
||||||
substitute_name: str
|
substitute_name: str
|
||||||
|
|
@ -73,8 +43,6 @@ class RecipeSuggestion(BaseModel):
|
||||||
source_url: str | None = None
|
source_url: str | None = None
|
||||||
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
||||||
estimated_time_min: int | None = None # derived from step count + method signals
|
estimated_time_min: int | None = None # derived from step count + method signals
|
||||||
time_effort: TimeEffortProfile | None = None # full time/effort profile from parse_time_effort
|
|
||||||
rerank_score: float | None = None # cross-encoder relevance score (paid+ only, None for free tier)
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLink(BaseModel):
|
class GroceryLink(BaseModel):
|
||||||
|
|
@ -93,18 +61,6 @@ class RecipeResult(BaseModel):
|
||||||
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
||||||
|
|
||||||
|
|
||||||
class RecipeJobQueued(BaseModel):
|
|
||||||
job_id: str
|
|
||||||
status: str = "queued"
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeJobStatus(BaseModel):
|
|
||||||
job_id: str
|
|
||||||
status: str
|
|
||||||
result: RecipeResult | None = None
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionFilters(BaseModel):
|
class NutritionFilters(BaseModel):
|
||||||
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
||||||
max_calories: float | None = None
|
max_calories: float | None = None
|
||||||
|
|
@ -115,10 +71,6 @@ class NutritionFilters(BaseModel):
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
class RecipeRequest(BaseModel):
|
||||||
pantry_items: list[str]
|
pantry_items: list[str]
|
||||||
# Maps product name → secondary state label for items past nominal expiry
|
|
||||||
# but still within their secondary use window (e.g. {"Bread": "stale"}).
|
|
||||||
# Used by the recipe engine to boost recipes suited to those specific states.
|
|
||||||
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
|
|
||||||
level: int = Field(default=1, ge=1, le=4)
|
level: int = Field(default=1, ge=1, le=4)
|
||||||
constraints: list[str] = Field(default_factory=list)
|
constraints: list[str] = Field(default_factory=list)
|
||||||
expiry_first: bool = False
|
expiry_first: bool = False
|
||||||
|
|
@ -132,13 +84,10 @@ class RecipeRequest(BaseModel):
|
||||||
allergies: list[str] = Field(default_factory=list)
|
allergies: list[str] = Field(default_factory=list)
|
||||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
excluded_ids: list[int] = Field(default_factory=list)
|
||||||
exclude_ingredients: list[str] = Field(default_factory=list)
|
|
||||||
shopping_mode: bool = False
|
shopping_mode: bool = False
|
||||||
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
||||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
||||||
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
||||||
max_total_min: int | None = None # filter by parsed total time (active + passive)
|
|
||||||
max_active_min: int | None = None # filter by hands-on active time only
|
|
||||||
unit_system: str = "metric" # "metric" | "imperial"
|
unit_system: str = "metric" # "metric" | "imperial"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -185,45 +134,3 @@ class BuildRequest(BaseModel):
|
||||||
|
|
||||||
template_id: str
|
template_id: str
|
||||||
role_overrides: dict[str, str] = Field(default_factory=dict)
|
role_overrides: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class StreamTokenRequest(BaseModel):
|
|
||||||
"""Request body for POST /recipes/stream-token.
|
|
||||||
|
|
||||||
Pantry items and dietary constraints are fetched from the DB at request
|
|
||||||
time — the client does not supply them here.
|
|
||||||
"""
|
|
||||||
level: int = Field(4, ge=3, le=4, description="Recipe level: 3=styled, 4=wildcard")
|
|
||||||
wildcard_confirmed: bool = Field(False, description="Required true for level 4")
|
|
||||||
|
|
||||||
|
|
||||||
class StreamTokenResponse(BaseModel):
|
|
||||||
"""Response from POST /recipes/stream-token.
|
|
||||||
|
|
||||||
The frontend opens EventSource at stream_url?token=<token> to receive
|
|
||||||
SSE chunks directly from the coordinator.
|
|
||||||
"""
|
|
||||||
stream_url: str
|
|
||||||
token: str
|
|
||||||
expires_in_s: int
|
|
||||||
|
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
|
||||||
"""Request body for POST /recipes/ask."""
|
|
||||||
question: str = Field(min_length=1, max_length=500)
|
|
||||||
pantry_items: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AskRecipeHit(BaseModel):
|
|
||||||
"""A single recipe result from the Ask endpoint."""
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
match_pct: float | None = None
|
|
||||||
category: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AskResponse(BaseModel):
|
|
||||||
"""Response from POST /recipes/ask."""
|
|
||||||
answer: str | None = None # LLM-synthesized response (Paid tier only)
|
|
||||||
recipes: list[AskRecipeHit]
|
|
||||||
tier: str
|
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
"""Pydantic schemas for the recipe scanner (kiwi#9).
|
|
||||||
|
|
||||||
Scan input → photo(s).
|
|
||||||
Scan output → ScannedRecipeResponse (for review + editing before save).
|
|
||||||
Save input → ScannedRecipeSaveRequest.
|
|
||||||
User recipe output → UserRecipeResponse (after save).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ScannedIngredientSchema(BaseModel):
|
|
||||||
"""One ingredient line extracted from a recipe photo."""
|
|
||||||
name: str # normalized generic name ("ranch dressing")
|
|
||||||
qty: str | None = None # quantity as string, preserving fractions ("1/2", "¼")
|
|
||||||
unit: str | None = None # unit of measure; null for countable items
|
|
||||||
raw: str | None = None # verbatim original line from the image
|
|
||||||
in_pantry: bool = False # True if this ingredient matches something in the pantry
|
|
||||||
|
|
||||||
|
|
||||||
# ── Scan response (returned immediately, not persisted) ───────────────────────
|
|
||||||
|
|
||||||
class ScannedRecipeResponse(BaseModel):
|
|
||||||
"""Structured recipe extracted from photo(s). Returned for user review before save."""
|
|
||||||
title: str | None = None
|
|
||||||
subtitle: str | None = None # e.g. "with Broccoli & Ranch Dressing"
|
|
||||||
servings: str | None = None # kept as string: "2", "4-6", "serves 8"
|
|
||||||
cook_time: str | None = None # kept as string: "25 min", "1 hour"
|
|
||||||
source_note: str | None = None # e.g. "Purple Carrot", "Betty Crocker"
|
|
||||||
ingredients: list[ScannedIngredientSchema] = Field(default_factory=list)
|
|
||||||
steps: list[str] = Field(default_factory=list)
|
|
||||||
notes: str | None = None
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
pantry_match_pct: int = 0 # 0-100: percentage of ingredients found in pantry
|
|
||||||
confidence: str = "medium" # "high" | "medium" | "low"
|
|
||||||
warnings: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Save request ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ScannedRecipeSaveRequest(BaseModel):
|
|
||||||
"""User-reviewed (possibly edited) recipe data to persist as a user recipe."""
|
|
||||||
title: str
|
|
||||||
subtitle: str | None = None
|
|
||||||
servings: str | None = None
|
|
||||||
cook_time: str | None = None
|
|
||||||
source_note: str | None = None
|
|
||||||
ingredients: list[ScannedIngredientSchema]
|
|
||||||
steps: list[str]
|
|
||||||
notes: str | None = None
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
source: str = "scan" # "scan" | "manual"
|
|
||||||
|
|
||||||
|
|
||||||
# ── User recipe (persisted) ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class UserRecipeResponse(BaseModel):
|
|
||||||
"""A user-created or user-scanned recipe stored in user_recipes table."""
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
subtitle: str | None = None
|
|
||||||
servings: str | None = None
|
|
||||||
cook_time: str | None = None
|
|
||||||
source_note: str | None = None
|
|
||||||
ingredients: list[ScannedIngredientSchema]
|
|
||||||
steps: list[str]
|
|
||||||
notes: str | None = None
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
source: str
|
|
||||||
pantry_match_pct: int | None = None
|
|
||||||
created_at: str
|
|
||||||
|
|
@ -3,11 +3,6 @@
|
||||||
Business logic services for Kiwi.
|
Business logic services for Kiwi.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.services.receipt_service import ReceiptService
|
||||||
|
|
||||||
__all__ = ["ReceiptService"]
|
__all__ = ["ReceiptService"]
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
|
||||||
if name == "ReceiptService":
|
|
||||||
from app.services.receipt_service import ReceiptService
|
|
||||||
return ReceiptService
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# app/services/ap/delivery.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from circuitforge_core.activitypub import deliver_activity
|
|
||||||
|
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_RETRIES = 3
|
|
||||||
_BACKOFF = [1.0, 4.0, 16.0]
|
|
||||||
|
|
||||||
|
|
||||||
def deliver_to_followers(post_slug: str, activity: dict, db_path: Path) -> None:
|
|
||||||
"""Deliver an AP activity to all active followers. Called as a background task.
|
|
||||||
|
|
||||||
Retries each inbox up to 3 times with exponential backoff.
|
|
||||||
Logs each attempt to ap_deliveries in the local kiwi.db.
|
|
||||||
"""
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
followers = conn.execute(
|
|
||||||
"SELECT inbox_url, shared_inbox FROM ap_followers WHERE active = 1"
|
|
||||||
).fetchall()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Deduplicate by shared_inbox where available
|
|
||||||
inboxes: set[str] = set()
|
|
||||||
for row in followers:
|
|
||||||
inbox = row["shared_inbox"] or row["inbox_url"]
|
|
||||||
inboxes.add(inbox)
|
|
||||||
|
|
||||||
for inbox_url in inboxes:
|
|
||||||
_deliver_with_retry(post_slug=post_slug, activity=activity, inbox_url=inbox_url, db_path=db_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _deliver_with_retry(
|
|
||||||
post_slug: str,
|
|
||||||
activity: dict,
|
|
||||||
inbox_url: str,
|
|
||||||
db_path: Path,
|
|
||||||
) -> None:
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO ap_deliveries (post_slug, target_inbox, status) VALUES (?,?,?)",
|
|
||||||
(post_slug, inbox_url, "pending"),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
last_error: str | None = None
|
|
||||||
for attempt, delay in enumerate(_BACKOFF[:_RETRIES]):
|
|
||||||
try:
|
|
||||||
resp = deliver_activity(activity=activity, inbox_url=inbox_url, actor=actor, timeout=10.0)
|
|
||||||
if resp.status_code < 300:
|
|
||||||
_update_delivery(db_path, post_slug, inbox_url, "delivered", None)
|
|
||||||
return
|
|
||||||
last_error = f"HTTP {resp.status_code}"
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = str(exc)[:200]
|
|
||||||
|
|
||||||
if attempt < _RETRIES - 1:
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
_update_delivery(db_path, post_slug, inbox_url, "failed", last_error)
|
|
||||||
logger.warning("AP delivery failed after %d attempts to %s: %s", _RETRIES, inbox_url, last_error)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_delivery(
|
|
||||||
db_path: Path,
|
|
||||||
post_slug: str,
|
|
||||||
inbox_url: str,
|
|
||||||
status: str,
|
|
||||||
error: str | None,
|
|
||||||
) -> None:
|
|
||||||
import sqlite3
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
if status == "delivered":
|
|
||||||
conn.execute(
|
|
||||||
"""UPDATE ap_deliveries SET status=?, attempts=attempts+1, delivered_at=?
|
|
||||||
WHERE post_slug=? AND target_inbox=?""",
|
|
||||||
(status, now, post_slug, inbox_url),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
"""UPDATE ap_deliveries SET status=?, attempts=attempts+1, last_error=?
|
|
||||||
WHERE post_slug=? AND target_inbox=?""",
|
|
||||||
(status, error, post_slug, inbox_url),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# app/services/ap/keys.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from circuitforge_core.activitypub import CFActor, generate_rsa_keypair, load_actor_from_key_file
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_actor: CFActor | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_actor() -> CFActor | None:
|
|
||||||
"""Return the loaded instance actor, or None if AP is not enabled."""
|
|
||||||
return _actor
|
|
||||||
|
|
||||||
|
|
||||||
def init_actor(host: str, key_path: Path) -> CFActor:
|
|
||||||
"""Load or generate the instance RSA keypair and build the CFActor singleton.
|
|
||||||
|
|
||||||
Called once at startup when AP_ENABLED=true. Generates a new 2048-bit keypair
|
|
||||||
if the key file does not yet exist (first boot).
|
|
||||||
"""
|
|
||||||
global _actor
|
|
||||||
|
|
||||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if not key_path.exists():
|
|
||||||
logger.info("AP: no key file found at %s — generating new RSA-2048 keypair", key_path)
|
|
||||||
private_pem, _pub = generate_rsa_keypair(bits=2048)
|
|
||||||
key_path.write_text(private_pem, encoding="utf-8")
|
|
||||||
key_path.chmod(0o600)
|
|
||||||
|
|
||||||
base = f"https://{host}"
|
|
||||||
actor_id = f"{base}/ap/actor"
|
|
||||||
|
|
||||||
_actor = load_actor_from_key_file(
|
|
||||||
actor_id=actor_id,
|
|
||||||
username="kiwi",
|
|
||||||
display_name="Kiwi Pantry",
|
|
||||||
private_key_path=str(key_path),
|
|
||||||
summary="Community pantry and recipe feed from a Kiwi instance.",
|
|
||||||
)
|
|
||||||
logger.info("AP: instance actor loaded — %s", actor_id)
|
|
||||||
return _actor
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
# app/services/ap/mastodon.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_APP_SCOPES = "write:statuses"
|
|
||||||
_APP_NAME = "Kiwi Pantry"
|
|
||||||
_APP_WEBSITE = "https://circuitforge.tech/kiwi"
|
|
||||||
|
|
||||||
|
|
||||||
def register_app(instance_url: str, redirect_uri: str) -> dict:
|
|
||||||
"""Dynamically register Kiwi as an OAuth app on the user's Mastodon instance.
|
|
||||||
|
|
||||||
Returns the app credentials dict (client_id, client_secret, etc.).
|
|
||||||
Raises httpx.HTTPError on failure.
|
|
||||||
"""
|
|
||||||
url = instance_url.rstrip("/") + "/api/v1/apps"
|
|
||||||
resp = httpx.post(
|
|
||||||
url,
|
|
||||||
data={
|
|
||||||
"client_name": _APP_NAME,
|
|
||||||
"redirect_uris": redirect_uri,
|
|
||||||
"scopes": _APP_SCOPES,
|
|
||||||
"website": _APP_WEBSITE,
|
|
||||||
},
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def build_authorize_url(instance_url: str, client_id: str, redirect_uri: str) -> str:
|
|
||||||
"""Return the OAuth authorize URL to redirect the user to."""
|
|
||||||
return (
|
|
||||||
f"{instance_url.rstrip('/')}/oauth/authorize"
|
|
||||||
f"?response_type=code"
|
|
||||||
f"&client_id={client_id}"
|
|
||||||
f"&redirect_uri={redirect_uri}"
|
|
||||||
f"&scope={_APP_SCOPES}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def exchange_code(
|
|
||||||
instance_url: str,
|
|
||||||
client_id: str,
|
|
||||||
client_secret: str,
|
|
||||||
code: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
) -> str:
|
|
||||||
"""Exchange an authorization code for an access token. Returns the token string."""
|
|
||||||
url = instance_url.rstrip("/") + "/oauth/token"
|
|
||||||
resp = httpx.post(
|
|
||||||
url,
|
|
||||||
data={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"code": code,
|
|
||||||
"scope": _APP_SCOPES,
|
|
||||||
},
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
def post_status(instance_url: str, access_token: str, content: str) -> dict:
|
|
||||||
"""Post a status to the user's Mastodon account. Returns the status response dict."""
|
|
||||||
url = instance_url.rstrip("/") + "/api/v1/statuses"
|
|
||||||
resp = httpx.post(
|
|
||||||
url,
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
json={"status": content, "visibility": "public"},
|
|
||||||
timeout=15.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def build_post_content(post: dict) -> str:
|
|
||||||
"""Format a community post dict as Mastodon-ready plain text."""
|
|
||||||
title = post.get("title") or "Untitled"
|
|
||||||
recipe = post.get("recipe_name")
|
|
||||||
notes = post.get("outcome_notes") or post.get("description")
|
|
||||||
tags_raw: list[str] = post.get("dietary_tags") or []
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
if recipe and recipe != title:
|
|
||||||
lines.append(f"🍽 {title} — {recipe}")
|
|
||||||
else:
|
|
||||||
lines.append(f"🍽 {title}")
|
|
||||||
|
|
||||||
if notes:
|
|
||||||
snippet = notes[:200].strip()
|
|
||||||
if len(notes) > 200:
|
|
||||||
snippet += "…"
|
|
||||||
lines.append(f"\n{snippet}")
|
|
||||||
|
|
||||||
hashtags = ["#Kiwi", "#Cooking"]
|
|
||||||
for tag in tags_raw[:3]:
|
|
||||||
ht = "#" + "".join(w.capitalize() for w in tag.replace("-", " ").split())
|
|
||||||
hashtags.append(ht)
|
|
||||||
lines.append("\n" + " ".join(hashtags))
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def store_token(
|
|
||||||
db_path: Path,
|
|
||||||
directus_user_id: str,
|
|
||||||
instance_url: str,
|
|
||||||
access_token: str,
|
|
||||||
encryption_key: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Persist a Mastodon access token in the user's local kiwi.db."""
|
|
||||||
token_to_store = _encrypt(access_token, encryption_key)
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO mastodon_tokens (directus_user_id, instance_url, access_token)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(directus_user_id) DO UPDATE SET
|
|
||||||
instance_url=excluded.instance_url,
|
|
||||||
access_token=excluded.access_token,
|
|
||||||
updated_at=datetime('now')""",
|
|
||||||
(directus_user_id, instance_url.rstrip("/"), token_to_store),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_token(
|
|
||||||
db_path: Path,
|
|
||||||
directus_user_id: str,
|
|
||||||
encryption_key: str | None,
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
"""Return (instance_url, plaintext_access_token) or None if not connected."""
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT instance_url, access_token FROM mastodon_tokens WHERE directus_user_id = ?",
|
|
||||||
(directus_user_id,),
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row[0], _decrypt(row[1], encryption_key)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_token(db_path: Path, directus_user_id: str) -> None:
|
|
||||||
"""Remove the user's stored Mastodon token."""
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM mastodon_tokens WHERE directus_user_id = ?", (directus_user_id,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _encrypt(plaintext: str, key: str | None) -> str:
|
|
||||||
if key is None:
|
|
||||||
return plaintext
|
|
||||||
try:
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
return Fernet(key.encode()).encrypt(plaintext.encode()).decode()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Mastodon token encryption failed — storing plaintext")
|
|
||||||
return plaintext
|
|
||||||
|
|
||||||
|
|
||||||
def _decrypt(ciphertext: str, key: str | None) -> str:
|
|
||||||
if key is None:
|
|
||||||
return ciphertext
|
|
||||||
try:
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
return Fernet(key.encode()).decrypt(ciphertext.encode()).decode()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Mastodon token decryption failed — returning as-is")
|
|
||||||
return ciphertext
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
# app/services/community/dedup.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SIMILARITY_TIERS = {
|
|
||||||
"exact_recipe": "This exact recipe is already in the community feed.",
|
|
||||||
"very_similar": "Very similar recipes already exist (70%+ ingredient overlap).",
|
|
||||||
"somewhat_similar": "Somewhat similar recipes exist (35-70% ingredient overlap).",
|
|
||||||
"different": "No close matches found.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ingredient_names(raw) -> set[str]:
|
|
||||||
"""Return a normalised set of ingredient name tokens from various stored formats."""
|
|
||||||
if raw is None:
|
|
||||||
return set()
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
raw = json.loads(raw)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return set()
|
|
||||||
names: set[str] = set()
|
|
||||||
for item in raw:
|
|
||||||
if isinstance(item, str):
|
|
||||||
names.add(item.lower().strip())
|
|
||||||
elif isinstance(item, dict):
|
|
||||||
name = item.get("name") or item.get("ingredient") or ""
|
|
||||||
if name:
|
|
||||||
names.add(name.lower().strip())
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def jaccard(a: set[str], b: set[str]) -> float:
|
|
||||||
if not a and not b:
|
|
||||||
return 1.0
|
|
||||||
if not a or not b:
|
|
||||||
return 0.0
|
|
||||||
return len(a & b) / len(a | b)
|
|
||||||
|
|
||||||
|
|
||||||
def similarity_tier(jaccard_score: float, exact_recipe: bool) -> str:
|
|
||||||
if exact_recipe:
|
|
||||||
return "exact_recipe"
|
|
||||||
if jaccard_score >= 0.70:
|
|
||||||
return "very_similar"
|
|
||||||
if jaccard_score >= 0.35:
|
|
||||||
return "somewhat_similar"
|
|
||||||
return "different"
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_recipe_ingredients(db_path: Path, recipe_id: int | None) -> set[str]:
|
|
||||||
"""Look up ingredient names for a recipe from the local corpus. Returns empty set on miss."""
|
|
||||||
if recipe_id is None:
|
|
||||||
return set()
|
|
||||||
try:
|
|
||||||
from app.db.store import Store
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
row = store.get_recipe(recipe_id)
|
|
||||||
if row is None:
|
|
||||||
return set()
|
|
||||||
return _parse_ingredient_names(row.get("ingredient_names"))
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("ingredient lookup failed for recipe_id=%s", recipe_id)
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def build_similar_post_result(
|
|
||||||
post,
|
|
||||||
incoming_recipe_id: int | None,
|
|
||||||
incoming_ingredients: set[str],
|
|
||||||
db_path: Path,
|
|
||||||
) -> dict:
|
|
||||||
"""Build a similarity result dict for one existing community post."""
|
|
||||||
exact = (
|
|
||||||
incoming_recipe_id is not None
|
|
||||||
and post.recipe_id is not None
|
|
||||||
and post.recipe_id == incoming_recipe_id
|
|
||||||
)
|
|
||||||
|
|
||||||
j_score = 0.0
|
|
||||||
if not exact and incoming_ingredients:
|
|
||||||
existing_ingredients = fetch_recipe_ingredients(db_path, post.recipe_id)
|
|
||||||
if existing_ingredients:
|
|
||||||
j_score = jaccard(incoming_ingredients, existing_ingredients)
|
|
||||||
|
|
||||||
tier = similarity_tier(j_score, exact)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"slug": post.slug,
|
|
||||||
"title": post.title,
|
|
||||||
"recipe_name": post.recipe_name,
|
|
||||||
"pseudonym": post.pseudonym,
|
|
||||||
"published": (
|
|
||||||
post.published.isoformat()
|
|
||||||
if hasattr(post.published, "isoformat")
|
|
||||||
else str(post.published)
|
|
||||||
),
|
|
||||||
"similarity_tier": tier,
|
|
||||||
"jaccard_score": round(j_score, 3) if not exact else None,
|
|
||||||
"tier_description": _SIMILARITY_TIERS.get(tier, ""),
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"""cf-orch coordinator proxy client.
|
|
||||||
|
|
||||||
Calls the coordinator's /proxy/authorize endpoint to obtain a one-time
|
|
||||||
stream URL + token for LLM streaming. Always raises CoordinatorError on
|
|
||||||
failure — callers decide how to handle it (stream-token endpoint returns
|
|
||||||
503 or 403 as appropriate).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CoordinatorError(Exception):
|
|
||||||
"""Raised when the coordinator returns an error or is unreachable."""
|
|
||||||
def __init__(self, message: str, status_code: int = 503):
|
|
||||||
super().__init__(message)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StreamTokenResult:
|
|
||||||
stream_url: str
|
|
||||||
token: str
|
|
||||||
expires_in_s: int
|
|
||||||
|
|
||||||
|
|
||||||
def _coordinator_url() -> str:
|
|
||||||
return os.environ.get("COORDINATOR_URL", "http://10.1.10.71:7700")
|
|
||||||
|
|
||||||
|
|
||||||
def _product_key() -> str:
|
|
||||||
return os.environ.get("COORDINATOR_KIWI_KEY", "")
|
|
||||||
|
|
||||||
|
|
||||||
async def coordinator_authorize(
|
|
||||||
prompt: str,
|
|
||||||
caller: str = "kiwi-recipe",
|
|
||||||
ttl_s: int = 300,
|
|
||||||
) -> StreamTokenResult:
|
|
||||||
"""Call POST /proxy/authorize on the coordinator.
|
|
||||||
|
|
||||||
Returns a StreamTokenResult with the stream URL and one-time token.
|
|
||||||
Raises CoordinatorError on any failure (network, auth, capacity).
|
|
||||||
"""
|
|
||||||
url = f"{_coordinator_url()}/proxy/authorize"
|
|
||||||
key = _product_key()
|
|
||||||
if not key:
|
|
||||||
raise CoordinatorError(
|
|
||||||
"COORDINATOR_KIWI_KEY env var is not set — streaming unavailable",
|
|
||||||
status_code=503,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"product": "kiwi",
|
|
||||||
"product_key": key,
|
|
||||||
"caller": caller,
|
|
||||||
"prompt": prompt,
|
|
||||||
"params": {},
|
|
||||||
"ttl_s": ttl_s,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.post(url, json=payload)
|
|
||||||
except httpx.RequestError as exc:
|
|
||||||
log.warning("coordinator_authorize network error: %s", exc)
|
|
||||||
raise CoordinatorError(f"Coordinator unreachable: {exc}", status_code=503)
|
|
||||||
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise CoordinatorError("Invalid product key", status_code=401)
|
|
||||||
if resp.status_code == 429:
|
|
||||||
raise CoordinatorError("Too many concurrent streams", status_code=429)
|
|
||||||
if resp.status_code == 503:
|
|
||||||
raise CoordinatorError("No GPU available for streaming", status_code=503)
|
|
||||||
if not resp.is_success:
|
|
||||||
raise CoordinatorError(
|
|
||||||
f"Coordinator error {resp.status_code}: {resp.text[:200]}",
|
|
||||||
status_code=503,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
# Use public_stream_url if coordinator provides it (cloud mode), else stream_url
|
|
||||||
stream_url = data.get("public_stream_url") or data["stream_url"]
|
|
||||||
return StreamTokenResult(
|
|
||||||
stream_url=stream_url,
|
|
||||||
token=data["token"],
|
|
||||||
expires_in_s=data["expires_in_s"],
|
|
||||||
)
|
|
||||||
|
|
@ -157,160 +157,42 @@ class ExpirationPredictor:
|
||||||
# These are NOT spoilage extensions — they describe a qualitative state
|
# These are NOT spoilage extensions — they describe a qualitative state
|
||||||
# change where the ingredient is specifically suited for certain preparations.
|
# change where the ingredient is specifically suited for certain preparations.
|
||||||
# Sources: USDA FoodKeeper, food science, culinary tradition.
|
# Sources: USDA FoodKeeper, food science, culinary tradition.
|
||||||
#
|
|
||||||
# Fields:
|
|
||||||
# window_days — days past nominal expiry still usable in secondary state
|
|
||||||
# label — short UI label for the state
|
|
||||||
# uses — recipe contexts suited to this state (shown in UI)
|
|
||||||
# warning — safety note, calm tone, None if none needed
|
|
||||||
# discard_signs — qualitative signs the item has gone past the secondary window
|
|
||||||
# constraints_exclude — dietary constraint labels that suppress this entry entirely
|
|
||||||
# (e.g. alcohol-containing items suppressed for halal/alcohol-free)
|
|
||||||
SECONDARY_WINDOW: dict[str, dict] = {
|
SECONDARY_WINDOW: dict[str, dict] = {
|
||||||
'bread': {
|
'bread': {
|
||||||
'window_days': 5,
|
'window_days': 5,
|
||||||
'label': 'stale',
|
'label': 'stale',
|
||||||
'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'],
|
'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'],
|
||||||
'warning': 'Check for mold before use — discard if any is visible.',
|
'warning': 'Check for mold before use — discard if any is visible.',
|
||||||
'discard_signs': 'Visible mold (any colour), or unpleasant smell beyond dry/yeasty.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
},
|
||||||
'bakery': {
|
'bakery': {
|
||||||
'window_days': 3,
|
'window_days': 3,
|
||||||
'label': 'day-old',
|
'label': 'day-old',
|
||||||
'uses': ['French toast', 'bread pudding', 'crumbles', 'trifle base', 'cake pops', 'streusel topping', 'bread crumbs'],
|
'uses': ['French toast', 'bread pudding', 'crumbles'],
|
||||||
'warning': 'Check for mold before use — discard if any is visible.',
|
'warning': 'Check for mold before use — discard if any is visible.',
|
||||||
'discard_signs': 'Visible mold, sliminess, or strong sour smell.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
},
|
||||||
'bananas': {
|
'bananas': {
|
||||||
'window_days': 5,
|
'window_days': 5,
|
||||||
'label': 'overripe',
|
'label': 'overripe',
|
||||||
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
|
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
|
||||||
'warning': None,
|
'warning': None,
|
||||||
'discard_signs': 'Leaking liquid, fermented smell, or mold on skin.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
},
|
||||||
'milk': {
|
'milk': {
|
||||||
'window_days': 3,
|
'window_days': 3,
|
||||||
'label': 'sour',
|
'label': 'sour',
|
||||||
'uses': ['pancakes', 'scones', 'waffles', 'muffins', 'quick breads', 'béchamel', 'baked mac and cheese'],
|
'uses': ['pancakes', 'quick breads', 'baking', 'sauces'],
|
||||||
'warning': 'Use only in cooked recipes — do not drink.',
|
'warning': 'Use only in cooked recipes — do not drink.',
|
||||||
'discard_signs': 'Chunky texture, strong unpleasant smell beyond tangy, or visible separation with grey colour.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
},
|
||||||
'dairy': {
|
'dairy': {
|
||||||
'window_days': 2,
|
'window_days': 2,
|
||||||
'label': 'sour',
|
'label': 'sour',
|
||||||
'uses': ['pancakes', 'scones', 'quick breads', 'muffins', 'waffles'],
|
'uses': ['pancakes', 'quick breads', 'baking'],
|
||||||
'warning': 'Use only in cooked recipes — do not drink.',
|
'warning': 'Use only in cooked recipes — do not drink.',
|
||||||
'discard_signs': 'Strong unpleasant smell, unusual colour, or chunky texture.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
},
|
||||||
'cheese': {
|
'cheese': {
|
||||||
'window_days': 14,
|
'window_days': 14,
|
||||||
'label': 'rind-ready',
|
'label': 'well-aged',
|
||||||
'uses': ['parmesan broth', 'minestrone', 'ribollita', 'risotto', 'polenta', 'bean soups', 'gratins'],
|
'uses': ['broth', 'soups', 'risotto', 'gratins'],
|
||||||
'warning': None,
|
'warning': None,
|
||||||
'discard_signs': 'Soft or wet texture on hard cheese, pink or black mold (white/green surface mold on hard cheese can be cut off with 1cm margin).',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'rice': {
|
|
||||||
'window_days': 2,
|
|
||||||
'label': 'day-old',
|
|
||||||
'uses': ['fried rice', 'onigiri', 'rice porridge', 'congee', 'arancini', 'stuffed peppers', 'rice fritters'],
|
|
||||||
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
|
|
||||||
'discard_signs': 'Slimy texture, unusual smell, or more than 4 days since cooking.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'tortillas': {
|
|
||||||
'window_days': 5,
|
|
||||||
'label': 'stale',
|
|
||||||
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
|
|
||||||
'warning': 'Check for mold, especially if stored in a sealed bag — discard if any is visible.',
|
|
||||||
'discard_signs': 'Visible mold (check seams and edges), or strong sour smell.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
# ── New entries ──────────────────────────────────────────────────────
|
|
||||||
'apples': {
|
|
||||||
'window_days': 7,
|
|
||||||
'label': 'soft',
|
|
||||||
'uses': ['applesauce', 'apple butter', 'baked apples', 'apple crisp', 'smoothies', 'chutney'],
|
|
||||||
'warning': None,
|
|
||||||
'discard_signs': 'Large bruised areas with fermented smell, visible mold, or liquid leaking from skin.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'leafy_greens': {
|
|
||||||
'window_days': 2,
|
|
||||||
'label': 'wilting',
|
|
||||||
'uses': ['sautéed greens', 'soups', 'smoothies', 'frittata', 'pasta add-in', 'stir fry'],
|
|
||||||
'warning': None,
|
|
||||||
'discard_signs': 'Slimy texture, strong unpleasant smell, or yellowed and mushy leaves.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'tomatoes': {
|
|
||||||
'window_days': 4,
|
|
||||||
'label': 'soft',
|
|
||||||
'uses': ['roasted tomatoes', 'tomato sauce', 'shakshuka', 'bruschetta', 'soup', 'salsa'],
|
|
||||||
'warning': None,
|
|
||||||
'discard_signs': 'Broken skin with liquid pooling, mold, or fermented smell.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'cooked_pasta': {
|
|
||||||
'window_days': 3,
|
|
||||||
'label': 'day-old',
|
|
||||||
'uses': ['pasta frittata', 'pasta salad', 'baked pasta', 'soup add-in', 'fried pasta cakes'],
|
|
||||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
|
||||||
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'cooked_potatoes': {
|
|
||||||
'window_days': 3,
|
|
||||||
'label': 'day-old',
|
|
||||||
'uses': ['potato pancakes', 'hash browns', 'potato soup', 'gnocchi', 'twice-baked potatoes', 'croquettes'],
|
|
||||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
|
||||||
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'yogurt': {
|
|
||||||
'window_days': 7,
|
|
||||||
'label': 'tangy',
|
|
||||||
'uses': ['marinades', 'flatbreads', 'smoothies', 'tzatziki', 'baked goods', 'salad dressings'],
|
|
||||||
'warning': None,
|
|
||||||
'discard_signs': 'Pink or orange discolouration, visible mold, or strongly unpleasant smell (not just tangy).',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'cream': {
|
|
||||||
'window_days': 2,
|
|
||||||
'label': 'sour',
|
|
||||||
'uses': ['soups', 'sauces', 'scones', 'quick breads', 'mashed potatoes'],
|
|
||||||
'warning': 'Use in cooked recipes only. Discard if the smell is strongly unpleasant rather than tangy.',
|
|
||||||
'discard_signs': 'Strong unpleasant smell beyond tangy, unusual colour, or chunky texture.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'wine': {
|
|
||||||
'window_days': 4,
|
|
||||||
'label': 'open',
|
|
||||||
'uses': ['pan sauces', 'braises', 'risotto', 'marinades', 'poaching liquid', 'wine reduction'],
|
|
||||||
'warning': None,
|
|
||||||
'discard_signs': 'Strong vinegar smell (still usable in braises/marinades), or visible cloudiness with off-smell.',
|
|
||||||
'constraints_exclude': ['halal', 'alcohol-free'],
|
|
||||||
},
|
|
||||||
'cooked_beans': {
|
|
||||||
'window_days': 3,
|
|
||||||
'label': 'day-old',
|
|
||||||
'uses': ['refried beans', 'bean soup', 'bean fritters', 'hummus', 'bean dip', 'grain bowls'],
|
|
||||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
|
||||||
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
|
||||||
'cooked_meat': {
|
|
||||||
'window_days': 2,
|
|
||||||
'label': 'leftover',
|
|
||||||
'uses': ['grain bowls', 'tacos', 'soups', 'fried rice', 'sandwiches', 'hash', 'pasta add-in'],
|
|
||||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
|
||||||
'discard_signs': 'Off smell, slimy texture, or more than 3–4 days since cooking.',
|
|
||||||
'constraints_exclude': [],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,15 +211,10 @@ class ExpirationPredictor:
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Return secondary use info if the item is in its post-expiry secondary window.
|
"""Return secondary use info if the item is in its post-expiry secondary window.
|
||||||
|
|
||||||
Returns a dict with label, uses, warning, discard_signs, constraints_exclude,
|
Returns a dict with label, uses, warning, days_past, and window_days when the
|
||||||
days_past, and window_days when the item is past its nominal expiry date but
|
item is past its nominal expiry date but still within the secondary use window.
|
||||||
still within the secondary use window.
|
|
||||||
Returns None in all other cases (unknown category, no window defined, not yet
|
Returns None in all other cases (unknown category, no window defined, not yet
|
||||||
expired, or past the secondary window).
|
expired, or past the secondary window).
|
||||||
|
|
||||||
Callers should apply constraints_exclude against user dietary constraints
|
|
||||||
and suppress the result entirely if any excluded constraint is active.
|
|
||||||
See filter_secondary_by_constraints().
|
|
||||||
"""
|
"""
|
||||||
if not category or not expiry_date:
|
if not category or not expiry_date:
|
||||||
return None
|
return None
|
||||||
|
|
@ -354,8 +231,6 @@ class ExpirationPredictor:
|
||||||
'label': entry['label'],
|
'label': entry['label'],
|
||||||
'uses': list(entry['uses']),
|
'uses': list(entry['uses']),
|
||||||
'warning': entry['warning'],
|
'warning': entry['warning'],
|
||||||
'discard_signs': entry.get('discard_signs'),
|
|
||||||
'constraints_exclude': list(entry.get('constraints_exclude') or []),
|
|
||||||
'days_past': days_past,
|
'days_past': days_past,
|
||||||
'window_days': entry['window_days'],
|
'window_days': entry['window_days'],
|
||||||
}
|
}
|
||||||
|
|
@ -363,23 +238,6 @@ class ExpirationPredictor:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def filter_secondary_by_constraints(
|
|
||||||
sec: dict | None,
|
|
||||||
user_constraints: list[str],
|
|
||||||
) -> dict | None:
|
|
||||||
"""Suppress secondary state entirely if any excluded constraint is active.
|
|
||||||
|
|
||||||
Call after secondary_state() when user dietary constraints are available.
|
|
||||||
Returns sec unchanged when no constraints match, or None when suppressed.
|
|
||||||
"""
|
|
||||||
if sec is None:
|
|
||||||
return None
|
|
||||||
excluded = sec.get('constraints_exclude') or []
|
|
||||||
if any(c.lower() in [e.lower() for e in excluded] for c in user_constraints):
|
|
||||||
return None
|
|
||||||
return sec
|
|
||||||
|
|
||||||
# Keyword lists are checked in declaration order — most specific first.
|
# Keyword lists are checked in declaration order — most specific first.
|
||||||
# Rules:
|
# Rules:
|
||||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"""Visual label capture service for unenriched products (kiwi#79).
|
|
||||||
|
|
||||||
Wraps the cf-core VisionRouter to extract structured nutrition data from a
|
|
||||||
photographed nutrition facts panel. When the VisionRouter is not yet wired
|
|
||||||
(NotImplementedError) the service falls back to a mock extraction so the
|
|
||||||
barcode scan flow can be exercised end-to-end in development.
|
|
||||||
|
|
||||||
JSON contract returned by the vision model (and mock):
|
|
||||||
{
|
|
||||||
"product_name": str | null,
|
|
||||||
"brand": str | null,
|
|
||||||
"serving_size_g": number | null,
|
|
||||||
"calories": number | null,
|
|
||||||
"fat_g": number | null,
|
|
||||||
"saturated_fat_g": number | null,
|
|
||||||
"carbs_g": number | null,
|
|
||||||
"sugar_g": number | null,
|
|
||||||
"fiber_g": number | null,
|
|
||||||
"protein_g": number | null,
|
|
||||||
"sodium_mg": number | null,
|
|
||||||
"ingredient_names": [str],
|
|
||||||
"allergens": [str],
|
|
||||||
"confidence": number (0.0–1.0)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Confidence below this threshold surfaces amber highlights in the UI.
|
|
||||||
REVIEW_THRESHOLD = 0.7
|
|
||||||
|
|
||||||
_MOCK_EXTRACTION: dict[str, Any] = {
|
|
||||||
"product_name": "Unknown Product",
|
|
||||||
"brand": None,
|
|
||||||
"serving_size_g": None,
|
|
||||||
"calories": None,
|
|
||||||
"fat_g": None,
|
|
||||||
"saturated_fat_g": None,
|
|
||||||
"carbs_g": None,
|
|
||||||
"sugar_g": None,
|
|
||||||
"fiber_g": None,
|
|
||||||
"protein_g": None,
|
|
||||||
"sodium_mg": None,
|
|
||||||
"ingredient_names": [],
|
|
||||||
"allergens": [],
|
|
||||||
"confidence": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
_EXTRACTION_PROMPT = """You are reading a nutrition facts label photograph.
|
|
||||||
Extract the following fields as a JSON object with no extra text:
|
|
||||||
|
|
||||||
{
|
|
||||||
"product_name": <product name or null>,
|
|
||||||
"brand": <brand name or null>,
|
|
||||||
"serving_size_g": <serving size in grams as a number or null>,
|
|
||||||
"calories": <calories per serving as a number or null>,
|
|
||||||
"fat_g": <total fat grams or null>,
|
|
||||||
"saturated_fat_g": <saturated fat grams or null>,
|
|
||||||
"carbs_g": <total carbohydrates grams or null>,
|
|
||||||
"sugar_g": <sugars grams or null>,
|
|
||||||
"fiber_g": <dietary fiber grams or null>,
|
|
||||||
"protein_g": <protein grams or null>,
|
|
||||||
"sodium_mg": <sodium milligrams or null>,
|
|
||||||
"ingredient_names": [list of individual ingredients as strings],
|
|
||||||
"allergens": [list of allergens explicitly stated on label],
|
|
||||||
"confidence": <your confidence this extraction is correct, 0.0 to 1.0>
|
|
||||||
}
|
|
||||||
|
|
||||||
Use null for any field you cannot read clearly. Do not guess values.
|
|
||||||
Respond with JSON only."""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_label(image_bytes: bytes) -> dict[str, Any]:
|
|
||||||
"""Run vision model extraction on raw label image bytes.
|
|
||||||
|
|
||||||
Returns a dict matching the nutrition JSON contract above.
|
|
||||||
Falls back to a zero-confidence mock if the VisionRouter is not yet
|
|
||||||
implemented (stub) or if the model returns unparseable output.
|
|
||||||
"""
|
|
||||||
# Allow unit tests to bypass the vision model entirely.
|
|
||||||
if os.environ.get("KIWI_LABEL_CAPTURE_MOCK") == "1":
|
|
||||||
log.debug("label_capture: mock mode active")
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from circuitforge_core.vision import caption as vision_caption
|
|
||||||
result = vision_caption(image_bytes, prompt=_EXTRACTION_PROMPT)
|
|
||||||
raw = result.caption or ""
|
|
||||||
return _parse_extraction(raw)
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("label_capture: extraction failed (%s) — returning mock extraction", exc)
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_extraction(raw: str) -> dict[str, Any]:
|
|
||||||
"""Parse the JSON string returned by the vision model.
|
|
||||||
|
|
||||||
Strips markdown code fences if present. Validates required shape.
|
|
||||||
Returns the mock on any parse error.
|
|
||||||
"""
|
|
||||||
text = raw.strip()
|
|
||||||
if text.startswith("```"):
|
|
||||||
# Strip ```json ... ``` fences
|
|
||||||
lines = text.splitlines()
|
|
||||||
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
log.warning("label_capture: could not parse vision response: %s", exc)
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
log.warning("label_capture: vision response is not a dict")
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
# Normalise list fields — model may return None instead of []
|
|
||||||
for list_key in ("ingredient_names", "allergens"):
|
|
||||||
if not isinstance(data.get(list_key), list):
|
|
||||||
data[list_key] = []
|
|
||||||
|
|
||||||
# Clamp confidence to [0, 1]
|
|
||||||
confidence = data.get("confidence")
|
|
||||||
if not isinstance(confidence, (int, float)):
|
|
||||||
confidence = 0.0
|
|
||||||
data["confidence"] = max(0.0, min(1.0, float(confidence)))
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def needs_review(extraction: dict[str, Any]) -> bool:
|
|
||||||
"""Return True when the extraction confidence is below REVIEW_THRESHOLD."""
|
|
||||||
return float(extraction.get("confidence", 0.0)) < REVIEW_THRESHOLD
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
# app/services/leftovers_predictor.py
|
|
||||||
"""Cooked-leftovers shelf-life predictor.
|
|
||||||
|
|
||||||
Fast path: deterministic lookup anchored to FDA/USDA safe food handling.
|
|
||||||
Fallback: LLM for unclassifiable edge cases (same gate as expiry_llm_matching).
|
|
||||||
|
|
||||||
Design notes:
|
|
||||||
- shortest-component-wins for proteins: a fish taco is bounded by the fish.
|
|
||||||
- category/keyword signals override ingredient signals for assembled dishes
|
|
||||||
(soup, stew, casserole) where the cooking method matters more than the
|
|
||||||
dominant protein.
|
|
||||||
- no urgency/panic framing — see feedback_kiwi_no_panic.md.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LeftoversResult:
|
|
||||||
fridge_days: int
|
|
||||||
freeze_days: int | None # None = "not recommended"
|
|
||||||
freeze_by_day: int | None # day number from cook date to freeze by; None = no need
|
|
||||||
storage_advice: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Protein priority table — shorter shelf life wins when multiple match.
|
|
||||||
# Values: (fridge_days, freeze_days). All fridge values are conservative.
|
|
||||||
# Sources: USDA FoodKeeper, FDA Safe Food Handling.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_PROTEIN_SIGNALS: list[tuple[list[str], int, int | None]] = [
|
|
||||||
# (keyword_list, fridge_days, freeze_days)
|
|
||||||
(["fish", "salmon", "tuna", "cod", "tilapia", "halibut", "trout", "bass",
|
|
||||||
"mahi", "snapper", "flounder", "catfish", "swordfish", "sardine", "anchovy"],
|
|
||||||
2, 90),
|
|
||||||
(["shrimp", "prawn", "scallop", "crab", "lobster", "clam", "mussel",
|
|
||||||
"oyster", "squid", "octopus", "seafood"],
|
|
||||||
2, 90),
|
|
||||||
(["ground beef", "ground turkey", "ground pork", "ground chicken",
|
|
||||||
"ground meat", "hamburger", "mince"],
|
|
||||||
3, 90),
|
|
||||||
(["chicken", "turkey", "poultry", "duck", "hen"],
|
|
||||||
3, 90),
|
|
||||||
(["pork", "ham", "bacon", "sausage", "chorizo", "bratwurst", "kielbasa",
|
|
||||||
"salami", "pepperoni"],
|
|
||||||
4, 120),
|
|
||||||
(["beef", "steak", "brisket", "roast", "lamb", "veal", "venison"],
|
|
||||||
4, 180),
|
|
||||||
(["egg", "eggs", "frittata", "quiche", "omelette"],
|
|
||||||
3, None),
|
|
||||||
(["tofu", "tempeh", "seitan"],
|
|
||||||
4, 90),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dish-type signals — override protein signal when a structural match fires.
|
|
||||||
# Ordered from most-perishable to least.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_DISH_SIGNALS: list[tuple[list[str], int, int | None, str]] = [
|
|
||||||
# (keywords, fridge_days, freeze_days, storage_advice_fragment)
|
|
||||||
|
|
||||||
# Ceviche: acid denatures proteins but does not kill pathogens.
|
|
||||||
# FDA/USDA classify it as raw seafood — 2-day fridge max, do not freeze.
|
|
||||||
(["ceviche", "tiradito", "leche de tigre"],
|
|
||||||
2, None,
|
|
||||||
"Acid marination is not the same as heat cooking — treat as raw seafood. "
|
|
||||||
"Best eaten the day it's made; 2 days maximum in the fridge."),
|
|
||||||
|
|
||||||
# Fermented / salt-cured dishes — preservation extends shelf life significantly.
|
|
||||||
# This matches dish names, not just presence of the ingredient (lardo in a pasta
|
|
||||||
# follows normal pasta rules, not this entry).
|
|
||||||
(["kimchi", "sauerkraut", "preserved lemon"],
|
|
||||||
14, None,
|
|
||||||
"Fermented and salt-preserved dishes keep well. Store submerged in their brine."),
|
|
||||||
|
|
||||||
(["confit", "gravlax", "gravad lax", "lardo"],
|
|
||||||
7, 60,
|
|
||||||
"Store covered in its fat or cure. Keep cold and away from strong-smelling foods."),
|
|
||||||
|
|
||||||
(["soup", "stew", "broth", "chowder", "bisque", "gumbo", "chili"],
|
|
||||||
4, 120,
|
|
||||||
"Soups and stews keep well in the fridge. Cool to room temperature before covering."),
|
|
||||||
(["curry"],
|
|
||||||
4, 90,
|
|
||||||
"Store curry in an airtight container. The flavours deepen overnight."),
|
|
||||||
(["casserole", "bake", "gratin", "lasagna", "lasagne", "moussaka",
|
|
||||||
"shepherd's pie", "pot pie"],
|
|
||||||
5, 90,
|
|
||||||
"Cover tightly. Reheat individual portions rather than the whole dish."),
|
|
||||||
(["pasta", "noodle", "spaghetti", "penne", "linguine", "fettuccine",
|
|
||||||
"macaroni", "risotto"],
|
|
||||||
4, 60,
|
|
||||||
"Store pasta and sauce separately if possible to prevent sogginess."),
|
|
||||||
(["rice", "fried rice", "pilaf", "biryani"],
|
|
||||||
3, 90,
|
|
||||||
"Cool rice quickly — spread on a tray if needed. Don't leave at room temperature for more than 1 hour."),
|
|
||||||
(["salad"],
|
|
||||||
2, None,
|
|
||||||
"Keep dressing separate. Once dressed, best eaten the same day."),
|
|
||||||
(["stir fry", "stir-fry"],
|
|
||||||
3, 60,
|
|
||||||
"Reheat in a hot pan or wok rather than a microwave to keep texture."),
|
|
||||||
(["sandwich", "wrap", "taco", "burrito"],
|
|
||||||
2, None,
|
|
||||||
"Assemble fresh when possible. Fillings keep better stored separately."),
|
|
||||||
(["pizza"],
|
|
||||||
4, 60,
|
|
||||||
"Reheat in a dry skillet for a crisp base rather than a microwave."),
|
|
||||||
(["muffin", "bread", "biscuit", "scone", "roll"],
|
|
||||||
3, 90,
|
|
||||||
"Wrap tightly or seal in a bag to prevent drying out."),
|
|
||||||
(["cake", "pie", "cookie", "brownie", "dessert", "pudding"],
|
|
||||||
5, 90,
|
|
||||||
"Store covered at room temperature or in the fridge depending on fillings."),
|
|
||||||
(["smoothie", "juice", "shake"],
|
|
||||||
1, 7,
|
|
||||||
"Best consumed fresh. Stir or shake well before drinking."),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Default when no signals match.
|
|
||||||
_DEFAULT_FRIDGE = 4
|
|
||||||
_DEFAULT_FREEZE = 90
|
|
||||||
_DEFAULT_ADVICE = "Store in an airtight container in the fridge. Reheat until piping hot before eating."
|
|
||||||
|
|
||||||
|
|
||||||
def _contains_any(text: str, keywords: list[str]) -> bool:
|
|
||||||
for kw in keywords:
|
|
||||||
if re.search(rf"\b{re.escape(kw)}\b", text, re.IGNORECASE):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_ingredients(ingredients: list[str]) -> tuple[int, int | None] | None:
|
|
||||||
"""Return (fridge_days, freeze_days) for the most-perishable protein found."""
|
|
||||||
joined = " ".join(str(i) for i in ingredients).lower()
|
|
||||||
best: tuple[int, int | None] | None = None
|
|
||||||
for keywords, fridge, freeze in _PROTEIN_SIGNALS:
|
|
||||||
if _contains_any(joined, keywords):
|
|
||||||
if best is None or fridge < best[0]:
|
|
||||||
best = (fridge, freeze)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_dish_type(text: str) -> tuple[int, int | None, str] | None:
|
|
||||||
"""Return (fridge_days, freeze_days, advice) for the first matching dish type."""
|
|
||||||
for keywords, fridge, freeze, advice in _DISH_SIGNALS:
|
|
||||||
if _contains_any(text, keywords):
|
|
||||||
return fridge, freeze, advice
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def predict_leftovers(
|
|
||||||
title: str,
|
|
||||||
ingredients: list[str],
|
|
||||||
category: str | None = None,
|
|
||||||
keywords: list[str] | None = None,
|
|
||||||
) -> LeftoversResult:
|
|
||||||
"""Predict cooked-leftover shelf life deterministically.
|
|
||||||
|
|
||||||
Falls back gracefully — always returns a result even for unknown recipes.
|
|
||||||
"""
|
|
||||||
# Build a combined text blob for dish-type scanning.
|
|
||||||
search_text = " ".join(filter(None, [
|
|
||||||
title,
|
|
||||||
category or "",
|
|
||||||
" ".join(keywords or []),
|
|
||||||
]))
|
|
||||||
|
|
||||||
# Dish-type match takes structural priority over raw ingredient protein signal.
|
|
||||||
dish = _scan_dish_type(search_text)
|
|
||||||
protein = _scan_ingredients(ingredients)
|
|
||||||
|
|
||||||
if dish:
|
|
||||||
fridge_days, freeze_days, base_advice = dish
|
|
||||||
# Still apply shortest-protein-wins if protein is more perishable than dish default.
|
|
||||||
if protein and protein[0] < fridge_days:
|
|
||||||
fridge_days = protein[0]
|
|
||||||
if protein[1] is not None and (freeze_days is None or protein[1] < freeze_days):
|
|
||||||
freeze_days = protein[1]
|
|
||||||
advice = base_advice
|
|
||||||
elif protein:
|
|
||||||
fridge_days, freeze_days = protein
|
|
||||||
advice = _DEFAULT_ADVICE
|
|
||||||
else:
|
|
||||||
fridge_days = _DEFAULT_FRIDGE
|
|
||||||
freeze_days = _DEFAULT_FREEZE
|
|
||||||
advice = _DEFAULT_ADVICE
|
|
||||||
|
|
||||||
# freeze_by_day: recommend freezing on day 2 if fridge window is tight (≤3 days).
|
|
||||||
freeze_by_day: int | None = None
|
|
||||||
if freeze_days is not None and fridge_days <= 3:
|
|
||||||
freeze_by_day = 2
|
|
||||||
|
|
||||||
return LeftoversResult(
|
|
||||||
fridge_days=fridge_days,
|
|
||||||
freeze_days=freeze_days,
|
|
||||||
freeze_by_day=freeze_by_day,
|
|
||||||
storage_advice=advice,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def predict_leftovers_from_row(recipe: dict[str, Any]) -> LeftoversResult:
|
|
||||||
"""Convenience wrapper that accepts a Store row dict directly."""
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
title = recipe.get("title") or ""
|
|
||||||
|
|
||||||
raw_ingredients = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(raw_ingredients, str):
|
|
||||||
try:
|
|
||||||
raw_ingredients = _json.loads(raw_ingredients)
|
|
||||||
except Exception:
|
|
||||||
raw_ingredients = [raw_ingredients]
|
|
||||||
|
|
||||||
raw_keywords = recipe.get("keywords") or []
|
|
||||||
if isinstance(raw_keywords, str):
|
|
||||||
try:
|
|
||||||
raw_keywords = _json.loads(raw_keywords)
|
|
||||||
except Exception:
|
|
||||||
raw_keywords = [raw_keywords]
|
|
||||||
|
|
||||||
return predict_leftovers(
|
|
||||||
title=title,
|
|
||||||
ingredients=[str(i) for i in raw_ingredients],
|
|
||||||
category=recipe.get("category"),
|
|
||||||
keywords=[str(k) for k in raw_keywords],
|
|
||||||
)
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""Magpie data-flywheel hook.
|
|
||||||
|
|
||||||
Fires anonymized recipe-signal events to the Magpie ingest endpoint when a
|
|
||||||
user saves or rates a recipe. This is the Kiwi side of the flywheel — Magpie
|
|
||||||
does not have a receiver endpoint yet, so the hook stubs out gracefully: if
|
|
||||||
``MAGPIE_INGEST_URL`` is unset, or the request fails for any reason, it logs
|
|
||||||
at DEBUG level and returns without raising.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_INGEST_PATH = "/api/v1/ingest/recipe-signal"
|
|
||||||
|
|
||||||
|
|
||||||
async def fire_recipe_signal(
|
|
||||||
db_path: Path,
|
|
||||||
recipe_id: int,
|
|
||||||
rating: int | None,
|
|
||||||
style_tags: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""Post an anonymized recipe signal to Magpie if the user has opted in.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the user's SQLite database.
|
|
||||||
recipe_id: Internal Kiwi recipe ID being rated/saved.
|
|
||||||
rating: Star rating (0–5) or None if not yet rated.
|
|
||||||
style_tags: Style tags applied to the saved recipe.
|
|
||||||
"""
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
if not settings.MAGPIE_INGEST_URL:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check per-user opt-in via a short-lived Store (own connection, own thread
|
|
||||||
# context is fine — this runs in the async event loop as a background task
|
|
||||||
# so we open and close the connection immediately).
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
try:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
opt_in = store.get_setting("magpie_opt_in")
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.debug("magpie_hook: could not read magpie_opt_in setting: %s", exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
if opt_in != "true":
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fetch the recipe to get its external_id (source URL slug / corpus key).
|
|
||||||
try:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
recipe = store.get_recipe(recipe_id)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.debug("magpie_hook: could not fetch recipe %d: %s", recipe_id, exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
if recipe is None:
|
|
||||||
logger.debug("magpie_hook: recipe %d not found, skipping", recipe_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
external_id: str | None = recipe.get("external_id") if isinstance(recipe, dict) else getattr(recipe, "external_id", None)
|
|
||||||
if not external_id:
|
|
||||||
# Corpus recipe not yet enriched with a source identifier — skip quietly.
|
|
||||||
logger.debug("magpie_hook: recipe %d has no external_id, skipping", recipe_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"product": "kiwi",
|
|
||||||
"signal": "recipe_rating",
|
|
||||||
"external_id": external_id,
|
|
||||||
"rating": rating,
|
|
||||||
"style_tags": style_tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
url = settings.MAGPIE_INGEST_URL.rstrip("/") + _INGEST_PATH
|
|
||||||
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
logger.debug(
|
|
||||||
"magpie_hook: POST %s → %d", url, response.status_code
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
# Magpie may not have a receiver yet — log and swallow.
|
|
||||||
logger.debug("magpie_hook: ingest request failed (stub): %s", exc)
|
|
||||||
|
|
@ -2,20 +2,17 @@
|
||||||
# BSL 1.1 — LLM feature
|
# BSL 1.1 — LLM feature
|
||||||
"""Provide a router-compatible LLM client for meal plan generation tasks.
|
"""Provide a router-compatible LLM client for meal plan generation tasks.
|
||||||
|
|
||||||
Cloud (CF_ORCH_URL set), tier 1 — task-based routing (preferred):
|
Cloud (CF_ORCH_URL set):
|
||||||
Calls /api/inference/task with product=kiwi, task=meal_plan.
|
Allocates a cf-text service via cf-orch (3B-7B GGUF, ~2GB VRAM).
|
||||||
The coordinator resolves the model from assignments.yaml.
|
Returns an _OrchTextRouter that wraps the cf-text HTTP endpoint
|
||||||
|
with a .complete(system, user, **kwargs) interface.
|
||||||
Cloud (CF_ORCH_URL set), tier 2 — direct allocation (fallback):
|
|
||||||
Allocates cf-text directly via client.allocate(). Used when the task
|
|
||||||
is not yet registered in the coordinator (cf-orch#61 not deployed).
|
|
||||||
|
|
||||||
Local / self-hosted (no CF_ORCH_URL):
|
Local / self-hosted (no CF_ORCH_URL):
|
||||||
Returns an LLMRouter instance which tries ollama, vllm, or any
|
Returns an LLMRouter instance which tries ollama, vllm, or any
|
||||||
backend configured in ~/.config/circuitforge/llm.yaml.
|
backend configured in ~/.config/circuitforge/llm.yaml.
|
||||||
|
|
||||||
All paths expose the same (router, ctx) interface so llm_planner.py
|
Both paths expose the same interface so llm_timing.py and llm_planner.py
|
||||||
needs no knowledge of the backend.
|
need no knowledge of the backend.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -25,7 +22,8 @@ from contextlib import nullcontext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# cf-orch service name and TTL for direct-allocate fallback path.
|
# cf-orch service name and VRAM budget for meal plan LLM tasks.
|
||||||
|
# These are lighter than recipe_llm (4.0 GB) — cf-text handles them.
|
||||||
_SERVICE_TYPE = "cf-text"
|
_SERVICE_TYPE = "cf-text"
|
||||||
_TTL_S = 120.0
|
_TTL_S = 120.0
|
||||||
_CALLER = "kiwi-meal-plan"
|
_CALLER = "kiwi-meal-plan"
|
||||||
|
|
@ -64,58 +62,16 @@ class _OrchTextRouter:
|
||||||
return resp.choices[0].message.content or ""
|
return resp.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
# Imported at module level so tests can patch the names in this module's namespace.
|
|
||||||
# app.services.task_inference.task_allocate — patch target for task routing tests.
|
|
||||||
try:
|
|
||||||
from app.services.task_inference import TaskNotRegistered, task_allocate
|
|
||||||
_HAS_TASK_INFERENCE = True
|
|
||||||
except ImportError:
|
|
||||||
_HAS_TASK_INFERENCE = False
|
|
||||||
|
|
||||||
# circuitforge_orch.client.CFOrchClient — patch target for direct-allocate fallback tests.
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.client import CFOrchClient
|
|
||||||
except ImportError:
|
|
||||||
CFOrchClient = None # type: ignore[assignment,misc]
|
|
||||||
|
|
||||||
# circuitforge_core.llm.router.LLMRouter — patch target for local-inference tests.
|
|
||||||
try:
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
except (ImportError, FileNotFoundError):
|
|
||||||
LLMRouter = None # type: ignore[assignment,misc]
|
|
||||||
|
|
||||||
|
|
||||||
def get_meal_plan_router():
|
def get_meal_plan_router():
|
||||||
"""Return an LLM client for meal plan tasks.
|
"""Return an LLM client for meal plan tasks.
|
||||||
|
|
||||||
Returns (router, ctx) where ctx is a context manager the caller holds
|
Tries cf-orch cf-text allocation first (cloud); falls back to LLMRouter
|
||||||
open for the duration of the LLM call. Returns (None, nullcontext(None))
|
(local ollama/vllm). Returns None if no backend is available.
|
||||||
if no backend is available.
|
|
||||||
"""
|
"""
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
|
|
||||||
if cf_orch_url:
|
if cf_orch_url:
|
||||||
# Tier 1: task-based routing — coordinator owns model selection.
|
|
||||||
if _HAS_TASK_INFERENCE:
|
|
||||||
try:
|
|
||||||
ctx = task_allocate(
|
|
||||||
"kiwi", "meal_plan",
|
|
||||||
service_hint=_SERVICE_TYPE,
|
|
||||||
ttl_s=_TTL_S,
|
|
||||||
)
|
|
||||||
alloc = ctx.__enter__()
|
|
||||||
return _OrchTextRouter(alloc.url), ctx
|
|
||||||
except TaskNotRegistered:
|
|
||||||
logger.debug(
|
|
||||||
"kiwi.meal_plan not in coordinator assignments — "
|
|
||||||
"falling back to direct cf-text allocation"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("task allocation failed, trying direct allocate: %s", exc)
|
|
||||||
|
|
||||||
# Tier 2: direct allocation — hardcoded service type.
|
|
||||||
if CFOrchClient is not None:
|
|
||||||
try:
|
try:
|
||||||
|
from circuitforge_orch.client import CFOrchClient
|
||||||
client = CFOrchClient(cf_orch_url)
|
client = CFOrchClient(cf_orch_url)
|
||||||
ctx = client.allocate(
|
ctx = client.allocate(
|
||||||
service=_SERVICE_TYPE,
|
service=_SERVICE_TYPE,
|
||||||
|
|
@ -125,13 +81,12 @@ def get_meal_plan_router():
|
||||||
alloc = ctx.__enter__()
|
alloc = ctx.__enter__()
|
||||||
if alloc is not None:
|
if alloc is not None:
|
||||||
return _OrchTextRouter(alloc.url), ctx
|
return _OrchTextRouter(alloc.url), ctx
|
||||||
ctx.__exit__(None, None, None) # release allocation before falling through
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc)
|
logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
|
|
||||||
# Tier 3: local inference — ollama / vllm / openai-compat.
|
# Local fallback: LLMRouter (ollama / vllm / openai-compat)
|
||||||
if LLMRouter is not None:
|
|
||||||
try:
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
return LLMRouter(), nullcontext(None)
|
return LLMRouter(), nullcontext(None)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled")
|
logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled")
|
||||||
|
|
@ -139,4 +94,3 @@ def get_meal_plan_router():
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("LLMRouter init failed: %s", exc)
|
logger.debug("LLMRouter init failed: %s", exc)
|
||||||
return None, nullcontext(None)
|
return None, nullcontext(None)
|
||||||
return None, nullcontext(None)
|
|
||||||
|
|
|
||||||
|
|
@ -18,51 +18,43 @@ class DocuvisionResult:
|
||||||
class DocuvisionClient:
|
class DocuvisionClient:
|
||||||
"""Thin client for the cf-docuvision service."""
|
"""Thin client for the cf-docuvision service."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, timeout: float = 120.0) -> None:
|
def __init__(self, base_url: str) -> None:
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
self._timeout = timeout
|
|
||||||
|
|
||||||
def extract_text(self, image_path: str | Path, hint: str = "text") -> DocuvisionResult:
|
def extract_text(self, image_path: str | Path) -> DocuvisionResult:
|
||||||
"""Send an image to docuvision and return extracted text.
|
"""Send an image to docuvision and return extracted text."""
|
||||||
|
|
||||||
Args:
|
|
||||||
image_path: Path to the image file.
|
|
||||||
hint: Docuvision extraction hint — "text" for dense prose (recipes),
|
|
||||||
"table" for tabular data, "form" for form fields, "auto" for
|
|
||||||
automatic detection.
|
|
||||||
"""
|
|
||||||
image_bytes = Path(image_path).read_bytes()
|
image_bytes = Path(image_path).read_bytes()
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
with httpx.Client(timeout=self._timeout) as client:
|
with httpx.Client(timeout=30.0) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{self._base_url}/extract",
|
f"{self._base_url}/extract",
|
||||||
json={"image_b64": b64, "hint": hint},
|
json={"image": b64},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
return DocuvisionResult(
|
return DocuvisionResult(
|
||||||
text=data.get("raw_text", ""),
|
text=data.get("text", ""),
|
||||||
confidence=data.get("metadata", {}).get("confidence"),
|
confidence=data.get("confidence"),
|
||||||
raw=data,
|
raw=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def extract_text_async(self, image_path: str | Path, hint: str = "text") -> DocuvisionResult:
|
async def extract_text_async(self, image_path: str | Path) -> DocuvisionResult:
|
||||||
"""Async version."""
|
"""Async version."""
|
||||||
image_bytes = Path(image_path).read_bytes()
|
image_bytes = Path(image_path).read_bytes()
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{self._base_url}/extract",
|
f"{self._base_url}/extract",
|
||||||
json={"image_b64": b64, "hint": hint},
|
json={"image": b64},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
return DocuvisionResult(
|
return DocuvisionResult(
|
||||||
text=data.get("raw_text", ""),
|
text=data.get("text", ""),
|
||||||
confidence=data.get("metadata", {}).get("confidence"),
|
confidence=data.get("confidence"),
|
||||||
raw=data,
|
raw=data,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,29 +32,6 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
if not cf_orch_url:
|
if not cf_orch_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Tier 1: task-based routing — coordinator owns model selection.
|
|
||||||
try:
|
|
||||||
from app.services.task_inference import task_allocate, TaskNotRegistered
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
|
||||||
try:
|
|
||||||
with task_allocate(
|
|
||||||
"kiwi", "ocr",
|
|
||||||
service_hint="cf-docuvision",
|
|
||||||
ttl_s=60.0,
|
|
||||||
) as alloc:
|
|
||||||
doc_client = DocuvisionClient(alloc.url)
|
|
||||||
result = doc_client.extract_text(image_path)
|
|
||||||
return result.text if result.text else None
|
|
||||||
except TaskNotRegistered:
|
|
||||||
logger.debug(
|
|
||||||
"kiwi.ocr not in coordinator assignments — "
|
|
||||||
"falling back to direct cf-docuvision allocation"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("task allocation path failed, trying direct allocate: %s", exc)
|
|
||||||
|
|
||||||
# Tier 2: direct allocation — hardcoded service type.
|
|
||||||
try:
|
try:
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
|
@ -72,7 +49,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
result = doc_client.extract_text(image_path)
|
result = doc_client.extract_text(image_path)
|
||||||
return result.text if result.text else None
|
return result.text if result.text else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("cf-docuvision fast-path failed, falling back to local VLM: %s", exc)
|
logger.debug("cf-docuvision fast-path failed, falling back: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"""
|
|
||||||
Browse counts cache — pre-computes and persists recipe counts for all
|
|
||||||
browse domain keyword sets so category/subcategory page loads never
|
|
||||||
hit the 3.8 GB FTS index at request time.
|
|
||||||
|
|
||||||
Counts change only when the corpus changes (after a pipeline run).
|
|
||||||
The cache is a small SQLite file separate from both the read-only
|
|
||||||
corpus DB and per-user kiwi.db files, so the container can write it.
|
|
||||||
|
|
||||||
Refresh triggers:
|
|
||||||
1. Startup — if cache is missing or older than STALE_DAYS
|
|
||||||
2. Nightly — asyncio background task started in main.py lifespan
|
|
||||||
3. Pipeline — infer_recipe_tags.py calls refresh() at end of run
|
|
||||||
|
|
||||||
The in-memory _COUNT_CACHE in store.py is pre-warmed from this file
|
|
||||||
on startup, so FTS queries are never needed for known keyword sets.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STALE_DAYS = 7
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _kw_key(keywords: list[str]) -> str:
|
|
||||||
"""Stable string key for a keyword list — sorted and pipe-joined."""
|
|
||||||
return "|".join(sorted(keywords))
|
|
||||||
|
|
||||||
|
|
||||||
def _fts_match_expr(keywords: list[str]) -> str:
|
|
||||||
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
|
||||||
return " OR ".join(phrases)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS browse_counts (
|
|
||||||
keywords_key TEXT PRIMARY KEY,
|
|
||||||
count INTEGER NOT NULL,
|
|
||||||
computed_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS browse_counts_meta (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_stale(cache_path: Path, max_age_days: int = STALE_DAYS) -> bool:
|
|
||||||
"""Return True if the cache is missing, empty, or older than max_age_days."""
|
|
||||||
if not cache_path.exists():
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(cache_path)
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT value FROM browse_counts_meta WHERE key = 'refreshed_at'"
|
|
||||||
).fetchone()
|
|
||||||
conn.close()
|
|
||||||
if row is None:
|
|
||||||
return True
|
|
||||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(row[0])).days
|
|
||||||
return age >= max_age_days
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def load_into_memory(cache_path: Path, count_cache: dict, corpus_path: str) -> int:
|
|
||||||
"""
|
|
||||||
Load all rows from the cache file into the in-memory count_cache dict.
|
|
||||||
|
|
||||||
Uses corpus_path (the current RECIPE_DB_PATH env value) as the cache key,
|
|
||||||
not what was stored in the file — the file may have been built against a
|
|
||||||
different mount path (e.g. pipeline ran on host, container sees a different
|
|
||||||
path). Counts are corpus-content-derived and path-independent.
|
|
||||||
|
|
||||||
Returns the number of entries loaded.
|
|
||||||
"""
|
|
||||||
if not cache_path.exists():
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(cache_path)
|
|
||||||
rows = conn.execute("SELECT keywords_key, count FROM browse_counts").fetchall()
|
|
||||||
conn.close()
|
|
||||||
loaded = 0
|
|
||||||
for kw_key, count in rows:
|
|
||||||
keywords = kw_key.split("|") if kw_key else []
|
|
||||||
cache_key = (corpus_path, *sorted(keywords))
|
|
||||||
count_cache[cache_key] = count
|
|
||||||
loaded += 1
|
|
||||||
logger.info("browse_counts: warmed %d entries from %s", loaded, cache_path)
|
|
||||||
return loaded
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: load failed: %s", exc)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def refresh(corpus_path: str, cache_path: Path) -> int:
|
|
||||||
"""
|
|
||||||
Run FTS5 queries for every keyword set in browser_domains.DOMAINS
|
|
||||||
and write results to cache_path.
|
|
||||||
|
|
||||||
Safe to call from both the host pipeline script and the in-container
|
|
||||||
nightly task. The corpus_path must be reachable and readable from
|
|
||||||
the calling process.
|
|
||||||
|
|
||||||
Returns the number of keyword sets computed.
|
|
||||||
"""
|
|
||||||
from app.services.recipe.browser_domains import DOMAINS # local import — avoid circular
|
|
||||||
|
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
cache_conn = sqlite3.connect(cache_path)
|
|
||||||
_ensure_schema(cache_conn)
|
|
||||||
|
|
||||||
# Collect every unique keyword list across all domains/categories/subcategories.
|
|
||||||
# DOMAINS structure: {domain: {label: str, categories: {cat_name: {keywords, subcategories}}}}
|
|
||||||
seen: dict[str, list[str]] = {}
|
|
||||||
for domain_data in DOMAINS.values():
|
|
||||||
for cat_data in domain_data.get("categories", {}).values():
|
|
||||||
if not isinstance(cat_data, dict):
|
|
||||||
continue
|
|
||||||
top_kws = cat_data.get("keywords", [])
|
|
||||||
if top_kws:
|
|
||||||
seen[_kw_key(top_kws)] = top_kws
|
|
||||||
for subcat_kws in cat_data.get("subcategories", {}).values():
|
|
||||||
if subcat_kws:
|
|
||||||
seen[_kw_key(subcat_kws)] = subcat_kws
|
|
||||||
|
|
||||||
try:
|
|
||||||
corpus_conn = sqlite3.connect(f"file:{corpus_path}?mode=ro", uri=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("browse_counts: cannot open corpus %s: %s", corpus_path, exc)
|
|
||||||
cache_conn.close()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
computed = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
for kw_key, kws in seen.items():
|
|
||||||
try:
|
|
||||||
row = corpus_conn.execute(
|
|
||||||
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
|
||||||
(_fts_match_expr(kws),),
|
|
||||||
).fetchone()
|
|
||||||
count = row[0] if row else 0
|
|
||||||
cache_conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO browse_counts (keywords_key, count, computed_at)"
|
|
||||||
" VALUES (?, ?, ?)",
|
|
||||||
(kw_key, count, now),
|
|
||||||
)
|
|
||||||
computed += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: query failed key=%r: %s", kw_key[:60], exc)
|
|
||||||
|
|
||||||
# Merge accepted community tags into counts.
|
|
||||||
# For each (domain, category, subcategory) that has accepted community
|
|
||||||
# tags, add the count of distinct tagged recipe_ids to the FTS count.
|
|
||||||
# The two overlap rarely (community tags exist precisely because FTS
|
|
||||||
# missed those recipes), so simple addition is accurate enough.
|
|
||||||
try:
|
|
||||||
_merge_community_tag_counts(cache_conn, DOMAINS, now)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: community merge skipped: %s", exc)
|
|
||||||
|
|
||||||
cache_conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('refreshed_at', ?)",
|
|
||||||
(now,),
|
|
||||||
)
|
|
||||||
cache_conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('corpus_path', ?)",
|
|
||||||
(corpus_path,),
|
|
||||||
)
|
|
||||||
cache_conn.commit()
|
|
||||||
logger.info("browse_counts: wrote %d counts → %s", computed, cache_path)
|
|
||||||
finally:
|
|
||||||
corpus_conn.close()
|
|
||||||
cache_conn.close()
|
|
||||||
|
|
||||||
return computed
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_community_tag_counts(
|
|
||||||
cache_conn: sqlite3.Connection,
|
|
||||||
domains: dict,
|
|
||||||
now: str,
|
|
||||||
threshold: int = 2,
|
|
||||||
) -> None:
|
|
||||||
"""Add accepted community tag counts on top of FTS counts in the cache.
|
|
||||||
|
|
||||||
Queries the community PostgreSQL store (if available) for accepted tags
|
|
||||||
grouped by (domain, category, subcategory), maps each back to its keyword
|
|
||||||
set key, then increments the cached count.
|
|
||||||
|
|
||||||
Silently skips if community features are unavailable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
for domain_id, domain_data in domains.items():
|
|
||||||
for cat_name, cat_data in domain_data.get("categories", {}).items():
|
|
||||||
if not isinstance(cat_data, dict):
|
|
||||||
continue
|
|
||||||
# Check subcategories
|
|
||||||
for subcat_name, subcat_kws in cat_data.get("subcategories", {}).items():
|
|
||||||
if not subcat_kws:
|
|
||||||
continue
|
|
||||||
ids = store.get_accepted_recipe_ids_for_subcategory(
|
|
||||||
domain=domain_id,
|
|
||||||
category=cat_name,
|
|
||||||
subcategory=subcat_name,
|
|
||||||
threshold=threshold,
|
|
||||||
)
|
|
||||||
if not ids:
|
|
||||||
continue
|
|
||||||
kw_key = _kw_key(subcat_kws)
|
|
||||||
cache_conn.execute(
|
|
||||||
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
|
||||||
(len(ids), kw_key),
|
|
||||||
)
|
|
||||||
# Check category-level tags (subcategory IS NULL)
|
|
||||||
top_kws = cat_data.get("keywords", [])
|
|
||||||
if top_kws:
|
|
||||||
ids = store.get_accepted_recipe_ids_for_subcategory(
|
|
||||||
domain=domain_id,
|
|
||||||
category=cat_name,
|
|
||||||
subcategory=None,
|
|
||||||
threshold=threshold,
|
|
||||||
)
|
|
||||||
if ids:
|
|
||||||
kw_key = _kw_key(top_kws)
|
|
||||||
cache_conn.execute(
|
|
||||||
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
|
||||||
(len(ids), kw_key),
|
|
||||||
)
|
|
||||||
logger.info("browse_counts: community tag counts merged")
|
|
||||||
|
|
@ -5,12 +5,6 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
|
||||||
Keyword matching is case-insensitive against the recipes.category column and the
|
Keyword matching is case-insensitive against the recipes.category column and the
|
||||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||||
|
|
||||||
Category values are either:
|
|
||||||
- list[str] — flat keyword list (no subcategories)
|
|
||||||
- dict — {"keywords": list[str], "subcategories": {name: list[str]}}
|
|
||||||
keywords covers the whole category (used for "All X" browse);
|
|
||||||
subcategories each have their own narrower keyword list.
|
|
||||||
|
|
||||||
These are starter mappings based on the food.com dataset structure. Run:
|
These are starter mappings based on the food.com dataset structure. Run:
|
||||||
|
|
||||||
SELECT category, count(*) FROM recipes
|
SELECT category, count(*) FROM recipes
|
||||||
|
|
@ -25,601 +19,51 @@ DOMAINS: dict[str, dict] = {
|
||||||
"cuisine": {
|
"cuisine": {
|
||||||
"label": "Cuisine",
|
"label": "Cuisine",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Italian": {
|
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||||
"keywords": ["cuisine:Italian", "italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
||||||
"subcategories": {
|
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
||||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
||||||
"involtini", "cannoli"],
|
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
||||||
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
||||||
"sfogliatelle", "ragù"],
|
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
||||||
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
|
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
||||||
"pappardelle", "crostini"],
|
|
||||||
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
|
|
||||||
"amatriciana", "gricia", "supplì"],
|
|
||||||
"Venetian": ["venetian", "venice", "risotto", "bigoli",
|
|
||||||
"baccalà", "sarde in saor"],
|
|
||||||
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
|
|
||||||
"trofie", "farinata"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Mexican": {
|
|
||||||
"keywords": ["cuisine:Mexican", "mexican", "taco", "enchilada", "burrito",
|
|
||||||
"salsa", "guacamole", "mole", "tamale"],
|
|
||||||
"subcategories": {
|
|
||||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
|
||||||
"chapulines", "mezcal", "tasajo", "memelas"],
|
|
||||||
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
|
|
||||||
"sopa de lima", "panuchos", "papadzules"],
|
|
||||||
"Veracruz": ["veracruz", "veracruzana", "huachinango",
|
|
||||||
"picadas", "enfrijoladas", "caldo de mariscos"],
|
|
||||||
"Street Food": ["taco", "elote", "tlacoyos", "torta", "tamale",
|
|
||||||
"quesadilla", "tostada", "sope", "gordita"],
|
|
||||||
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
|
|
||||||
"mole poblano", "mole amarillo", "pipián"],
|
|
||||||
"Baja / Cal-Mex": ["baja", "baja california", "cal-mex", "baja fish taco",
|
|
||||||
"fish taco", "carne asada fries", "california burrito",
|
|
||||||
"birria", "birria tacos", "quesabirria",
|
|
||||||
"lobster puerto nuevo", "tijuana", "ensenada",
|
|
||||||
"agua fresca", "caesar salad tijuana"],
|
|
||||||
"Mexico City": ["mexico city", "chilaquiles", "tlayuda cdmx",
|
|
||||||
"tacos de canasta", "torta ahogada", "pozole",
|
|
||||||
"chiles en nogada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Asian": {
|
|
||||||
"keywords": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Korean",
|
|
||||||
"cuisine:Thai", "cuisine:Vietnamese",
|
|
||||||
"asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
|
||||||
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
|
||||||
"taiwanese", "singaporean", "burmese", "cambodian",
|
|
||||||
"laotian", "mongolian", "hong kong"],
|
|
||||||
"subcategories": {
|
|
||||||
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
|
|
||||||
"doenjang", "gochujang", "tteokbokki", "sundubu",
|
|
||||||
"galbi", "jjigae", "kbbq", "korean fried chicken"],
|
|
||||||
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
|
|
||||||
"teriyaki", "udon", "soba", "bento", "yakitori",
|
|
||||||
"tonkatsu", "onigiri", "okonomiyaki", "takoyaki",
|
|
||||||
"kaiseki", "izakaya"],
|
|
||||||
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
|
|
||||||
"spring roll", "szechuan", "sichuan", "cantonese",
|
|
||||||
"chow mein", "mapo tofu", "lo mein", "hot pot",
|
|
||||||
"peking duck", "char siu", "congee"],
|
|
||||||
"Thai": ["thai", "pad thai", "green curry", "red curry",
|
|
||||||
"coconut milk", "lemongrass", "satay", "tom yum",
|
|
||||||
"larb", "khao man gai", "massaman", "pad see ew"],
|
|
||||||
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
|
|
||||||
"vermicelli", "nuoc cham", "bun bo hue",
|
|
||||||
"banh xeo", "com tam", "bun cha"],
|
|
||||||
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
|
|
||||||
"kare-kare", "lechon", "sisig", "halo-halo",
|
|
||||||
"dinuguan", "tinola", "bistek"],
|
|
||||||
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
|
|
||||||
"tempeh", "sambal", "soto", "opor ayam",
|
|
||||||
"bakso", "mie goreng", "nasi uduk"],
|
|
||||||
"Malaysian": ["malaysian", "laksa", "nasi lemak", "char kway teow",
|
|
||||||
"satay malaysia", "roti canai", "bak kut teh",
|
|
||||||
"cendol", "mee goreng mamak", "curry laksa"],
|
|
||||||
"Taiwanese": ["taiwanese", "beef noodle soup", "lu rou fan",
|
|
||||||
"oyster vermicelli", "scallion pancake taiwan",
|
|
||||||
"pork chop rice", "three cup chicken",
|
|
||||||
"bubble tea", "stinky tofu", "ba wan"],
|
|
||||||
"Singaporean": ["singaporean", "chicken rice", "chili crab",
|
|
||||||
"singaporean laksa", "bak chor mee", "rojak",
|
|
||||||
"kaya toast", "nasi padang", "satay singapore"],
|
|
||||||
"Burmese": ["burmese", "myanmar", "mohinga", "laphet thoke",
|
|
||||||
"tea leaf salad", "ohn no khao swe",
|
|
||||||
"mont di", "nangyi thoke"],
|
|
||||||
"Hong Kong": ["hong kong", "hk style", "pineapple bun",
|
|
||||||
"wonton noodle soup", "hk milk tea", "egg tart",
|
|
||||||
"typhoon shelter crab", "char siu bao", "jook",
|
|
||||||
"congee hk", "silk stocking tea", "dan tat",
|
|
||||||
"siu mai hk", "cheung fun"],
|
|
||||||
"Cambodian": ["cambodian", "khmer", "amok", "lok lak",
|
|
||||||
"kuy teav", "bai sach chrouk", "nom banh chok",
|
|
||||||
"samlor korko", "beef loc lac"],
|
|
||||||
"Laotian": ["laotian", "lao", "larb", "tam mak hoong",
|
|
||||||
"or lam", "khao niaw", "ping kai",
|
|
||||||
"naem khao", "khao piak sen", "mok pa"],
|
|
||||||
"Mongolian": ["mongolian", "buuz", "khuushuur", "tsuivan",
|
|
||||||
"boodog", "airag", "khorkhog", "bansh",
|
|
||||||
"guriltai shol", "suutei tsai"],
|
|
||||||
"South Asian Fusion": ["south asian fusion", "indo-chinese",
|
|
||||||
"hakka chinese", "chilli chicken",
|
|
||||||
"manchurian", "schezwan"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Indian": {
|
|
||||||
"keywords": ["cuisine:Indian", "indian", "curry", "lentil", "dal", "tikka", "masala",
|
|
||||||
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
|
||||||
"bangladeshi", "nepali"],
|
|
||||||
"subcategories": {
|
|
||||||
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
|
|
||||||
"naan", "tandoori", "butter chicken", "palak paneer",
|
|
||||||
"chole", "rajma", "aloo gobi"],
|
|
||||||
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
|
|
||||||
"sambar", "rasam", "coconut chutney", "appam",
|
|
||||||
"fish curry kerala", "puttu", "payasam"],
|
|
||||||
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe ilish",
|
|
||||||
"mishti doi", "rasgulla", "kosha mangsho"],
|
|
||||||
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu",
|
|
||||||
"khandvi", "fafda", "gujarati dal"],
|
|
||||||
"Pakistani": ["pakistani", "nihari", "haleem", "seekh kebab",
|
|
||||||
"karahi", "biryani karachi", "chapli kebab",
|
|
||||||
"halwa puri", "paya"],
|
|
||||||
"Sri Lankan": ["sri lankan", "kottu roti", "hoppers", "pol sambol",
|
|
||||||
"sri lankan curry", "lamprais", "string hoppers",
|
|
||||||
"wambatu moju"],
|
|
||||||
"Bangladeshi": ["bangladeshi", "bangladesh", "dhaka biryani",
|
|
||||||
"shutki", "pitha", "hilsa curry", "kacchi biryani",
|
|
||||||
"bhuna khichuri", "doi maach", "rezala"],
|
|
||||||
"Nepali": ["nepali", "dal bhat", "momos", "sekuwa",
|
|
||||||
"sel roti", "gundruk", "thukpa"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Mediterranean": {
|
|
||||||
"keywords": ["cuisine:Mediterranean", "cuisine:Greek", "cuisine:Middle Eastern",
|
|
||||||
"mediterranean", "greek", "middle eastern", "turkish",
|
|
||||||
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
|
||||||
"syrian", "iraqi", "jordanian"],
|
|
||||||
"subcategories": {
|
|
||||||
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
|
|
||||||
"souvlaki", "dolmades", "spanakopita", "tiropita",
|
|
||||||
"galaktoboureko"],
|
|
||||||
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
|
|
||||||
"lahmacun", "menemen", "pide", "iskender",
|
|
||||||
"kisir", "simit"],
|
|
||||||
"Syrian": ["syrian", "fattet hummus", "kibbeh syria",
|
|
||||||
"muhammara", "maklouba syria", "sfeeha",
|
|
||||||
"halawet el jibn"],
|
|
||||||
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
|
|
||||||
"tabbouleh", "kibbeh", "fattoush", "manakish",
|
|
||||||
"kafta", "sfiha"],
|
|
||||||
"Jewish": ["jewish", "israeli", "ashkenazi", "sephardic",
|
|
||||||
"shakshuka", "sabich", "za'atar", "tahini",
|
|
||||||
"zhug", "zhoug", "s'khug", "z'houg",
|
|
||||||
"hawaiij", "hawaij", "hawayej",
|
|
||||||
"matzo", "latke", "rugelach", "babka", "challah",
|
|
||||||
"cholent", "gefilte fish", "brisket", "kugel",
|
|
||||||
"new york jewish", "new york deli", "pastrami",
|
|
||||||
"knish", "lox", "bagel and lox", "jewish deli"],
|
|
||||||
"Palestinian": ["palestinian", "musakhan", "maqluba", "knafeh",
|
|
||||||
"maftoul", "freekeh", "sumac chicken"],
|
|
||||||
"Yemeni": ["yemeni", "saltah", "lahoh", "bint al-sahn",
|
|
||||||
"zhug", "zhoug", "hulba", "fahsa",
|
|
||||||
"hawaiij", "hawaij", "hawayej"],
|
|
||||||
"Egyptian": ["egyptian", "koshari", "molokhia", "mahshi",
|
|
||||||
"ful medames", "ta'ameya", "feteer meshaltet"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"American": {
|
|
||||||
"keywords": ["cuisine:American", "cuisine:Southern", "cuisine:Cajun",
|
|
||||||
"american", "southern", "comfort food", "cajun", "creole",
|
|
||||||
"hawaiian", "tex-mex", "soul food"],
|
|
||||||
"subcategories": {
|
|
||||||
"Southern": ["southern", "soul food", "fried chicken",
|
|
||||||
"collard greens", "cornbread", "biscuits and gravy",
|
|
||||||
"mac and cheese", "sweet potato pie", "okra"],
|
|
||||||
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
|
|
||||||
"jambalaya", "etouffee", "dirty rice", "po'boy",
|
|
||||||
"muffuletta", "red beans and rice"],
|
|
||||||
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
|
|
||||||
"queso", "breakfast taco", "chile con carne"],
|
|
||||||
"New England": ["new england", "chowder", "lobster", "clam",
|
|
||||||
"maple", "yankee", "boston baked beans",
|
|
||||||
"johnnycake", "fish and chips"],
|
|
||||||
"Pacific Northwest": ["pacific northwest", "pnw", "dungeness crab",
|
|
||||||
"salmon", "cedar plank", "razor clam",
|
|
||||||
"geoduck", "chanterelle", "marionberry"],
|
|
||||||
"Hawaiian": ["hawaiian", "hawaii", "plate lunch", "loco moco",
|
|
||||||
"poke", "spam musubi", "kalua pig", "lau lau",
|
|
||||||
"haupia", "poi", "manapua", "garlic shrimp",
|
|
||||||
"saimin", "huli huli", "malasada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"BBQ & Smoke": {
|
|
||||||
# Top-level keywords: cuisine:BBQ inferred tag + broad corpus terms.
|
|
||||||
"keywords": ["cuisine:BBQ", "bbq", "barbecue", "barbeque", "smoked", "smoky",
|
|
||||||
"smoke", "pit", "smoke ring", "low and slow",
|
|
||||||
"brisket", "pulled pork", "ribs", "spare ribs",
|
|
||||||
"baby back", "baby back ribs", "dry rub", "wet rub",
|
|
||||||
"cookout", "smoker", "smoked meat", "smoked chicken",
|
|
||||||
"smoked pork", "smoked beef", "smoked turkey",
|
|
||||||
"pit smoked", "wood smoked", "slow smoked",
|
|
||||||
"charcoal", "chargrilled", "burnt ends"],
|
|
||||||
"subcategories": {
|
|
||||||
"Texas BBQ": ["texas bbq", "central texas bbq", "brisket",
|
|
||||||
"beef brisket", "beef ribs", "smoked brisket",
|
|
||||||
"post oak", "salt and pepper rub",
|
|
||||||
"east texas bbq", "lockhart", "franklin style"],
|
|
||||||
"Carolina BBQ": ["carolina bbq", "north carolina bbq", "whole hog",
|
|
||||||
"vinegar sauce", "vinegar bbq", "lexington style",
|
|
||||||
"eastern nc", "south carolina bbq", "mustard sauce",
|
|
||||||
"carolina pulled pork"],
|
|
||||||
"Kansas City BBQ": ["kansas city bbq", "kc bbq", "burnt ends",
|
|
||||||
"sweet bbq sauce", "tomato molasses sauce",
|
|
||||||
"baby back ribs", "kansas city ribs"],
|
|
||||||
"Memphis BBQ": ["memphis bbq", "dry rub ribs", "wet ribs",
|
|
||||||
"memphis style", "dry rub pork", "memphis ribs"],
|
|
||||||
"Alabama BBQ": ["alabama bbq", "white sauce", "alabama white sauce",
|
|
||||||
"smoked chicken", "white bbq sauce"],
|
|
||||||
"Kentucky BBQ": ["kentucky bbq", "mutton bbq", "owensboro bbq",
|
|
||||||
"black dip", "western kentucky barbecue", "mutton"],
|
|
||||||
"St. Louis BBQ": ["st louis bbq", "st louis ribs", "st. louis ribs",
|
|
||||||
"st louis cut ribs", "spare ribs st louis"],
|
|
||||||
"Backyard Grill": ["backyard bbq", "cookout", "grilled burgers",
|
|
||||||
"charcoal grill", "kettle grill", "tailgate",
|
|
||||||
"grill out", "backyard grilling"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"European": {
|
|
||||||
"keywords": ["cuisine:French", "cuisine:German", "cuisine:Spanish",
|
|
||||||
"french", "german", "spanish", "british", "irish", "scottish",
|
|
||||||
"welsh", "scandinavian", "nordic", "eastern european"],
|
|
||||||
"subcategories": {
|
|
||||||
"French": ["french", "provencal", "beurre", "crepe",
|
|
||||||
"ratatouille", "cassoulet", "bouillabaisse"],
|
|
||||||
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
|
|
||||||
"tortilla espanola", "chorizo"],
|
|
||||||
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
|
|
||||||
"pretzel", "strudel"],
|
|
||||||
"British": ["british", "english", "pub food", "cornish",
|
|
||||||
"shepherd's pie", "bangers", "toad in the hole",
|
|
||||||
"coronation chicken", "london", "londoner",
|
|
||||||
"cornish pasty", "ploughman's"],
|
|
||||||
"Irish": ["irish", "ireland", "colcannon", "coddle",
|
|
||||||
"irish stew", "soda bread", "boxty", "champ"],
|
|
||||||
"Scottish": ["scottish", "scotland", "haggis", "cullen skink",
|
|
||||||
"cranachan", "scotch broth", "glaswegian",
|
|
||||||
"neeps and tatties", "tablet"],
|
|
||||||
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
|
|
||||||
"danish", "finnish", "gravlax", "swedish meatballs",
|
|
||||||
"lefse", "smörgåsbord", "fika", "crispbread",
|
|
||||||
"cardamom bun", "herring", "æbleskiver",
|
|
||||||
"lingonberry", "lutefisk", "janssons frestelse",
|
|
||||||
"knäckebröd", "kladdkaka"],
|
|
||||||
"Eastern European": ["eastern european", "polish", "russian", "ukrainian",
|
|
||||||
"czech", "hungarian", "pierogi", "borscht",
|
|
||||||
"goulash", "kielbasa", "varenyky", "pelmeni"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Latin American": {
|
|
||||||
"keywords": ["cuisine:Latin American", "cuisine:Caribbean",
|
|
||||||
"latin american", "peruvian", "argentinian", "colombian",
|
|
||||||
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
|
||||||
"subcategories": {
|
|
||||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
|
||||||
"aji amarillo", "causa", "leche de tigre",
|
|
||||||
"arroz con leche peru", "pollo a la brasa"],
|
|
||||||
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
|
|
||||||
"brigadeiro", "coxinha", "moqueca", "vatapa",
|
|
||||||
"caipirinha", "acai bowl"],
|
|
||||||
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
|
|
||||||
"sancocho", "ajiaco", "buñuelos", "changua"],
|
|
||||||
"Argentinian": ["argentinian", "asado", "chimichurri", "empanadas argentina",
|
|
||||||
"milanesa", "locro", "dulce de leche", "medialunas"],
|
|
||||||
"Venezuelan": ["venezuelan", "pabellón criollo", "arepas venezuela",
|
|
||||||
"hallacas", "cachapas", "tequeños", "caraotas"],
|
|
||||||
"Chilean": ["chilean", "cazuela", "pastel de choclo", "curanto",
|
|
||||||
"sopaipillas", "charquicán", "completo"],
|
|
||||||
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
|
|
||||||
"picadillo", "lechon cubano", "vaca frita",
|
|
||||||
"tostones", "platanos maduros"],
|
|
||||||
"Jamaican": ["jamaican", "jerk chicken", "jerk pork", "ackee saltfish",
|
|
||||||
"curry goat", "rice and peas", "escovitch",
|
|
||||||
"jamaican patty", "callaloo jamaica", "festival"],
|
|
||||||
"Puerto Rican": ["puerto rican", "mofongo", "pernil", "arroz con gandules",
|
|
||||||
"sofrito", "pasteles", "tostones pr", "tembleque",
|
|
||||||
"coquito", "asopao"],
|
|
||||||
"Dominican": ["dominican", "mangu", "sancocho dominicano",
|
|
||||||
"pollo guisado", "habichuelas guisadas",
|
|
||||||
"tostones dominicanos", "morir soñando"],
|
|
||||||
"Haitian": ["haitian", "griot", "pikliz", "riz et pois",
|
|
||||||
"joumou", "akra", "pain patate", "labouyi"],
|
|
||||||
"Trinidad": ["trinidadian", "doubles", "roti trinidad", "pelau",
|
|
||||||
"callaloo trinidad", "bake and shark",
|
|
||||||
"curry duck", "oil down"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Central American": {
|
|
||||||
"keywords": ["central american", "salvadoran", "guatemalan",
|
|
||||||
"honduran", "nicaraguan", "costa rican", "panamanian"],
|
|
||||||
"subcategories": {
|
|
||||||
"Salvadoran": ["salvadoran", "el salvador", "pupusas", "curtido",
|
|
||||||
"sopa de pata", "nuégados", "atol shuco"],
|
|
||||||
"Guatemalan": ["guatemalan", "pepián", "jocon", "kak'ik",
|
|
||||||
"hilachas", "rellenitos", "fiambre"],
|
|
||||||
"Costa Rican": ["costa rican", "gallo pinto", "casado",
|
|
||||||
"olla de carne", "arroz con leche cr",
|
|
||||||
"tres leches cr"],
|
|
||||||
"Honduran": ["honduran", "baleadas", "sopa de caracol",
|
|
||||||
"tapado", "machuca", "catrachitas"],
|
|
||||||
"Nicaraguan": ["nicaraguan", "nacatamal", "vigorón", "indio viejo",
|
|
||||||
"gallo pinto nicaragua", "güirilas"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"African": {
|
|
||||||
"keywords": ["african", "west african", "east african", "ethiopian",
|
|
||||||
"nigerian", "ghanaian", "kenyan", "south african",
|
|
||||||
"senegalese", "tunisian"],
|
|
||||||
"subcategories": {
|
|
||||||
"West African": ["west african", "nigerian", "ghanaian",
|
|
||||||
"jollof rice", "egusi soup", "fufu", "suya",
|
|
||||||
"groundnut stew", "kelewele", "kontomire",
|
|
||||||
"waakye", "ofam", "bitterleaf soup"],
|
|
||||||
"Senegalese": ["senegalese", "senegal", "thieboudienne",
|
|
||||||
"yassa", "mafe", "thiou", "ceebu jen",
|
|
||||||
"domoda"],
|
|
||||||
"Ethiopian & Eritrean": ["ethiopian", "eritrean", "injera", "doro wat",
|
|
||||||
"kitfo", "tibs", "shiro", "misir wat",
|
|
||||||
"gomen", "ful ethiopian", "tegamino"],
|
|
||||||
"East African": ["east african", "kenyan", "tanzanian", "ugandan",
|
|
||||||
"nyama choma", "ugali", "sukuma wiki",
|
|
||||||
"pilau kenya", "mandazi", "matoke",
|
|
||||||
"githeri", "irio"],
|
|
||||||
"North African": ["north african", "tunisian", "algerian", "libyan",
|
|
||||||
"brik", "lablabi", "merguez", "shakshuka tunisian",
|
|
||||||
"harissa tunisian", "couscous algerian"],
|
|
||||||
"South African": ["south african", "braai", "bobotie", "boerewors",
|
|
||||||
"bunny chow", "pap", "chakalaka", "biltong",
|
|
||||||
"malva pudding", "koeksister", "potjiekos"],
|
|
||||||
"Moroccan": ["moroccan", "tagine", "couscous morocco",
|
|
||||||
"harissa", "chermoula", "preserved lemon",
|
|
||||||
"pastilla", "mechoui", "bastilla"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pacific & Oceania": {
|
|
||||||
"keywords": ["pacific", "oceania", "polynesian", "melanesian",
|
|
||||||
"micronesian", "maori", "fijian", "samoan", "tongan",
|
|
||||||
"hawaiian", "australian", "new zealand"],
|
|
||||||
"subcategories": {
|
|
||||||
"Māori / New Zealand": ["maori", "new zealand", "hangi", "rewena bread",
|
|
||||||
"boil-up", "paua", "kumara", "pavlova nz",
|
|
||||||
"whitebait fritter", "kina", "hokey pokey"],
|
|
||||||
"Australian": ["australian", "meat pie", "lamington",
|
|
||||||
"anzac biscuits", "damper", "barramundi",
|
|
||||||
"vegemite", "pavlova australia", "tim tam",
|
|
||||||
"sausage sizzle", "chiko roll", "fairy bread"],
|
|
||||||
"Fijian": ["fijian", "fiji", "kokoda", "lovo",
|
|
||||||
"rourou", "palusami fiji", "duruka",
|
|
||||||
"vakalolo"],
|
|
||||||
"Samoan": ["samoan", "samoa", "palusami", "oka",
|
|
||||||
"fa'ausi", "chop suey samoa", "sapasui",
|
|
||||||
"koko alaisa", "supo esi"],
|
|
||||||
"Tongan": ["tongan", "tonga", "lu pulu", "'ota 'ika",
|
|
||||||
"fekkai", "faikakai topai", "kapisi pulu"],
|
|
||||||
"Papua New Guinean": ["papua new guinea", "png", "mumu",
|
|
||||||
"sago", "aibika", "kaukau",
|
|
||||||
"taro png", "coconut crab"],
|
|
||||||
"Hawaiian": ["hawaiian", "hawaii", "poke", "loco moco",
|
|
||||||
"plate lunch", "kalua pig", "haupia",
|
|
||||||
"spam musubi", "poi", "malasada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Central Asian & Caucasus": {
|
|
||||||
"keywords": ["central asian", "caucasus", "georgian", "armenian", "uzbek",
|
|
||||||
"afghan", "persian", "iranian", "azerbaijani", "kazakh"],
|
|
||||||
"subcategories": {
|
|
||||||
"Persian / Iranian": ["persian", "iranian", "ghormeh sabzi", "fesenjan",
|
|
||||||
"tahdig", "joojeh kabab", "ash reshteh",
|
|
||||||
"zereshk polo", "khoresh", "mast o khiar",
|
|
||||||
"kashk-e-bademjan", "mirza ghasemi",
|
|
||||||
"baghali polo"],
|
|
||||||
"Georgian": ["georgian", "georgia", "khachapuri", "khinkali",
|
|
||||||
"churchkhela", "ajapsandali", "satsivi",
|
|
||||||
"pkhali", "lobiani", "badrijani nigvzit"],
|
|
||||||
"Armenian": ["armenian", "dolma armenia", "lahmajoun",
|
|
||||||
"manti armenia", "ghapama", "basturma",
|
|
||||||
"harissa armenia", "nazook", "tolma"],
|
|
||||||
"Azerbaijani": ["azerbaijani", "azerbaijan", "plov azerbaijan",
|
|
||||||
"dolma azeri", "dushbara", "levengi",
|
|
||||||
"shah plov", "gutab"],
|
|
||||||
"Uzbek": ["uzbek", "uzbekistan", "plov", "samsa",
|
|
||||||
"lagman", "shashlik", "manti uzbek",
|
|
||||||
"non bread", "dimlama", "sumalak"],
|
|
||||||
"Afghan": ["afghan", "afghanistan", "kabuli pulao", "mantu",
|
|
||||||
"bolani", "qorma", "ashak", "shorwa",
|
|
||||||
"aushak", "borani banjan"],
|
|
||||||
"Kazakh": ["kazakh", "beshbarmak", "kuyrdak", "baursak",
|
|
||||||
"kurt", "shubat", "kazy"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"meal_type": {
|
"meal_type": {
|
||||||
"label": "Meal Type",
|
"label": "Meal Type",
|
||||||
"categories": {
|
"categories": {
|
||||||
# Keywords use two complementary sources:
|
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
||||||
# 1. inferred_tag phrases ("meal:X", "main:X") — indexed in recipe_browser_fts.inferred_tags.
|
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||||
# FTS5 tokenises "meal:Breakfast" → ["meal","breakfast"], so the quoted phrase
|
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||||
# "meal:Breakfast" matches exactly that consecutive token pair.
|
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
||||||
# 2. Corpus keyword/category text — only covers the ~1,200 keyword-tagged recipes.
|
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
||||||
# Kept as a fallback; not the primary signal.
|
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||||
"Breakfast": {
|
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||||
"keywords": ["meal:Breakfast", "breakfast", "brunch", "pancakes",
|
|
||||||
"waffles", "oatmeal", "muffin"],
|
|
||||||
"subcategories": {
|
|
||||||
"Eggs": ["meal:Breakfast", "egg", "omelette", "frittata",
|
|
||||||
"quiche", "scrambled", "benedict", "shakshuka"],
|
|
||||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
|
||||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
|
||||||
"coffee cake", "danish"],
|
|
||||||
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
|
|
||||||
"overnight oats"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Lunch": {
|
|
||||||
# meal:Lunch tag covers explicitly-tagged recipes.
|
|
||||||
# Coverage is limited — most lunch-style recipes have no distinct meal-type tag.
|
|
||||||
"keywords": ["meal:Lunch", "lunch", "sandwich", "wrap", "salad",
|
|
||||||
"soup", "light meal"],
|
|
||||||
"subcategories": {
|
|
||||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
|
||||||
"grilled cheese", "blt"],
|
|
||||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
|
||||||
"cobb"],
|
|
||||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
|
||||||
"minestrone", "lentil soup"],
|
|
||||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
|
||||||
"quesadilla"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Dinner": {
|
|
||||||
# Primary: main:X inferred tags (800k+ recipes).
|
|
||||||
# "meal:Dinner" does not exist in the inferred-tag vocabulary — main-protein
|
|
||||||
# tags are the best available proxy for main-course dinner recipes.
|
|
||||||
"keywords": ["main:Chicken", "main:Beef", "main:Pork", "main:Fish",
|
|
||||||
"main:Pasta", "dinner", "main dish", "entree",
|
|
||||||
"main course", "supper"],
|
|
||||||
"subcategories": {
|
|
||||||
"Chicken": ["main:Chicken"],
|
|
||||||
"Beef": ["main:Beef"],
|
|
||||||
"Pork": ["main:Pork"],
|
|
||||||
"Fish & Seafood": ["main:Fish"],
|
|
||||||
"Pasta": ["main:Pasta"],
|
|
||||||
"Casseroles": ["casserole", "bake", "gratin", "pot pie"],
|
|
||||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
|
||||||
"daube"],
|
|
||||||
"Grilled": ["grilled", "grill", "barbecue", "kebab", "skewer"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Snack": {
|
|
||||||
"keywords": ["meal:Snack", "snack", "appetizer", "finger food",
|
|
||||||
"dip", "bite", "starter"],
|
|
||||||
"subcategories": {
|
|
||||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
|
||||||
"salsa", "pate"],
|
|
||||||
"Finger Foods": ["finger food", "bite", "skewer", "slider",
|
|
||||||
"wing", "nugget"],
|
|
||||||
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
|
|
||||||
"pretzel"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Dessert": {
|
|
||||||
# "sweet" removed — it matches flavor:Sweet inferred tags, causing false positives.
|
|
||||||
"keywords": ["meal:Dessert", "dessert", "cake", "cookie", "pie",
|
|
||||||
"pudding", "ice cream", "brownie"],
|
|
||||||
"subcategories": {
|
|
||||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
|
||||||
"cheesecake", "torte"],
|
|
||||||
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
|
|
||||||
"biscotti", "shortbread"],
|
|
||||||
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
|
|
||||||
"crumble"],
|
|
||||||
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
|
|
||||||
"popsicle", "granita"],
|
|
||||||
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
|
|
||||||
"flan", "creme brulee"],
|
|
||||||
"Candy": ["candy", "fudge", "truffle", "brittle",
|
|
||||||
"caramel", "toffee"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Beverage": ["meal:Beverage", "drink", "smoothie", "cocktail", "beverage",
|
|
||||||
"juice", "shake", "lemonade"],
|
|
||||||
"Side Dish": {
|
|
||||||
# meal:Side Dish not in inferred-tag vocabulary.
|
|
||||||
# main:Vegetables and main:Grains are the best proxies — will overlap
|
|
||||||
# with some vegetarian mains, which is acceptable.
|
|
||||||
"keywords": ["main:Vegetables", "main:Grains", "side dish", "side",
|
|
||||||
"pilaf", "accompaniment"],
|
|
||||||
"subcategories": {
|
|
||||||
"Vegetables": ["main:Vegetables"],
|
|
||||||
"Grains & Rice": ["main:Grains", "rice", "pilaf", "quinoa"],
|
|
||||||
"Bread": ["meal:Bread", "bread", "roll", "biscuit"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dietary": {
|
"dietary": {
|
||||||
"label": "Dietary",
|
"label": "Dietary",
|
||||||
# Primary: dietary:X inferred tags (indexed in recipe_browser_fts.inferred_tags).
|
|
||||||
# Secondary: text tokens kept as fallback for keyword-tagged recipes.
|
|
||||||
# IMPORTANT: Use ONLY structured dietary:X phrases here.
|
|
||||||
# Bare text keywords like "vegan", "low-carb" also match can_be:Vegan,
|
|
||||||
# can_be:Low-Carb etc. — those are "achievable with substitutions", not
|
|
||||||
# "recipe already is". The structured phrase "dietary:Vegan" (consecutive
|
|
||||||
# FTS tokens "dietary"+"vegan") does NOT match can_be:Vegan.
|
|
||||||
"categories": {
|
"categories": {
|
||||||
"Vegetarian": ["dietary:Vegetarian"],
|
"Vegetarian": ["vegetarian"],
|
||||||
"Vegan": ["dietary:Vegan"],
|
"Vegan": ["vegan", "plant-based", "plant based"],
|
||||||
"Gluten-Free": ["dietary:Gluten-Free"],
|
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
|
||||||
"Low-Carb": ["dietary:Low-Carb"],
|
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
|
||||||
"High-Protein": ["dietary:High-Protein"],
|
"High-Protein": ["high protein", "high-protein"],
|
||||||
"Low-Fat": ["dietary:Low-Fat"],
|
"Low-Fat": ["low-fat", "low fat", "light"],
|
||||||
"Dairy-Free": ["dietary:Dairy-Free"],
|
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
|
||||||
"Low-Sodium": ["dietary:Low-Sodium"],
|
|
||||||
"Paleo": ["dietary:Paleo"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"main_ingredient": {
|
"main_ingredient": {
|
||||||
"label": "Main Ingredient",
|
"label": "Main Ingredient",
|
||||||
"categories": {
|
"categories": {
|
||||||
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
# These values match the inferred_tags written by tag_inferrer._MAIN_INGREDIENT_SIGNALS
|
||||||
"Chicken": {
|
# and indexed into recipe_browser_fts — use exact tag strings.
|
||||||
"keywords": ["main:Chicken"],
|
"Chicken": ["main:Chicken"],
|
||||||
"subcategories": {
|
"Beef": ["main:Beef"],
|
||||||
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
"Pork": ["main:Pork"],
|
||||||
"chicken bake"],
|
"Fish": ["main:Fish"],
|
||||||
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
|
|
||||||
"chicken skewer"],
|
|
||||||
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
|
|
||||||
"crispy chicken"],
|
|
||||||
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
|
|
||||||
"chicken curry", "chicken braise"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Beef": {
|
|
||||||
"keywords": ["main:Beef"],
|
|
||||||
"subcategories": {
|
|
||||||
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
|
|
||||||
"bolognese", "burger"],
|
|
||||||
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
|
|
||||||
"filet mignon", "t-bone"],
|
|
||||||
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
|
|
||||||
"chuck roast"],
|
|
||||||
"Stews": ["beef stew", "beef braise", "beef bourguignon",
|
|
||||||
"short ribs"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pork": {
|
|
||||||
"keywords": ["main:Pork"],
|
|
||||||
"subcategories": {
|
|
||||||
"Chops": ["pork chop", "pork loin", "pork cutlet"],
|
|
||||||
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
|
|
||||||
"carnitas", "slow cooker pork"],
|
|
||||||
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
|
|
||||||
"Italian sausage"],
|
|
||||||
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
|
|
||||||
"pork belly"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Fish": {
|
|
||||||
"keywords": ["main:Fish"],
|
|
||||||
"subcategories": {
|
|
||||||
"Salmon": ["salmon", "smoked salmon", "gravlax"],
|
|
||||||
"Tuna": ["tuna", "albacore", "ahi"],
|
|
||||||
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
|
|
||||||
"flounder", "bass"],
|
|
||||||
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
|
|
||||||
"mussel", "clam", "oyster"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pasta": ["main:Pasta"],
|
"Pasta": ["main:Pasta"],
|
||||||
"Vegetables": {
|
"Vegetables": ["main:Vegetables"],
|
||||||
"keywords": ["main:Vegetables"],
|
|
||||||
"subcategories": {
|
|
||||||
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
|
|
||||||
"parsnip", "turnip"],
|
|
||||||
"Leafy": ["spinach", "kale", "chard", "arugula",
|
|
||||||
"collard greens", "lettuce"],
|
|
||||||
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
|
|
||||||
"cabbage", "bok choy"],
|
|
||||||
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
|
|
||||||
"squash"],
|
|
||||||
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
|
|
||||||
"chanterelle"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Eggs": ["main:Eggs"],
|
"Eggs": ["main:Eggs"],
|
||||||
"Legumes": ["main:Legumes"],
|
"Legumes": ["main:Legumes"],
|
||||||
"Grains": ["main:Grains"],
|
"Grains": ["main:Grains"],
|
||||||
|
|
@ -629,53 +73,16 @@ DOMAINS: dict[str, dict] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
|
|
||||||
"""Return the raw category definition, or None if not found."""
|
|
||||||
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
|
|
||||||
|
|
||||||
|
|
||||||
def get_domain_labels() -> list[dict]:
|
def get_domain_labels() -> list[dict]:
|
||||||
"""Return [{id, label}] for all available domains."""
|
"""Return [{id, label}] for all available domains."""
|
||||||
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
||||||
"""Return the keyword list for the category (top-level, covers all subcategories).
|
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
||||||
|
domain_data = DOMAINS.get(domain, {})
|
||||||
For flat categories returns the list directly.
|
categories = domain_data.get("categories", {})
|
||||||
For nested categories returns the 'keywords' key.
|
return categories.get(category, [])
|
||||||
Returns [] if category or domain not found.
|
|
||||||
"""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if cat_def is None:
|
|
||||||
return []
|
|
||||||
if isinstance(cat_def, list):
|
|
||||||
return cat_def
|
|
||||||
return cat_def.get("keywords", [])
|
|
||||||
|
|
||||||
|
|
||||||
def category_has_subcategories(domain: str, category: str) -> bool:
|
|
||||||
"""Return True when a category has a subcategory level."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return False
|
|
||||||
return bool(cat_def.get("subcategories"))
|
|
||||||
|
|
||||||
|
|
||||||
def get_subcategory_names(domain: str, category: str) -> list[str]:
|
|
||||||
"""Return subcategory names for a category, or [] if none exist."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return []
|
|
||||||
return list(cat_def.get("subcategories", {}).keys())
|
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
|
|
||||||
"""Return keyword list for a specific subcategory, or [] if not found."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return []
|
|
||||||
return cat_def.get("subcategories", {}).get(subcategory, [])
|
|
||||||
|
|
||||||
|
|
||||||
def get_category_names(domain: str) -> list[str]:
|
def get_category_names(domain: str) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -93,18 +93,7 @@ class ElementClassifier:
|
||||||
return self._heuristic_profile(name)
|
return self._heuristic_profile(name)
|
||||||
|
|
||||||
def classify_batch(self, names: list[str]) -> list[IngredientProfile]:
|
def classify_batch(self, names: list[str]) -> list[IngredientProfile]:
|
||||||
"""Classify multiple names in one DB round-trip, falling back to heuristics."""
|
return [self.classify(n) for n in names]
|
||||||
if not names:
|
|
||||||
return []
|
|
||||||
normalised = [n.lower().strip() for n in names]
|
|
||||||
c = self._store._cp
|
|
||||||
placeholders = ",".join("?" * len(normalised))
|
|
||||||
rows = self._store._fetch_all(
|
|
||||||
f"SELECT * FROM {c}ingredient_profiles WHERE name IN ({placeholders})",
|
|
||||||
tuple(normalised),
|
|
||||||
)
|
|
||||||
by_name = {r["name"]: self._row_to_profile(r) for r in rows}
|
|
||||||
return [by_name.get(n) or self._heuristic_profile(n) for n in normalised]
|
|
||||||
|
|
||||||
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
||||||
"""Return element names that have no coverage in the given profile list."""
|
"""Return element names that have no coverage in the given profile list."""
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ Walmart is kept inline until cf-core adds Impact network support:
|
||||||
|
|
||||||
Links are always generated (plain URLs are useful even without affiliate IDs).
|
Links are always generated (plain URLs are useful even without affiliate IDs).
|
||||||
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
||||||
Instacart and Walmart are US/CA-only; other locales get Amazon only.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -24,26 +23,18 @@ from urllib.parse import quote_plus
|
||||||
from circuitforge_core.affiliates import wrap_url
|
from circuitforge_core.affiliates import wrap_url
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink
|
from app.models.schemas.recipe import GroceryLink
|
||||||
from app.services.recipe.locale_config import get_locale
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _amazon_link(ingredient: str, locale: str) -> GroceryLink:
|
def _amazon_fresh_link(ingredient: str) -> GroceryLink:
|
||||||
cfg = get_locale(locale)
|
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
domain = cfg["amazon_domain"]
|
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
|
||||||
dept = cfg["amazon_grocery_dept"]
|
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon"))
|
||||||
base = f"https://www.{domain}/s?k={q}&{dept}"
|
|
||||||
retailer = "Amazon" if locale != "us" else "Amazon Fresh"
|
|
||||||
return GroceryLink(ingredient=ingredient, retailer=retailer, url=wrap_url(base, "amazon"))
|
|
||||||
|
|
||||||
|
|
||||||
def _instacart_link(ingredient: str, locale: str) -> GroceryLink:
|
def _instacart_link(ingredient: str) -> GroceryLink:
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
if locale == "ca":
|
|
||||||
base = f"https://www.instacart.ca/store/s?k={q}"
|
|
||||||
else:
|
|
||||||
base = f"https://www.instacart.com/store/s?k={q}"
|
base = f"https://www.instacart.com/store/s?k={q}"
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
||||||
|
|
||||||
|
|
@ -59,28 +50,26 @@ def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkBuilder:
|
class GroceryLinkBuilder:
|
||||||
def __init__(self, tier: str = "free", has_byok: bool = False, locale: str = "us") -> None:
|
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
||||||
self._tier = tier
|
self._tier = tier
|
||||||
self._locale = locale
|
|
||||||
self._locale_cfg = get_locale(locale)
|
|
||||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
||||||
|
|
||||||
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
||||||
"""Build grocery deeplinks for a single ingredient.
|
"""Build grocery deeplinks for a single ingredient.
|
||||||
|
|
||||||
Amazon link is always included, routed to the user's locale domain.
|
Amazon Fresh and Instacart links are always included; wrap_url handles
|
||||||
Instacart and Walmart are only shown where they operate (US/CA).
|
affiliate ID injection (or returns a plain URL if none is configured).
|
||||||
wrap_url handles affiliate ID injection for supported programs.
|
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a
|
||||||
|
path-based redirect that doesn't degrade cleanly to a plain URL).
|
||||||
"""
|
"""
|
||||||
if not ingredient.strip():
|
if not ingredient.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
links: list[GroceryLink] = [_amazon_link(ingredient, self._locale)]
|
links: list[GroceryLink] = [
|
||||||
|
_amazon_fresh_link(ingredient),
|
||||||
if self._locale_cfg["instacart"]:
|
_instacart_link(ingredient),
|
||||||
links.append(_instacart_link(ingredient, self._locale))
|
]
|
||||||
|
if self._walmart_id:
|
||||||
if self._locale_cfg["walmart"] and self._walmart_id:
|
|
||||||
links.append(_walmart_link(ingredient, self._walmart_id))
|
links.append(_walmart_link(ingredient, self._walmart_id))
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
"""LLM-driven recipe generator for Levels 3 and 4."""
|
"""LLM-driven recipe generator for Levels 3 and 4."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from typing import TYPE_CHECKING, AsyncGenerator
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from openai import AsyncOpenAI, OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
@ -69,9 +68,6 @@ class LLMRecipeGenerator:
|
||||||
if allergy_list:
|
if allergy_list:
|
||||||
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
|
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
|
||||||
|
|
||||||
if req.exclude_ingredients:
|
|
||||||
lines.append(f"IMPORTANT — user does not want these today: {', '.join(req.exclude_ingredients)}. Do not include them.")
|
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
|
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
|
||||||
|
|
||||||
|
|
@ -128,9 +124,6 @@ class LLMRecipeGenerator:
|
||||||
if allergy_list:
|
if allergy_list:
|
||||||
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
||||||
|
|
||||||
if req.exclude_ingredients:
|
|
||||||
lines.append(f"Do not use today: {', '.join(req.exclude_ingredients)}")
|
|
||||||
|
|
||||||
unit_line = (
|
unit_line = (
|
||||||
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
||||||
if req.unit_system == "metric"
|
if req.unit_system == "metric"
|
||||||
|
|
@ -151,7 +144,6 @@ class LLMRecipeGenerator:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
_SERVICE_TYPE = "cf-text"
|
_SERVICE_TYPE = "cf-text"
|
||||||
_MODEL_CANDIDATES = ["granite-4.1-8b", "deepseek-r1-1.5b"]
|
|
||||||
_TTL_S = 300.0
|
_TTL_S = 300.0
|
||||||
_CALLER = "kiwi-recipe"
|
_CALLER = "kiwi-recipe"
|
||||||
|
|
||||||
|
|
@ -169,10 +161,8 @@ class LLMRecipeGenerator:
|
||||||
client = CFOrchClient(cf_orch_url)
|
client = CFOrchClient(cf_orch_url)
|
||||||
return client.allocate(
|
return client.allocate(
|
||||||
service=self._SERVICE_TYPE,
|
service=self._SERVICE_TYPE,
|
||||||
model_candidates=self._MODEL_CANDIDATES,
|
|
||||||
ttl_s=self._TTL_S,
|
ttl_s=self._TTL_S,
|
||||||
caller=self._CALLER,
|
caller=self._CALLER,
|
||||||
pipeline=os.environ.get("CF_APP_NAME") or None,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
||||||
|
|
@ -183,12 +173,7 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
||||||
calls the OpenAI-compatible API directly against the allocated service URL.
|
calls the OpenAI-compatible API directly against the allocated service URL.
|
||||||
Falls back to LLMRouter when:
|
Allocation failure falls through to LLMRouter rather than silently returning "".
|
||||||
- Allocation succeeded but the service is cold (warm=False) — avoids
|
|
||||||
making the user wait for model load; LLMRouter uses Ollama which is
|
|
||||||
already running.
|
|
||||||
- Allocation succeeded but the connection to the service URL fails — the
|
|
||||||
agent may have registered the service but failed to start it.
|
|
||||||
Without CF_ORCH_URL: uses LLMRouter directly.
|
Without CF_ORCH_URL: uses LLMRouter directly.
|
||||||
"""
|
"""
|
||||||
ctx = self._get_llm_context()
|
ctx = self._get_llm_context()
|
||||||
|
|
@ -196,33 +181,11 @@ class LLMRecipeGenerator:
|
||||||
try:
|
try:
|
||||||
alloc = ctx.__enter__()
|
alloc = ctx.__enter__()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
msg = str(exc)
|
|
||||||
# 429 = coordinator at capacity (all nodes at max_concurrent limit).
|
|
||||||
# Don't fall back to LLMRouter — it's also overloaded and the slow
|
|
||||||
# fallback causes nginx 504s. Return "" fast so the caller degrades
|
|
||||||
# gracefully (empty recipe result) rather than timing out.
|
|
||||||
if "429" in msg or "max_concurrent" in msg.lower():
|
|
||||||
logger.info("cf-orch at capacity — returning empty result (graceful degradation)")
|
|
||||||
if ctx is not None:
|
|
||||||
try:
|
|
||||||
ctx.__exit__(None, None, None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
ctx = None # __enter__ raised — do not call __exit__
|
ctx = None # __enter__ raised — do not call __exit__
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if alloc is not None:
|
if alloc is not None:
|
||||||
# Skip cold services — model not yet loaded means the user would
|
|
||||||
# wait 60–120 s for model load before any response. Use LLMRouter
|
|
||||||
# (Ollama) instead, which is already warm on the host.
|
|
||||||
if not alloc.warm:
|
|
||||||
logger.info(
|
|
||||||
"cf-orch vllm allocated but cold (warm=False) — releasing and falling back to LLMRouter"
|
|
||||||
)
|
|
||||||
raise RuntimeError("vllm cold")
|
|
||||||
|
|
||||||
base_url = alloc.url.rstrip("/") + "/v1"
|
base_url = alloc.url.rstrip("/") + "/v1"
|
||||||
client = OpenAI(base_url=base_url, api_key="any")
|
client = OpenAI(base_url=base_url, api_key="any")
|
||||||
model = alloc.model or "__auto__"
|
model = alloc.model or "__auto__"
|
||||||
|
|
@ -238,20 +201,6 @@ class LLMRecipeGenerator:
|
||||||
return LLMRouter().complete(prompt)
|
return LLMRouter().complete(prompt)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("LLM call failed: %s", exc)
|
logger.error("LLM call failed: %s", exc)
|
||||||
# When cf-orch gave us an allocation but the service is unreachable
|
|
||||||
# (cold skip, connection refused, or other error), fall back to
|
|
||||||
# LLMRouter rather than silently returning empty.
|
|
||||||
# Skip "vllm" in the fallback order — that backend also routes through
|
|
||||||
# cf-orch, which would trigger a second (wasted) cold allocation.
|
|
||||||
if alloc is not None:
|
|
||||||
logger.info("Falling back to LLMRouter after vllm failure")
|
|
||||||
try:
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
router = LLMRouter()
|
|
||||||
_order = [b for b in (router.config.get("fallback_order") or []) if b != "vllm"]
|
|
||||||
return router.complete(prompt, fallback_order=_order or None)
|
|
||||||
except Exception as fallback_exc:
|
|
||||||
logger.error("LLMRouter fallback also failed: %s", fallback_exc)
|
|
||||||
return ""
|
return ""
|
||||||
finally:
|
finally:
|
||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
|
|
@ -388,91 +337,3 @@ class LLMRecipeGenerator:
|
||||||
suggestions=[suggestion],
|
suggestions=[suggestion],
|
||||||
element_gaps=gaps,
|
element_gaps=gaps,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream_generate(
|
|
||||||
self,
|
|
||||||
req: RecipeRequest,
|
|
||||||
profiles: list,
|
|
||||||
gaps: list[str],
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Stream LLM tokens for L3/L4. Yields raw text chunks as they arrive.
|
|
||||||
|
|
||||||
Tries cf-orch warm vllm first; falls back to Ollama via AsyncOpenAI.
|
|
||||||
When neither is reachable, falls back to blocking _call_llm and yields
|
|
||||||
the complete response as a single chunk so the caller always gets output.
|
|
||||||
"""
|
|
||||||
if req.level == 4:
|
|
||||||
prompt = self.build_level4_prompt(req)
|
|
||||||
else:
|
|
||||||
prompt = self.build_level3_prompt(req, profiles, gaps)
|
|
||||||
|
|
||||||
# Phase 1: try cf-orch warm vllm (sync allocation, wrapped in thread)
|
|
||||||
alloc_info = await asyncio.to_thread(self._try_alloc_for_stream)
|
|
||||||
if alloc_info is not None:
|
|
||||||
alloc, ctx = alloc_info
|
|
||||||
try:
|
|
||||||
async for token in self._stream_openai_compat(
|
|
||||||
alloc.url.rstrip("/") + "/v1", "any", alloc.model or "__auto__", prompt
|
|
||||||
):
|
|
||||||
yield token
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-orch stream failed, falling back to Ollama: %s", exc)
|
|
||||||
finally:
|
|
||||||
await asyncio.to_thread(lambda: _safe_exit(ctx))
|
|
||||||
|
|
||||||
# Phase 2: Ollama streaming via OpenAI-compat API
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
router = LLMRouter()
|
|
||||||
ollama = router.config.get("backends", {}).get("ollama")
|
|
||||||
if ollama and ollama.get("enabled", True):
|
|
||||||
base_url = ollama["base_url"]
|
|
||||||
model = ollama.get("model", "llama3")
|
|
||||||
try:
|
|
||||||
async for token in self._stream_openai_compat(base_url, "any", model, prompt):
|
|
||||||
yield token
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Ollama streaming failed, falling back to blocking: %s", exc)
|
|
||||||
|
|
||||||
# Phase 3: blocking fallback — yields full response at once
|
|
||||||
result = await asyncio.to_thread(self._call_llm, prompt)
|
|
||||||
if result:
|
|
||||||
yield result
|
|
||||||
|
|
||||||
def _try_alloc_for_stream(self):
|
|
||||||
"""Attempt cf-orch allocation synchronously; return (alloc, ctx) or None."""
|
|
||||||
ctx = self._get_llm_context()
|
|
||||||
try:
|
|
||||||
alloc = ctx.__enter__()
|
|
||||||
if alloc is not None and alloc.warm:
|
|
||||||
return alloc, ctx
|
|
||||||
# Not warm — release and signal fallback
|
|
||||||
_safe_exit(ctx)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-orch alloc for stream failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _stream_openai_compat(
|
|
||||||
base_url: str, api_key: str, model: str, prompt: str
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
|
|
||||||
if model == "__auto__":
|
|
||||||
models = await client.models.list()
|
|
||||||
model = models.data[0].id
|
|
||||||
stream = await client.chat.completions.create(
|
|
||||||
model=model,
|
|
||||||
messages=[{"role": "user", "content": prompt}],
|
|
||||||
stream=True,
|
|
||||||
)
|
|
||||||
async for chunk in stream:
|
|
||||||
if chunk.choices and chunk.choices[0].delta.content:
|
|
||||||
yield chunk.choices[0].delta.content
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_exit(ctx) -> None:
|
|
||||||
try:
|
|
||||||
ctx.__exit__(None, None, None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
"""
|
|
||||||
Shopping locale configuration.
|
|
||||||
|
|
||||||
Maps a locale key to Amazon domain, currency metadata, and retailer availability.
|
|
||||||
Instacart and Walmart are US/CA-only; all other locales get Amazon only.
|
|
||||||
Amazon Fresh (&i=amazonfresh) is US-only — international domains use the general
|
|
||||||
grocery department (&rh=n:16310101) where available, plain search elsewhere.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
class LocaleConfig(TypedDict):
|
|
||||||
amazon_domain: str
|
|
||||||
amazon_grocery_dept: str # URL fragment for grocery department on this locale's site
|
|
||||||
currency_code: str
|
|
||||||
currency_symbol: str
|
|
||||||
instacart: bool
|
|
||||||
walmart: bool
|
|
||||||
|
|
||||||
|
|
||||||
LOCALES: dict[str, LocaleConfig] = {
|
|
||||||
"us": {
|
|
||||||
"amazon_domain": "amazon.com",
|
|
||||||
"amazon_grocery_dept": "i=amazonfresh",
|
|
||||||
"currency_code": "USD",
|
|
||||||
"currency_symbol": "$",
|
|
||||||
"instacart": True,
|
|
||||||
"walmart": True,
|
|
||||||
},
|
|
||||||
"ca": {
|
|
||||||
"amazon_domain": "amazon.ca",
|
|
||||||
"amazon_grocery_dept": "rh=n:6967215011", # Grocery dept on .ca # gitleaks:allow
|
|
||||||
"currency_code": "CAD",
|
|
||||||
"currency_symbol": "CA$",
|
|
||||||
"instacart": True,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"gb": {
|
|
||||||
"amazon_domain": "amazon.co.uk",
|
|
||||||
"amazon_grocery_dept": "rh=n:340831031", # Grocery dept on .co.uk
|
|
||||||
"currency_code": "GBP",
|
|
||||||
"currency_symbol": "£",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"au": {
|
|
||||||
"amazon_domain": "amazon.com.au",
|
|
||||||
"amazon_grocery_dept": "rh=n:5765081051", # Pantry/grocery on .com.au # gitleaks:allow
|
|
||||||
"currency_code": "AUD",
|
|
||||||
"currency_symbol": "A$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"nz": {
|
|
||||||
# NZ has no Amazon storefront — route to .com.au as nearest option
|
|
||||||
"amazon_domain": "amazon.com.au",
|
|
||||||
"amazon_grocery_dept": "rh=n:5765081051", # gitleaks:allow
|
|
||||||
"currency_code": "NZD",
|
|
||||||
"currency_symbol": "NZ$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"de": {
|
|
||||||
"amazon_domain": "amazon.de",
|
|
||||||
"amazon_grocery_dept": "rh=n:340843031", # Lebensmittel & Getränke
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"amazon_domain": "amazon.fr",
|
|
||||||
"amazon_grocery_dept": "rh=n:197858031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"amazon_domain": "amazon.it",
|
|
||||||
"amazon_grocery_dept": "rh=n:525616031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"amazon_domain": "amazon.es",
|
|
||||||
"amazon_grocery_dept": "rh=n:599364031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"nl": {
|
|
||||||
"amazon_domain": "amazon.nl",
|
|
||||||
"amazon_grocery_dept": "rh=n:16584827031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"se": {
|
|
||||||
"amazon_domain": "amazon.se",
|
|
||||||
"amazon_grocery_dept": "rh=n:20741393031",
|
|
||||||
"currency_code": "SEK",
|
|
||||||
"currency_symbol": "kr",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"jp": {
|
|
||||||
"amazon_domain": "amazon.co.jp",
|
|
||||||
"amazon_grocery_dept": "rh=n:2246283051", # gitleaks:allow
|
|
||||||
"currency_code": "JPY",
|
|
||||||
"currency_symbol": "¥",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"in": {
|
|
||||||
"amazon_domain": "amazon.in",
|
|
||||||
"amazon_grocery_dept": "rh=n:2454178031", # gitleaks:allow
|
|
||||||
"currency_code": "INR",
|
|
||||||
"currency_symbol": "₹",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"mx": {
|
|
||||||
"amazon_domain": "amazon.com.mx",
|
|
||||||
"amazon_grocery_dept": "rh=n:10737659011",
|
|
||||||
"currency_code": "MXN",
|
|
||||||
"currency_symbol": "MX$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"br": {
|
|
||||||
"amazon_domain": "amazon.com.br",
|
|
||||||
"amazon_grocery_dept": "rh=n:17878420011",
|
|
||||||
"currency_code": "BRL",
|
|
||||||
"currency_symbol": "R$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"sg": {
|
|
||||||
"amazon_domain": "amazon.sg",
|
|
||||||
"amazon_grocery_dept": "rh=n:6981647051", # gitleaks:allow
|
|
||||||
"currency_code": "SGD",
|
|
||||||
"currency_symbol": "S$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_LOCALE = "us"
|
|
||||||
|
|
||||||
|
|
||||||
def get_locale(key: str) -> LocaleConfig:
|
|
||||||
"""Return locale config for *key*, falling back to US if unknown."""
|
|
||||||
return LOCALES.get(key, LOCALES[DEFAULT_LOCALE])
|
|
||||||
|
|
@ -20,13 +20,10 @@ from typing import TYPE_CHECKING
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, StepAnalysis, TimeEffortProfile, SwapCandidate
|
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
|
||||||
from app.services.recipe.element_classifier import ElementClassifier
|
from app.services.recipe.element_classifier import ElementClassifier
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
||||||
from app.services.recipe.substitution_engine import SubstitutionEngine
|
from app.services.recipe.substitution_engine import SubstitutionEngine
|
||||||
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
|
|
||||||
from app.services.recipe.time_effort import parse_time_effort
|
|
||||||
from app.services.recipe.reranker import rerank_suggestions
|
|
||||||
|
|
||||||
_LEFTOVER_DAILY_MAX_FREE = 5
|
_LEFTOVER_DAILY_MAX_FREE = 5
|
||||||
|
|
||||||
|
|
@ -36,38 +33,6 @@ _SWAP_STOPWORDS = frozenset({
|
||||||
"to", "from", "at", "by", "as", "on",
|
"to", "from", "at", "by", "as", "on",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Marketing / prep / packaging words stripped when tokenising product-label names
|
|
||||||
# into individual ingredient tokens. Parallel to Store._FTS_TOKEN_STOPWORDS —
|
|
||||||
# both lists should agree. Kept here to avoid a circular import at runtime.
|
|
||||||
_PRODUCT_TOKEN_STOPWORDS = frozenset({
|
|
||||||
# Basic English stopwords
|
|
||||||
"a", "an", "the", "of", "in", "for", "with", "and", "or", "to",
|
|
||||||
"from", "at", "by", "as", "on", "into",
|
|
||||||
# Brand / marketing words that appear in product names
|
|
||||||
"lean", "cuisine", "healthy", "choice", "stouffer", "original",
|
|
||||||
"classic", "deluxe", "homestyle", "family", "style", "grade",
|
|
||||||
"premium", "select", "natural", "organic", "fresh", "lite",
|
|
||||||
"ready", "quick", "easy", "instant", "microwave", "frozen",
|
|
||||||
"brand", "size", "large", "small", "medium", "extra",
|
|
||||||
# Plant-based / alt-meat brand names
|
|
||||||
"daring", "gardein", "morningstar", "lightlife", "tofurky",
|
|
||||||
"quorn", "omni", "nuggs", "simulate",
|
|
||||||
# Preparation states
|
|
||||||
"cut", "diced", "sliced", "chopped", "minced", "shredded",
|
|
||||||
"cooked", "raw", "whole", "boneless", "skinless", "trimmed",
|
|
||||||
"pre", "prepared", "marinated", "seasoned", "breaded", "battered",
|
|
||||||
"grilled", "roasted", "smoked", "canned", "dried", "dehydrated",
|
|
||||||
"pieces", "piece", "strips", "strip", "chunks", "chunk",
|
|
||||||
"fillets", "fillet", "cutlets", "cutlet", "tenders", "nuggets",
|
|
||||||
# Units / packaging
|
|
||||||
"oz", "lb", "lbs", "pkg", "pack", "box", "can", "bag", "jar",
|
|
||||||
# Adjectives that aren't ingredients
|
|
||||||
"firm", "soft", "silken", "hard", "crispy", "crunchy", "smooth",
|
|
||||||
"mild", "spicy", "hot", "sweet", "savory", "unsalted", "salted",
|
|
||||||
"low", "high", "reduced", "free", "fat", "sodium", "sugar", "calorie",
|
|
||||||
"dairy", "gluten", "vegan", "plant", "based", "free",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Maps product-label substrings to recipe-corpus canonical terms.
|
# Maps product-label substrings to recipe-corpus canonical terms.
|
||||||
# Kept in sync with Store._FTS_SYNONYMS — both must agree on canonical names.
|
# Kept in sync with Store._FTS_SYNONYMS — both must agree on canonical names.
|
||||||
# Used to expand pantry_set so single-word recipe ingredients can match
|
# Used to expand pantry_set so single-word recipe ingredients can match
|
||||||
|
|
@ -190,56 +155,6 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# When a pantry item is in a secondary state (e.g. bread → "stale"), expand
|
|
||||||
# the pantry set with terms that recipe ingredients commonly use to describe
|
|
||||||
# that state. This lets "stale bread" in a recipe ingredient match a pantry
|
|
||||||
# entry that is simply called "Bread" but is past its nominal use-by date.
|
|
||||||
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
|
|
||||||
# Values are additional strings added to the pantry set for FTS coverage.
|
|
||||||
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
|
|
||||||
# ── Existing entries (corrected) ─────────────────────────────────────────
|
|
||||||
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
|
|
||||||
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry",
|
|
||||||
"day-old croissant", "stale croissant", "day-old muffin",
|
|
||||||
"stale cake", "old pastry", "day-old baguette"],
|
|
||||||
("bananas", "overripe"): ["overripe bananas", "very ripe bananas", "spotty bananas",
|
|
||||||
"brown bananas", "black bananas", "mushy bananas",
|
|
||||||
"mashed banana", "ripe bananas"],
|
|
||||||
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk",
|
|
||||||
"soured milk", "off milk", "milk gone sour"],
|
|
||||||
("dairy", "sour"): ["sour milk", "slightly sour milk", "soured milk"],
|
|
||||||
("cheese", "rind-ready"): ["parmesan rind", "cheese rind", "aged cheese",
|
|
||||||
"hard cheese rind", "parmigiano rind", "grana padano rind",
|
|
||||||
"pecorino rind", "dry cheese"],
|
|
||||||
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice",
|
|
||||||
"old rice"],
|
|
||||||
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
|
|
||||||
# ── New entries ──────────────────────────────────────────────────────────
|
|
||||||
("apples", "soft"): ["soft apples", "mealy apples", "overripe apples",
|
|
||||||
"bruised apples", "mushy apple"],
|
|
||||||
("leafy_greens", "wilting"):["wilted spinach", "wilted greens", "limp lettuce",
|
|
||||||
"wilted kale", "tired greens"],
|
|
||||||
("tomatoes", "soft"): ["overripe tomatoes", "very ripe tomatoes", "ripe tomatoes",
|
|
||||||
"soft tomatoes", "bruised tomatoes"],
|
|
||||||
("cooked_pasta", "day-old"):["leftover pasta", "cooked pasta", "day-old pasta",
|
|
||||||
"cold pasta", "pre-cooked pasta"],
|
|
||||||
("cooked_potatoes", "day-old"): ["leftover potatoes", "cooked potatoes", "day-old potatoes",
|
|
||||||
"mashed potatoes", "baked potatoes"],
|
|
||||||
("yogurt", "tangy"): ["sour yogurt", "tangy yogurt", "past-date yogurt",
|
|
||||||
"older yogurt", "well-cultured yogurt"],
|
|
||||||
("cream", "sour"): ["slightly soured cream", "cultured cream",
|
|
||||||
"heavy cream gone sour", "soured cream"],
|
|
||||||
("wine", "open"): ["open wine", "leftover wine", "day-old wine",
|
|
||||||
"cooking wine", "red wine", "white wine"],
|
|
||||||
("cooked_beans", "day-old"):["leftover beans", "cooked beans", "day-old beans",
|
|
||||||
"cold beans", "pre-cooked beans",
|
|
||||||
"cooked chickpeas", "cooked lentils"],
|
|
||||||
("cooked_meat", "leftover"):["leftover chicken", "shredded chicken", "leftover beef",
|
|
||||||
"cooked chicken", "pulled chicken", "leftover pork",
|
|
||||||
"cooked meat", "rotisserie chicken"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
||||||
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
||||||
# "3 oz. butter" → "butter"
|
# "3 oz. butter" → "butter"
|
||||||
|
|
@ -369,24 +284,14 @@ def _prep_note_for(ingredient: str) -> str | None:
|
||||||
return template.format(ingredient=ingredient_name)
|
return template.format(ingredient=ingredient_name)
|
||||||
|
|
||||||
|
|
||||||
def _expand_pantry_set(
|
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
|
||||||
pantry_items: list[str],
|
|
||||||
secondary_pantry_items: dict[str, str] | None = None,
|
|
||||||
) -> set[str]:
|
|
||||||
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
||||||
|
|
||||||
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
||||||
and adds the canonical form. This lets single-word recipe ingredients
|
and adds the canonical form. This lets single-word recipe ingredients
|
||||||
("hamburger", "chicken") match product-label pantry entries
|
("hamburger", "chicken") match product-label pantry entries
|
||||||
("burger patties", "rotisserie chicken").
|
("burger patties", "rotisserie chicken").
|
||||||
|
|
||||||
If secondary_pantry_items is provided (product_name → state label), items
|
|
||||||
in a secondary state also receive state-specific synonym expansion so that
|
|
||||||
recipe ingredients like "stale bread" or "day-old rice" are matched.
|
|
||||||
"""
|
"""
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
_predictor = ExpirationPredictor()
|
|
||||||
|
|
||||||
expanded: set[str] = set()
|
expanded: set[str] = set()
|
||||||
for item in pantry_items:
|
for item in pantry_items:
|
||||||
lower = item.lower().strip()
|
lower = item.lower().strip()
|
||||||
|
|
@ -394,22 +299,6 @@ def _expand_pantry_set(
|
||||||
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
||||||
if pattern in lower:
|
if pattern in lower:
|
||||||
expanded.add(canonical)
|
expanded.add(canonical)
|
||||||
|
|
||||||
# Extract individual ingredient tokens from multi-word product names.
|
|
||||||
# "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice".
|
|
||||||
# This catches plain ingredients that _PANTRY_LABEL_SYNONYMS doesn't translate.
|
|
||||||
for token in lower.split():
|
|
||||||
if len(token) >= 4 and token not in _PRODUCT_TOKEN_STOPWORDS:
|
|
||||||
expanded.add(token)
|
|
||||||
|
|
||||||
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
|
|
||||||
if secondary_pantry_items and item in secondary_pantry_items:
|
|
||||||
state_label = secondary_pantry_items[item]
|
|
||||||
category = _predictor.get_category_from_product(item)
|
|
||||||
if category:
|
|
||||||
synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), [])
|
|
||||||
expanded.update(synonyms)
|
|
||||||
|
|
||||||
return expanded
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -686,21 +575,6 @@ def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
||||||
return max(10, 20 + steps * 4) # moderate
|
return max(10, 20 + steps * 4) # moderate
|
||||||
|
|
||||||
|
|
||||||
def _within_time(directions: list[str], max_total_min: int) -> bool:
|
|
||||||
"""Return True if parsed total time (active + passive) is within max_total_min.
|
|
||||||
|
|
||||||
Graceful degradation:
|
|
||||||
- Empty directions -> True (no data, don't hide)
|
|
||||||
- total_min == 0 (no time signals found) -> True (unparseable, don't hide)
|
|
||||||
"""
|
|
||||||
if not directions:
|
|
||||||
return True
|
|
||||||
profile = parse_time_effort(directions)
|
|
||||||
if profile.total_min == 0:
|
|
||||||
return True
|
|
||||||
return profile.total_min <= max_total_min
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_method_complexity(
|
def _classify_method_complexity(
|
||||||
directions: list[str],
|
directions: list[str],
|
||||||
available_equipment: list[str] | None = None,
|
available_equipment: list[str] | None = None,
|
||||||
|
|
@ -760,8 +634,7 @@ class RecipeEngine:
|
||||||
|
|
||||||
profiles = self._classifier.classify_batch(req.pantry_items)
|
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||||
gaps = self._classifier.identify_gaps(profiles)
|
gaps = self._classifier.identify_gaps(profiles)
|
||||||
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
|
pantry_set = _expand_pantry_set(req.pantry_items)
|
||||||
exclude_set = _expand_pantry_set(req.exclude_ingredients) if req.exclude_ingredients else set()
|
|
||||||
|
|
||||||
if req.level >= 3:
|
if req.level >= 3:
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
|
@ -775,13 +648,9 @@ class RecipeEngine:
|
||||||
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
|
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
|
||||||
_l1 = req.level == 1 and not req.shopping_mode
|
_l1 = req.level == 1 and not req.shopping_mode
|
||||||
nf = req.nutrition_filters
|
nf = req.nutrition_filters
|
||||||
# L1 uses a larger candidate pool — the ratio gate below will prune
|
|
||||||
# aggressively anyway, so we need more raw candidates to end up with
|
|
||||||
# enough results for a packaged-food / plant-based pantry.
|
|
||||||
_fts_limit = 60 if _l1 else 20
|
|
||||||
rows = self._store.search_recipes_by_ingredients(
|
rows = self._store.search_recipes_by_ingredients(
|
||||||
req.pantry_items,
|
req.pantry_items,
|
||||||
limit=_fts_limit,
|
limit=20,
|
||||||
category=req.category or None,
|
category=req.category or None,
|
||||||
max_calories=nf.max_calories,
|
max_calories=nf.max_calories,
|
||||||
max_sugar_g=nf.max_sugar_g,
|
max_sugar_g=nf.max_sugar_g,
|
||||||
|
|
@ -792,21 +661,14 @@ class RecipeEngine:
|
||||||
)
|
)
|
||||||
|
|
||||||
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
|
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
|
||||||
# 0.35 allows ~1/3 ingredient coverage — low enough for packaged/plant-based
|
|
||||||
# pantries that rarely match raw-ingredient corpus recipes 1:1, but still
|
|
||||||
# filters out recipes where only one common staple matched.
|
|
||||||
_L1_MAX_MISSING_DEFAULT = 2
|
_L1_MAX_MISSING_DEFAULT = 2
|
||||||
_L1_MIN_MATCH_RATIO = 0.35
|
_L1_MIN_MATCH_RATIO = 0.6
|
||||||
effective_max_missing = req.max_missing
|
effective_max_missing = req.max_missing
|
||||||
if _l1 and effective_max_missing is None:
|
if _l1 and effective_max_missing is None:
|
||||||
effective_max_missing = _L1_MAX_MISSING_DEFAULT
|
effective_max_missing = _L1_MAX_MISSING_DEFAULT
|
||||||
|
|
||||||
# Load sensory preferences -- applied as silent post-score filter
|
|
||||||
_sensory_prefs_json = self._store.get_setting("sensory_preferences")
|
|
||||||
_sensory_exclude = build_sensory_exclude(_sensory_prefs_json)
|
|
||||||
|
|
||||||
suggestions = []
|
suggestions = []
|
||||||
hard_day_tier_map: dict[int, int] = {} # recipe_id -> tier when hard_day_mode
|
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
ingredient_names: list[str] = row.get("ingredient_names") or []
|
ingredient_names: list[str] = row.get("ingredient_names") or []
|
||||||
|
|
@ -816,15 +678,6 @@ class RecipeEngine:
|
||||||
except Exception:
|
except Exception:
|
||||||
ingredient_names = []
|
ingredient_names = []
|
||||||
|
|
||||||
# Skip recipes that require any ingredient the user has excluded.
|
|
||||||
if exclude_set and any(_ingredient_in_pantry(n, exclude_set) for n in ingredient_names):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sensory filter -- silent exclusion of recipes exceeding user tolerance
|
|
||||||
if not _sensory_exclude.is_empty():
|
|
||||||
if not passes_sensory_filter(row.get("sensory_tags"), _sensory_exclude):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Compute missing ingredients, detecting pantry coverage first.
|
# Compute missing ingredients, detecting pantry coverage first.
|
||||||
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
||||||
# → note "Melt the butter before starting.") to surface separately.
|
# → note "Melt the butter before starting.") to surface separately.
|
||||||
|
|
@ -880,14 +733,9 @@ class RecipeEngine:
|
||||||
except Exception:
|
except Exception:
|
||||||
directions = [directions]
|
directions = [directions]
|
||||||
|
|
||||||
# Compute complexity + parse time effort once — reused for filters and response.
|
# Compute complexity for every suggestion (used for badge + filter).
|
||||||
row_complexity = _classify_method_complexity(directions, available_equipment)
|
row_complexity = _classify_method_complexity(directions, available_equipment)
|
||||||
row_time_min = _estimate_time_min(directions, row_complexity)
|
row_time_min = _estimate_time_min(directions, row_complexity)
|
||||||
row_time_effort = parse_time_effort(
|
|
||||||
directions,
|
|
||||||
ingredients=row.get("ingredients") or [],
|
|
||||||
ingredient_names=row.get("ingredient_names") or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter and tier-rank by hard_day_mode
|
# Filter and tier-rank by hard_day_mode
|
||||||
if req.hard_day_mode:
|
if req.hard_day_mode:
|
||||||
|
|
@ -907,25 +755,6 @@ class RecipeEngine:
|
||||||
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Total time filter (kiwi#52).
|
|
||||||
# Prefer parsed time extracted from direction text (explicit "15 minutes" mentions).
|
|
||||||
# When directions contain no parseable time signals, fall back to the
|
|
||||||
# step-count estimate so the filter still has teeth on the corpus majority.
|
|
||||||
if req.max_total_min is not None:
|
|
||||||
if row_time_effort.total_min > 0:
|
|
||||||
if row_time_effort.total_min > req.max_total_min:
|
|
||||||
continue
|
|
||||||
elif row_time_min > req.max_total_min:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Active (hands-on) time filter — independent of total time.
|
|
||||||
# Lets users request "≤30 min hands-on, any total" to include slow braises.
|
|
||||||
# Skips recipes where active_min == 0 (no time signals parsed) to avoid
|
|
||||||
# hiding valid results when the parser couldn't extract timing.
|
|
||||||
if req.max_active_min is not None and row_time_effort.active_min > 0:
|
|
||||||
if row_time_effort.active_min > req.max_active_min:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||||
if req.level == 2 and req.constraints:
|
if req.level == 2 and req.constraints:
|
||||||
for ing in ingredient_names:
|
for ing in ingredient_names:
|
||||||
|
|
@ -963,21 +792,6 @@ class RecipeEngine:
|
||||||
v is not None
|
v is not None
|
||||||
for v in (nutrition.calories, nutrition.sugar_g, nutrition.carbs_g)
|
for v in (nutrition.calories, nutrition.sugar_g, nutrition.carbs_g)
|
||||||
)
|
)
|
||||||
te = TimeEffortProfile(
|
|
||||||
active_min=row_time_effort.active_min,
|
|
||||||
passive_min=row_time_effort.passive_min,
|
|
||||||
total_min=row_time_effort.total_min,
|
|
||||||
effort_label=row_time_effort.effort_label,
|
|
||||||
equipment=list(row_time_effort.equipment),
|
|
||||||
step_analyses=[
|
|
||||||
StepAnalysis(
|
|
||||||
is_passive=sa.is_passive,
|
|
||||||
detected_minutes=sa.detected_minutes,
|
|
||||||
prep_min=sa.prep_min,
|
|
||||||
)
|
|
||||||
for sa in row_time_effort.step_analyses
|
|
||||||
],
|
|
||||||
)
|
|
||||||
suggestions.append(RecipeSuggestion(
|
suggestions.append(RecipeSuggestion(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
|
|
@ -986,31 +800,19 @@ class RecipeEngine:
|
||||||
swap_candidates=swap_candidates,
|
swap_candidates=swap_candidates,
|
||||||
matched_ingredients=matched,
|
matched_ingredients=matched,
|
||||||
missing_ingredients=missing,
|
missing_ingredients=missing,
|
||||||
directions=directions,
|
|
||||||
prep_notes=sorted(prep_note_set),
|
prep_notes=sorted(prep_note_set),
|
||||||
level=req.level,
|
level=req.level,
|
||||||
nutrition=nutrition if has_nutrition else None,
|
nutrition=nutrition if has_nutrition else None,
|
||||||
source_url=_build_source_url(row),
|
source_url=_build_source_url(row),
|
||||||
complexity=row_complexity,
|
complexity=row_complexity,
|
||||||
estimated_time_min=row_time_min,
|
estimated_time_min=row_time_min,
|
||||||
time_effort=te,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Sort corpus results.
|
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
||||||
# Paid+ tier: cross-encoder reranker orders by full pantry + dietary fit.
|
# Hard day mode: primary sort by tier (0=premade, 1=simple, 2=moderate),
|
||||||
# Free tier (or reranker failure): overlap sort with hard_day_mode tier grouping.
|
# then by match_count within each tier.
|
||||||
reranked = rerank_suggestions(req, suggestions)
|
# Normal mode: sort by match_count descending.
|
||||||
if reranked is not None:
|
|
||||||
# Reranker provided relevance order. In hard_day_mode, still respect
|
|
||||||
# tier grouping as primary sort; reranker order applies within each tier.
|
|
||||||
if req.hard_day_mode and hard_day_tier_map:
|
if req.hard_day_mode and hard_day_tier_map:
|
||||||
suggestions = sorted(
|
|
||||||
reranked,
|
|
||||||
key=lambda s: hard_day_tier_map.get(s.id, 1),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
suggestions = reranked
|
|
||||||
elif req.hard_day_mode and hard_day_tier_map:
|
|
||||||
suggestions = sorted(
|
suggestions = sorted(
|
||||||
suggestions,
|
suggestions,
|
||||||
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
|
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
|
||||||
|
|
|
||||||
|
|
@ -1,524 +0,0 @@
|
||||||
"""Recipe scanner service (kiwi#9).
|
|
||||||
|
|
||||||
Extracts structured recipe data from one or more photos of recipe cards,
|
|
||||||
cookbook pages, or handwritten notes.
|
|
||||||
|
|
||||||
Pipeline:
|
|
||||||
photo(s) -> EXIF correction -> VLM extraction -> JSON parse -> pantry cross-ref
|
|
||||||
|
|
||||||
Vision backend priority (mirrors receipt OCR pattern):
|
|
||||||
1. cf-orch vision service (if CF_ORCH_URL set)
|
|
||||||
2. Local Qwen2.5-VL (if GPU available)
|
|
||||||
3. Anthropic API (BYOK -- if ANTHROPIC_API_KEY set)
|
|
||||||
|
|
||||||
BSL 1.1 -- requires Paid tier or BYOK.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Maximum number of photos per scan call (to limit VLM context / VRAM)
|
|
||||||
MAX_IMAGES = 4
|
|
||||||
|
|
||||||
# VLM prompt -- adapted from tests/fixtures/recipe_scan/extract_test.py
|
|
||||||
_EXTRACTION_PROMPT = """
|
|
||||||
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.
|
|
||||||
|
|
||||||
If two or more images are provided, treat them as a single recipe across multiple pages
|
|
||||||
(e.g. ingredients on page 1, directions on page 2).
|
|
||||||
|
|
||||||
Return a single JSON object with these fields:
|
|
||||||
- title: recipe name (string)
|
|
||||||
- subtitle: any secondary title or serving suggestion e.g. "with Broccoli & Ranch Dressing" (string or null)
|
|
||||||
- servings: serving size if shown, as a string e.g. "2", "4-6" (string or null)
|
|
||||||
- cook_time: total cook time if shown, e.g. "15 min", "1 hour" (string or null)
|
|
||||||
- source_note: any attribution text like "From Betty Crocker" or "Purple Carrot" (string or null)
|
|
||||||
- ingredients: array of ingredient objects, each with:
|
|
||||||
- name: normalized generic ingredient name, lowercase, no quantities, no brand names
|
|
||||||
(e.g. "Follow Your Heart Vegan Ranch" becomes "ranch dressing")
|
|
||||||
- qty: quantity as a string, preserving fractions e.g. "1/2", a quarter symbol (string or null)
|
|
||||||
- unit: unit of measure, null for countable items (e.g. "3 eggs" has unit: null)
|
|
||||||
- raw: the original ingredient line verbatim, exactly as it appears
|
|
||||||
- steps: ordered array of instruction strings, one distinct step per element
|
|
||||||
- notes: any tips, substitutions, storage instructions, or variations (string or null)
|
|
||||||
- confidence: "high" if text is clear and complete, "medium" if some parts are uncertain,
|
|
||||||
"low" if mostly handwritten or significantly degraded
|
|
||||||
- warnings: array of strings describing anything the user should double-check
|
|
||||||
(e.g. "Directions appear to continue on another page not shown")
|
|
||||||
|
|
||||||
Return only valid JSON. No markdown fences. No explanation outside the JSON.
|
|
||||||
If the image does not appear to be a recipe at all, return: {"error": "not_a_recipe"}
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Data types ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ScannedIngredient:
|
|
||||||
name: str
|
|
||||||
qty: str | None = None
|
|
||||||
unit: str | None = None
|
|
||||||
raw: str | None = None
|
|
||||||
in_pantry: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ScannedRecipeResult:
|
|
||||||
title: str | None
|
|
||||||
subtitle: str | None
|
|
||||||
servings: str | None
|
|
||||||
cook_time: str | None
|
|
||||||
source_note: str | None
|
|
||||||
ingredients: list[ScannedIngredient]
|
|
||||||
steps: list[str]
|
|
||||||
notes: str | None
|
|
||||||
tags: list[str]
|
|
||||||
pantry_match_pct: int
|
|
||||||
confidence: str
|
|
||||||
warnings: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Image helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _load_image_b64(path: Path) -> str:
|
|
||||||
"""Load image, apply EXIF rotation, return base64-encoded JPEG bytes."""
|
|
||||||
from PIL import Image, ImageOps
|
|
||||||
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
raw = f.read()
|
|
||||||
img = Image.open(io.BytesIO(raw))
|
|
||||||
img = ImageOps.exif_transpose(img).convert("RGB")
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format="JPEG", quality=90)
|
|
||||||
return base64.b64encode(buf.getvalue()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Vision backend ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _call_via_anthropic(image_paths: list[Path], prompt: str) -> str:
|
|
||||||
"""Send image(s) + prompt to Anthropic API. Raises RuntimeError if unavailable."""
|
|
||||||
try:
|
|
||||||
import anthropic
|
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError("anthropic package not installed") from exc
|
|
||||||
|
|
||||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
||||||
if not api_key:
|
|
||||||
raise RuntimeError("ANTHROPIC_API_KEY not set")
|
|
||||||
|
|
||||||
client = anthropic.Anthropic(api_key=api_key)
|
|
||||||
|
|
||||||
content: list[dict] = []
|
|
||||||
for i, path in enumerate(image_paths):
|
|
||||||
if i > 0:
|
|
||||||
content.append({"type": "text", "text": f"(Page {i + 1} of the same recipe:)"})
|
|
||||||
content.append({
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": "image/jpeg",
|
|
||||||
"data": _load_image_b64(path),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
content.append({"type": "text", "text": prompt})
|
|
||||||
|
|
||||||
msg = client.messages.create(
|
|
||||||
# Haiku is cost-efficient for well-structured extraction prompts
|
|
||||||
model="claude-haiku-4-5-20251001",
|
|
||||||
max_tokens=2048,
|
|
||||||
messages=[{"role": "user", "content": content}],
|
|
||||||
)
|
|
||||||
return msg.content[0].text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
|
|
||||||
"""Send image(s) + prompt to local Qwen2.5-VL. Raises RuntimeError if unavailable."""
|
|
||||||
try:
|
|
||||||
import torch
|
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError("torch not installed") from exc
|
|
||||||
|
|
||||||
if not torch.cuda.is_available():
|
|
||||||
raise RuntimeError("No CUDA device -- local VLM unavailable")
|
|
||||||
|
|
||||||
# Lazy import so the module loads fast when GPU is absent
|
|
||||||
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
|
||||||
from PIL import Image, ImageOps
|
|
||||||
|
|
||||||
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
|
||||||
logger.info("Loading local VLM for recipe scan: %s", model_name)
|
|
||||||
|
|
||||||
model = Qwen2VLForConditionalGeneration.from_pretrained(
|
|
||||||
model_name,
|
|
||||||
torch_dtype=torch.float16,
|
|
||||||
device_map="auto",
|
|
||||||
low_cpu_mem_usage=True,
|
|
||||||
)
|
|
||||||
processor = AutoProcessor.from_pretrained(model_name)
|
|
||||||
model.train(False) # inference mode
|
|
||||||
|
|
||||||
images = []
|
|
||||||
for path in image_paths:
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
raw = f.read()
|
|
||||||
img = Image.open(io.BytesIO(raw))
|
|
||||||
img = ImageOps.exif_transpose(img).convert("RGB")
|
|
||||||
images.append(img)
|
|
||||||
|
|
||||||
inputs = processor(images=images, text=prompt, return_tensors="pt")
|
|
||||||
inputs = {k: v.to("cuda", torch.float16) if isinstance(v, torch.Tensor) else v
|
|
||||||
for k, v in inputs.items()}
|
|
||||||
|
|
||||||
with torch.no_grad():
|
|
||||||
output_ids = model.generate(
|
|
||||||
**inputs,
|
|
||||||
max_new_tokens=2048,
|
|
||||||
do_sample=False,
|
|
||||||
temperature=0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
output = processor.decode(output_ids[0], skip_special_tokens=True)
|
|
||||||
output = output.replace(prompt, "").strip()
|
|
||||||
|
|
||||||
# Free VRAM
|
|
||||||
del model
|
|
||||||
torch.cuda.empty_cache()
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def _build_ocr_extraction_prompt(ocr_text: str) -> str:
|
|
||||||
"""Build a text-LLM prompt for structuring OCR output into recipe JSON.
|
|
||||||
|
|
||||||
Swaps the image-centric preamble of _EXTRACTION_PROMPT for an OCR-centric
|
|
||||||
one, then appends the combined OCR text as input. The JSON schema section
|
|
||||||
is shared verbatim to keep the two paths in sync.
|
|
||||||
"""
|
|
||||||
schema_idx = _EXTRACTION_PROMPT.find("Return a single JSON object")
|
|
||||||
schema_part = _EXTRACTION_PROMPT[schema_idx:] if schema_idx != -1 else _EXTRACTION_PROMPT
|
|
||||||
return (
|
|
||||||
"You are extracting a recipe from OCR text taken from a recipe card, "
|
|
||||||
"cookbook page, or handwritten note.\n\n"
|
|
||||||
"The text below was obtained via optical character recognition and may "
|
|
||||||
"contain minor scanning artifacts or formatting irregularities.\n\n"
|
|
||||||
f"{schema_part}\n\nOCR Text:\n{ocr_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _call_via_cf_text_vlm(alloc_url: str, image_paths: list[Path], prompt: str) -> str:
|
|
||||||
"""Call the cf-text OpenAI-compat API with images via the llama.cpp multimodal backend."""
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
content: list[dict] = []
|
|
||||||
for i, path in enumerate(image_paths):
|
|
||||||
if i > 0:
|
|
||||||
content.append({"type": "text", "text": f"(Page {i + 1} of the same recipe:)"})
|
|
||||||
b64 = _load_image_b64(path)
|
|
||||||
content.append({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
|
||||||
})
|
|
||||||
content.append({"type": "text", "text": prompt})
|
|
||||||
|
|
||||||
resp = httpx.post(
|
|
||||||
f"{alloc_url.rstrip('/')}/v1/chat/completions",
|
|
||||||
json={
|
|
||||||
"model": "local",
|
|
||||||
"messages": [{"role": "user", "content": content}],
|
|
||||||
"max_tokens": 2048,
|
|
||||||
"temperature": 0.0,
|
|
||||||
},
|
|
||||||
timeout=180.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["choices"][0]["message"]["content"].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _call_vision_backend(
|
|
||||||
image_paths: list[Path],
|
|
||||||
prompt: str,
|
|
||||||
progress_cb: "Callable[[str, str], None] | None" = None,
|
|
||||||
) -> str:
|
|
||||||
"""Dispatch to the best available vision backend.
|
|
||||||
|
|
||||||
Priority: cf-orch (Qwen2-VL GGUF via cf-text) -> local Qwen2.5-VL -> Anthropic API.
|
|
||||||
Raises RuntimeError with a clear message when no backend is available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_paths: Images to process.
|
|
||||||
prompt: Extraction prompt (used by local VLM / Anthropic paths).
|
|
||||||
progress_cb: Optional callback(status, message) for SSE progress events.
|
|
||||||
Called synchronously from the thread — caller bridges to async.
|
|
||||||
"""
|
|
||||||
def _progress(status: str, message: str) -> None:
|
|
||||||
if progress_cb:
|
|
||||||
progress_cb(status, message)
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
# 1. Try cf-orch task allocation → cf-docuvision (Qwen2-VL GGUF via llama.cpp).
|
|
||||||
# Two-step: docuvision OCRs the image(s), then LLMRouter structures the text into JSON.
|
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
|
||||||
if cf_orch_url:
|
|
||||||
try:
|
|
||||||
from app.services.task_inference import TaskNotRegistered, task_allocate
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
|
|
||||||
try:
|
|
||||||
_progress("allocating", "Starting vision service...")
|
|
||||||
with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc:
|
|
||||||
_progress("scanning", "Extracting recipe text from photo...")
|
|
||||||
doc_client = DocuvisionClient(alloc.url)
|
|
||||||
ocr_parts: list[str] = []
|
|
||||||
for i, path in enumerate(image_paths):
|
|
||||||
result = doc_client.extract_text(path, hint="text")
|
|
||||||
prefix = f"(Page {i + 1} of the same recipe)\n" if len(image_paths) > 1 else ""
|
|
||||||
ocr_parts.append(f"{prefix}{result.text}")
|
|
||||||
combined_ocr = "\n\n".join(ocr_parts)
|
|
||||||
|
|
||||||
if not combined_ocr.strip():
|
|
||||||
raise ValueError("Docuvision returned no text — image may not be a recipe")
|
|
||||||
|
|
||||||
_progress("structuring", "Parsing recipe structure...")
|
|
||||||
text = LLMRouter().complete(
|
|
||||||
_build_ocr_extraction_prompt(combined_ocr),
|
|
||||||
system="You are a recipe data extractor. Return ONLY valid JSON. No markdown, no explanation, no code fences.",
|
|
||||||
)
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
except TaskNotRegistered:
|
|
||||||
logger.debug("kiwi.recipe_scan not yet registered in cf-orch assignments")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-orch vision failed for recipe scan: %s", exc)
|
|
||||||
errors.append(f"cf-orch: {exc}")
|
|
||||||
|
|
||||||
# 2. Try local Qwen2.5-VL
|
|
||||||
try:
|
|
||||||
return _call_via_local_vlm(image_paths, prompt)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Local VLM unavailable for recipe scan: %s", exc)
|
|
||||||
errors.append(f"local VLM: {exc}")
|
|
||||||
|
|
||||||
# 3. Try Anthropic API (BYOK)
|
|
||||||
try:
|
|
||||||
return _call_via_anthropic(image_paths, prompt)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Anthropic API failed for recipe scan: %s", exc)
|
|
||||||
errors.append(f"Anthropic: {exc}")
|
|
||||||
|
|
||||||
raise RuntimeError(
|
|
||||||
"No vision backend configured for recipe scanning. "
|
|
||||||
"Options: cf-orch (CF_ORCH_URL), local GPU, or ANTHROPIC_API_KEY (BYOK). "
|
|
||||||
f"Errors: {'; '.join(errors)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Parsing helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _normalize_ingredient_name(name: str) -> str:
|
|
||||||
"""Lowercase + strip whitespace. Preserves multi-word names as-is."""
|
|
||||||
return name.lower().strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_object(text: str) -> str | None:
|
|
||||||
"""Return the first balanced JSON object from text, or None if not found.
|
|
||||||
|
|
||||||
Uses brace-counting rather than a greedy regex so trailing prose and
|
|
||||||
nested objects are handled correctly.
|
|
||||||
"""
|
|
||||||
start = text.find("{")
|
|
||||||
if start == -1:
|
|
||||||
return None
|
|
||||||
depth = 0
|
|
||||||
in_string = False
|
|
||||||
escape_next = False
|
|
||||||
for i, ch in enumerate(text[start:], start):
|
|
||||||
if escape_next:
|
|
||||||
escape_next = False
|
|
||||||
continue
|
|
||||||
if ch == "\\" and in_string:
|
|
||||||
escape_next = True
|
|
||||||
continue
|
|
||||||
if ch == '"':
|
|
||||||
in_string = not in_string
|
|
||||||
continue
|
|
||||||
if in_string:
|
|
||||||
continue
|
|
||||||
if ch == "{":
|
|
||||||
depth += 1
|
|
||||||
elif ch == "}":
|
|
||||||
depth -= 1
|
|
||||||
if depth == 0:
|
|
||||||
return text[start : i + 1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_scanner_json(raw_text: str) -> dict:
|
|
||||||
"""Extract and return the JSON dict from VLM output.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Pure JSON
|
|
||||||
- JSON in ```json ... ``` markdown fences
|
|
||||||
- Qwen3-style <think>...</think> or <thinking>...</thinking> preambles
|
|
||||||
- JSON preceded or followed by prose
|
|
||||||
|
|
||||||
Raises ValueError on not_a_recipe or unparseable output.
|
|
||||||
"""
|
|
||||||
text = raw_text.strip()
|
|
||||||
|
|
||||||
# Strip thinking-token blocks emitted by reasoning models (Qwen3, DeepSeek-R1, etc.)
|
|
||||||
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE).strip()
|
|
||||||
text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE).strip()
|
|
||||||
|
|
||||||
# Strip markdown fences if present
|
|
||||||
if "```" in text:
|
|
||||||
# Find the content between the first ``` pair
|
|
||||||
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
||||||
if fence_match:
|
|
||||||
text = fence_match.group(1).strip()
|
|
||||||
|
|
||||||
# Try direct parse
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Fall back to brace-balanced extraction from anywhere in the output
|
|
||||||
candidate = _extract_json_object(text)
|
|
||||||
if not candidate:
|
|
||||||
logger.warning("Could not parse JSON from LLM output (first 400 chars): %r", text[:400])
|
|
||||||
raise ValueError(f"Could not parse JSON from VLM output: {text[:200]!r}")
|
|
||||||
try:
|
|
||||||
data = json.loads(candidate)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
logger.warning("Brace-extracted JSON still invalid: %r", candidate[:400])
|
|
||||||
raise ValueError(f"Could not parse JSON from VLM output: {exc}") from exc
|
|
||||||
|
|
||||||
if isinstance(data, dict) and data.get("error") == "not_a_recipe":
|
|
||||||
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pantry cross-reference ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _cross_reference_pantry(
|
|
||||||
ingredients: list[ScannedIngredient],
|
|
||||||
pantry_names: list[str],
|
|
||||||
) -> tuple[list[ScannedIngredient], int]:
|
|
||||||
"""Mark ingredients found in the pantry and return updated list + match percent.
|
|
||||||
|
|
||||||
Matching is bidirectional by token:
|
|
||||||
- "broccoli florets" matches pantry item "broccoli" (pantry token in ingredient)
|
|
||||||
- "pumpkin seeds" matches pantry "pumpkin seeds" (exact)
|
|
||||||
|
|
||||||
Returns (updated_ingredients, pantry_match_pct).
|
|
||||||
"""
|
|
||||||
if not ingredients:
|
|
||||||
return ingredients, 0
|
|
||||||
|
|
||||||
normalized_pantry = [_normalize_ingredient_name(p) for p in pantry_names]
|
|
||||||
updated: list[ScannedIngredient] = []
|
|
||||||
matched = 0
|
|
||||||
|
|
||||||
for ingr in ingredients:
|
|
||||||
norm_ingr = _normalize_ingredient_name(ingr.name)
|
|
||||||
in_pantry = any(
|
|
||||||
(p_tok in norm_ingr or norm_ingr in p_tok)
|
|
||||||
for p in normalized_pantry
|
|
||||||
for p_tok in p.split()
|
|
||||||
if len(p_tok) >= 4 # skip short stop-words like "of", "and", "the"
|
|
||||||
)
|
|
||||||
updated.append(ScannedIngredient(
|
|
||||||
name=ingr.name,
|
|
||||||
qty=ingr.qty,
|
|
||||||
unit=ingr.unit,
|
|
||||||
raw=ingr.raw,
|
|
||||||
in_pantry=in_pantry,
|
|
||||||
))
|
|
||||||
if in_pantry:
|
|
||||||
matched += 1
|
|
||||||
|
|
||||||
pct = round(matched / len(ingredients) * 100)
|
|
||||||
return updated, pct
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main scanner class ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class RecipeScanner:
|
|
||||||
"""Stateless recipe scanner. One instance can be reused across requests."""
|
|
||||||
|
|
||||||
def scan(
|
|
||||||
self,
|
|
||||||
image_paths: list[Path],
|
|
||||||
pantry_names: list[str] | None = None,
|
|
||||||
progress_cb: Callable[[str, str], None] | None = None,
|
|
||||||
) -> ScannedRecipeResult:
|
|
||||||
"""Extract a structured recipe from one or more photos.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_paths: 1-4 image files (phone photos, scans).
|
|
||||||
pantry_names: Flat list of product names from user's inventory.
|
|
||||||
Pass [] or None to skip pantry cross-reference.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ScannedRecipeResult with all fields populated.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Image is not a recipe, or JSON could not be parsed.
|
|
||||||
RuntimeError: No vision backend is configured.
|
|
||||||
"""
|
|
||||||
if not image_paths:
|
|
||||||
raise ValueError("At least one image is required")
|
|
||||||
if len(image_paths) > MAX_IMAGES:
|
|
||||||
raise ValueError(f"Maximum {MAX_IMAGES} images per scan (got {len(image_paths)})")
|
|
||||||
|
|
||||||
# Call vision backend
|
|
||||||
raw_text = _call_vision_backend(image_paths, _EXTRACTION_PROMPT, progress_cb=progress_cb)
|
|
||||||
|
|
||||||
# Parse JSON from VLM output
|
|
||||||
data = _parse_scanner_json(raw_text)
|
|
||||||
|
|
||||||
# Build ingredient list
|
|
||||||
raw_ingredients = data.get("ingredients") or []
|
|
||||||
ingredients: list[ScannedIngredient] = [
|
|
||||||
ScannedIngredient(
|
|
||||||
name=str(item.get("name") or "").strip() or "unknown",
|
|
||||||
qty=str(item["qty"]) if item.get("qty") is not None else None,
|
|
||||||
unit=str(item["unit"]) if item.get("unit") is not None else None,
|
|
||||||
raw=str(item["raw"]) if item.get("raw") is not None else None,
|
|
||||||
)
|
|
||||||
for item in raw_ingredients
|
|
||||||
if isinstance(item, dict)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Pantry cross-reference
|
|
||||||
ingredients, pct = _cross_reference_pantry(
|
|
||||||
ingredients,
|
|
||||||
pantry_names or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
return ScannedRecipeResult(
|
|
||||||
title=data.get("title") or None,
|
|
||||||
subtitle=data.get("subtitle") or None,
|
|
||||||
servings=str(data["servings"]) if data.get("servings") is not None else None,
|
|
||||||
cook_time=str(data["cook_time"]) if data.get("cook_time") is not None else None,
|
|
||||||
source_note=data.get("source_note") or None,
|
|
||||||
ingredients=ingredients,
|
|
||||||
steps=[str(s) for s in (data.get("steps") or []) if s],
|
|
||||||
notes=data.get("notes") or None,
|
|
||||||
tags=list(data.get("tags") or []),
|
|
||||||
pantry_match_pct=pct,
|
|
||||||
confidence=data.get("confidence") or "medium",
|
|
||||||
warnings=list(data.get("warnings") or []),
|
|
||||||
)
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
"""
|
|
||||||
Reranker integration for recipe suggestions.
|
|
||||||
|
|
||||||
Wraps circuitforge_core.reranker to score recipe candidates against a
|
|
||||||
natural-language query built from the user's pantry, constraints, and
|
|
||||||
preferences. Paid+ tier only; free tier returns None (caller keeps
|
|
||||||
existing sort). All exceptions are caught and logged — the reranker
|
|
||||||
must never break recipe suggestions.
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
CF_RERANKER_MOCK=1 — force mock backend (tests, no model required)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Tiers that get reranker access.
|
|
||||||
_RERANKER_TIERS: frozenset[str] = frozenset({"paid", "premium", "local"})
|
|
||||||
|
|
||||||
# Minimum candidates worth reranking — below this the cross-encoder
|
|
||||||
# overhead is not justified and the overlap sort is fine.
|
|
||||||
_MIN_CANDIDATES: int = 3
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RerankerInput:
|
|
||||||
"""Intermediate representation passed to the reranker."""
|
|
||||||
query: str
|
|
||||||
candidates: list[str]
|
|
||||||
suggestion_ids: list[int] # parallel to candidates, for re-mapping
|
|
||||||
|
|
||||||
|
|
||||||
# ── Query builder ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_query(req: RecipeRequest) -> str:
|
|
||||||
"""Build a natural-language query string from the recipe request.
|
|
||||||
|
|
||||||
Encodes the user's full context so the cross-encoder can score
|
|
||||||
relevance, dietary fit, and expiry urgency in a single pass.
|
|
||||||
Only non-empty segments are included.
|
|
||||||
"""
|
|
||||||
parts: list[str] = []
|
|
||||||
|
|
||||||
if req.pantry_items:
|
|
||||||
parts.append(f"Recipe using: {', '.join(req.pantry_items)}")
|
|
||||||
|
|
||||||
if req.exclude_ingredients:
|
|
||||||
parts.append(f"Avoid: {', '.join(req.exclude_ingredients)}")
|
|
||||||
|
|
||||||
if req.allergies:
|
|
||||||
parts.append(f"Allergies: {', '.join(req.allergies)}")
|
|
||||||
|
|
||||||
if req.constraints:
|
|
||||||
parts.append(f"Dietary: {', '.join(req.constraints)}")
|
|
||||||
|
|
||||||
if req.category:
|
|
||||||
parts.append(f"Category: {req.category}")
|
|
||||||
|
|
||||||
if req.style_id:
|
|
||||||
parts.append(f"Style: {req.style_id}")
|
|
||||||
|
|
||||||
if req.complexity_filter:
|
|
||||||
parts.append(f"Prefer: {req.complexity_filter}")
|
|
||||||
|
|
||||||
if req.hard_day_mode:
|
|
||||||
parts.append("Prefer: easy, minimal effort")
|
|
||||||
|
|
||||||
# Secondary pantry items carry a state label (e.g. "stale", "overripe")
|
|
||||||
# that helps the reranker favour recipes suited to those specific states.
|
|
||||||
if req.secondary_pantry_items:
|
|
||||||
expiry_parts = [f"{name} ({state})" for name, state in req.secondary_pantry_items.items()]
|
|
||||||
parts.append(f"Use soon: {', '.join(expiry_parts)}")
|
|
||||||
elif req.expiry_first:
|
|
||||||
parts.append("Prefer: recipes that use expiring items first")
|
|
||||||
|
|
||||||
return ". ".join(parts) + "." if parts else "Recipe."
|
|
||||||
|
|
||||||
|
|
||||||
# ── Candidate builder ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_candidate_string(suggestion: RecipeSuggestion) -> str:
|
|
||||||
"""Build a candidate string for a single recipe suggestion.
|
|
||||||
|
|
||||||
Format: "{title}. Ingredients: {comma-joined ingredients}"
|
|
||||||
Matched ingredients appear before missing ones.
|
|
||||||
Directions excluded to stay within BGE's 512-token window.
|
|
||||||
"""
|
|
||||||
ingredients = suggestion.matched_ingredients + suggestion.missing_ingredients
|
|
||||||
if not ingredients:
|
|
||||||
return suggestion.title
|
|
||||||
return f"{suggestion.title}. Ingredients: {', '.join(ingredients)}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Input assembler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_reranker_input(
|
|
||||||
req: RecipeRequest,
|
|
||||||
suggestions: list[RecipeSuggestion],
|
|
||||||
) -> RerankerInput:
|
|
||||||
"""Assemble query and candidate strings for the reranker."""
|
|
||||||
query = build_query(req)
|
|
||||||
candidates: list[str] = []
|
|
||||||
ids: list[int] = []
|
|
||||||
for s in suggestions:
|
|
||||||
candidates.append(build_candidate_string(s))
|
|
||||||
ids.append(s.id)
|
|
||||||
return RerankerInput(query=query, candidates=candidates, suggestion_ids=ids)
|
|
||||||
|
|
||||||
|
|
||||||
# ── cf-core seam (isolated for monkeypatching in tests) ──────────────────────
|
|
||||||
|
|
||||||
def _do_rerank(query: str, candidates: list[str], top_n: int = 0):
|
|
||||||
"""Thin wrapper around cf-core rerank(). Extracted so tests can patch it."""
|
|
||||||
from circuitforge_core.reranker import rerank
|
|
||||||
return rerank(query, candidates, top_n=top_n)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Public entry point ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def rerank_suggestions(
|
|
||||||
req: RecipeRequest,
|
|
||||||
suggestions: list[RecipeSuggestion],
|
|
||||||
) -> list[RecipeSuggestion] | None:
|
|
||||||
"""Rerank suggestions using the cf-core cross-encoder.
|
|
||||||
|
|
||||||
Returns a reordered list with rerank_score populated, or None when:
|
|
||||||
- Tier is not paid+ (free tier keeps overlap sort)
|
|
||||||
- Fewer than _MIN_CANDIDATES suggestions (not worth the overhead)
|
|
||||||
- Any exception is raised (graceful fallback to existing sort)
|
|
||||||
|
|
||||||
The caller should treat None as "keep existing sort order".
|
|
||||||
Original suggestions are never mutated.
|
|
||||||
"""
|
|
||||||
if req.tier not in _RERANKER_TIERS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if len(suggestions) < _MIN_CANDIDATES:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
rinput = build_reranker_input(req, suggestions)
|
|
||||||
results = _do_rerank(rinput.query, rinput.candidates, top_n=0)
|
|
||||||
|
|
||||||
# Map reranked results back to RecipeSuggestion objects using the
|
|
||||||
# candidate string as key (build_candidate_string is deterministic).
|
|
||||||
candidate_map: dict[str, RecipeSuggestion] = {
|
|
||||||
build_candidate_string(s): s for s in suggestions
|
|
||||||
}
|
|
||||||
|
|
||||||
reranked: list[RecipeSuggestion] = []
|
|
||||||
for rr in results:
|
|
||||||
suggestion = candidate_map.get(rr.candidate)
|
|
||||||
if suggestion is not None:
|
|
||||||
reranked.append(suggestion.model_copy(
|
|
||||||
update={"rerank_score": round(float(rr.score), 4)}
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(reranked) < len(suggestions):
|
|
||||||
log.warning(
|
|
||||||
"Reranker lost %d/%d suggestions during mapping, falling back",
|
|
||||||
len(suggestions) - len(reranked),
|
|
||||||
len(suggestions),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return reranked
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
log.exception("Reranker failed, falling back to overlap sort")
|
|
||||||
return None
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
"""
|
|
||||||
Sensory filter dataclass and helpers.
|
|
||||||
|
|
||||||
SensoryExclude bridges user preferences (from user_settings) to the
|
|
||||||
store browse methods and recipe engine suggest flow.
|
|
||||||
|
|
||||||
Recipes with sensory_tags = '{}' (untagged) pass ALL filters --
|
|
||||||
graceful degradation when tag_sensory_profiles.py has not run.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
_SMELL_LEVELS: tuple[str, ...] = ("mild", "aromatic", "pungent", "fermented")
|
|
||||||
_NOISE_LEVELS: tuple[str, ...] = ("quiet", "moderate", "loud", "very_loud")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SensoryExclude:
|
|
||||||
"""Derived filter criteria from user sensory preferences.
|
|
||||||
|
|
||||||
textures: texture tags to exclude (empty tuple = no texture filter)
|
|
||||||
smell_above: if set, exclude recipes whose smell level is strictly above
|
|
||||||
this level in the smell spectrum
|
|
||||||
noise_above: if set, exclude recipes whose noise level is strictly above
|
|
||||||
this level in the noise spectrum
|
|
||||||
"""
|
|
||||||
textures: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
smell_above: str | None = None
|
|
||||||
noise_above: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def empty(cls) -> "SensoryExclude":
|
|
||||||
"""No filtering -- pass-through for users with no preferences set."""
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
|
||||||
"""True when no filtering will be applied."""
|
|
||||||
return not self.textures and self.smell_above is None and self.noise_above is None
|
|
||||||
|
|
||||||
|
|
||||||
def build_sensory_exclude(prefs_json: str | None) -> SensoryExclude:
|
|
||||||
"""Parse user_settings value for 'sensory_preferences' into a SensoryExclude.
|
|
||||||
|
|
||||||
Expected JSON shape:
|
|
||||||
{
|
|
||||||
"avoid_textures": ["mushy", "slimy"],
|
|
||||||
"max_smell": "pungent",
|
|
||||||
"max_noise": "loud"
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns SensoryExclude.empty() on missing, null, or malformed input.
|
|
||||||
"""
|
|
||||||
if not prefs_json:
|
|
||||||
return SensoryExclude.empty()
|
|
||||||
try:
|
|
||||||
prefs = json.loads(prefs_json)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return SensoryExclude.empty()
|
|
||||||
if not isinstance(prefs, dict):
|
|
||||||
return SensoryExclude.empty()
|
|
||||||
|
|
||||||
avoid_textures = tuple(
|
|
||||||
t for t in (prefs.get("avoid_textures") or [])
|
|
||||||
if isinstance(t, str)
|
|
||||||
)
|
|
||||||
max_smell: str | None = prefs.get("max_smell") or None
|
|
||||||
max_noise: str | None = prefs.get("max_noise") or None
|
|
||||||
|
|
||||||
if max_smell and max_smell not in _SMELL_LEVELS:
|
|
||||||
max_smell = None
|
|
||||||
if max_noise and max_noise not in _NOISE_LEVELS:
|
|
||||||
max_noise = None
|
|
||||||
|
|
||||||
return SensoryExclude(
|
|
||||||
textures=avoid_textures,
|
|
||||||
smell_above=max_smell,
|
|
||||||
noise_above=max_noise,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def passes_sensory_filter(
|
|
||||||
sensory_tags_raw: str | dict | None,
|
|
||||||
exclude: SensoryExclude,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if the recipe passes the sensory exclude criteria.
|
|
||||||
|
|
||||||
sensory_tags_raw: the sensory_tags column value (JSON string or already-parsed dict).
|
|
||||||
exclude: derived filter criteria.
|
|
||||||
|
|
||||||
Untagged recipes (empty dict or '{}') always pass -- graceful degradation.
|
|
||||||
Empty SensoryExclude always passes -- no preferences set.
|
|
||||||
"""
|
|
||||||
if exclude.is_empty():
|
|
||||||
return True
|
|
||||||
|
|
||||||
if sensory_tags_raw is None:
|
|
||||||
return True
|
|
||||||
if isinstance(sensory_tags_raw, str):
|
|
||||||
try:
|
|
||||||
tags: dict = json.loads(sensory_tags_raw)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
tags = sensory_tags_raw
|
|
||||||
|
|
||||||
if not tags:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if exclude.textures:
|
|
||||||
recipe_textures: list[str] = tags.get("textures") or []
|
|
||||||
for t in recipe_textures:
|
|
||||||
if t in exclude.textures:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if exclude.smell_above is not None:
|
|
||||||
recipe_smell: str | None = tags.get("smell")
|
|
||||||
if recipe_smell and recipe_smell in _SMELL_LEVELS:
|
|
||||||
max_idx = _SMELL_LEVELS.index(exclude.smell_above)
|
|
||||||
recipe_idx = _SMELL_LEVELS.index(recipe_smell)
|
|
||||||
if recipe_idx > max_idx:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if exclude.noise_above is not None:
|
|
||||||
recipe_noise: str | None = tags.get("noise")
|
|
||||||
if recipe_noise and recipe_noise in _NOISE_LEVELS:
|
|
||||||
max_idx = _NOISE_LEVELS.index(exclude.noise_above)
|
|
||||||
recipe_idx = _NOISE_LEVELS.index(recipe_noise)
|
|
||||||
if recipe_idx > max_idx:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
# app/services/recipe/style_classifier.py
|
|
||||||
# BSL 1.1 — LLM feature
|
|
||||||
"""LLM style-tag classifier for saved recipes.
|
|
||||||
|
|
||||||
Reads recipe title, ingredients, and directions and suggests 3–5 style tags
|
|
||||||
from the curated vocabulary shared with SaveRecipeModal.vue.
|
|
||||||
|
|
||||||
Cloud (CF_ORCH_URL set): allocates a cf-text service via cf-orch (2 GB VRAM).
|
|
||||||
Local: falls back to LLMRouter (ollama / vllm / openai-compat).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from contextlib import nullcontext
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SERVICE_TYPE = "cf-text"
|
|
||||||
_TTL_S = 60.0
|
|
||||||
_CALLER = "kiwi-style-classify"
|
|
||||||
|
|
||||||
# Canonical vocabulary — must stay in sync with SUGGESTED_TAGS in SaveRecipeModal.vue.
|
|
||||||
STYLE_TAG_VOCAB: frozenset[str] = frozenset({
|
|
||||||
"comforting", "light", "spicy", "umami", "sweet", "savory", "rich",
|
|
||||||
"crispy", "creamy", "hearty", "quick", "hands-off", "meal-prep-friendly",
|
|
||||||
"fancy", "one-pot",
|
|
||||||
})
|
|
||||||
|
|
||||||
_SYSTEM_PROMPT = """\
|
|
||||||
You are a culinary tagger. Given a recipe, suggest 3 to 5 style tags that best \
|
|
||||||
describe its character. You MUST only use tags from this list:
|
|
||||||
|
|
||||||
comforting, light, spicy, umami, sweet, savory, rich, crispy, creamy, hearty, \
|
|
||||||
quick, hands-off, meal-prep-friendly, fancy, one-pot
|
|
||||||
|
|
||||||
Return ONLY a JSON array of strings, no explanation. Example:
|
|
||||||
["comforting", "hearty", "one-pot"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _build_router():
|
|
||||||
"""Return (router, context_manager) for style classify tasks.
|
|
||||||
|
|
||||||
Tries cf-orch cf-text allocation first; falls back to LLMRouter.
|
|
||||||
Returns (None, nullcontext) if no backend is available.
|
|
||||||
"""
|
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
|
||||||
if cf_orch_url:
|
|
||||||
try:
|
|
||||||
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
|
|
||||||
from circuitforge_orch.client import CFOrchClient
|
|
||||||
client = CFOrchClient(cf_orch_url)
|
|
||||||
ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER)
|
|
||||||
alloc = ctx.__enter__()
|
|
||||||
if alloc is not None:
|
|
||||||
return _OrchTextRouter(alloc.url), ctx
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-orch allocation failed for style classify, falling back: %s", exc)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
return LLMRouter(), nullcontext(None)
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.debug("LLMRouter: no llm.yaml — style classifier LLM disabled")
|
|
||||||
return None, nullcontext(None)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("LLMRouter init failed: %s", exc)
|
|
||||||
return None, nullcontext(None)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_tags(raw: str) -> list[str]:
|
|
||||||
"""Extract valid vocab tags from raw LLM output.
|
|
||||||
|
|
||||||
Tries JSON parse first; falls back to extracting any vocab word present
|
|
||||||
in the response text so minor formatting deviations still work.
|
|
||||||
"""
|
|
||||||
# Strip markdown fences
|
|
||||||
raw = re.sub(r"```[a-z]*", "", raw).strip()
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
return [t for t in parsed if isinstance(t, str) and t in STYLE_TAG_VOCAB][:5]
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: scan for vocab words
|
|
||||||
found = [t for t in STYLE_TAG_VOCAB if re.search(rf"\b{re.escape(t)}\b", raw, re.IGNORECASE)]
|
|
||||||
return sorted(found, key=lambda t: raw.lower().index(t.lower()))[:5]
|
|
||||||
|
|
||||||
|
|
||||||
def classify_style(recipe: dict[str, Any]) -> list[str]:
|
|
||||||
"""Return 3–5 suggested style tags for *recipe*.
|
|
||||||
|
|
||||||
*recipe* is a Store row dict with at least ``title``, ``ingredient_names``
|
|
||||||
(list[str]), and ``directions`` (list[str] or str).
|
|
||||||
|
|
||||||
Returns an empty list if no LLM backend is available.
|
|
||||||
"""
|
|
||||||
router, ctx = _build_router()
|
|
||||||
if router is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
title = recipe.get("title") or "Unknown"
|
|
||||||
ingredients = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(ingredients, str):
|
|
||||||
try:
|
|
||||||
ingredients = json.loads(ingredients)
|
|
||||||
except Exception:
|
|
||||||
ingredients = [ingredients]
|
|
||||||
|
|
||||||
directions = recipe.get("directions") or []
|
|
||||||
if isinstance(directions, str):
|
|
||||||
try:
|
|
||||||
directions = json.loads(directions)
|
|
||||||
except Exception:
|
|
||||||
directions = [directions]
|
|
||||||
|
|
||||||
user_prompt = (
|
|
||||||
f"Recipe: {title}\n"
|
|
||||||
f"Ingredients: {', '.join(str(i) for i in ingredients[:20])}\n"
|
|
||||||
f"Steps: {' '.join(str(d) for d in directions[:8])[:600]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with ctx:
|
|
||||||
raw = router.complete(
|
|
||||||
system=_SYSTEM_PROMPT,
|
|
||||||
user=user_prompt,
|
|
||||||
max_tokens=64,
|
|
||||||
temperature=0.3,
|
|
||||||
)
|
|
||||||
return _parse_tags(raw)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Style classifier LLM call failed: %s", exc)
|
|
||||||
return []
|
|
||||||
|
|
@ -22,8 +22,6 @@ queries find recipes the food.com corpus tags alone would miss.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Text-signal tables
|
# Text-signal tables
|
||||||
|
|
@ -70,15 +68,6 @@ _CUISINE_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
|
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
|
||||||
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
|
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
|
||||||
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
|
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
|
||||||
# BBQ detection: match on title terms and key ingredients; these rarely appear
|
|
||||||
# in food.com's own keyword/category taxonomy so we derive the tag from content.
|
|
||||||
("cuisine:BBQ", ["brisket", "pulled pork", "spare ribs", "baby back ribs",
|
|
||||||
"baby back", "burnt ends", "pit smoked", "smoke ring",
|
|
||||||
"low and slow", "hickory", "mesquite", "liquid smoke",
|
|
||||||
"bbq brisket", "smoked brisket", "barbecue brisket",
|
|
||||||
"carolina bbq", "texas bbq", "kansas city bbq",
|
|
||||||
"memphis bbq", "smoked ribs", "smoked pulled pork",
|
|
||||||
"dry rub ribs", "wet rub ribs", "beer can chicken smoked"]),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
|
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
|
|
@ -123,50 +112,6 @@ _TIME_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Meal type signals — matched against TITLE ONLY (not ingredient text).
|
|
||||||
# Ingredient names frequently contain words like "cake flour" or "sandwich
|
|
||||||
# bread" which would produce false meal-type tags if matched against the full
|
|
||||||
# title+ingredient string.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_MEAL_SIGNALS: list[tuple[str, list[str]]] = [
|
|
||||||
("meal:Breakfast", [
|
|
||||||
"breakfast", "pancake", "waffle", "french toast", "scrambled egg",
|
|
||||||
"frittata", "hash brown", "hash browns", "breakfast burrito",
|
|
||||||
"breakfast sandwich", "breakfast casserole", "overnight oat",
|
|
||||||
"granola", "oatmeal", "muffin", "morning glory", "eggs benedict",
|
|
||||||
"shakshuka", "crepe", "scone",
|
|
||||||
]),
|
|
||||||
("meal:Dessert", [
|
|
||||||
"dessert", "cake", "cookie", "brownie", "cheesecake", "pudding",
|
|
||||||
"fudge", "ice cream", "sorbet", "cupcake", "mousse", "candy",
|
|
||||||
"truffle", "gelato", "donut", "doughnut", "cobbler", "crisp",
|
|
||||||
"crumble", "tiramisu", "eclair", "sundae", "milkshake", "parfait",
|
|
||||||
"biscotti", "macaron", "panna cotta", "baklava", "churro", "tart",
|
|
||||||
"torte", "strudel", "compote", "semifreddo",
|
|
||||||
]),
|
|
||||||
("meal:Snack", [
|
|
||||||
"snack", "appetizer", "dip", "chips", "popcorn", "trail mix",
|
|
||||||
"energy ball", "deviled egg", "cheese ball", "nachos",
|
|
||||||
"pretzel bites", "protein ball", "granola bar",
|
|
||||||
]),
|
|
||||||
("meal:Beverage", [
|
|
||||||
"smoothie", "cocktail", "mocktail", "lemonade", "limeade",
|
|
||||||
"margarita", "sangria", "punch", "milkshake", "milk shake",
|
|
||||||
"juice", "spritzer", "iced tea", "hot chocolate", "chai latte",
|
|
||||||
"mulled wine", "eggnog", "slushie", "frappe", "horchata",
|
|
||||||
"agua fresca", "shrub", "switchel",
|
|
||||||
]),
|
|
||||||
("meal:Lunch", [
|
|
||||||
"lunch", "sandwich", "panini", "grilled cheese", "wrap",
|
|
||||||
"lunchbox", "lunch box",
|
|
||||||
]),
|
|
||||||
("meal:Bread", [
|
|
||||||
"bread", "sourdough", "focaccia", "flatbread", "dinner roll",
|
|
||||||
"loaf", "baguette", "ciabatta", "brioche", "challah", "pita",
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
|
|
||||||
_MAIN_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
|
_MAIN_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
||||||
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
|
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
|
||||||
|
|
@ -242,29 +187,6 @@ def _match_signals(text: str, table: list[tuple[str, list[str]]]) -> list[str]:
|
||||||
return [tag for tag, pats in table if any(p in text for p in pats)]
|
return [tag for tag, pats in table if any(p in text for p in pats)]
|
||||||
|
|
||||||
|
|
||||||
def _match_title_signals(title: str, table: list[tuple[str, list[str]]]) -> list[str]:
|
|
||||||
"""Match signals against title text only, using word-boundary + optional plural.
|
|
||||||
|
|
||||||
Pattern: `\\bWORD(?:s|es)?\\b`
|
|
||||||
|
|
||||||
This handles:
|
|
||||||
- Plurals: "cookie" matches "cookies", "sandwich" matches "sandwiches"
|
|
||||||
- Substring rejection: "cake" does NOT match "pancake" (no word boundary
|
|
||||||
before 'c' in pan|cake), "tart" does NOT match "tartare" (after "tart"
|
|
||||||
the 'a' is a word char, not a boundary)
|
|
||||||
- Avoids false positives from ingredient text ("cake flour", "sandwich bread")
|
|
||||||
by only matching the recipe title, not the full title+ingredient string.
|
|
||||||
"""
|
|
||||||
t = title.lower()
|
|
||||||
return [
|
|
||||||
tag for tag, pats in table
|
|
||||||
if any(
|
|
||||||
re.search(r"\b" + re.escape(p.strip()) + r"(?:s|es)?\b", t)
|
|
||||||
for p in pats
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def infer_tags(
|
def infer_tags(
|
||||||
title: str,
|
title: str,
|
||||||
ingredient_names: list[str],
|
ingredient_names: list[str],
|
||||||
|
|
@ -327,9 +249,6 @@ def infer_tags(
|
||||||
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
||||||
tags.update(_match_signals(text, _MAIN_INGREDIENT_SIGNALS))
|
tags.update(_match_signals(text, _MAIN_INGREDIENT_SIGNALS))
|
||||||
|
|
||||||
# Meal type: title-only to avoid "cake flour" → meal:Dessert false positives
|
|
||||||
tags.update(_match_title_signals(title, _MEAL_SIGNALS))
|
|
||||||
|
|
||||||
# 3. Time signals from corpus keywords + text
|
# 3. Time signals from corpus keywords + text
|
||||||
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
||||||
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
|
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
|
||||||
|
|
|
||||||
|
|
@ -1,602 +0,0 @@
|
||||||
"""
|
|
||||||
Runtime parser for active/passive time split, prep effort, and equipment detection.
|
|
||||||
|
|
||||||
Operates over a list of direction strings plus an optional ingredient list.
|
|
||||||
No I/O — pure Python functions. Sub-millisecond for up to 20 recipes.
|
|
||||||
|
|
||||||
Time estimation strategy (in priority order):
|
|
||||||
1. Explicit time mention in step text ("simmer for 20 minutes")
|
|
||||||
2. Passive keyword + per-technique default ("bake until golden" → 30 min)
|
|
||||||
3. Prep action + ingredient quantity scaling ("dice 2 lbs potatoes" → ~5 min)
|
|
||||||
4. Fallback active default (assembly/misc steps → 2 min each)
|
|
||||||
|
|
||||||
Quantity scaling uses n^0.75 (sub-linear, matching human batch-work curves).
|
|
||||||
Pass `ingredients` + `ingredient_names` to enable cross-referenced scaling.
|
|
||||||
Without them, prep actions use base times only (no scaling).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
# ── Passive step keywords ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_PASSIVE_PATTERNS: Final[list[str]] = [
|
|
||||||
"simmer", "bake", "roast", "broil", "refrigerate", "marinate",
|
|
||||||
"chill", "cool", "freeze", "rest", "stand", "set", "soak",
|
|
||||||
"steep", "proof", "rise", "let", "wait", "overnight", "braise",
|
|
||||||
r"slow\s+cook", r"pressure\s+cook",
|
|
||||||
]
|
|
||||||
|
|
||||||
_PASSIVE_RE: re.Pattern[str] = re.compile(
|
|
||||||
r"\b(?:" + "|".join(_PASSIVE_PATTERNS) + r")\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Per-technique passive defaults (minutes) — used when no explicit time found.
|
|
||||||
# Calibrated to conservative midpoints from USDA FoodKeeper + culinary practice.
|
|
||||||
_PASSIVE_DEFAULTS: Final[list[tuple[re.Pattern[str], int]]] = [
|
|
||||||
# Multi-word first (longer match wins)
|
|
||||||
(re.compile(r"\bslow\s+cook\b", re.IGNORECASE), 300), # 5 hr crockpot default
|
|
||||||
(re.compile(r"\bpressure\s+cook\b", re.IGNORECASE), 15),
|
|
||||||
(re.compile(r"\bovernight\b", re.IGNORECASE), 480), # 8 hr
|
|
||||||
# Single-word
|
|
||||||
(re.compile(r"\bbraise\b", re.IGNORECASE), 90),
|
|
||||||
(re.compile(r"\bmarinate\b", re.IGNORECASE), 60),
|
|
||||||
(re.compile(r"\brefrigerate\b", re.IGNORECASE), 120),
|
|
||||||
(re.compile(r"\bproof\b|\brise\b", re.IGNORECASE), 60),
|
|
||||||
(re.compile(r"\bsoak\b", re.IGNORECASE), 30),
|
|
||||||
(re.compile(r"\bfreeze\b", re.IGNORECASE), 120),
|
|
||||||
(re.compile(r"\bchill\b", re.IGNORECASE), 60),
|
|
||||||
(re.compile(r"\broast\b", re.IGNORECASE), 40),
|
|
||||||
(re.compile(r"\bbake\b", re.IGNORECASE), 30),
|
|
||||||
(re.compile(r"\bbroil\b", re.IGNORECASE), 8),
|
|
||||||
(re.compile(r"\bsimmer\b", re.IGNORECASE), 20),
|
|
||||||
(re.compile(r"\bset\b", re.IGNORECASE), 30), # gelatin / custard set
|
|
||||||
(re.compile(r"\bsteep\b", re.IGNORECASE), 5),
|
|
||||||
(re.compile(r"\brest\b|\bstand\b", re.IGNORECASE), 10),
|
|
||||||
(re.compile(r"\bcool\b", re.IGNORECASE), 15),
|
|
||||||
(re.compile(r"\bwait\b|\blet\b", re.IGNORECASE), 5),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Explicit time extraction ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
_TIME_RE: re.Pattern[str] = re.compile(
|
|
||||||
r"(\d+)\s*(?:[-\u2013]|-to-)\s*(\d+)\s*(hour|hr|minute|min|second|sec)s?"
|
|
||||||
r"|"
|
|
||||||
r"(\d+)\s*(hour|hr|minute|min|second|sec)s?",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
_MAX_MINUTES_PER_STEP: Final[int] = 480 # 8-hour sanity cap
|
|
||||||
|
|
||||||
# ── Prep action detection ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Base times (minutes) per prep action, calibrated to ~3 items / 0.5 lb reference.
|
|
||||||
# These are starting points — flagged for calibration against real recipe timing data.
|
|
||||||
_PREP_ACTION_BASES: Final[dict[str, float]] = {
|
|
||||||
# Peeling / stripping
|
|
||||||
"peel": 1.5,
|
|
||||||
"pare": 1.5,
|
|
||||||
"hull": 1.5,
|
|
||||||
"pit": 2.0, # cherries, avocados
|
|
||||||
"core": 1.0,
|
|
||||||
"stem": 1.0,
|
|
||||||
"trim": 1.0,
|
|
||||||
# Cutting
|
|
||||||
"chop": 2.0,
|
|
||||||
"cut": 1.5,
|
|
||||||
"dice": 2.5, # more precise than chop
|
|
||||||
"mince": 2.0,
|
|
||||||
"slice": 1.5,
|
|
||||||
"julienne": 4.0,
|
|
||||||
"cube": 2.0,
|
|
||||||
"quarter": 1.0,
|
|
||||||
"halve": 0.5,
|
|
||||||
"shred": 2.0,
|
|
||||||
# Grating / zesting
|
|
||||||
"grate": 3.0,
|
|
||||||
"zest": 2.0,
|
|
||||||
# Crushing
|
|
||||||
"crush": 0.5,
|
|
||||||
"smash": 0.5,
|
|
||||||
"crack": 0.5,
|
|
||||||
# Mixing / assembly (lower base — less physical effort)
|
|
||||||
"knead": 8.0, # bread dough: consistent regardless of quantity
|
|
||||||
"whisk": 1.5,
|
|
||||||
"beat": 2.0,
|
|
||||||
"cream": 3.0, # butter + sugar until fluffy
|
|
||||||
"fold": 1.5,
|
|
||||||
"stir": 0.5,
|
|
||||||
"combine": 0.5,
|
|
||||||
"mix": 1.0,
|
|
||||||
"season": 0.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Compiled regex — longer patterns first to avoid partial matches.
|
|
||||||
_PREP_RE: re.Pattern[str] = re.compile(
|
|
||||||
r"\b(?:" + "|".join(
|
|
||||||
re.escape(k) for k in sorted(_PREP_ACTION_BASES, key=len, reverse=True)
|
|
||||||
) + r")\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default active time per step when no explicit time and no prep action detected.
|
|
||||||
_ACTIVE_STEP_DEFAULT_MIN: Final[float] = 2.0
|
|
||||||
|
|
||||||
# ── Prep-needing ingredient classification ────────────────────────────────
|
|
||||||
#
|
|
||||||
# Only ingredients in this set get quantity-scaled prep time.
|
|
||||||
# Liquids, spices, canned goods, and dry staples are excluded — they require
|
|
||||||
# no physical prep beyond measuring.
|
|
||||||
|
|
||||||
_PREP_NEEDING: Final[frozenset[str]] = frozenset({
|
|
||||||
# Alliums
|
|
||||||
"onion", "shallot", "leek", "scallion", "green onion", "chive", "garlic",
|
|
||||||
# Root / stem vegetables
|
|
||||||
"ginger", "carrot", "celery", "potato", "sweet potato", "yam",
|
|
||||||
"beet", "turnip", "parsnip", "radish", "fennel", "celeriac",
|
|
||||||
# Squash / gourd family
|
|
||||||
"zucchini", "squash", "pumpkin", "cucumber",
|
|
||||||
# Peppers
|
|
||||||
"pepper", "bell pepper", "jalapeño", "jalapeno", "chili", "chile",
|
|
||||||
# Brassicas
|
|
||||||
"broccoli", "cauliflower", "cabbage", "kale", "chard", "spinach",
|
|
||||||
"brussels sprout",
|
|
||||||
# Other vegetables
|
|
||||||
"tomato", "eggplant", "aubergine", "corn", "artichoke", "asparagus",
|
|
||||||
"green bean", "snow pea", "snap pea", "mushroom", "lettuce",
|
|
||||||
# Fruits
|
|
||||||
"apple", "pear", "peach", "nectarine", "plum", "apricot",
|
|
||||||
"mango", "papaya", "pineapple", "melon", "watermelon", "cantaloupe",
|
|
||||||
"avocado", "banana",
|
|
||||||
"strawberry", "raspberry", "blackberry", "blueberry", "cherry",
|
|
||||||
"citrus", "lemon", "lime", "orange", "grapefruit",
|
|
||||||
# Protein (trimming / portioning)
|
|
||||||
"chicken", "turkey", "duck",
|
|
||||||
"beef", "pork", "lamb", "veal",
|
|
||||||
"fish", "salmon", "tuna", "cod", "tilapia", "halibut", "shrimp",
|
|
||||||
"scallop", "crab", "lobster",
|
|
||||||
# Dairy requiring active prep
|
|
||||||
"cheese",
|
|
||||||
# Nuts / seeds (chopping)
|
|
||||||
"almond", "walnut", "pecan", "cashew", "peanut", "hazelnut",
|
|
||||||
"pistachio", "macadamia", "nut",
|
|
||||||
# Fresh herbs (chopping / tearing)
|
|
||||||
"basil", "parsley", "cilantro", "thyme", "rosemary", "sage",
|
|
||||||
"dill", "mint", "tarragon",
|
|
||||||
# Other
|
|
||||||
"bread",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _is_prep_needing(name: str) -> bool:
|
|
||||||
"""True if the normalized ingredient name contains any prep-needing keyword."""
|
|
||||||
nl = name.lower()
|
|
||||||
return any(kw in nl for kw in _PREP_NEEDING)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Quantity extraction ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_FRAC_RE: re.Pattern[str] = re.compile(r"(\d+)\s*/\s*(\d+)")
|
|
||||||
|
|
||||||
# Weight units → converted to pounds internally
|
|
||||||
_WEIGHT_RE: re.Pattern[str] = re.compile(
|
|
||||||
r"(\d+(?:\.\d+)?|\d+\s*/\s*\d+)\s*"
|
|
||||||
r"(pound|lb|ounce|oz|gram|g(?![a-z])|kilogram|kg)\s*s?\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Volume (cups only — the common recipe unit for quantity scaling)
|
|
||||||
_VOLUME_CUP_RE: re.Pattern[str] = re.compile(
|
|
||||||
r"(\d+(?:\.\d+)?|\d+\s*/\s*\d+)\s*cups?\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Count — bare integer or decimal followed by optional size/unit word
|
|
||||||
_COUNT_RE: re.Pattern[str] = re.compile(
|
|
||||||
r"(?<!\d)(\d+(?:\.\d+)?)\s*"
|
|
||||||
r"(?:large|medium|small|whole|clove|cloves|head|heads|ear|ears|"
|
|
||||||
r"stalk|stalks|sprig|sprigs|bunch|bunches|fillet|fillets|"
|
|
||||||
r"breast|breasts|piece|pieces|slice|slices)?\s*\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reference quantities: the "1× base" for each unit type.
|
|
||||||
# Calibrated so that a typical single-ingredient amount = 1× prep time.
|
|
||||||
_QTY_REFS: Final[dict[str, float]] = {
|
|
||||||
"lb": 0.5, # 0.5 lb is the base → 1 lb = 1.4×, 2 lb = 2.0×
|
|
||||||
"cup": 1.0, # 1 cup = base
|
|
||||||
"count": 3.0, # 3 items = base → 1 = 0.46×, 6 = 1.6×
|
|
||||||
}
|
|
||||||
|
|
||||||
_SCALE_POWER: Final[float] = 0.75 # sub-linear; revisit with empirical data
|
|
||||||
_MAX_SCALE: Final[float] = 4.0 # cap at 4× regardless of quantity
|
|
||||||
_MIN_SCALE: Final[float] = 0.33 # floor at 1/3× for tiny amounts
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_fraction(s: str) -> float:
|
|
||||||
m = _FRAC_RE.search(s)
|
|
||||||
if m:
|
|
||||||
try:
|
|
||||||
return float(m.group(1)) / float(m.group(2))
|
|
||||||
except (ValueError, ZeroDivisionError):
|
|
||||||
return 1.0
|
|
||||||
try:
|
|
||||||
return float(s.replace(" ", ""))
|
|
||||||
except ValueError:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_qty(text: str) -> tuple[float, str] | None:
|
|
||||||
"""Return (quantity_in_canonical_units, unit_type) or None.
|
|
||||||
|
|
||||||
Unit types: "lb" (weight in pounds), "cup", "count".
|
|
||||||
All weights are normalised to pounds.
|
|
||||||
"""
|
|
||||||
# Weight (most specific — check first)
|
|
||||||
m = _WEIGHT_RE.search(text)
|
|
||||||
if m:
|
|
||||||
qty = _parse_fraction(m.group(1))
|
|
||||||
u = m.group(2).lower().rstrip("s")
|
|
||||||
if u in ("pound", "lb"):
|
|
||||||
return (qty, "lb")
|
|
||||||
if u in ("ounce", "oz"):
|
|
||||||
return (qty / 16.0, "lb")
|
|
||||||
if u in ("gram", "g"):
|
|
||||||
return (qty / 453.6, "lb")
|
|
||||||
if u in ("kilogram", "kg"):
|
|
||||||
return (qty * 2.205, "lb")
|
|
||||||
|
|
||||||
# Volume (cups)
|
|
||||||
m = _VOLUME_CUP_RE.search(text)
|
|
||||||
if m:
|
|
||||||
return (_parse_fraction(m.group(1)), "cup")
|
|
||||||
|
|
||||||
# Count — only accept values in a sane range to avoid false positives
|
|
||||||
m = _COUNT_RE.search(text)
|
|
||||||
if m:
|
|
||||||
qty = float(m.group(1))
|
|
||||||
if 0 < qty <= 24:
|
|
||||||
return (qty, "count")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_inline_qty_for(text: str, ing_name: str) -> tuple[float, str] | None:
|
|
||||||
"""Extract the quantity specifically associated with `ing_name` in a direction step.
|
|
||||||
|
|
||||||
Looks for a number immediately before the ingredient name (plus optional size/unit
|
|
||||||
words). Falls back to None if the pattern does not match.
|
|
||||||
|
|
||||||
Example: "Dice 2 large onions and 3 carrots" → for "onion" returns (2.0, "count").
|
|
||||||
"""
|
|
||||||
pattern = re.compile(
|
|
||||||
r"(\d+(?:\.\d+)?|\d+\s*/\s*\d+)\s*"
|
|
||||||
r"(?:large|medium|small|whole|"
|
|
||||||
r"(?:pound|lb|ounce|oz|gram|g|kilogram|kg|cup|clove|cloves|"
|
|
||||||
r"head|heads|fillet|fillets|breast|breasts|piece|pieces)s?)??\s*"
|
|
||||||
+ re.escape(ing_name) + r"(?:es|s)?\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
m = pattern.search(text)
|
|
||||||
if m:
|
|
||||||
# Re-extract with _extract_qty on the full matched span to get unit too
|
|
||||||
span = text[m.start(): m.end()]
|
|
||||||
result = _extract_qty(span)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
# Fallback: bare count
|
|
||||||
try:
|
|
||||||
return (_parse_fraction(m.group(1)), "count")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _quantity_scale(qty: float, unit: str) -> float:
|
|
||||||
"""Apply n^0.75 scaling relative to unit reference, clamped to [MIN, MAX]."""
|
|
||||||
ref = _QTY_REFS.get(unit, 1.0)
|
|
||||||
if ref <= 0 or qty <= 0:
|
|
||||||
return 1.0
|
|
||||||
raw = (qty / ref) ** _SCALE_POWER
|
|
||||||
return max(_MIN_SCALE, min(_MAX_SCALE, raw))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Equipment detection ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_EQUIPMENT_RULES: Final[list[tuple[re.Pattern[str], str]]] = [
|
|
||||||
(re.compile(r"\b(?:chop|dice|mince|slice|julienne)\b", re.IGNORECASE), "Knife"),
|
|
||||||
(re.compile(r"\b(?:skillet|sauté|saute|fry|sear|pan-fry|pan fry)\b", re.IGNORECASE), "Skillet"),
|
|
||||||
(re.compile(r"\b(?:wooden spoon|spatula|stir|fold)\b", re.IGNORECASE), "Spoon"),
|
|
||||||
(re.compile(r"\b(?:pot|boil|simmer|blanch|stock)\b", re.IGNORECASE), "Pot"),
|
|
||||||
(re.compile(r"\b(?:oven|bake|roast|preheat|broil)\b", re.IGNORECASE), "Oven"),
|
|
||||||
(re.compile(r"\b(?:blender|blend|purée|puree|food processor)\b", re.IGNORECASE), "Blender"),
|
|
||||||
(re.compile(r"\b(?:stand mixer|hand mixer|whip|beat)\b", re.IGNORECASE), "Mixer"),
|
|
||||||
(re.compile(r"\b(?:grill|barbecue|char|griddle)\b", re.IGNORECASE), "Grill"),
|
|
||||||
(re.compile(r"\b(?:slow cooker|crockpot|low and slow)\b", re.IGNORECASE), "Slow cooker"),
|
|
||||||
(re.compile(r"\b(?:pressure cooker|instant pot)\b", re.IGNORECASE), "Pressure cooker"),
|
|
||||||
(re.compile(r"\b(?:drain|strain|colander|rinse pasta)\b", re.IGNORECASE), "Colander"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_equipment(all_text: str, has_passive: bool) -> list[str]:
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for pattern, label in _EQUIPMENT_RULES:
|
|
||||||
if label not in seen and pattern.search(all_text):
|
|
||||||
seen.add(label)
|
|
||||||
result.append(label)
|
|
||||||
if has_passive and "Timer" not in seen:
|
|
||||||
result.append("Timer")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ── Ingredient–step cross-reference ──────────────────────────────────────
|
|
||||||
|
|
||||||
def _ingredient_mentioned(text: str, name: str) -> bool:
|
|
||||||
"""True if `name` appears in `text` as a whole word.
|
|
||||||
|
|
||||||
Handles both regular plurals (onion → onions) and -es plurals
|
|
||||||
(potato → potatoes, tomato → tomatoes).
|
|
||||||
"""
|
|
||||||
pattern = re.compile(r"\b" + re.escape(name.lower()) + r"(?:es|s)?\b", re.IGNORECASE)
|
|
||||||
return bool(pattern.search(text))
|
|
||||||
|
|
||||||
|
|
||||||
def _build_step_ingredient_qtys(
|
|
||||||
ingredients: list[str],
|
|
||||||
ingredient_names: list[str],
|
|
||||||
directions: list[str],
|
|
||||||
) -> list[dict[str, tuple[float, str]]]:
|
|
||||||
"""Return, for each direction step, {ing_name: (qty_for_this_step, unit)}.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
- Filter ingredient pairs to prep-needing items only.
|
|
||||||
- Parse total quantities from the raw ingredient strings.
|
|
||||||
- For each step, try to find an inline quantity tied to that ingredient name.
|
|
||||||
- If no inline quantity, distribute the total evenly across all steps that
|
|
||||||
mention the ingredient (handles "3 onions" split across 2 steps).
|
|
||||||
"""
|
|
||||||
# Build total qty map for prep-needing ingredients
|
|
||||||
total_qtys: dict[str, tuple[float, str]] = {}
|
|
||||||
for raw, name in zip(ingredients, ingredient_names):
|
|
||||||
base = name.lower().strip()
|
|
||||||
if not _is_prep_needing(base):
|
|
||||||
continue
|
|
||||||
result = _extract_qty(raw)
|
|
||||||
if result is not None:
|
|
||||||
total_qtys[base] = result
|
|
||||||
|
|
||||||
if not total_qtys:
|
|
||||||
return [{} for _ in directions]
|
|
||||||
|
|
||||||
# Count how many steps mention each ingredient
|
|
||||||
step_counts: dict[str, int] = {n: 0 for n in total_qtys}
|
|
||||||
for step in directions:
|
|
||||||
for name in total_qtys:
|
|
||||||
if _ingredient_mentioned(step, name):
|
|
||||||
step_counts[name] += 1
|
|
||||||
|
|
||||||
# Build per-step qty maps
|
|
||||||
per_step: list[dict[str, tuple[float, str]]] = []
|
|
||||||
for step in directions:
|
|
||||||
step_map: dict[str, tuple[float, str]] = {}
|
|
||||||
for name, (total, unit) in total_qtys.items():
|
|
||||||
if not _ingredient_mentioned(step, name):
|
|
||||||
continue
|
|
||||||
# Try ingredient-specific inline quantity first
|
|
||||||
inline = _extract_inline_qty_for(step, name)
|
|
||||||
if inline is not None:
|
|
||||||
step_map[name] = inline
|
|
||||||
else:
|
|
||||||
# Distribute total across steps that reference this ingredient
|
|
||||||
n = max(step_counts.get(name, 1), 1)
|
|
||||||
step_map[name] = (total / n, unit)
|
|
||||||
per_step.append(step_map)
|
|
||||||
|
|
||||||
return per_step
|
|
||||||
|
|
||||||
|
|
||||||
# ── Dataclasses ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StepAnalysis:
|
|
||||||
"""Analysis result for a single direction step."""
|
|
||||||
is_passive: bool
|
|
||||||
detected_minutes: int | None # explicit or estimated time (None = no signal)
|
|
||||||
prep_min: int | None = None # estimated physical prep time from action detection
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TimeEffortProfile:
|
|
||||||
"""Aggregated time and effort profile for a full recipe."""
|
|
||||||
active_min: int
|
|
||||||
passive_min: int
|
|
||||||
total_min: int
|
|
||||||
step_analyses: list[StepAnalysis] = field(default_factory=list)
|
|
||||||
equipment: list[str] = field(default_factory=list)
|
|
||||||
effort_label: str = "moderate" # "quick" | "moderate" | "involved"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Core parsing helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_minutes(text: str) -> int | None:
|
|
||||||
"""Return explicit minutes from text, or None."""
|
|
||||||
m = _TIME_RE.search(text)
|
|
||||||
if m is None:
|
|
||||||
return None
|
|
||||||
if m.group(1) is not None:
|
|
||||||
low, high = int(m.group(1)), int(m.group(2))
|
|
||||||
unit = m.group(3).lower()
|
|
||||||
raw: float = (low + high) / 2
|
|
||||||
else:
|
|
||||||
low = int(m.group(4))
|
|
||||||
unit = m.group(5).lower()
|
|
||||||
raw = float(low)
|
|
||||||
|
|
||||||
if unit in ("hour", "hr"):
|
|
||||||
minutes: float = raw * 60
|
|
||||||
elif unit in ("second", "sec"):
|
|
||||||
minutes = max(1.0, math.ceil(raw / 60))
|
|
||||||
else:
|
|
||||||
minutes = raw
|
|
||||||
|
|
||||||
return min(int(minutes), _MAX_MINUTES_PER_STEP)
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_passive(text: str) -> bool:
|
|
||||||
return _PASSIVE_RE.search(text) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _passive_default(text: str) -> int | None:
|
|
||||||
"""Return estimated passive minutes from per-keyword defaults."""
|
|
||||||
for pattern, minutes in _PASSIVE_DEFAULTS:
|
|
||||||
if pattern.search(text):
|
|
||||||
return minutes
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _prep_estimate(
|
|
||||||
text: str,
|
|
||||||
step_ing_qtys: dict[str, tuple[float, str]],
|
|
||||||
) -> int:
|
|
||||||
"""Estimate active prep time from the first detected prep action + ingredient qtys.
|
|
||||||
|
|
||||||
If no prep-needing ingredient is identified in the step, uses the action's
|
|
||||||
base time at 1× (no scaling).
|
|
||||||
"""
|
|
||||||
m = _PREP_RE.search(text)
|
|
||||||
if m is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
action = m.group(0).lower()
|
|
||||||
base = _PREP_ACTION_BASES.get(action, _ACTIVE_STEP_DEFAULT_MIN)
|
|
||||||
|
|
||||||
# Find which prep-needing ingredients this step mentions
|
|
||||||
matches: list[tuple[float, str]] = [
|
|
||||||
qty_unit
|
|
||||||
for name, qty_unit in step_ing_qtys.items()
|
|
||||||
if _ingredient_mentioned(text, name)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
return round(base) # no ingredient context — use base unscaled
|
|
||||||
|
|
||||||
total = sum(base * _quantity_scale(qty, unit) for qty, unit in matches)
|
|
||||||
return round(total)
|
|
||||||
|
|
||||||
|
|
||||||
def _effort_label(total_min: int, step_count: int) -> str:
|
|
||||||
"""Effort label based on total estimated time; falls back to step count."""
|
|
||||||
if total_min > 0:
|
|
||||||
if total_min <= 20:
|
|
||||||
return "quick"
|
|
||||||
if total_min <= 45:
|
|
||||||
return "moderate"
|
|
||||||
return "involved"
|
|
||||||
# No time signals at all — fall back to step count heuristic
|
|
||||||
if step_count <= 3:
|
|
||||||
return "quick"
|
|
||||||
if step_count <= 7:
|
|
||||||
return "moderate"
|
|
||||||
return "involved"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def parse_time_effort(
|
|
||||||
directions: list[str],
|
|
||||||
ingredients: list[str] | None = None,
|
|
||||||
ingredient_names: list[str] | None = None,
|
|
||||||
) -> TimeEffortProfile:
|
|
||||||
"""Parse direction strings into a TimeEffortProfile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
directions: List of step strings from the recipe corpus.
|
|
||||||
ingredients: Raw ingredient strings ("2 large onions", "1.5 lbs potatoes").
|
|
||||||
Parallel to ingredient_names.
|
|
||||||
ingredient_names: Normalised ingredient names ("onion", "potato").
|
|
||||||
Required alongside ingredients to enable quantity scaling.
|
|
||||||
|
|
||||||
Returns a zero-value profile with empty lists when directions is empty.
|
|
||||||
Never raises — all failures produce sensible defaults.
|
|
||||||
"""
|
|
||||||
if not directions:
|
|
||||||
return TimeEffortProfile(
|
|
||||||
active_min=0, passive_min=0, total_min=0,
|
|
||||||
step_analyses=[], equipment=[], effort_label="quick",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build per-step ingredient quantity maps (empty dicts if no ingredient data)
|
|
||||||
use_ingredients = (
|
|
||||||
bool(ingredients)
|
|
||||||
and bool(ingredient_names)
|
|
||||||
and len(ingredients) == len(ingredient_names)
|
|
||||||
)
|
|
||||||
step_ing_qtys: list[dict[str, tuple[float, str]]]
|
|
||||||
if use_ingredients:
|
|
||||||
step_ing_qtys = _build_step_ingredient_qtys(
|
|
||||||
list(ingredients), # type: ignore[arg-type]
|
|
||||||
list(ingredient_names), # type: ignore[arg-type]
|
|
||||||
directions,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
step_ing_qtys = [{} for _ in directions]
|
|
||||||
|
|
||||||
step_analyses: list[StepAnalysis] = []
|
|
||||||
active_min = 0
|
|
||||||
passive_min = 0
|
|
||||||
has_any_passive = False
|
|
||||||
|
|
||||||
for i, step in enumerate(directions):
|
|
||||||
is_passive = _classify_passive(step)
|
|
||||||
detected = _extract_minutes(step)
|
|
||||||
prep_estimate: int | None = None
|
|
||||||
|
|
||||||
if is_passive:
|
|
||||||
has_any_passive = True
|
|
||||||
if detected is not None:
|
|
||||||
passive_min += detected
|
|
||||||
else:
|
|
||||||
# Fall back to per-technique default
|
|
||||||
default = _passive_default(step)
|
|
||||||
if default is not None:
|
|
||||||
passive_min += default
|
|
||||||
detected = default # surface in UI as the hint time
|
|
||||||
else:
|
|
||||||
if detected is not None:
|
|
||||||
active_min += detected
|
|
||||||
|
|
||||||
# Estimate prep time from action detection + quantity scaling
|
|
||||||
prep_est = _prep_estimate(step, step_ing_qtys[i])
|
|
||||||
if prep_est > 0:
|
|
||||||
prep_estimate = prep_est
|
|
||||||
active_min += prep_est
|
|
||||||
elif detected is None:
|
|
||||||
# General active step with no time signal — apply a small default
|
|
||||||
active_min += round(_ACTIVE_STEP_DEFAULT_MIN)
|
|
||||||
|
|
||||||
step_analyses.append(StepAnalysis(
|
|
||||||
is_passive=is_passive,
|
|
||||||
detected_minutes=detected,
|
|
||||||
prep_min=prep_estimate,
|
|
||||||
))
|
|
||||||
|
|
||||||
combined_text = " ".join(directions)
|
|
||||||
equipment = _detect_equipment(combined_text, has_any_passive)
|
|
||||||
total = active_min + passive_min
|
|
||||||
|
|
||||||
return TimeEffortProfile(
|
|
||||||
active_min=active_min,
|
|
||||||
passive_min=passive_min,
|
|
||||||
total_min=total,
|
|
||||||
step_analyses=step_analyses,
|
|
||||||
equipment=equipment,
|
|
||||||
effort_label=_effort_label(total, len(directions)),
|
|
||||||
)
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
# app/services/task_inference.py
|
|
||||||
# BSL 1.1 — LLM feature
|
|
||||||
"""Task-based service allocation via the cf-orch coordinator.
|
|
||||||
|
|
||||||
Calls POST /api/inference/task instead of a hardcoded service type.
|
|
||||||
The coordinator resolves model_id and service_type from assignments.yaml.
|
|
||||||
|
|
||||||
Fallback contract (for callers):
|
|
||||||
- 404 → TaskNotRegistered (fall back to direct client.allocate())
|
|
||||||
- other error → RuntimeError
|
|
||||||
- CF_ORCH_URL unset → RuntimeError (guard with os.environ.get first)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from collections.abc import Generator
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskNotRegistered(Exception):
|
|
||||||
"""Coordinator returned 404 for a product/task pair.
|
|
||||||
|
|
||||||
Means the task is not yet in assignments.yaml. Callers should fall
|
|
||||||
back to direct service allocation (client.allocate()).
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Allocation:
|
|
||||||
url: str
|
|
||||||
allocation_id: str
|
|
||||||
service: str
|
|
||||||
|
|
||||||
|
|
||||||
def _orch_url() -> str:
|
|
||||||
return os.environ.get("CF_ORCH_URL", "").rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def task_allocate(
|
|
||||||
product: str,
|
|
||||||
task: str,
|
|
||||||
*,
|
|
||||||
service_hint: str,
|
|
||||||
ttl_s: float = 120.0,
|
|
||||||
) -> Generator[Allocation, None, None]:
|
|
||||||
"""Context manager: allocate a service via task-based routing.
|
|
||||||
|
|
||||||
Calls POST /api/inference/task, yields Allocation, releases on exit.
|
|
||||||
Supports both `with task_allocate(...) as alloc:` and manual
|
|
||||||
`ctx = task_allocate(...); alloc = ctx.__enter__()` patterns.
|
|
||||||
|
|
||||||
**Sync-only**: uses the synchronous httpx API. Do not call from an
|
|
||||||
``async def`` handler without wrapping in ``asyncio.to_thread``. Current
|
|
||||||
call sites (``llm_router.py``, ``vl_model.py``) are synchronous.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
product: CF product name (e.g. "kiwi")
|
|
||||||
task: Task identifier (e.g. "meal_plan", "ocr")
|
|
||||||
service_hint: Service type for the release DELETE call. The
|
|
||||||
coordinator response does not include service_type, so the
|
|
||||||
caller provides it. When the coordinator is updated to return
|
|
||||||
service in the response (cf-orch#63), this becomes unused.
|
|
||||||
ttl_s: Allocation TTL in seconds.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TaskNotRegistered: Coordinator returned 404.
|
|
||||||
RuntimeError: Coordinator unreachable, returned non-404 error, or
|
|
||||||
returned a malformed (non-JSON / missing fields) response.
|
|
||||||
RuntimeError: CF_ORCH_URL is not set.
|
|
||||||
"""
|
|
||||||
base = _orch_url()
|
|
||||||
if not base:
|
|
||||||
raise RuntimeError("CF_ORCH_URL is not set")
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
f"{base}/api/inference/task",
|
|
||||||
json={"product": product, "task": task, "payload": {}},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
except httpx.RequestError as exc:
|
|
||||||
raise RuntimeError(f"cf-orch unreachable: {exc}") from exc
|
|
||||||
|
|
||||||
if resp.status_code == 404:
|
|
||||||
raise TaskNotRegistered(
|
|
||||||
f"No assignment for product={product!r} task={task!r} — "
|
|
||||||
"ensure cf-orch#61/62 are deployed and coordinator reloaded"
|
|
||||||
)
|
|
||||||
if not resp.is_success:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"cf-orch /api/inference/task failed: "
|
|
||||||
f"HTTP {resp.status_code} — {resp.text[:200]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
alloc = Allocation(
|
|
||||||
url=data["url"],
|
|
||||||
allocation_id=data["allocation_id"],
|
|
||||||
service=data.get("service") or service_hint,
|
|
||||||
)
|
|
||||||
except (KeyError, ValueError) as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"cf-orch /api/inference/task returned malformed response: {exc} — "
|
|
||||||
f"body: {resp.text[:200]}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield alloc
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
httpx.delete(
|
|
||||||
f"{base}/api/services/{alloc.service}/allocations/{alloc.allocation_id}",
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-orch task allocation release failed (non-fatal): %s", exc)
|
|
||||||
|
|
@ -22,7 +22,7 @@ from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback", "recipe_llm"})
|
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).
|
||||||
|
|
@ -88,8 +88,6 @@ def run_task(
|
||||||
try:
|
try:
|
||||||
if task_type == "expiry_llm_fallback":
|
if task_type == "expiry_llm_fallback":
|
||||||
_run_expiry_llm_fallback(db_path, job_id, params)
|
_run_expiry_llm_fallback(db_path, job_id, params)
|
||||||
elif task_type == "recipe_llm":
|
|
||||||
_run_recipe_llm(db_path, job_id, params)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
|
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
|
||||||
_update_task_status(db_path, task_id, "completed")
|
_update_task_status(db_path, task_id, "completed")
|
||||||
|
|
@ -145,41 +143,3 @@ def _run_expiry_llm_fallback(
|
||||||
expiry,
|
expiry,
|
||||||
days,
|
days,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_recipe_llm(db_path: Path, _job_id_int: int, params: str | None) -> None:
|
|
||||||
"""Run LLM recipe generation for an async recipe job.
|
|
||||||
|
|
||||||
params JSON keys:
|
|
||||||
job_id (required) — recipe_jobs.job_id string (e.g. "rec_a1b2c3...")
|
|
||||||
|
|
||||||
Creates its own Store — follows same pattern as _suggest_in_thread.
|
|
||||||
MUST call store.fail_recipe_job() before re-raising so recipe_jobs.status
|
|
||||||
doesn't stay 'running' while background_tasks shows 'failed'.
|
|
||||||
"""
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.recipe import RecipeRequest
|
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
|
||||||
|
|
||||||
p = json.loads(params or "{}")
|
|
||||||
recipe_job_id: str = p.get("job_id", "")
|
|
||||||
if not recipe_job_id:
|
|
||||||
raise ValueError("recipe_llm: 'job_id' is required in params")
|
|
||||||
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
store.update_recipe_job_running(recipe_job_id)
|
|
||||||
row = store._fetch_one(
|
|
||||||
"SELECT request FROM recipe_jobs WHERE job_id=?", (recipe_job_id,)
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
raise ValueError(f"recipe_llm: recipe_jobs row not found: {recipe_job_id!r}")
|
|
||||||
req = RecipeRequest.model_validate_json(row["request"])
|
|
||||||
result = RecipeEngine(store).suggest(req)
|
|
||||||
store.complete_recipe_job(recipe_job_id, result.model_dump_json())
|
|
||||||
log.info("recipe_llm: job %s completed (%d suggestion(s))", recipe_job_id, len(result.suggestions))
|
|
||||||
except Exception as exc:
|
|
||||||
store.fail_recipe_job(recipe_job_id, str(exc))
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
# app/tasks/scheduler.py
|
# app/tasks/scheduler.py
|
||||||
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler.
|
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler."""
|
||||||
|
|
||||||
Local mode (CLOUD_MODE unset): LocalScheduler — simple FIFO, no coordinator.
|
|
||||||
Cloud mode (CLOUD_MODE=true): OrchestratedScheduler — coordinator-aware, fans
|
|
||||||
out concurrent jobs across all registered cf-orch GPU nodes.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -12,68 +7,15 @@ from pathlib import Path
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskScheduler,
|
TaskScheduler,
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
reset_scheduler as _reset_local, # re-export for tests
|
reset_scheduler, # re-export for tests
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.cloud_session import CLOUD_MODE
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
||||||
|
|
||||||
|
|
||||||
def _orch_available() -> bool:
|
|
||||||
"""Return True if circuitforge_orch is installed in this environment."""
|
|
||||||
try:
|
|
||||||
import circuitforge_orch # noqa: F401
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _use_orch() -> bool:
|
|
||||||
"""Return True if the OrchestratedScheduler should be used.
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
1. USE_ORCH_SCHEDULER env var — explicit override always wins.
|
|
||||||
2. CLOUD_MODE=true — use orch in managed cloud deployments.
|
|
||||||
3. circuitforge_orch installed — paid+ local users who have cf-orch
|
|
||||||
set up get coordinator-aware scheduling (local GPU first) automatically.
|
|
||||||
"""
|
|
||||||
override = settings.USE_ORCH_SCHEDULER
|
|
||||||
if override is not None:
|
|
||||||
return override
|
|
||||||
return CLOUD_MODE or _orch_available()
|
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler(db_path: Path) -> TaskScheduler:
|
def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
"""Return the process-level TaskScheduler singleton for Kiwi.
|
"""Return the process-level TaskScheduler singleton for Kiwi."""
|
||||||
|
|
||||||
OrchestratedScheduler: coordinator-aware, fans out concurrent jobs across
|
|
||||||
all registered cf-orch GPU nodes. Active when USE_ORCH_SCHEDULER=true,
|
|
||||||
CLOUD_MODE=true, or circuitforge_orch is installed locally (paid+ users
|
|
||||||
running their own cf-orch stack get this automatically; local GPU is
|
|
||||||
preferred by the coordinator's allocation queue).
|
|
||||||
|
|
||||||
LocalScheduler: serial FIFO, no coordinator dependency. Free-tier local
|
|
||||||
installs without circuitforge_orch installed use this automatically.
|
|
||||||
"""
|
|
||||||
if _use_orch():
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.scheduler import get_orch_scheduler
|
|
||||||
except ImportError:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"circuitforge_orch not installed — falling back to LocalScheduler"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return get_orch_scheduler(
|
|
||||||
db_path=db_path,
|
|
||||||
run_task_fn=run_task,
|
|
||||||
task_types=LLM_TASK_TYPES,
|
|
||||||
vram_budgets=VRAM_BUDGETS,
|
|
||||||
coordinator_url=settings.COORDINATOR_URL,
|
|
||||||
service_name="kiwi",
|
|
||||||
)
|
|
||||||
|
|
||||||
return _base_get_scheduler(
|
return _base_get_scheduler(
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
run_task_fn=run_task,
|
run_task_fn=run_task,
|
||||||
|
|
@ -82,15 +24,3 @@ def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
coordinator_url=settings.COORDINATOR_URL,
|
coordinator_url=settings.COORDINATOR_URL,
|
||||||
service_name="kiwi",
|
service_name="kiwi",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def reset_scheduler() -> None:
|
|
||||||
"""Shut down and clear the active scheduler singleton. TEST TEARDOWN ONLY."""
|
|
||||||
if _use_orch():
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.scheduler import reset_orch_scheduler
|
|
||||||
reset_orch_scheduler()
|
|
||||||
return
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
_reset_local()
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"recipe_suggestions",
|
"recipe_suggestions",
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
"recipe_scan",
|
|
||||||
"style_classifier",
|
"style_classifier",
|
||||||
"meal_plan_llm",
|
"meal_plan_llm",
|
||||||
"meal_plan_llm_timing",
|
"meal_plan_llm_timing",
|
||||||
|
|
@ -45,7 +44,6 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
|
|
||||||
# Paid tier
|
# Paid tier
|
||||||
"receipt_ocr": "paid", # BYOK-unlockable
|
"receipt_ocr": "paid", # BYOK-unlockable
|
||||||
"visual_label_capture": "paid", # Camera capture for unenriched barcodes (kiwi#79)
|
|
||||||
"recipe_suggestions": "paid", # BYOK-unlockable
|
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||||
"expiry_llm_matching": "paid", # BYOK-unlockable
|
"expiry_llm_matching": "paid", # BYOK-unlockable
|
||||||
"meal_planning": "free",
|
"meal_planning": "free",
|
||||||
|
|
@ -59,9 +57,6 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||||
|
|
||||||
# Paid tier (continued)
|
|
||||||
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
|
||||||
|
|
||||||
# Premium tier
|
# Premium tier
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
"background_monitoring": "premium",
|
"background_monitoring": "premium",
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,6 @@ services:
|
||||||
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
||||||
# cf-orch: route LLM calls through the coordinator for managed GPU inference
|
# cf-orch: route LLM calls through the coordinator for managed GPU inference
|
||||||
CF_ORCH_URL: http://host.docker.internal:7700
|
CF_ORCH_URL: http://host.docker.internal:7700
|
||||||
# Product identifier for coordinator analytics — per-product VRAM/request breakdown
|
|
||||||
CF_APP_NAME: kiwi
|
|
||||||
# cf-orch streaming proxy — coordinator URL + product key for /proxy/authorize
|
|
||||||
# COORDINATOR_KIWI_KEY must be set in .env (never commit the value)
|
|
||||||
COORDINATOR_URL: http://10.1.10.71:7700
|
|
||||||
COORDINATOR_KIWI_KEY: ${COORDINATOR_KIWI_KEY:-}
|
|
||||||
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
|
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
|
||||||
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
|
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
|
||||||
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
|
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,23 @@ services:
|
||||||
# Docker can follow the symlink inside the container.
|
# Docker can follow the symlink inside the container.
|
||||||
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
||||||
|
|
||||||
# cf-orch agent sidecar removed 2026-04-24: Sif is now a dedicated compute node
|
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
|
||||||
# with its own systemd cf-orch-agent service (port 7703, advertise-host 10.1.10.158).
|
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
||||||
# This sidecar was only valid when Kiwi ran on Sif directly.
|
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.
|
||||||
|
cf-orch-agent:
|
||||||
|
image: kiwi-api # reuse local api image — cf-core already installed there
|
||||||
|
network_mode: host
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Override coordinator URL here or via .env
|
||||||
|
COORDINATOR_URL: ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||||
|
command: >
|
||||||
|
conda run -n kiwi cf-orch agent
|
||||||
|
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||||
|
--node-id kiwi
|
||||||
|
--host 0.0.0.0
|
||||||
|
--port 7702
|
||||||
|
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# Kiwi — LLM backend configuration
|
|
||||||
#
|
|
||||||
# Copy to ~/.config/circuitforge/llm.yaml (shared across all CF products)
|
|
||||||
# or to config/llm.yaml (Kiwi-local, takes precedence).
|
|
||||||
#
|
|
||||||
# Kiwi uses LLMs for:
|
|
||||||
# - Expiry prediction fallback (unknown products not in the lookup table)
|
|
||||||
# - Meal planning suggestions
|
|
||||||
#
|
|
||||||
# Local inference (Ollama / vLLM) is the default path — no API key required.
|
|
||||||
# BYOK (bring your own key): set api_key_env to point at your API key env var.
|
|
||||||
# cf-orch trunk: set CF_ORCH_URL env var to allocate cf-text on-demand via
|
|
||||||
# the coordinator instead of hitting a static URL.
|
|
||||||
|
|
||||||
backends:
|
|
||||||
ollama:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: true
|
|
||||||
base_url: http://localhost:11434/v1
|
|
||||||
model: llama3.2:3b
|
|
||||||
api_key: ollama
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
vllm:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8000/v1
|
|
||||||
model: __auto__ # resolved from /v1/models at runtime
|
|
||||||
api_key: ''
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
# ── cf-orch trunk services ──────────────────────────────────────────────────
|
|
||||||
# These allocate via cf-orch rather than connecting to a static URL.
|
|
||||||
# cf-orch starts the service on-demand and returns its live URL.
|
|
||||||
# Set CF_ORCH_URL env var or fill in url below; leave enabled: false if
|
|
||||||
# cf-orch is not deployed in your environment.
|
|
||||||
|
|
||||||
cf_text:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8008/v1 # fallback when cf-orch is not available
|
|
||||||
model: __auto__
|
|
||||||
api_key: any
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
service: cf-text
|
|
||||||
# model_candidates: leave empty to use the service's default_model,
|
|
||||||
# or specify a catalog alias (e.g. "qwen2.5-3b").
|
|
||||||
model_candidates: []
|
|
||||||
ttl_s: 3600
|
|
||||||
|
|
||||||
# ── Cloud / BYOK ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
anthropic:
|
|
||||||
type: anthropic
|
|
||||||
enabled: false
|
|
||||||
model: claude-haiku-4-5-20251001
|
|
||||||
api_key_env: ANTHROPIC_API_KEY
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
openai:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: https://api.openai.com/v1
|
|
||||||
model: gpt-4o-mini
|
|
||||||
api_key_env: OPENAI_API_KEY
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
fallback_order:
|
|
||||||
- cf_text
|
|
||||||
- ollama
|
|
||||||
- vllm
|
|
||||||
- anthropic
|
|
||||||
- openai
|
|
||||||
|
|
@ -8,7 +8,7 @@ server {
|
||||||
# Proxy API requests to the FastAPI container via Docker bridge network.
|
# Proxy API requests to the FastAPI container via Docker bridge network.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8512;
|
proxy_pass http://api:8512;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $host;
|
||||||
# Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr
|
# Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr
|
||||||
# when accessed directly on LAN without Caddy in the path.
|
# when accessed directly on LAN without Caddy in the path.
|
||||||
proxy_set_header X-Real-IP $http_x_real_ip;
|
proxy_set_header X-Real-IP $http_x_real_ip;
|
||||||
|
|
@ -18,28 +18,6 @@ server {
|
||||||
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).
|
# Allow image uploads (barcode/receipt photos from phone cameras).
|
||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
# LLM inference (recipe suggestions, expiry fallback) can take 60-120s.
|
|
||||||
# Default proxy_read_timeout is 60s which causes 504s on full recipe generation.
|
|
||||||
proxy_read_timeout 180s;
|
|
||||||
proxy_send_timeout 180s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
|
|
||||||
# builds API calls as /kiwi/api/v1/... — proxy these to the API container.
|
|
||||||
# Through Caddy the /kiwi prefix is stripped before reaching nginx, so this block
|
|
||||||
# is only active for direct-port access without Caddy in the path.
|
|
||||||
# Longer prefix (/kiwi/api/ = 10 chars) beats ^~/kiwi/ (6 chars) per nginx rules.
|
|
||||||
location /kiwi/api/ {
|
|
||||||
rewrite ^/kiwi(/api/.*)$ $1 break;
|
|
||||||
proxy_pass http://api:8512;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Real-IP $http_x_real_ip;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
|
||||||
client_max_body_size 20m;
|
|
||||||
proxy_read_timeout 180s;
|
|
||||||
proxy_send_timeout 180s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,8 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#e8a820" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Kiwi" />
|
|
||||||
<title>Kiwi — Pantry Tracker</title>
|
<title>Kiwi — Pantry Tracker</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|
|
||||||
4684
frontend/package-lock.json
generated
4684
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,6 @@
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
|
||||||
"vue-tsc": "^3.1.0"
|
"vue-tsc": "^3.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
|
|
@ -88,22 +88,22 @@
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div v-if="mountedTabs.has('inventory')" v-show="currentTab === 'inventory'" class="tab-content fade-in">
|
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
|
||||||
<InventoryList />
|
<InventoryList />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('receipts')" v-show="currentTab === 'receipts'" class="tab-content fade-in">
|
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
|
||||||
<ReceiptsView />
|
<ReceiptsView />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
|
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
|
||||||
<RecipesView />
|
<RecipesView />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('settings')" v-show="currentTab === 'settings'" class="tab-content fade-in">
|
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('mealplan')" v-show="currentTab === 'mealplan'" class="tab-content">
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
<MealPlanView />
|
<MealPlanView />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('shopping')" v-show="currentTab === 'shopping'" class="tab-content fade-in">
|
<div v-show="currentTab === 'shopping'" class="tab-content fade-in">
|
||||||
<ShoppingView />
|
<ShoppingView />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,7 +204,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, onMounted } 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 RecipesView from './components/RecipesView.vue'
|
||||||
|
|
@ -220,10 +220,6 @@ type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' | 'sho
|
||||||
|
|
||||||
const currentTab = ref<Tab>('recipes')
|
const currentTab = ref<Tab>('recipes')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
// Lazy-mount: tabs mount on first visit and stay mounted (KeepAlive-like behaviour).
|
|
||||||
// Only 'recipes' is in the initial set so non-active tabs don't mount simultaneously
|
|
||||||
// on page load — eliminates concurrent onMounted calls across all tab components.
|
|
||||||
const mountedTabs = reactive(new Set<Tab>(['recipes']))
|
|
||||||
const inventoryStore = useInventoryStore()
|
const inventoryStore = useInventoryStore()
|
||||||
const { kiwiVisible, kiwiDirection } = useEasterEggs()
|
const { kiwiVisible, kiwiDirection } = useEasterEggs()
|
||||||
|
|
||||||
|
|
@ -243,7 +239,6 @@ function onWordmarkClick() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchTab(tab: Tab) {
|
async function switchTab(tab: Tab) {
|
||||||
mountedTabs.add(tab)
|
|
||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
||||||
await inventoryStore.fetchItems()
|
await inventoryStore.fetchItems()
|
||||||
|
|
|
||||||
|
|
@ -138,103 +138,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Label Capture Panel (paid tier — appears after gap detection) -->
|
|
||||||
<div v-if="capturePhase !== null" class="label-capture-panel">
|
|
||||||
|
|
||||||
<!-- Offer phase -->
|
|
||||||
<div v-if="capturePhase === 'offer'" class="capture-offer">
|
|
||||||
<p class="capture-offer-text">We couldn't find this product. Photograph the nutrition label to add it.</p>
|
|
||||||
<div class="capture-offer-actions">
|
|
||||||
<button class="btn btn-primary" type="button" @click="triggerCaptureLabelInput">
|
|
||||||
Capture label
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost" type="button" @click="dismissCapture">
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref="captureFileInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
style="display: none"
|
|
||||||
@change="handleLabelPhotoSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Uploading / processing phase -->
|
|
||||||
<div v-else-if="capturePhase === 'uploading'" class="capture-processing">
|
|
||||||
<div class="loading-inline">
|
|
||||||
<div class="spinner spinner-sm"></div>
|
|
||||||
<span>Reading the label…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Review phase -->
|
|
||||||
<div v-else-if="capturePhase === 'reviewing' && captureExtraction" class="capture-review">
|
|
||||||
<p class="capture-review-note">
|
|
||||||
Check the details below.
|
|
||||||
<span v-if="captureExtraction.needs_review" class="capture-review-low-conf">
|
|
||||||
Fields highlighted in amber weren't fully legible — please verify them.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Product name</label>
|
|
||||||
<input v-model="captureReview.product_name" type="text" class="form-input" placeholder="Product name" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Brand</label>
|
|
||||||
<input v-model="captureReview.brand" type="text" class="form-input" placeholder="Brand (optional)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="form-section-label">Nutrition per serving</p>
|
|
||||||
<div class="capture-nutrition-grid">
|
|
||||||
<div
|
|
||||||
v-for="field in captureNutritionFields"
|
|
||||||
:key="field.key"
|
|
||||||
class="form-group"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
:class="['form-label', { 'capture-field-amber': captureExtraction.needs_review && captureExtraction[field.src as keyof typeof captureExtraction] == null }]"
|
|
||||||
>{{ field.label }}</label>
|
|
||||||
<input
|
|
||||||
v-model="captureReview[field.key as keyof typeof captureReview]"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
class="form-input"
|
|
||||||
:placeholder="field.unit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: var(--spacing-sm)">
|
|
||||||
<label class="form-label">Ingredients (comma-separated)</label>
|
|
||||||
<input v-model="captureReview.ingredients" type="text" class="form-input" placeholder="flour, water, salt…" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Allergens (comma-separated)</label>
|
|
||||||
<input v-model="captureReview.allergens" type="text" class="form-input" placeholder="wheat, milk…" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="capture-review-actions">
|
|
||||||
<button class="btn btn-primary" type="button" :disabled="captureLoading" @click="confirmCapture">
|
|
||||||
<span v-if="captureLoading"><div class="spinner spinner-sm"></div></span>
|
|
||||||
<span v-else>Looks good — save</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost" type="button" @click="capturePhase = 'offer'">
|
|
||||||
Retake photo
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost" type="button" @click="dismissCapture">
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Camera Scan Panel -->
|
<!-- Camera Scan Panel -->
|
||||||
<div v-if="scanMode === 'camera'" class="scan-panel">
|
<div v-if="scanMode === 'camera'" class="scan-panel">
|
||||||
<div class="upload-area" @click="triggerBarcodeInput">
|
<div class="upload-area" @click="triggerBarcodeInput">
|
||||||
|
|
@ -719,7 +622,7 @@ import { storeToRefs } from 'pinia'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { inventoryAPI } from '../services/api'
|
import { inventoryAPI } from '../services/api'
|
||||||
import type { InventoryItem, LabelCaptureResult } from '../services/api'
|
import type { InventoryItem } from '../services/api'
|
||||||
import { formatQuantity } from '../utils/units'
|
import { formatQuantity } from '../utils/units'
|
||||||
import EditItemModal from './EditItemModal.vue'
|
import EditItemModal from './EditItemModal.vue'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
|
@ -781,16 +684,6 @@ function daysLabel(dateStr: string): string {
|
||||||
const scanMode = ref<'gun' | 'camera' | 'manual'>('gun')
|
const scanMode = ref<'gun' | 'camera' | 'manual'>('gun')
|
||||||
|
|
||||||
// Options for button groups
|
// Options for button groups
|
||||||
// Label capture nutrition field descriptors used in the review form
|
|
||||||
const captureNutritionFields = [
|
|
||||||
{ key: 'calories', src: 'calories', label: 'Calories', unit: 'kcal' },
|
|
||||||
{ key: 'fat_g', src: 'fat_g', label: 'Total fat', unit: 'g' },
|
|
||||||
{ key: 'saturated_fat_g', src: 'saturated_fat_g', label: 'Saturated fat', unit: 'g' },
|
|
||||||
{ key: 'carbs_g', src: 'carbs_g', label: 'Carbs', unit: 'g' },
|
|
||||||
{ key: 'protein_g', src: 'protein_g', label: 'Protein', unit: 'g' },
|
|
||||||
{ key: 'sodium_mg', src: 'sodium_mg', label: 'Sodium', unit: 'mg' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const locations = [
|
const locations = [
|
||||||
{ value: 'fridge', label: 'Fridge', icon: '🧊' },
|
{ value: 'fridge', label: 'Fridge', icon: '🧊' },
|
||||||
{ value: 'freezer', label: 'Freezer', icon: '❄️' },
|
{ value: 'freezer', label: 'Freezer', icon: '❄️' },
|
||||||
|
|
@ -887,29 +780,6 @@ const barcodeQuantity = ref(1)
|
||||||
const barcodeLoading = ref(false)
|
const barcodeLoading = ref(false)
|
||||||
const barcodeResults = ref<Array<{ type: string; message: string }>>([])
|
const barcodeResults = ref<Array<{ type: string; message: string }>>([])
|
||||||
|
|
||||||
// Label Capture Flow (kiwi#79)
|
|
||||||
type CapturePhase = 'offer' | 'uploading' | 'reviewing' | null
|
|
||||||
const capturePhase = ref<CapturePhase>(null)
|
|
||||||
const captureBarcode = ref('')
|
|
||||||
const captureLocation = ref('pantry')
|
|
||||||
const captureQuantity = ref(1)
|
|
||||||
const captureLoading = ref(false)
|
|
||||||
const captureFileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const captureExtraction = ref<LabelCaptureResult | null>(null)
|
|
||||||
// Editable review form — populated from extraction, user may correct fields
|
|
||||||
const captureReview = ref({
|
|
||||||
product_name: '',
|
|
||||||
brand: '',
|
|
||||||
calories: '' as string,
|
|
||||||
fat_g: '' as string,
|
|
||||||
saturated_fat_g: '' as string,
|
|
||||||
carbs_g: '' as string,
|
|
||||||
protein_g: '' as string,
|
|
||||||
sodium_mg: '' as string,
|
|
||||||
ingredients: '',
|
|
||||||
allergens: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Manual Form
|
// Manual Form
|
||||||
const manualForm = ref({
|
const manualForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -1065,15 +935,6 @@ async function handleScannerGunInput() {
|
||||||
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
|
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
|
||||||
})
|
})
|
||||||
await refreshItems()
|
await refreshItems()
|
||||||
} else if (item?.needs_visual_capture) {
|
|
||||||
captureBarcode.value = barcode
|
|
||||||
captureLocation.value = scannerLocation.value
|
|
||||||
captureQuantity.value = scannerQuantity.value
|
|
||||||
capturePhase.value = 'offer'
|
|
||||||
scannerResults.value.push({
|
|
||||||
type: 'info',
|
|
||||||
message: item.message,
|
|
||||||
})
|
|
||||||
} else if (item?.needs_manual_entry) {
|
} else if (item?.needs_manual_entry) {
|
||||||
// Barcode not found in any database — guide user to manual entry
|
// Barcode not found in any database — guide user to manual entry
|
||||||
scannerResults.value.push({
|
scannerResults.value.push({
|
||||||
|
|
@ -1146,88 +1007,6 @@ async function handleBarcodeImageSelect(e: Event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label Capture Functions
|
|
||||||
|
|
||||||
function triggerCaptureLabelInput() {
|
|
||||||
captureFileInput.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissCapture() {
|
|
||||||
capturePhase.value = null
|
|
||||||
captureBarcode.value = ''
|
|
||||||
captureExtraction.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLabelPhotoSelect(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement
|
|
||||||
const file = target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
captureLoading.value = true
|
|
||||||
capturePhase.value = 'uploading'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await inventoryAPI.captureLabelPhoto(file, captureBarcode.value)
|
|
||||||
captureExtraction.value = result
|
|
||||||
// Pre-populate the review form with extracted values
|
|
||||||
captureReview.value = {
|
|
||||||
product_name: result.product_name || '',
|
|
||||||
brand: result.brand || '',
|
|
||||||
calories: result.calories != null ? String(result.calories) : '',
|
|
||||||
fat_g: result.fat_g != null ? String(result.fat_g) : '',
|
|
||||||
saturated_fat_g: result.saturated_fat_g != null ? String(result.saturated_fat_g) : '',
|
|
||||||
carbs_g: result.carbs_g != null ? String(result.carbs_g) : '',
|
|
||||||
protein_g: result.protein_g != null ? String(result.protein_g) : '',
|
|
||||||
sodium_mg: result.sodium_mg != null ? String(result.sodium_mg) : '',
|
|
||||||
ingredients: (result.ingredient_names || []).join(', '),
|
|
||||||
allergens: (result.allergens || []).join(', '),
|
|
||||||
}
|
|
||||||
capturePhase.value = 'reviewing'
|
|
||||||
} catch {
|
|
||||||
showToast('Could not read the label. Please try again or add manually.', 'error')
|
|
||||||
capturePhase.value = 'offer'
|
|
||||||
} finally {
|
|
||||||
captureLoading.value = false
|
|
||||||
if (target) target.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmCapture() {
|
|
||||||
if (!captureBarcode.value) return
|
|
||||||
|
|
||||||
captureLoading.value = true
|
|
||||||
try {
|
|
||||||
const toNum = (s: string) => s ? parseFloat(s) || null : null
|
|
||||||
const toList = (s: string) => s.split(',').map(x => x.trim()).filter(Boolean)
|
|
||||||
|
|
||||||
await inventoryAPI.confirmLabelCapture({
|
|
||||||
barcode: captureBarcode.value,
|
|
||||||
product_name: captureReview.value.product_name || null,
|
|
||||||
brand: captureReview.value.brand || null,
|
|
||||||
calories: toNum(captureReview.value.calories),
|
|
||||||
fat_g: toNum(captureReview.value.fat_g),
|
|
||||||
saturated_fat_g: toNum(captureReview.value.saturated_fat_g),
|
|
||||||
carbs_g: toNum(captureReview.value.carbs_g),
|
|
||||||
protein_g: toNum(captureReview.value.protein_g),
|
|
||||||
sodium_mg: toNum(captureReview.value.sodium_mg),
|
|
||||||
ingredient_names: toList(captureReview.value.ingredients),
|
|
||||||
allergens: toList(captureReview.value.allergens),
|
|
||||||
confidence: captureExtraction.value?.confidence ?? 0,
|
|
||||||
location: captureLocation.value,
|
|
||||||
quantity: captureQuantity.value,
|
|
||||||
auto_add: true,
|
|
||||||
})
|
|
||||||
const name = captureReview.value.product_name || 'item'
|
|
||||||
showToast(`${name} saved and added to ${captureLocation.value}`, 'success')
|
|
||||||
await refreshItems()
|
|
||||||
dismissCapture()
|
|
||||||
} catch {
|
|
||||||
showToast('Could not save. Please try again.', 'error')
|
|
||||||
} finally {
|
|
||||||
captureLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manual Add Functions
|
// Manual Add Functions
|
||||||
async function addManualItem() {
|
async function addManualItem() {
|
||||||
const { name, brand, quantity, unit, location, expirationDate } = manualForm.value
|
const { name, brand, quantity, unit, location, expirationDate } = manualForm.value
|
||||||
|
|
@ -1835,79 +1614,6 @@ function getItemClass(item: InventoryItem): string {
|
||||||
border: 1px solid var(--color-warning-border, #fcd34d);
|
border: 1px solid var(--color-warning-border, #fcd34d);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LABEL CAPTURE FLOW (kiwi#79)
|
|
||||||
============================================ */
|
|
||||||
.label-capture-panel {
|
|
||||||
margin: var(--spacing-md) 0;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-offer-text {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 0 0 var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-offer-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-processing {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--spacing-md) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-review-note {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 0 0 var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-review-low-conf {
|
|
||||||
color: var(--color-amber, #d97706);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
display: block;
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section-label {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin: var(--spacing-md) 0 var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-nutrition-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Amber highlight for unread/low-confidence label fields */
|
|
||||||
.capture-field-amber {
|
|
||||||
color: var(--color-amber, #d97706);
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-field-amber + input {
|
|
||||||
border-color: var(--color-amber, #d97706);
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture-review-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
EXPORT CARD
|
EXPORT CARD
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
|
|
@ -106,39 +106,6 @@
|
||||||
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
|
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Similarity check results -->
|
|
||||||
<div
|
|
||||||
v-if="similarPosts.length > 0"
|
|
||||||
class="similar-panel"
|
|
||||||
role="region"
|
|
||||||
aria-label="Similar stories found"
|
|
||||||
>
|
|
||||||
<p class="similar-heading text-sm">
|
|
||||||
<strong>Similar stories already exist.</strong>
|
|
||||||
You can publish as-is, mark yours as a variation, or cancel.
|
|
||||||
</p>
|
|
||||||
<ul class="similar-list" aria-label="Existing similar posts">
|
|
||||||
<li
|
|
||||||
v-for="hit in similarPosts"
|
|
||||||
:key="hit.slug"
|
|
||||||
class="similar-item"
|
|
||||||
>
|
|
||||||
<span class="similar-tier-badge" :class="`tier-${hit.similarity_tier}`">
|
|
||||||
{{ tierLabel(hit.similarity_tier) }}
|
|
||||||
</span>
|
|
||||||
<span class="similar-title">{{ hit.title }}</span>
|
|
||||||
<span class="similar-by text-muted text-xs">by {{ hit.pseudonym }}</span>
|
|
||||||
<button
|
|
||||||
class="btn-link text-xs"
|
|
||||||
:class="{ 'selected-ref': selectedRef === hit.slug }"
|
|
||||||
@click="toggleRef(hit.slug)"
|
|
||||||
>
|
|
||||||
{{ selectedRef === hit.slug ? 'Unmark variation' : 'Mark as variation' }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submission feedback (aria-live region, always rendered) -->
|
<!-- Submission feedback (aria-live region, always rendered) -->
|
||||||
<div
|
<div
|
||||||
class="feedback-region"
|
class="feedback-region"
|
||||||
|
|
@ -152,24 +119,13 @@
|
||||||
<!-- Footer actions -->
|
<!-- Footer actions -->
|
||||||
<div class="modal-footer flex gap-sm">
|
<div class="modal-footer flex gap-sm">
|
||||||
<button
|
<button
|
||||||
v-if="!similarPosts.length || similarChecked"
|
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="submitting || !title.trim()"
|
:disabled="submitting || !title.trim()"
|
||||||
:aria-busy="submitting"
|
:aria-busy="submitting"
|
||||||
@click="onSubmit"
|
@click="onSubmit"
|
||||||
>
|
>
|
||||||
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
|
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
|
||||||
{{ submitting ? 'Publishing...' : (selectedRef ? 'Publish as variation' : 'Publish') }}
|
{{ submitting ? 'Publishing...' : 'Publish' }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="checking || !title.trim()"
|
|
||||||
:aria-busy="checking"
|
|
||||||
@click="onCheckThenSubmit"
|
|
||||||
>
|
|
||||||
<span v-if="checking" class="spinner spinner-sm" aria-hidden="true"></span>
|
|
||||||
{{ checking ? 'Checking...' : 'Publish' }}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" @click="$emit('close')">
|
<button class="btn btn-secondary" @click="$emit('close')">
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -183,7 +139,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useCommunityStore } from '../stores/community'
|
import { useCommunityStore } from '../stores/community'
|
||||||
import type { PublishPayload, SimilarPost, SimilarityTier } from '../stores/community'
|
import type { PublishPayload } from '../stores/community'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
recipeId: number | null
|
recipeId: number | null
|
||||||
|
|
@ -206,21 +162,6 @@ const submitting = ref(false)
|
||||||
const submitError = ref<string | null>(null)
|
const submitError = ref<string | null>(null)
|
||||||
const submitSuccess = ref<string | null>(null)
|
const submitSuccess = ref<string | null>(null)
|
||||||
|
|
||||||
const checking = ref(false)
|
|
||||||
const similarChecked = ref(false)
|
|
||||||
const similarPosts = ref<SimilarPost[]>([])
|
|
||||||
const selectedRef = ref<string | null>(null)
|
|
||||||
|
|
||||||
function tierLabel(tier: SimilarityTier): string {
|
|
||||||
if (tier === 'exact_recipe') return 'Same recipe'
|
|
||||||
if (tier === 'very_similar') return 'Very similar'
|
|
||||||
return 'Similar'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRef(slug: string) {
|
|
||||||
selectedRef.value = selectedRef.value === slug ? null : slug
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialogRef = ref<HTMLElement | null>(null)
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
const firstFocusRef = ref<HTMLButtonElement | null>(null)
|
const firstFocusRef = ref<HTMLButtonElement | null>(null)
|
||||||
let previousFocus: HTMLElement | null = null
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
@ -274,17 +215,6 @@ onUnmounted(() => {
|
||||||
previousFocus?.focus()
|
previousFocus?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onCheckThenSubmit() {
|
|
||||||
if (!title.value.trim()) return
|
|
||||||
checking.value = true
|
|
||||||
similarPosts.value = await store.checkSimilar(title.value.trim(), props.recipeId, postType.value)
|
|
||||||
similarChecked.value = true
|
|
||||||
checking.value = false
|
|
||||||
if (!similarPosts.value.length) {
|
|
||||||
await onSubmit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
submitSuccess.value = null
|
submitSuccess.value = null
|
||||||
|
|
@ -298,7 +228,6 @@ async function onSubmit() {
|
||||||
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
|
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
|
||||||
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
|
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
|
||||||
if (props.recipeId != null) payload.recipe_id = props.recipeId
|
if (props.recipeId != null) payload.recipe_id = props.recipeId
|
||||||
if (selectedRef.value) payload.similar_to_ref = selectedRef.value
|
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -420,82 +349,6 @@ async function onSubmit() {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-panel {
|
|
||||||
background: var(--color-surface-alt, var(--color-surface));
|
|
||||||
border: 1px solid var(--color-warning, #f59e0b);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-heading {
|
|
||||||
margin: 0 0 var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-tier-badge {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-exact_recipe {
|
|
||||||
background: var(--color-error-bg, #fee2e2);
|
|
||||||
color: var(--color-error, #dc2626);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-very_similar {
|
|
||||||
background: var(--color-warning-bg, #fef3c7);
|
|
||||||
color: var(--color-warning-text, #92400e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-somewhat_similar {
|
|
||||||
background: var(--color-surface-alt, #f3f4f6);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-by {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link.selected-ref {
|
|
||||||
color: var(--color-success);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
|
|
|
||||||
|
|
@ -78,39 +78,6 @@
|
||||||
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
|
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Similarity check results (shown before final confirm) -->
|
|
||||||
<div
|
|
||||||
v-if="similarPosts.length > 0"
|
|
||||||
class="similar-panel"
|
|
||||||
role="region"
|
|
||||||
aria-label="Similar posts found"
|
|
||||||
>
|
|
||||||
<p class="similar-heading text-sm">
|
|
||||||
<strong>Similar plans already exist.</strong>
|
|
||||||
You can publish as-is, mark yours as a variation, or cancel.
|
|
||||||
</p>
|
|
||||||
<ul class="similar-list" aria-label="Existing similar posts">
|
|
||||||
<li
|
|
||||||
v-for="hit in similarPosts"
|
|
||||||
:key="hit.slug"
|
|
||||||
class="similar-item"
|
|
||||||
>
|
|
||||||
<span class="similar-tier-badge" :class="`tier-${hit.similarity_tier}`">
|
|
||||||
{{ tierLabel(hit.similarity_tier) }}
|
|
||||||
</span>
|
|
||||||
<span class="similar-title">{{ hit.title }}</span>
|
|
||||||
<span class="similar-by text-muted text-xs">by {{ hit.pseudonym }}</span>
|
|
||||||
<button
|
|
||||||
class="btn-link text-xs"
|
|
||||||
:class="{ 'selected-ref': selectedRef === hit.slug }"
|
|
||||||
@click="toggleRef(hit.slug)"
|
|
||||||
>
|
|
||||||
{{ selectedRef === hit.slug ? 'Unmark variation' : 'Mark as variation' }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submission feedback (aria-live region, always rendered) -->
|
<!-- Submission feedback (aria-live region, always rendered) -->
|
||||||
<div
|
<div
|
||||||
class="feedback-region"
|
class="feedback-region"
|
||||||
|
|
@ -124,24 +91,13 @@
|
||||||
<!-- Footer actions -->
|
<!-- Footer actions -->
|
||||||
<div class="modal-footer flex gap-sm">
|
<div class="modal-footer flex gap-sm">
|
||||||
<button
|
<button
|
||||||
v-if="!similarPosts.length || similarChecked"
|
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="submitting || !title.trim()"
|
:disabled="submitting || !title.trim()"
|
||||||
:aria-busy="submitting"
|
:aria-busy="submitting"
|
||||||
@click="onSubmit"
|
@click="onSubmit"
|
||||||
>
|
>
|
||||||
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
|
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
|
||||||
{{ submitting ? 'Publishing...' : (selectedRef ? 'Publish as variation' : 'Publish') }}
|
{{ submitting ? 'Publishing...' : 'Publish' }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="checking || !title.trim()"
|
|
||||||
:aria-busy="checking"
|
|
||||||
@click="onCheckThenSubmit"
|
|
||||||
>
|
|
||||||
<span v-if="checking" class="spinner spinner-sm" aria-hidden="true"></span>
|
|
||||||
{{ checking ? 'Checking...' : 'Publish' }}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" @click="$emit('close')">
|
<button class="btn btn-secondary" @click="$emit('close')">
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -155,7 +111,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useCommunityStore } from '../stores/community'
|
import { useCommunityStore } from '../stores/community'
|
||||||
import type { PublishPayload, SimilarPost, SimilarityTier } from '../stores/community'
|
import type { PublishPayload } from '../stores/community'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
plan?: {
|
plan?: {
|
||||||
|
|
@ -180,21 +136,6 @@ const submitting = ref(false)
|
||||||
const submitError = ref<string | null>(null)
|
const submitError = ref<string | null>(null)
|
||||||
const submitSuccess = ref<string | null>(null)
|
const submitSuccess = ref<string | null>(null)
|
||||||
|
|
||||||
const checking = ref(false)
|
|
||||||
const similarChecked = ref(false)
|
|
||||||
const similarPosts = ref<SimilarPost[]>([])
|
|
||||||
const selectedRef = ref<string | null>(null)
|
|
||||||
|
|
||||||
function tierLabel(tier: SimilarityTier): string {
|
|
||||||
if (tier === 'exact_recipe') return 'Same recipe'
|
|
||||||
if (tier === 'very_similar') return 'Very similar'
|
|
||||||
return 'Similar'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRef(slug: string) {
|
|
||||||
selectedRef.value = selectedRef.value === slug ? null : slug
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialogRef = ref<HTMLElement | null>(null)
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
const firstFocusRef = ref<HTMLInputElement | null>(null)
|
const firstFocusRef = ref<HTMLInputElement | null>(null)
|
||||||
let previousFocus: HTMLElement | null = null
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
@ -248,19 +189,6 @@ onUnmounted(() => {
|
||||||
previousFocus?.focus()
|
previousFocus?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onCheckThenSubmit() {
|
|
||||||
if (!title.value.trim()) return
|
|
||||||
checking.value = true
|
|
||||||
const planRecipeIds = props.plan?.slots?.map((s) => s.recipe_id) ?? []
|
|
||||||
const firstRecipeId = planRecipeIds[0] ?? null
|
|
||||||
similarPosts.value = await store.checkSimilar(title.value.trim(), firstRecipeId, 'plan')
|
|
||||||
similarChecked.value = true
|
|
||||||
checking.value = false
|
|
||||||
if (!similarPosts.value.length) {
|
|
||||||
await onSubmit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
submitSuccess.value = null
|
submitSuccess.value = null
|
||||||
|
|
@ -277,7 +205,6 @@ async function onSubmit() {
|
||||||
if (props.plan?.slots?.length) {
|
if (props.plan?.slots?.length) {
|
||||||
payload.slots = props.plan.slots.map(({ day, meal_type, recipe_id }) => ({ day, meal_type, recipe_id }))
|
payload.slots = props.plan.slots.map(({ day, meal_type, recipe_id }) => ({ day, meal_type, recipe_id }))
|
||||||
}
|
}
|
||||||
if (selectedRef.value) payload.similar_to_ref = selectedRef.value
|
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -368,82 +295,6 @@ async function onSubmit() {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-panel {
|
|
||||||
background: var(--color-surface-alt, var(--color-surface));
|
|
||||||
border: 1px solid var(--color-warning, #f59e0b);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-heading {
|
|
||||||
margin: 0 0 var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-tier-badge {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-exact_recipe {
|
|
||||||
background: var(--color-error-bg, #fee2e2);
|
|
||||||
color: var(--color-error, #dc2626);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-very_similar {
|
|
||||||
background: var(--color-warning-bg, #fef3c7);
|
|
||||||
color: var(--color-warning-text, #92400e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tier-somewhat_similar {
|
|
||||||
background: var(--color-surface-alt, #f3f4f6);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.similar-by {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link.selected-ref {
|
|
||||||
color: var(--color-success);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,7 @@ async function uploadFile(file: File) {
|
||||||
|
|
||||||
async function loadReceipts() {
|
async function loadReceipts() {
|
||||||
try {
|
try {
|
||||||
const raw = await receiptsAPI.listReceipts()
|
const data = await receiptsAPI.listReceipts()
|
||||||
const data = Array.isArray(raw) ? raw : []
|
|
||||||
// Fetch OCR data for each receipt
|
// Fetch OCR data for each receipt
|
||||||
receipts.value = await Promise.all(
|
receipts.value = await Promise.all(
|
||||||
data.map(async (receipt: any) => {
|
data.map(async (receipt: any) => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
v-for="domain in domains"
|
v-for="domain in domains"
|
||||||
:key="domain.id"
|
:key="domain.id"
|
||||||
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
|
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
:aria-pressed="activeDomain === domain.id"
|
|
||||||
@click="selectDomain(domain.id)"
|
@click="selectDomain(domain.id)"
|
||||||
>
|
>
|
||||||
{{ domain.label }}
|
{{ domain.label }}
|
||||||
|
|
@ -16,30 +15,16 @@
|
||||||
<div v-if="loadingDomains" class="text-secondary text-sm">Loading…</div>
|
<div v-if="loadingDomains" class="text-secondary text-sm">Loading…</div>
|
||||||
|
|
||||||
<div v-else-if="activeDomain" class="browser-body">
|
<div v-else-if="activeDomain" class="browser-body">
|
||||||
<!-- Corpus unavailable notice — shown when all category counts are 0 -->
|
|
||||||
<div v-if="allCountsZero" class="browser-unavailable card p-md text-secondary text-sm">
|
|
||||||
Recipe library is not available on this instance yet. Browse categories will appear once the recipe corpus is loaded.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category list + Surprise Me -->
|
<!-- Category list + Surprise Me -->
|
||||||
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
|
<div class="category-list mb-md flex flex-wrap gap-xs">
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
|
|
||||||
:aria-pressed="activeCategory === '_all'"
|
|
||||||
@click="selectCategory('_all')"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat.category"
|
:key="cat.category"
|
||||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
|
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
|
||||||
:aria-pressed="activeCategory === cat.category"
|
|
||||||
@click="selectCategory(cat.category)"
|
@click="selectCategory(cat.category)"
|
||||||
>
|
>
|
||||||
{{ cat.category }}
|
{{ cat.category }}
|
||||||
<span class="cat-count">{{ cat.recipe_count }}</span>
|
<span class="cat-count">{{ cat.recipe_count }}</span>
|
||||||
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories">›</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="categories.length > 1"
|
v-if="categories.length > 1"
|
||||||
|
|
@ -51,132 +36,26 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subcategory row — shown when the active category has subcategories -->
|
|
||||||
<div
|
|
||||||
v-if="activeCategoryHasSubs && (subcategories.length > 0 || loadingSubcategories)"
|
|
||||||
class="subcategory-list mb-md flex flex-wrap gap-xs"
|
|
||||||
>
|
|
||||||
<span v-if="loadingSubcategories" class="text-secondary text-xs">Loading…</span>
|
|
||||||
<template v-else>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
|
|
||||||
:aria-pressed="activeSubcategory === null"
|
|
||||||
@click="selectSubcategory(null)"
|
|
||||||
>
|
|
||||||
All {{ activeCategory }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="sub in subcategories"
|
|
||||||
:key="sub.subcategory"
|
|
||||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
|
|
||||||
:aria-pressed="activeSubcategory === sub.subcategory"
|
|
||||||
@click="selectSubcategory(sub.subcategory)"
|
|
||||||
>
|
|
||||||
{{ sub.subcategory }}
|
|
||||||
<span class="cat-count">{{ sub.recipe_count }}</span>
|
|
||||||
<span
|
|
||||||
v-if="sub.recipe_count === 0"
|
|
||||||
class="tag-cta"
|
|
||||||
title="Know a recipe in this category? Tag it!"
|
|
||||||
@click.stop="openTagModal(sub.subcategory)"
|
|
||||||
>+</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browse breadcrumb — shows current position in domain > category > subcategory hierarchy -->
|
|
||||||
<nav v-if="activeDomain && activeCategory" class="browse-breadcrumb" aria-label="Browse location">
|
|
||||||
<button
|
|
||||||
class="crumb-btn"
|
|
||||||
@click="selectDomain(activeDomain)"
|
|
||||||
:aria-current="!activeCategory ? 'page' : undefined"
|
|
||||||
>{{ domains.find(d => d.id === activeDomain)?.label ?? activeDomain }}</button>
|
|
||||||
<span class="crumb-sep" aria-hidden="true">›</span>
|
|
||||||
<button
|
|
||||||
class="crumb-btn"
|
|
||||||
@click="selectCategory(activeCategory)"
|
|
||||||
:aria-current="!activeSubcategory ? 'page' : undefined"
|
|
||||||
>{{ activeCategory === '_all' ? 'All' : activeCategory }}</button>
|
|
||||||
<template v-if="activeSubcategory">
|
|
||||||
<span class="crumb-sep" aria-hidden="true">›</span>
|
|
||||||
<span class="crumb-current" aria-current="page">{{ activeSubcategory }}</span>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Recipe grid -->
|
||||||
<template v-if="activeCategory">
|
<template v-if="activeCategory">
|
||||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Search + sort controls -->
|
|
||||||
<div class="browser-controls flex gap-sm mb-sm flex-wrap align-center">
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
@input="onSearchInput"
|
|
||||||
type="search"
|
|
||||||
placeholder="Filter by title…"
|
|
||||||
class="browser-search"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="requiredIngredient"
|
|
||||||
@keyup.enter="onRequiredIngredientCommit"
|
|
||||||
@search="onRequiredIngredientCommit"
|
|
||||||
type="search"
|
|
||||||
placeholder="Must include ingredient… (Enter)"
|
|
||||||
class="browser-search"
|
|
||||||
title="Type an ingredient and press Enter to filter"
|
|
||||||
/>
|
|
||||||
<div class="sort-btns flex gap-xs">
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
|
|
||||||
:aria-pressed="sortOrder === 'default'"
|
|
||||||
@click="setSort('default')"
|
|
||||||
title="Corpus order"
|
|
||||||
>Default</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
|
|
||||||
:aria-pressed="sortOrder === 'alpha'"
|
|
||||||
@click="setSort('alpha')"
|
|
||||||
title="Alphabetical A→Z"
|
|
||||||
>A→Z</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
|
|
||||||
:aria-pressed="sortOrder === 'alpha_desc'"
|
|
||||||
@click="setSort('alpha_desc')"
|
|
||||||
title="Alphabetical Z→A"
|
|
||||||
>Z→A</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
|
|
||||||
:aria-pressed="sortOrder === 'match'"
|
|
||||||
:disabled="pantryCount === 0"
|
|
||||||
@click="setSort('match')"
|
|
||||||
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
|
|
||||||
>Best match</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="results-header flex-between mb-sm">
|
<div class="results-header flex-between mb-sm">
|
||||||
<span
|
<span class="text-sm text-secondary">
|
||||||
class="text-sm text-secondary"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
>
|
|
||||||
{{ total }} recipes
|
{{ total }} recipes
|
||||||
<span v-if="pantryCount > 0"> — pantry match shown</span>
|
<span v-if="pantryCount > 0"> — pantry match shown</span>
|
||||||
<span v-if="requiredIngredient.trim()"> — must include "{{ requiredIngredient.trim() }}"</span>
|
|
||||||
</span>
|
</span>
|
||||||
<div class="pagination flex gap-xs">
|
<div class="pagination flex gap-xs">
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-xs"
|
class="btn btn-secondary btn-xs"
|
||||||
:disabled="page <= 1"
|
:disabled="page <= 1"
|
||||||
aria-label="Previous page"
|
|
||||||
@click="changePage(page - 1)"
|
@click="changePage(page - 1)"
|
||||||
>‹ Prev</button>
|
>‹ Prev</button>
|
||||||
<span class="text-sm text-secondary page-indicator" aria-live="polite">{{ page }} / {{ totalPages }}</span>
|
<span class="text-sm text-secondary page-indicator">{{ page }} / {{ totalPages }}</span>
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-xs"
|
class="btn btn-secondary btn-xs"
|
||||||
:disabled="page >= totalPages"
|
:disabled="page >= totalPages"
|
||||||
aria-label="Next page"
|
|
||||||
@click="changePage(page + 1)"
|
@click="changePage(page + 1)"
|
||||||
>Next ›</button>
|
>Next ›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,19 +86,6 @@
|
||||||
{{ Math.round(recipe.match_pct * 100) }}%
|
{{ Math.round(recipe.match_pct * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Time & effort split pill -->
|
|
||||||
<span
|
|
||||||
v-if="recipe.active_min !== null"
|
|
||||||
class="time-split-pill"
|
|
||||||
:title="`~${formatMin(recipe.active_min)} active · ~${formatMin(recipe.passive_min ?? 0)} passive`"
|
|
||||||
>
|
|
||||||
<span class="pill-active">🧑🍳 ~{{ formatMin(recipe.active_min) }}</span>
|
|
||||||
<span
|
|
||||||
v-if="recipe.passive_min !== null && recipe.passive_min > 0"
|
|
||||||
class="pill-passive"
|
|
||||||
>💤 ~{{ formatMin(recipe.passive_min) }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Save toggle -->
|
<!-- Save toggle -->
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-xs"
|
class="btn btn-secondary btn-xs"
|
||||||
|
|
@ -235,7 +101,7 @@
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="!allCountsZero" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-else class="text-secondary text-sm">Loading recipes…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading…</div>
|
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading…</div>
|
||||||
|
|
@ -249,85 +115,12 @@
|
||||||
@saved="savingRecipe = null"
|
@saved="savingRecipe = null"
|
||||||
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
|
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Community tag modal — opened from zero-count subcategory CTA -->
|
|
||||||
<div v-if="tagModal.open" class="modal-backdrop" @click.self="tagModal.open = false">
|
|
||||||
<div class="modal-box" role="dialog" aria-modal="true" aria-label="Tag a recipe">
|
|
||||||
<h3 class="text-md font-semibold mb-sm">Tag a recipe as {{ tagModal.subcategory }}</h3>
|
|
||||||
<p class="text-sm text-secondary mb-sm">
|
|
||||||
Search for a recipe you know belongs here. Your tag helps other users discover it.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Recipe search -->
|
|
||||||
<input
|
|
||||||
class="form-input mb-xs"
|
|
||||||
v-model="tagModal.searchQuery"
|
|
||||||
placeholder="Search recipe title…"
|
|
||||||
@input="onTagSearchInput"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<div v-if="tagModal.searching" class="text-sm text-secondary mb-xs">Searching…</div>
|
|
||||||
<ul v-else-if="tagModal.results.length > 0" class="tag-search-results mb-sm">
|
|
||||||
<li
|
|
||||||
v-for="r in tagModal.results"
|
|
||||||
:key="r.id"
|
|
||||||
:class="['tag-result-row', { selected: tagModal.selectedRecipe?.id === r.id }]"
|
|
||||||
@click="tagModal.selectedRecipe = r"
|
|
||||||
>
|
|
||||||
<span class="tag-result-title">{{ r.title }}</span>
|
|
||||||
<span class="tag-result-check" v-if="tagModal.selectedRecipe?.id === r.id">✓</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else-if="tagModal.searchQuery.length > 2" class="text-sm text-secondary mb-sm">
|
|
||||||
No results — try a different title.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Location correction (pre-filled from active browse context) -->
|
|
||||||
<div class="form-group mb-xs">
|
|
||||||
<label class="form-label text-xs">Domain</label>
|
|
||||||
<select class="form-input" v-model="tagModal.domain">
|
|
||||||
<option v-for="d in domains" :key="d.id" :value="d.id">{{ d.label }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group mb-xs">
|
|
||||||
<label class="form-label text-xs">Category</label>
|
|
||||||
<select class="form-input" v-model="tagModal.category">
|
|
||||||
<option v-for="c in categories" :key="c.category" :value="c.category">
|
|
||||||
{{ c.category }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group mb-sm">
|
|
||||||
<label class="form-label text-xs">Subcategory (optional)</label>
|
|
||||||
<select class="form-input" v-model="tagModal.subcategoryEdit">
|
|
||||||
<option value="">— none (category level) —</option>
|
|
||||||
<option v-for="s in subcategories" :key="s.subcategory" :value="s.subcategory">
|
|
||||||
{{ s.subcategory }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-sm">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
:disabled="!tagModal.selectedRecipe || tagModal.submitting"
|
|
||||||
@click="submitTag"
|
|
||||||
>
|
|
||||||
<span v-if="tagModal.submitting">Submitting…</span>
|
|
||||||
<span v-else>Tag this recipe</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="tagModal.open = false">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="tagModal.error" class="text-sm status-badge status-error mt-xs">{{ tagModal.error }}</p>
|
|
||||||
<p v-if="tagModal.success" class="text-sm status-badge status-ok mt-xs">{{ tagModal.success }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
|
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
@ -343,9 +136,6 @@ const domains = ref<BrowserDomain[]>([])
|
||||||
const activeDomain = ref<string | null>(null)
|
const activeDomain = ref<string | null>(null)
|
||||||
const categories = ref<BrowserCategory[]>([])
|
const categories = ref<BrowserCategory[]>([])
|
||||||
const activeCategory = ref<string | null>(null)
|
const activeCategory = ref<string | null>(null)
|
||||||
const subcategories = ref<BrowserSubcategory[]>([])
|
|
||||||
const activeSubcategory = ref<string | null>(null)
|
|
||||||
const loadingSubcategories = ref(false)
|
|
||||||
const recipes = ref<BrowserRecipe[]>([])
|
const recipes = ref<BrowserRecipe[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
@ -353,37 +143,8 @@ const pageSize = 20
|
||||||
const loadingDomains = ref(false)
|
const loadingDomains = ref(false)
|
||||||
const loadingRecipes = ref(false)
|
const loadingRecipes = ref(false)
|
||||||
const savingRecipe = ref<BrowserRecipe | null>(null)
|
const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||||
const searchQuery = ref('')
|
|
||||||
const requiredIngredient = ref('')
|
|
||||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default')
|
|
||||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
// ── Tag modal state ────────────────────────────────────────────────────────
|
|
||||||
const tagModal = ref({
|
|
||||||
open: false,
|
|
||||||
subcategory: '', // display label (pre-filled from CTA)
|
|
||||||
domain: '', // editable, pre-filled
|
|
||||||
category: '', // editable, pre-filled
|
|
||||||
subcategoryEdit: '', // editable, pre-filled
|
|
||||||
searchQuery: '',
|
|
||||||
searching: false,
|
|
||||||
results: [] as Array<{ id: number; title: string }>,
|
|
||||||
selectedRecipe: null as { id: number; title: string } | null,
|
|
||||||
submitting: false,
|
|
||||||
error: '',
|
|
||||||
success: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
const allCountsZero = computed(() =>
|
|
||||||
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
|
|
||||||
)
|
|
||||||
const activeCategoryHasSubs = computed(() => {
|
|
||||||
if (!activeCategory.value || activeCategory.value === '_all') return false
|
|
||||||
return categories.value.find(c => c.category === activeCategory.value)?.has_subcategories ?? false
|
|
||||||
})
|
|
||||||
|
|
||||||
const pantryItems = computed(() =>
|
const pantryItems = computed(() =>
|
||||||
inventoryStore.items
|
inventoryStore.items
|
||||||
|
|
@ -398,18 +159,6 @@ function matchBadgeClass(pct: number): string {
|
||||||
return 'status-secondary'
|
return 'status-secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format minutes as a compact display string.
|
|
||||||
* < 60 → "15m"
|
|
||||||
* >= 60 → "1h 30m" (omits minutes when zero: "2h")
|
|
||||||
*/
|
|
||||||
function formatMin(minutes: number): string {
|
|
||||||
if (minutes < 60) return `${minutes}m`
|
|
||||||
const h = Math.floor(minutes / 60)
|
|
||||||
const m = minutes % 60
|
|
||||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadingDomains.value = true
|
loadingDomains.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -423,58 +172,15 @@ onMounted(async () => {
|
||||||
if (!savedStore.savedIds.size) savedStore.load()
|
if (!savedStore.savedIds.size) savedStore.load()
|
||||||
})
|
})
|
||||||
|
|
||||||
function onSearchInput() {
|
|
||||||
if (searchDebounce) clearTimeout(searchDebounce)
|
|
||||||
searchDebounce = setTimeout(() => {
|
|
||||||
page.value = 1
|
|
||||||
loadRecipes()
|
|
||||||
}, 350)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRequiredIngredientCommit() {
|
|
||||||
page.value = 1
|
|
||||||
loadRecipes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-clear results when the field is emptied via backspace/select-delete
|
|
||||||
watch(requiredIngredient, (val, prev) => {
|
|
||||||
if (val === '' && prev !== '') {
|
|
||||||
page.value = 1
|
|
||||||
loadRecipes()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function setSort(s: 'default' | 'alpha' | 'alpha_desc' | 'match') {
|
|
||||||
if (sortOrder.value === s) return
|
|
||||||
sortOrder.value = s
|
|
||||||
page.value = 1
|
|
||||||
loadRecipes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// When pantry items first become available while browsing, auto-engage match sort.
|
|
||||||
// When pantry empties out mid-session, drop back to default so the button disables cleanly.
|
|
||||||
watch(pantryCount, (newCount, oldCount) => {
|
|
||||||
if (newCount > 0 && oldCount === 0 && activeCategory.value) {
|
|
||||||
setSort('match')
|
|
||||||
} else if (newCount === 0 && sortOrder.value === 'match') {
|
|
||||||
setSort('default')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function selectDomain(domainId: string) {
|
async function selectDomain(domainId: string) {
|
||||||
activeDomain.value = domainId
|
activeDomain.value = domainId
|
||||||
activeCategory.value = null
|
activeCategory.value = null
|
||||||
recipes.value = []
|
recipes.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
page.value = 1
|
page.value = 1
|
||||||
searchQuery.value = ''
|
|
||||||
requiredIngredient.value = ''
|
|
||||||
sortOrder.value = 'default'
|
|
||||||
categories.value = await browserAPI.listCategories(domainId)
|
categories.value = await browserAPI.listCategories(domainId)
|
||||||
// Auto-select the most-populated category so content appears immediately.
|
// Auto-select the most-populated category so content appears immediately
|
||||||
// Skip when all counts are 0 (corpus not seeded) — no point loading an empty result.
|
if (categories.value.length > 0) {
|
||||||
const hasRecipes = categories.value.some(c => c.recipe_count > 0)
|
|
||||||
if (hasRecipes) {
|
|
||||||
const top = categories.value.reduce((best, c) =>
|
const top = categories.value.reduce((best, c) =>
|
||||||
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
|
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
|
||||||
selectCategory(top.category)
|
selectCategory(top.category)
|
||||||
|
|
@ -489,27 +195,6 @@ function surpriseMe() {
|
||||||
|
|
||||||
async function selectCategory(category: string) {
|
async function selectCategory(category: string) {
|
||||||
activeCategory.value = category
|
activeCategory.value = category
|
||||||
activeSubcategory.value = null
|
|
||||||
subcategories.value = []
|
|
||||||
page.value = 1
|
|
||||||
searchQuery.value = ''
|
|
||||||
sortOrder.value = 'default'
|
|
||||||
|
|
||||||
// Fetch subcategories in the background when the category supports them,
|
|
||||||
// then immediately start loading recipes at the full-category level.
|
|
||||||
const catMeta = categories.value.find(c => c.category === category)
|
|
||||||
if (catMeta?.has_subcategories) {
|
|
||||||
loadingSubcategories.value = true
|
|
||||||
browserAPI.listSubcategories(activeDomain.value!, category)
|
|
||||||
.then(subs => { subcategories.value = subs })
|
|
||||||
.finally(() => { loadingSubcategories.value = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadRecipes()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectSubcategory(subcat: string | null) {
|
|
||||||
activeSubcategory.value = subcat
|
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await loadRecipes()
|
await loadRecipes()
|
||||||
}
|
}
|
||||||
|
|
@ -532,10 +217,6 @@ async function loadRecipes() {
|
||||||
pantry_items: pantryItems.value.length > 0
|
pantry_items: pantryItems.value.length > 0
|
||||||
? pantryItems.value.join(',')
|
? pantryItems.value.join(',')
|
||||||
: undefined,
|
: undefined,
|
||||||
subcategory: activeSubcategory.value ?? undefined,
|
|
||||||
q: searchQuery.value.trim() || undefined,
|
|
||||||
sort: sortOrder.value !== 'default' ? sortOrder.value : undefined,
|
|
||||||
required_ingredient: requiredIngredient.value.trim() || undefined,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipes.value = result.recipes
|
recipes.value = result.recipes
|
||||||
|
|
@ -557,75 +238,6 @@ async function doUnsave(recipeId: number) {
|
||||||
savingRecipe.value = null
|
savingRecipe.value = null
|
||||||
await savedStore.unsave(recipeId)
|
await savedStore.unsave(recipeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tag modal ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openTagModal(subcategoryName: string) {
|
|
||||||
Object.assign(tagModal.value, {
|
|
||||||
open: true,
|
|
||||||
subcategory: subcategoryName,
|
|
||||||
domain: activeDomain.value ?? '',
|
|
||||||
category: activeCategory.value ?? '',
|
|
||||||
subcategoryEdit: subcategoryName,
|
|
||||||
searchQuery: '',
|
|
||||||
searching: false,
|
|
||||||
results: [],
|
|
||||||
selectedRecipe: null,
|
|
||||||
submitting: false,
|
|
||||||
error: '',
|
|
||||||
success: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTagSearchInput() {
|
|
||||||
if (tagSearchDebounce) clearTimeout(tagSearchDebounce)
|
|
||||||
const q = tagModal.value.searchQuery.trim()
|
|
||||||
if (q.length < 3) {
|
|
||||||
tagModal.value.results = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagSearchDebounce = setTimeout(async () => {
|
|
||||||
tagModal.value.searching = true
|
|
||||||
try {
|
|
||||||
// Use the first available domain with category=_all to search all recipes by title.
|
|
||||||
// Domain must be a real domain slug — '_all' is not valid at the browse endpoint.
|
|
||||||
const searchDomain = domains.value[0]?.id ?? 'cuisine'
|
|
||||||
const res = await browserAPI.browse(searchDomain, '_all', { page: 1, q })
|
|
||||||
tagModal.value.results = (res.recipes ?? []).slice(0, 8).map(
|
|
||||||
(r: { id: number; title: string }) => ({ id: r.id, title: r.title })
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
tagModal.value.results = []
|
|
||||||
} finally {
|
|
||||||
tagModal.value.searching = false
|
|
||||||
}
|
|
||||||
}, 350)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitTag() {
|
|
||||||
const m = tagModal.value
|
|
||||||
if (!m.selectedRecipe) return
|
|
||||||
m.submitting = true
|
|
||||||
m.error = ''
|
|
||||||
m.success = ''
|
|
||||||
try {
|
|
||||||
await browserAPI.submitRecipeTag({
|
|
||||||
recipe_id: m.selectedRecipe.id,
|
|
||||||
domain: m.domain,
|
|
||||||
category: m.category,
|
|
||||||
subcategory: m.subcategoryEdit || null,
|
|
||||||
pseudonym: 'anon', // TODO: wire real pseudonym from community store
|
|
||||||
})
|
|
||||||
m.success = `Tagged! It will appear here once a second user confirms.`
|
|
||||||
setTimeout(() => { m.open = false }, 2500)
|
|
||||||
} catch (err: any) {
|
|
||||||
m.error = err?.message === '409'
|
|
||||||
? 'You have already tagged this recipe here.'
|
|
||||||
: 'Failed to submit — please try again.'
|
|
||||||
} finally {
|
|
||||||
m.submitting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -667,68 +279,6 @@ async function submitTag() {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-drill-indicator {
|
|
||||||
margin-left: var(--spacing-xs);
|
|
||||||
opacity: 0.5;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcategory-list {
|
|
||||||
padding-left: var(--spacing-sm);
|
|
||||||
border-left: 2px solid var(--color-border);
|
|
||||||
margin-left: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcat-btn {
|
|
||||||
font-size: var(--font-size-xs, 0.78rem);
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcat-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcat-btn.active .cat-count {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-controls {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-search {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
max-width: 260px;
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-btn {
|
|
||||||
font-size: var(--font-size-xs, 0.75rem);
|
|
||||||
padding: 2px var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-grid {
|
.recipe-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -786,142 +336,4 @@ async function submitTag() {
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Time & effort split pill ──────────────────────────────────────────── */
|
|
||||||
.time-split-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: stretch;
|
|
||||||
border-radius: var(--radius-pill, 999px);
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: var(--font-size-xs, 0.72rem);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-active {
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(232, 168, 32, 0.18);
|
|
||||||
color: #f0bc48;
|
|
||||||
border-radius: var(--radius-pill, 999px) 0 0 var(--radius-pill, 999px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When there is no passive segment, active gets full pill rounding */
|
|
||||||
.time-split-pill:not(:has(.pill-passive)) .pill-active {
|
|
||||||
border-radius: var(--radius-pill, 999px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-passive {
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: rgba(41, 128, 185, 0.15);
|
|
||||||
color: #5dade2;
|
|
||||||
border-radius: 0 var(--radius-pill, 999px) var(--radius-pill, 999px) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Community tag CTA ──────────────────────────────────────────────────── */
|
|
||||||
.tag-cta {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
width: 1.1rem;
|
|
||||||
height: 1.1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background: var(--color-accent, #7c6fcd);
|
|
||||||
color: #fff;
|
|
||||||
opacity: 0.75;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.tag-cta:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tag modal ──────────────────────────────────────────────────────────── */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
.modal-box {
|
|
||||||
background: var(--color-surface, #fff);
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
padding: 1.5rem;
|
|
||||||
max-width: 28rem;
|
|
||||||
width: 90vw;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
|
||||||
}
|
|
||||||
.tag-search-results {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid var(--color-border, #e0e0e0);
|
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
|
||||||
max-height: 12rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.tag-result-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.tag-result-row:hover,
|
|
||||||
.tag-result-row.selected {
|
|
||||||
background: var(--color-hover, #f0eeff);
|
|
||||||
}
|
|
||||||
.tag-result-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.tag-result-check {
|
|
||||||
color: var(--color-accent, #7c6fcd);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Browse breadcrumb ───────────────────────────────────────────────────── */
|
|
||||||
.browse-breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-xs, 0.78rem);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.crumb-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 2px 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: inherit;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.crumb-btn:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crumb-sep {
|
|
||||||
opacity: 0.5;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crumb-current {
|
|
||||||
padding: 2px 4px;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,6 @@
|
||||||
@click="showSaveModal = true"
|
@click="showSaveModal = true"
|
||||||
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
|
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
|
||||||
>{{ isSaved ? '★ Saved' : '☆ Save' }}</button>
|
>{{ isSaved ? '★ Saved' : '☆ Save' }}</button>
|
||||||
<!-- Cook mode toggle -->
|
|
||||||
<button
|
|
||||||
v-if="recipe.directions.length > 0"
|
|
||||||
class="btn btn-cook"
|
|
||||||
:class="{ 'btn-cook--active': cookModeActive }"
|
|
||||||
@click="cookModeActive ? exitCookMode() : enterCookMode()"
|
|
||||||
:aria-label="cookModeActive ? 'Exit cook mode' : 'Enter cook mode'"
|
|
||||||
:aria-pressed="cookModeActive"
|
|
||||||
>{{ cookModeActive ? '✕ Exit' : 'Cook' }}</button>
|
|
||||||
|
|
||||||
<button class="btn-close" @click="$emit('close')" aria-label="Close panel">✕</button>
|
<button class="btn-close" @click="$emit('close')" aria-label="Close panel">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,19 +33,8 @@
|
||||||
>View original ↗</a>
|
>View original ↗</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cook mode bar: progress + step counter -->
|
<!-- Scrollable body -->
|
||||||
<div v-if="cookModeActive" class="cook-mode-bar" role="status" :aria-label="`Step ${cookStep + 1} of ${cookStepCount}`">
|
<div class="detail-body">
|
||||||
<div class="cook-progress-track">
|
|
||||||
<div
|
|
||||||
class="cook-progress-fill"
|
|
||||||
:style="{ width: `${cookProgress * 100}%` }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="cook-step-counter">Step {{ cookStep + 1 }} of {{ cookStepCount }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Normal scrollable body -->
|
|
||||||
<div v-if="!cookModeActive" class="detail-body">
|
|
||||||
|
|
||||||
<!-- Serving multiplier -->
|
<!-- Serving multiplier -->
|
||||||
<div class="serving-scale-row">
|
<div class="serving-scale-row">
|
||||||
|
|
@ -72,14 +51,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ingredients: have vs. need in a two-column layout -->
|
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||||
<details open class="ingredients-collapsible">
|
|
||||||
<summary class="ingredients-collapsible-summary">
|
|
||||||
Ingredients
|
|
||||||
<span class="ingr-summary-counts">
|
|
||||||
<span v-if="recipe.matched_ingredients?.length" class="ingr-count ingr-count-have">{{ recipe.matched_ingredients.length }} ✓</span>
|
|
||||||
<span v-if="recipe.missing_ingredients?.length" class="ingr-count ingr-count-need">{{ recipe.missing_ingredients.length }} needed</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div class="ingredients-grid">
|
<div class="ingredients-grid">
|
||||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||||
<h3 class="col-label col-label-have">From your pantry</h3>
|
<h3 class="col-label col-label-have">From your pantry</h3>
|
||||||
|
|
@ -127,35 +98,6 @@
|
||||||
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
|
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Time & effort summary cards -->
|
|
||||||
<div v-if="recipe.time_effort" class="effort-summary">
|
|
||||||
<div class="effort-card effort-card-active">
|
|
||||||
<span class="effort-label">Active</span>
|
|
||||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.active_min) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="recipe.time_effort.passive_min > 0" class="effort-card effort-card-passive">
|
|
||||||
<span class="effort-label">Hands-off</span>
|
|
||||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.passive_min) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="effort-card effort-card-total">
|
|
||||||
<span class="effort-label">Total</span>
|
|
||||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.total_min) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="effort-level-badge" :class="'effort-' + recipe.time_effort.effort_label">
|
|
||||||
{{ recipe.time_effort.effort_label }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Equipment chips -->
|
|
||||||
<div v-if="recipe.time_effort?.equipment?.length" class="equipment-chips">
|
|
||||||
<span
|
|
||||||
v-for="eq in recipe.time_effort.equipment"
|
|
||||||
:key="eq"
|
|
||||||
class="equipment-chip"
|
|
||||||
>{{ EQUIPMENT_ICONS[eq] ?? '🍴' }} {{ eq }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Swap candidates -->
|
<!-- Swap candidates -->
|
||||||
<details v-if="recipe.swap_candidates.length > 0" class="detail-collapsible">
|
<details v-if="recipe.swap_candidates.length > 0" class="detail-collapsible">
|
||||||
|
|
@ -203,121 +145,24 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Directions (annotated) -->
|
<!-- Directions -->
|
||||||
<details open v-if="recipe.directions.length > 0" class="steps-collapsible">
|
<div v-if="recipe.directions.length > 0" class="detail-section">
|
||||||
<summary class="steps-collapsible-summary">
|
<h3 class="section-label">Steps</h3>
|
||||||
Steps <span class="steps-count">({{ recipe.directions.length }})</span>
|
<ol class="directions-list">
|
||||||
</summary>
|
<li v-for="(step, i) in recipe.directions" :key="i" class="text-sm direction-step">{{ step }}</li>
|
||||||
<ol class="directions-list directions-list-annotated">
|
|
||||||
<li
|
|
||||||
v-for="(step, i) in recipe.directions"
|
|
||||||
:key="i"
|
|
||||||
class="text-sm direction-step direction-step-annotated"
|
|
||||||
:class="{ 'step-passive': stepAnalysis(i)?.is_passive }"
|
|
||||||
>
|
|
||||||
<div class="step-badge-row">
|
|
||||||
<span v-if="stepAnalysis(i)?.is_passive" class="step-type-badge step-type-wait">Wait</span>
|
|
||||||
<span v-else-if="stepAnalysis(i)" class="step-type-badge step-type-active">Active</span>
|
|
||||||
</div>
|
|
||||||
<p class="step-text">{{ step }}</p>
|
|
||||||
<p v-if="passiveHint(stepAnalysis(i))" class="step-passive-hint">{{ passiveHint(stepAnalysis(i)) }}</p>
|
|
||||||
</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Community tags — accepted location tags from other users -->
|
|
||||||
<div v-if="communityTags.length > 0" class="detail-section community-tags-section">
|
|
||||||
<h3 class="section-label">Community categories</h3>
|
|
||||||
<div class="community-tags-list">
|
|
||||||
<span
|
|
||||||
v-for="tag in communityTags"
|
|
||||||
:key="tag.id"
|
|
||||||
class="community-tag-chip"
|
|
||||||
:class="{ 'community-tag-chip--accepted': tag.accepted }"
|
|
||||||
:title="tag.accepted ? 'Confirmed by the community' : 'Pending confirmation'"
|
|
||||||
>
|
|
||||||
{{ tag.domain }} › {{ tag.category }}<template v-if="tag.subcategory"> › {{ tag.subcategory }}</template>
|
|
||||||
<span v-if="tag.accepted" class="community-tag-check" aria-label="Confirmed">✓</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
|
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
|
||||||
<div style="height: var(--spacing-xl)" />
|
<div style="height: var(--spacing-xl)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cook mode: single-step view -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="detail-body cook-step-view"
|
|
||||||
@touchstart.passive="onTouchStart"
|
|
||||||
@touchend.passive="onTouchEnd"
|
|
||||||
>
|
|
||||||
<div class="cook-step-label">STEP {{ cookStep + 1 }}</div>
|
|
||||||
|
|
||||||
<div v-if="currentStepAnalysis" class="cook-step-badge-row">
|
|
||||||
<span
|
|
||||||
class="cook-step-badge"
|
|
||||||
:class="currentStepAnalysis.is_passive ? 'cook-badge--wait' : 'cook-badge--active'"
|
|
||||||
>{{ currentStepAnalysis.is_passive ? 'Wait' : 'Active' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="cook-step-text">{{ recipe.directions[cookStep] }}</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="currentStepAnalysis?.detected_minutes != null"
|
|
||||||
class="cook-step-hint"
|
|
||||||
>~{{ currentStepAnalysis.detected_minutes }} min hands-off</p>
|
|
||||||
|
|
||||||
<div class="cook-nav">
|
|
||||||
<button
|
|
||||||
class="btn cook-nav-prev"
|
|
||||||
:class="{ 'cook-nav--disabled': cookStep === 0 }"
|
|
||||||
:disabled="cookStep === 0"
|
|
||||||
:aria-label="cookStep === 0 ? 'No previous step' : 'Previous step'"
|
|
||||||
@click="prevStep"
|
|
||||||
>← Prev</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn cook-nav-next"
|
|
||||||
:class="{ 'cook-nav--done': isLastStep }"
|
|
||||||
:aria-label="isLastStep ? 'Done cooking' : 'Next step'"
|
|
||||||
@click="nextStep"
|
|
||||||
>{{ isLastStep ? 'Done ✓' : 'Next →' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sticky footer -->
|
<!-- Sticky footer -->
|
||||||
<div class="detail-footer">
|
<div class="detail-footer">
|
||||||
<div v-if="cookDone" class="cook-success">
|
<div v-if="cookDone" class="cook-success">
|
||||||
<span class="cook-success-icon">✓</span>
|
<span class="cook-success-icon">✓</span>
|
||||||
Enjoy your meal! Recipe dismissed from suggestions.
|
Enjoy your meal! Recipe dismissed from suggestions.
|
||||||
<button class="btn btn-secondary btn-sm mt-xs" @click="$emit('close')">Close</button>
|
<button class="btn btn-secondary btn-sm mt-xs" @click="$emit('close')">Close</button>
|
||||||
|
|
||||||
<!-- Leftover shelf-life section -->
|
|
||||||
<div v-if="leftoversLoading" class="leftovers-panel text-sm text-secondary mt-sm">
|
|
||||||
Working out storage info…
|
|
||||||
</div>
|
|
||||||
<div v-else-if="leftovers && !leftoversDismissed" class="leftovers-panel mt-sm">
|
|
||||||
<div class="leftovers-header flex-between">
|
|
||||||
<span class="text-sm font-semibold">Leftovers</span>
|
|
||||||
<button class="btn-icon btn-xs" @click="leftoversDismissed = true" aria-label="Dismiss storage info">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="leftovers-grid mt-xs">
|
|
||||||
<div class="leftovers-cell">
|
|
||||||
<span class="leftovers-icon">❄️</span>
|
|
||||||
<span class="text-sm">Fridge: <strong>{{ leftovers.fridge_days }} day{{ leftovers.fridge_days !== 1 ? 's' : '' }}</strong></span>
|
|
||||||
</div>
|
|
||||||
<div v-if="leftovers.freeze_days !== null" class="leftovers-cell">
|
|
||||||
<span class="leftovers-icon">🧊</span>
|
|
||||||
<span class="text-sm">Freezer: <strong>{{ leftovers.freeze_days }} day{{ leftovers.freeze_days !== 1 ? 's' : '' }}</strong></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="leftovers.freeze_by_day" class="text-xs text-secondary mt-xs">
|
|
||||||
Freeze by day {{ leftovers.freeze_by_day }} for best results.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-secondary mt-xs">{{ leftovers.storage_advice }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button class="btn btn-secondary" @click="$emit('close')">Back</button>
|
<button class="btn btn-secondary" @click="$emit('close')">Back</button>
|
||||||
|
|
@ -371,27 +216,15 @@
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import { inventoryAPI, recipesAPI, browserAPI } from '../services/api'
|
import { inventoryAPI } from '../services/api'
|
||||||
import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
||||||
const dialogRef = ref<HTMLElement | null>(null)
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
let previousFocus: HTMLElement | null = null
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') emit('close')
|
||||||
emit('close')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (cookModeActive.value) {
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault()
|
|
||||||
nextStep()
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault()
|
|
||||||
prevStep()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -403,12 +236,6 @@ onMounted(() => {
|
||||||
)
|
)
|
||||||
;(focusable ?? dialogRef.value)?.focus()
|
;(focusable ?? dialogRef.value)?.focus()
|
||||||
})
|
})
|
||||||
// Load community tags in the background — non-critical, silently skip on error
|
|
||||||
browserAPI.listRecipeTags(props.recipe.id).then((tags) => {
|
|
||||||
communityTags.value = tags
|
|
||||||
}).catch(() => {
|
|
||||||
// Community tags are supplemental; silently skip on error
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -433,77 +260,6 @@ const showSaveModal = ref(false)
|
||||||
const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
||||||
|
|
||||||
const cookDone = ref(false)
|
const cookDone = ref(false)
|
||||||
|
|
||||||
// ── Community tags ────────────────────────────────────────
|
|
||||||
type CommunityTag = { id: number; domain: string; category: string; subcategory: string | null; pseudonym: string; upvotes: number; accepted: boolean }
|
|
||||||
const communityTags = ref<CommunityTag[]>([])
|
|
||||||
|
|
||||||
// ── Leftover shelf-life ────────────────────────────────────
|
|
||||||
type LeftoversData = { fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }
|
|
||||||
const leftovers = ref<LeftoversData | null>(null)
|
|
||||||
const leftoversLoading = ref(false)
|
|
||||||
const leftoversDismissed = ref(false)
|
|
||||||
|
|
||||||
// ── Cook mode ─────────────────────────────────────────────
|
|
||||||
const cookModeActive = ref(false)
|
|
||||||
const cookStep = ref(0) // 0-indexed
|
|
||||||
|
|
||||||
function enterCookMode() {
|
|
||||||
cookModeActive.value = true
|
|
||||||
cookStep.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitCookMode() {
|
|
||||||
cookModeActive.value = false
|
|
||||||
cookStep.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextStep() {
|
|
||||||
const lastIdx = props.recipe.directions.length - 1
|
|
||||||
if (cookStep.value < lastIdx) {
|
|
||||||
cookStep.value++
|
|
||||||
} else {
|
|
||||||
handleCook()
|
|
||||||
exitCookMode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevStep() {
|
|
||||||
if (cookStep.value > 0) cookStep.value--
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads step_analyses from kiwi#50 time_effort — null-safe
|
|
||||||
const currentStepAnalysis = computed(() => {
|
|
||||||
return props.recipe.time_effort?.step_analyses?.[cookStep.value] ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const cookStepCount = computed(() => props.recipe.directions.length)
|
|
||||||
const isLastStep = computed(() => cookStep.value === cookStepCount.value - 1)
|
|
||||||
const cookProgress = computed(() =>
|
|
||||||
cookStepCount.value > 1 ? cookStep.value / (cookStepCount.value - 1) : 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// Touch state for swipe navigation
|
|
||||||
const touchStartX = ref(0)
|
|
||||||
const touchStartY = ref(0)
|
|
||||||
|
|
||||||
function onTouchStart(e: TouchEvent) {
|
|
||||||
touchStartX.value = e.changedTouches[0]!.clientX
|
|
||||||
touchStartY.value = e.changedTouches[0]!.clientY
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchEnd(e: TouchEvent) {
|
|
||||||
const dx = e.changedTouches[0]!.clientX - touchStartX.value
|
|
||||||
const dy = e.changedTouches[0]!.clientY - touchStartY.value
|
|
||||||
// Require predominantly horizontal gesture
|
|
||||||
if (Math.abs(dx) >= 40 && Math.abs(dy) < 80) {
|
|
||||||
if (dx < 0) {
|
|
||||||
nextStep() // swipe left → next
|
|
||||||
} else {
|
|
||||||
prevStep() // swipe right → prev
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const shareCopied = ref(false)
|
const shareCopied = ref(false)
|
||||||
|
|
||||||
// Serving scale multiplier: 1×, 2×, 3×, 4×
|
// Serving scale multiplier: 1×, 2×, 3×, 4×
|
||||||
|
|
@ -569,39 +325,6 @@ function scaleIngredient(ing: string, scale: number): string {
|
||||||
return scaled + ing.slice(m[0].length)
|
return scaled + ing.slice(m[0].length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time & effort helpers
|
|
||||||
function formatDetailMin(minutes: number): string {
|
|
||||||
if (minutes < 60) return `${minutes} min`
|
|
||||||
const h = Math.floor(minutes / 60)
|
|
||||||
const m = minutes % 60
|
|
||||||
return m === 0 ? `${h} hr` : `${h} hr ${m} min`
|
|
||||||
}
|
|
||||||
|
|
||||||
const EQUIPMENT_ICONS: Record<string, string> = {
|
|
||||||
oven: '♨',
|
|
||||||
stovetop: '🔥',
|
|
||||||
blender: '⚡',
|
|
||||||
'food processor': '⚡',
|
|
||||||
microwave: '📡',
|
|
||||||
grill: '🔥',
|
|
||||||
'slow cooker': '⏲',
|
|
||||||
'instant pot': '⏲',
|
|
||||||
mixer: '🌀',
|
|
||||||
skillet: '🍳',
|
|
||||||
'cast iron': '🍳',
|
|
||||||
wok: '🍳',
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepAnalysis(i: number): StepAnalysis | null {
|
|
||||||
return props.recipe.time_effort?.step_analyses?.[i] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function passiveHint(analysis: StepAnalysis | null): string {
|
|
||||||
if (!analysis?.is_passive) return ''
|
|
||||||
if (analysis.detected_minutes) return `~${analysis.detected_minutes} min hands-off`
|
|
||||||
return 'Hands-off time'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shopping: add purchased ingredients to pantry
|
// Shopping: add purchased ingredients to pantry
|
||||||
const checkedIngredients = ref<Set<string>>(new Set())
|
const checkedIngredients = ref<Set<string>>(new Set())
|
||||||
const addingToPantry = ref(false)
|
const addingToPantry = ref(false)
|
||||||
|
|
@ -680,20 +403,10 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
||||||
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCook() {
|
function handleCook() {
|
||||||
recipesStore.logCook(props.recipe.id, props.recipe.title)
|
recipesStore.logCook(props.recipe.id, props.recipe.title)
|
||||||
cookDone.value = true
|
cookDone.value = true
|
||||||
emit('cooked', props.recipe)
|
emit('cooked', props.recipe)
|
||||||
if (props.recipe.id) {
|
|
||||||
leftoversLoading.value = true
|
|
||||||
try {
|
|
||||||
leftovers.value = await recipesAPI.getLeftovers(props.recipe.id)
|
|
||||||
} catch {
|
|
||||||
// Silently skip — shelf life is supplemental info, not critical
|
|
||||||
} finally {
|
|
||||||
leftoversLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -777,36 +490,6 @@ async function handleCook() {
|
||||||
border-color: var(--color-warning);
|
border-color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cook mode button ───────────────────────────────────── */
|
|
||||||
.btn-cook {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
background: rgba(232, 168, 32, 0.15);
|
|
||||||
border: 1px solid rgba(232, 168, 32, 0.3);
|
|
||||||
color: #f0bc48;
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cook:hover {
|
|
||||||
background: rgba(232, 168, 32, 0.25);
|
|
||||||
border-color: rgba(232, 168, 32, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cook--active {
|
|
||||||
background: rgba(232, 168, 32, 0.22);
|
|
||||||
border-color: rgba(232, 168, 32, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 380px) {
|
|
||||||
.btn-cook {
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close {
|
.btn-close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -1188,377 +871,6 @@ async function handleCook() {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Ingredients collapsible ────────────────────────────── */
|
|
||||||
.ingredients-collapsible {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients-collapsible-summary {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients-collapsible-summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients-collapsible-summary::before {
|
|
||||||
content: '\25B6';
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: transform 0.15s;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
details[open].ingredients-collapsible .ingredients-collapsible-summary::before {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingr-summary-counts {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingr-count {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingr-count-have {
|
|
||||||
background: var(--color-success-bg, #dcfce7);
|
|
||||||
color: var(--color-success, #16a34a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingr-count-need {
|
|
||||||
background: var(--color-warning-bg, #fef9c3);
|
|
||||||
color: var(--color-warning, #ca8a04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Effort summary cards ───────────────────────────────── */
|
|
||||||
.effort-summary {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
min-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-card-active {
|
|
||||||
background: var(--color-success-bg, #dcfce7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-card-passive {
|
|
||||||
background: var(--color-info-bg, #dbeafe);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-card-total {
|
|
||||||
background: var(--color-bg-secondary, #f5f5f5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-label {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-value {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-level-badge {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: capitalize;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-quick {
|
|
||||||
background: var(--color-success-bg, #dcfce7);
|
|
||||||
color: var(--color-success, #16a34a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-moderate {
|
|
||||||
background: var(--color-info-bg, #dbeafe);
|
|
||||||
color: var(--color-info-light, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.effort-involved {
|
|
||||||
background: var(--color-warning-bg, #fef9c3);
|
|
||||||
color: var(--color-warning, #ca8a04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Equipment chips ────────────────────────────────────── */
|
|
||||||
.equipment-chips {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.equipment-chip {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--color-bg-secondary, #f5f5f5);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Steps collapsible ──────────────────────────────────── */
|
|
||||||
.steps-collapsible {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-collapsible-summary {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-collapsible-summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-collapsible-summary::before {
|
|
||||||
content: '\25B6';
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: transform 0.15s;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
details[open].steps-collapsible .steps-collapsible-summary::before {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-count {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directions-list-annotated {
|
|
||||||
padding-left: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.direction-step-annotated {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
border-left: 3px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-passive {
|
|
||||||
border-left-color: var(--color-info-light, #60a5fa);
|
|
||||||
background: var(--color-info-bg, #dbeafe);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-badge-row {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-type-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-type-active {
|
|
||||||
background: var(--color-success-bg, #dcfce7);
|
|
||||||
color: var(--color-success, #16a34a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-type-wait {
|
|
||||||
background: var(--color-info-bg, #dbeafe);
|
|
||||||
color: var(--color-info-light, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-text {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-passive-hint {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-info-light, #2563eb);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cook mode bar ──────────────────────────────────────── */
|
|
||||||
.cook-mode-bar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-xs);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-progress-track {
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: #f0bc48;
|
|
||||||
transition: width 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-step-counter {
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(255, 248, 235, 0.38);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cook mode step view ────────────────────────────────── */
|
|
||||||
.cook-step-view {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: var(--spacing-lg) var(--spacing-md) var(--spacing-md);
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-step-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: rgba(255, 248, 235, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-step-badge-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-step-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-badge--active {
|
|
||||||
background: rgba(232, 168, 32, 0.18);
|
|
||||||
color: #f0bc48;
|
|
||||||
border: 1px solid rgba(232, 168, 32, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-badge--wait {
|
|
||||||
background: rgba(96, 165, 250, 0.15);
|
|
||||||
color: #93c5fd;
|
|
||||||
border: 1px solid rgba(96, 165, 250, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-step-text {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 248, 235, 0.92);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-step-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(255, 248, 235, 0.38);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cook mode navigation ───────────────────────────────── */
|
|
||||||
.cook-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-nav-prev {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-nav--disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-nav-next {
|
|
||||||
flex: 2;
|
|
||||||
background: rgba(232, 168, 32, 0.18);
|
|
||||||
border: 1px solid rgba(232, 168, 32, 0.4);
|
|
||||||
color: #f0bc48;
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-nav-next:hover {
|
|
||||||
background: rgba(232, 168, 32, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-nav--done {
|
|
||||||
background: rgba(127, 192, 115, 0.18);
|
|
||||||
border-color: rgba(127, 192, 115, 0.4);
|
|
||||||
color: #7fc073;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cook-nav--done:hover {
|
|
||||||
background: rgba(127, 192, 115, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sticky footer ──────────────────────────────────────── */
|
/* ── Sticky footer ──────────────────────────────────────── */
|
||||||
.detail-footer {
|
.detail-footer {
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
|
|
@ -1626,68 +938,4 @@ details[open].steps-collapsible .steps-collapsible-summary::before {
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftovers-panel {
|
|
||||||
background: var(--color-surface-alt, var(--color-surface));
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftovers-header {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftovers-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftovers-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftovers-icon {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Community tags section ──────────────────────────────── */
|
|
||||||
.community-tags-section {
|
|
||||||
padding-top: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.community-tags-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.community-tag-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 2px var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-pill, 999px);
|
|
||||||
font-size: var(--font-size-xs, 0.72rem);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.community-tag-chip--accepted {
|
|
||||||
background: rgba(124, 111, 205, 0.12);
|
|
||||||
color: var(--color-accent, #7c6fcd);
|
|
||||||
border-color: rgba(124, 111, 205, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.community-tag-check {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,849 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="modal-overlay" @click.self="close" role="dialog" aria-modal="true" :aria-labelledby="titleId">
|
|
||||||
<div class="modal-panel scan-modal">
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 :id="titleId" class="modal-title">
|
|
||||||
<span v-if="phase === 'upload'">Scan a Recipe</span>
|
|
||||||
<span v-else-if="phase === 'processing'">Scanning...</span>
|
|
||||||
<span v-else>Review Recipe</span>
|
|
||||||
</h2>
|
|
||||||
<button class="btn-icon close-btn" @click="close" aria-label="Close">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Upload phase ── -->
|
|
||||||
<div v-if="phase === 'upload'" class="modal-body">
|
|
||||||
<p class="hint-text">
|
|
||||||
Photograph a recipe card, cookbook page, or handwritten note.
|
|
||||||
For multi-page recipes (ingredients on one page, directions on another)
|
|
||||||
select both photos together — up to 4 images.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Drop zone -->
|
|
||||||
<div
|
|
||||||
class="drop-zone"
|
|
||||||
:class="{ 'drop-zone-active': isDragging, 'has-files': selectedFiles.length > 0 }"
|
|
||||||
@dragover.prevent="isDragging = true"
|
|
||||||
@dragleave="isDragging = false"
|
|
||||||
@drop.prevent="onDrop"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@keydown.enter.space="fileInput?.click()"
|
|
||||||
aria-label="Click or drop photos here"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic,image/heif"
|
|
||||||
multiple
|
|
||||||
class="hidden-input"
|
|
||||||
@change="onFileChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="selectedFiles.length === 0" class="drop-zone-empty">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="camera-icon">
|
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
|
||||||
<circle cx="12" cy="13" r="4"/>
|
|
||||||
</svg>
|
|
||||||
<p class="drop-zone-label">Tap or drop photos here</p>
|
|
||||||
<p class="drop-zone-sub">JPEG, PNG, WebP, HEIC — up to 4 photos</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="file-preview-grid">
|
|
||||||
<div
|
|
||||||
v-for="(_file, i) in selectedFiles"
|
|
||||||
:key="i"
|
|
||||||
class="file-preview-item"
|
|
||||||
>
|
|
||||||
<img :src="previewUrls[i]" :alt="`Photo ${i + 1}`" class="preview-img" />
|
|
||||||
<button
|
|
||||||
class="remove-file-btn"
|
|
||||||
@click.stop="removeFile(i)"
|
|
||||||
:aria-label="`Remove photo ${i + 1}`"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<p class="preview-label">Page {{ i + 1 }}</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="selectedFiles.length < 4"
|
|
||||||
class="file-preview-add"
|
|
||||||
@click.stop="fileInput?.click()"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@keydown.enter.space.stop="fileInput?.click()"
|
|
||||||
aria-label="Add another photo"
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="uploadError" class="status-badge status-error mt-sm" role="alert">
|
|
||||||
{{ uploadError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" @click="close">Cancel</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="selectedFiles.length === 0"
|
|
||||||
@click="startScan"
|
|
||||||
>
|
|
||||||
Scan Recipe
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Processing phase ── -->
|
|
||||||
<div v-else-if="phase === 'processing'" class="modal-body processing-body">
|
|
||||||
<div class="scan-spinner" aria-live="polite" aria-label="Scanning recipe">
|
|
||||||
<svg class="spin-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
|
||||||
<circle cx="12" cy="13" r="4"/>
|
|
||||||
</svg>
|
|
||||||
<p class="processing-label">{{ scanStatusMessage }}</p>
|
|
||||||
<p class="processing-sub">This can take up to a minute on first use.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Review phase ── -->
|
|
||||||
<div v-else-if="phase === 'review' && extracted" class="modal-body review-body">
|
|
||||||
|
|
||||||
<!-- Confidence banner -->
|
|
||||||
<div
|
|
||||||
v-if="extracted.confidence !== 'high' || extracted.warnings.length > 0"
|
|
||||||
:class="['status-badge', extracted.confidence === 'low' ? 'status-warning' : 'status-info', 'mb-sm']"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span v-if="extracted.confidence === 'low'">Low confidence scan — handwritten or degraded text. Please review carefully.</span>
|
|
||||||
<span v-else>Medium confidence. Check the fields below.</span>
|
|
||||||
<ul v-if="extracted.warnings.length > 0" class="warning-list">
|
|
||||||
<li v-for="w in extracted.warnings" :key="w">{{ w }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pantry match badge -->
|
|
||||||
<div v-if="extracted.ingredients.length > 0" class="pantry-match-row mb-sm">
|
|
||||||
<span class="pantry-badge" :class="pantryMatchClass">
|
|
||||||
{{ extracted.pantry_match_pct }}% pantry match
|
|
||||||
({{ pantryCount }} of {{ extracted.ingredients.length }} ingredients on hand)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Editable fields -->
|
|
||||||
<div class="review-form">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="scan-title">Recipe name</label>
|
|
||||||
<input
|
|
||||||
id="scan-title"
|
|
||||||
v-model="editTitle"
|
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Recipe name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="scan-servings">Servings</label>
|
|
||||||
<input id="scan-servings" v-model="editServings" class="form-input" type="text" placeholder="e.g. 2" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="scan-cooktime">Cook time</label>
|
|
||||||
<input id="scan-cooktime" v-model="editCookTime" class="form-input" type="text" placeholder="e.g. 25 min" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ingredients -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Ingredients</label>
|
|
||||||
<div class="ingredient-list">
|
|
||||||
<div
|
|
||||||
v-for="(ingr, i) in editIngredients"
|
|
||||||
:key="i"
|
|
||||||
:class="['ingredient-row', ingr.in_pantry ? 'in-pantry' : '']"
|
|
||||||
>
|
|
||||||
<span v-if="ingr.in_pantry" class="pantry-dot" title="In your pantry" aria-label="In pantry"></span>
|
|
||||||
<input
|
|
||||||
v-model="ingr.qty"
|
|
||||||
class="form-input ingr-qty"
|
|
||||||
type="text"
|
|
||||||
placeholder="qty"
|
|
||||||
:aria-label="`Ingredient ${i + 1} quantity`"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="ingr.unit"
|
|
||||||
class="form-input ingr-unit"
|
|
||||||
type="text"
|
|
||||||
placeholder="unit"
|
|
||||||
:aria-label="`Ingredient ${i + 1} unit`"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="ingr.name"
|
|
||||||
class="form-input ingr-name"
|
|
||||||
type="text"
|
|
||||||
placeholder="ingredient"
|
|
||||||
:aria-label="`Ingredient ${i + 1} name`"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn-icon remove-ingr-btn"
|
|
||||||
@click="removeIngredient(i)"
|
|
||||||
:aria-label="`Remove ingredient ${i + 1}`"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-ghost btn-sm mt-xs" @click="addIngredient">+ Add ingredient</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Steps -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Steps</label>
|
|
||||||
<div class="step-list">
|
|
||||||
<div v-for="(_step, i) in editSteps" :key="i" class="step-row">
|
|
||||||
<span class="step-num">{{ i + 1 }}</span>
|
|
||||||
<textarea
|
|
||||||
v-model="editSteps[i]"
|
|
||||||
class="form-input step-textarea"
|
|
||||||
rows="2"
|
|
||||||
:aria-label="`Step ${i + 1}`"
|
|
||||||
></textarea>
|
|
||||||
<button
|
|
||||||
class="btn-icon remove-step-btn"
|
|
||||||
@click="removeStep(i)"
|
|
||||||
:aria-label="`Remove step ${i + 1}`"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-ghost btn-sm mt-xs" @click="addStep">+ Add step</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notes (optional) -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="scan-notes">Notes <span class="optional-label">(optional)</span></label>
|
|
||||||
<textarea id="scan-notes" v-model="editNotes" class="form-input" rows="2" placeholder="Tips, variations, storage..."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Source attribution -->
|
|
||||||
<div v-if="extracted.source_note" class="source-note">
|
|
||||||
Source: {{ extracted.source_note }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="saveError" class="status-badge status-error mt-sm" role="alert">
|
|
||||||
{{ saveError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" @click="phase = 'upload'">Re-scan</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="!editTitle.trim() || saving"
|
|
||||||
@click="save"
|
|
||||||
>
|
|
||||||
{{ saving ? 'Saving...' : 'Save Recipe' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
|
||||||
import { type ScannedRecipe, type ScannedIngredient, recipeScanAPI } from '@/services/api'
|
|
||||||
|
|
||||||
type Phase = 'upload' | 'processing' | 'review'
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'close'): void
|
|
||||||
(e: 'saved', recipe: { id: number; title: string }): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const titleId = 'scan-modal-title'
|
|
||||||
|
|
||||||
// ── Upload state ──────────────────────────────────────────────────────────────
|
|
||||||
const phase = ref<Phase>('upload')
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const selectedFiles = ref<File[]>([])
|
|
||||||
const previewUrls = ref<string[]>([])
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const uploadError = ref('')
|
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
|
||||||
isDragging.value = false
|
|
||||||
const dt = e.dataTransfer
|
|
||||||
if (!dt) return
|
|
||||||
addFiles(Array.from(dt.files))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
if (!input.files) return
|
|
||||||
addFiles(Array.from(input.files))
|
|
||||||
// Reset so the same file can be re-selected after removal
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFiles(incoming: File[]) {
|
|
||||||
uploadError.value = ''
|
|
||||||
const combined = [...selectedFiles.value, ...incoming]
|
|
||||||
if (combined.length > 4) {
|
|
||||||
uploadError.value = 'Maximum 4 photos per scan.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Revoke old preview URLs before replacing
|
|
||||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
|
||||||
selectedFiles.value = combined
|
|
||||||
previewUrls.value = combined.map((f) => URL.createObjectURL(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(index: number) {
|
|
||||||
URL.revokeObjectURL(previewUrls.value[index] ?? '')
|
|
||||||
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
|
|
||||||
previewUrls.value = previewUrls.value.filter((_, i) => i !== index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scan ──────────────────────────────────────────────────────────────────────
|
|
||||||
const extracted = ref<ScannedRecipe | null>(null)
|
|
||||||
const scanStatusMessage = ref('Uploading photos...')
|
|
||||||
|
|
||||||
async function startScan() {
|
|
||||||
if (selectedFiles.value.length === 0) return
|
|
||||||
uploadError.value = ''
|
|
||||||
scanStatusMessage.value = 'Uploading photos...'
|
|
||||||
phase.value = 'processing'
|
|
||||||
try {
|
|
||||||
const result = await recipeScanAPI.scanStream(
|
|
||||||
selectedFiles.value,
|
|
||||||
(_status: string, message: string) => { scanStatusMessage.value = message },
|
|
||||||
)
|
|
||||||
extracted.value = result
|
|
||||||
initEditState(result)
|
|
||||||
phase.value = 'review'
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
uploadError.value = msg.includes('not appear to contain a recipe')
|
|
||||||
? 'This photo does not look like a recipe. Please try a different photo.'
|
|
||||||
: msg.includes('No vision backend')
|
|
||||||
? 'Recipe scanning is not available right now. Check your BYOK settings.'
|
|
||||||
: `Scan failed: ${msg}`
|
|
||||||
phase.value = 'upload'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Review/edit state ─────────────────────────────────────────────────────────
|
|
||||||
const editTitle = ref('')
|
|
||||||
const editServings = ref('')
|
|
||||||
const editCookTime = ref('')
|
|
||||||
const editIngredients = ref<ScannedIngredient[]>([])
|
|
||||||
const editSteps = ref<string[]>([])
|
|
||||||
const editNotes = ref('')
|
|
||||||
|
|
||||||
function initEditState(r: ScannedRecipe) {
|
|
||||||
editTitle.value = r.title ?? ''
|
|
||||||
editServings.value = r.servings ?? ''
|
|
||||||
editCookTime.value = r.cook_time ?? ''
|
|
||||||
editIngredients.value = r.ingredients.map((i) => ({ ...i }))
|
|
||||||
editSteps.value = [...r.steps]
|
|
||||||
editNotes.value = r.notes ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeIngredient(i: number) {
|
|
||||||
editIngredients.value = editIngredients.value.filter((_, idx) => idx !== i)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addIngredient() {
|
|
||||||
editIngredients.value = [...editIngredients.value, { name: '', qty: null, unit: null, raw: null, in_pantry: false }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStep(i: number) {
|
|
||||||
editSteps.value = editSteps.value.filter((_, idx) => idx !== i)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStep() {
|
|
||||||
editSteps.value = [...editSteps.value, '']
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pantry match display ──────────────────────────────────────────────────────
|
|
||||||
const pantryCount = computed(() =>
|
|
||||||
editIngredients.value.filter((i) => i.in_pantry).length
|
|
||||||
)
|
|
||||||
|
|
||||||
const pantryMatchClass = computed(() => {
|
|
||||||
const pct = extracted.value?.pantry_match_pct ?? 0
|
|
||||||
if (pct >= 80) return 'pantry-high'
|
|
||||||
if (pct >= 50) return 'pantry-mid'
|
|
||||||
return 'pantry-low'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Save ──────────────────────────────────────────────────────────────────────
|
|
||||||
const saving = ref(false)
|
|
||||||
const saveError = ref('')
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!editTitle.value.trim()) return
|
|
||||||
saving.value = true
|
|
||||||
saveError.value = ''
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
title: editTitle.value.trim(),
|
|
||||||
subtitle: extracted.value?.subtitle ?? null,
|
|
||||||
servings: editServings.value || null,
|
|
||||||
cook_time: editCookTime.value || null,
|
|
||||||
source_note: extracted.value?.source_note ?? null,
|
|
||||||
ingredients: editIngredients.value.filter((i) => i.name.trim()),
|
|
||||||
steps: editSteps.value.filter((s) => s.trim()),
|
|
||||||
notes: editNotes.value.trim() || null,
|
|
||||||
tags: extracted.value?.tags ?? [],
|
|
||||||
source: 'scan' as const,
|
|
||||||
}
|
|
||||||
const saved = await recipeScanAPI.saveScanned(payload)
|
|
||||||
emit('saved', { id: saved.id, title: saved.title })
|
|
||||||
close()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
saveError.value = err instanceof Error ? err.message : 'Failed to save recipe.'
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
||||||
function close() {
|
|
||||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal, 1000);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-panel {
|
|
||||||
background: var(--bg-card, #fff);
|
|
||||||
border-radius: var(--radius-lg, 12px);
|
|
||||||
box-shadow: var(--shadow-xl, 0 20px 60px rgba(0,0,0,0.2));
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
max-height: 90vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: var(--font-lg, 1.125rem);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #111);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: var(--bg-hover, #f3f4f6);
|
|
||||||
color: var(--text-primary, #111);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding-top: var(--spacing-md);
|
|
||||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Upload ── */
|
|
||||||
.hint-text {
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed var(--border-color, #d1d5db);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
min-height: 160px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone:hover,
|
|
||||||
.drop-zone-active {
|
|
||||||
border-color: var(--color-primary, #4f46e5);
|
|
||||||
background: var(--bg-hover, #f5f3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone.has-files {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--color-primary, #4f46e5);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-icon {
|
|
||||||
color: var(--text-secondary, #9ca3af);
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #111);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-sub {
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-item {
|
|
||||||
position: relative;
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-img {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
border: 1px solid var(--border-color, #e5e7eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-file-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: -6px;
|
|
||||||
right: -6px;
|
|
||||||
background: var(--color-danger, #ef4444);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-label {
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
margin: 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-add {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
border: 2px dashed var(--border-color, #d1d5db);
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary, #9ca3af);
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-add:hover {
|
|
||||||
border-color: var(--color-primary, #4f46e5);
|
|
||||||
color: var(--color-primary, #4f46e5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Processing ── */
|
|
||||||
.processing-body {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-spinner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin-icon {
|
|
||||||
color: var(--color-primary, #4f46e5);
|
|
||||||
animation: spin 1.5s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #111);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-sub {
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Review ── */
|
|
||||||
.review-body {
|
|
||||||
padding-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pantry-match-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pantry-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pantry-high { background: var(--color-success-bg, #d1fae5); color: var(--color-success, #065f46); }
|
|
||||||
.pantry-mid { background: var(--color-info-bg, #dbeafe); color: var(--color-info, #1e40af); }
|
|
||||||
.pantry-low { background: var(--bg-secondary, #f3f4f6); color: var(--text-secondary, #374151); }
|
|
||||||
|
|
||||||
.review-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row-2 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ingredients */
|
|
||||||
.ingredient-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredient-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pantry-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-success, #10b981);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-pantry {
|
|
||||||
background: var(--color-success-bg-faint, #f0fdf4);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
padding: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingr-qty { width: 60px; flex-shrink: 0; }
|
|
||||||
.ingr-unit { width: 70px; flex-shrink: 0; }
|
|
||||||
.ingr-name { flex: 1; }
|
|
||||||
|
|
||||||
.remove-ingr-btn,
|
|
||||||
.remove-step-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
color: var(--text-secondary, #9ca3af);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-ingr-btn:hover,
|
|
||||||
.remove-step-btn:hover {
|
|
||||||
background: var(--color-danger-bg, #fee2e2);
|
|
||||||
color: var(--color-danger, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Steps */
|
|
||||||
.step-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-num {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-secondary, #f3f4f6);
|
|
||||||
color: var(--text-secondary, #374151);
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-textarea {
|
|
||||||
flex: 1;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Source */
|
|
||||||
.source-note {
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
color: var(--text-secondary, #9ca3af);
|
|
||||||
text-align: right;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.optional-label {
|
|
||||||
color: var(--text-secondary, #9ca3af);
|
|
||||||
font-weight: normal;
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
padding-left: 16px;
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-primary, #4f46e5);
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover {
|
|
||||||
background: var(--bg-hover, #f5f3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-xs { margin-top: var(--spacing-xs, 4px); }
|
|
||||||
.mt-sm { margin-top: var(--spacing-sm, 8px); }
|
|
||||||
.mb-sm { margin-bottom: var(--spacing-sm, 8px); }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.form-row-2 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.modal-panel {
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
max-height: 95vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -46,14 +46,7 @@
|
||||||
|
|
||||||
<!-- Style tags -->
|
<!-- Style tags -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="flex-between mb-xs">
|
<label class="form-label">Style tags</label>
|
||||||
<label class="form-label" style="margin-bottom: 0;">Style tags</label>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary btn-xs"
|
|
||||||
:disabled="classifying"
|
|
||||||
@click="suggestTags"
|
|
||||||
>{{ classifying ? 'Suggesting…' : 'Suggest tags' }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||||
<span
|
<span
|
||||||
v-for="tag in localTags"
|
v-for="tag in localTags"
|
||||||
|
|
@ -96,7 +89,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import { savedRecipesAPI } from '../services/api'
|
|
||||||
|
|
||||||
const SUGGESTED_TAGS = [
|
const SUGGESTED_TAGS = [
|
||||||
'comforting', 'light', 'spicy', 'umami', 'sweet', 'savory', 'rich',
|
'comforting', 'light', 'spicy', 'umami', 'sweet', 'savory', 'rich',
|
||||||
|
|
@ -148,7 +140,6 @@ const localTags = ref<string[]>([...(existing.value?.style_tags ?? [])])
|
||||||
const hoverRating = ref<number | null>(null)
|
const hoverRating = ref<number | null>(null)
|
||||||
const tagInput = ref('')
|
const tagInput = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const classifying = ref(false)
|
|
||||||
|
|
||||||
const unusedSuggestions = computed(() =>
|
const unusedSuggestions = computed(() =>
|
||||||
SUGGESTED_TAGS.filter((s) => !localTags.value.includes(s))
|
SUGGESTED_TAGS.filter((s) => !localTags.value.includes(s))
|
||||||
|
|
@ -183,23 +174,6 @@ function onTagKey(e: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function suggestTags() {
|
|
||||||
classifying.value = true
|
|
||||||
try {
|
|
||||||
const suggestions = await savedRecipesAPI.classifyStyle(props.recipeId)
|
|
||||||
// Merge suggestions into localTags — new ones only, preserving user's existing tags
|
|
||||||
for (const tag of suggestions) {
|
|
||||||
if (!localTags.value.includes(tag)) {
|
|
||||||
localTags.value = [...localTags.value, tag]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore — tier gate returns 403, no LLM returns empty list
|
|
||||||
} finally {
|
|
||||||
classifying.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
<option value="saved_at">Recently saved</option>
|
<option value="saved_at">Recently saved</option>
|
||||||
<option value="rating">Highest rated</option>
|
<option value="rating">Highest rated</option>
|
||||||
<option value="title">A–Z</option>
|
<option value="title">A–Z</option>
|
||||||
<option value="last_cooked">Last cooked</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -47,7 +46,7 @@
|
||||||
<!-- Recipe cards -->
|
<!-- Recipe cards -->
|
||||||
<div class="saved-list flex-col gap-sm">
|
<div class="saved-list flex-col gap-sm">
|
||||||
<div
|
<div
|
||||||
v-for="recipe in sortedSaved"
|
v-for="recipe in store.saved"
|
||||||
:key="recipe.id"
|
:key="recipe.id"
|
||||||
class="card-sm saved-card"
|
class="card-sm saved-card"
|
||||||
:class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }"
|
:class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }"
|
||||||
|
|
@ -80,8 +79,8 @@
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last cooked chip (orbital cadence: neutral, no urgency) -->
|
<!-- Last cooked hint -->
|
||||||
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-chip text-xs mt-xs">
|
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-hint text-xs text-muted mt-xs">
|
||||||
{{ lastCookedLabel(recipe.recipe_id) }}
|
{{ lastCookedLabel(recipe.recipe_id) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -166,32 +165,20 @@ const recipesStore = useRecipesStore()
|
||||||
const editingRecipe = ref<SavedRecipe | null>(null)
|
const editingRecipe = ref<SavedRecipe | null>(null)
|
||||||
|
|
||||||
function lastCookedLabel(recipeId: number): string | null {
|
function lastCookedLabel(recipeId: number): string | null {
|
||||||
const days = recipesStore.lastCookedDaysAgo(recipeId)
|
const entries = recipesStore.cookLog.filter((e) => e.id === recipeId)
|
||||||
if (days === null) return null
|
if (entries.length === 0) return null
|
||||||
if (days === 0) return 'made today'
|
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
|
||||||
if (days === 1) return 'made yesterday'
|
const diffMs = Date.now() - latestMs
|
||||||
if (days < 7) return `made ${days} days ago`
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
if (days < 14) return 'made 1 week ago'
|
if (diffDays === 0) return 'Last made: today'
|
||||||
const weeks = Math.floor(days / 7)
|
if (diffDays === 1) return 'Last made: yesterday'
|
||||||
if (days < 60) return `made ${weeks} weeks ago`
|
if (diffDays < 7) return `Last made: ${diffDays} days ago`
|
||||||
const months = Math.floor(days / 30)
|
if (diffDays < 14) return 'Last made: 1 week ago'
|
||||||
return `made ${months} month${months !== 1 ? 's' : ''} ago`
|
const diffWeeks = Math.floor(diffDays / 7)
|
||||||
|
if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago`
|
||||||
|
const diffMonths = Math.floor(diffDays / 30)
|
||||||
|
return `Last made: ${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side last_cooked sort — resolves from localStorage cook log so no API change needed.
|
|
||||||
// Recipes with a cook date surface oldest-first (natural "due for a revisit" order without
|
|
||||||
// framing it that way). Recipes never cooked sort to the end.
|
|
||||||
const sortedSaved = computed(() => {
|
|
||||||
if (store.sortBy !== 'last_cooked') return store.saved
|
|
||||||
return [...store.saved].sort((a, b) => {
|
|
||||||
const daysA = recipesStore.lastCookedDaysAgo(a.recipe_id)
|
|
||||||
const daysB = recipesStore.lastCookedDaysAgo(b.recipe_id)
|
|
||||||
if (daysA === null && daysB === null) return 0
|
|
||||||
if (daysA === null) return 1 // never cooked → end
|
|
||||||
if (daysB === null) return -1 // never cooked → end
|
|
||||||
return daysB - daysA // oldest cooked first (largest days value first)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const showNewCollection = ref(false)
|
const showNewCollection = ref(false)
|
||||||
|
|
||||||
// #44: two-step remove confirmation
|
// #44: two-step remove confirmation
|
||||||
|
|
@ -376,14 +363,9 @@ async function createCollection() {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-cooked-chip {
|
.last-cooked-hint {
|
||||||
display: inline-block;
|
font-style: italic;
|
||||||
color: var(--color-text-muted, var(--color-secondary, #888));
|
opacity: 0.75;
|
||||||
background: var(--color-surface-subtle, transparent);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
padding: 0 var(--spacing-xs, 4px);
|
|
||||||
font-style: normal;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
<div class="settings-view">
|
<div class="settings-view">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="section-title text-xl mb-md">Settings</h2>
|
<h2 class="section-title text-xl mb-md">Settings</h2>
|
||||||
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
|
|
||||||
|
|
||||||
<!-- Cooking Equipment -->
|
<!-- Cooking Equipment -->
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -20,7 +19,7 @@
|
||||||
class="tag-chip status-badge status-info"
|
class="tag-chip status-badge status-info"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
<button class="chip-remove" @click="removeEquipment(item)" :aria-label="'Remove equipment: ' + item">×</button>
|
<button class="chip-remove" @click="removeEquipment(item)" aria-label="Remove">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -51,78 +50,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
<!-- Save button -->
|
||||||
|
<div class="flex-start gap-sm">
|
||||||
<!-- Sensory Preferences -->
|
|
||||||
<section class="mt-md">
|
|
||||||
<h3 class="text-lg font-semibold mb-xs">Sensory Preferences</h3>
|
|
||||||
<p class="text-sm text-secondary mb-md">
|
|
||||||
Tell Kiwi what your senses prefer. Recipes that don't match will be
|
|
||||||
filtered out quietly in Browse and Find. Leave everything unset and nothing is filtered.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Texture avoid pills -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">
|
|
||||||
<span class="mr-xs">Texture — avoid</span>
|
|
||||||
<span class="text-xs text-muted">(select any textures you'd rather skip)</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Texture avoidance">
|
|
||||||
<button
|
<button
|
||||||
v-for="tex in TEXTURE_OPTIONS"
|
class="btn btn-primary"
|
||||||
:key="tex.tag"
|
:disabled="settingsStore.loading"
|
||||||
:class="[
|
@click="settingsStore.save()"
|
||||||
'sensory-pill',
|
>
|
||||||
settingsStore.sensoryPreferences.avoid_textures.includes(tex.tag)
|
<span v-if="settingsStore.loading">Saving…</span>
|
||||||
? 'sensory-pill--avoided'
|
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||||
: 'sensory-pill--neutral',
|
<span v-else>Save Settings</span>
|
||||||
]"
|
</button>
|
||||||
:aria-pressed="settingsStore.sensoryPreferences.avoid_textures.includes(tex.tag)"
|
|
||||||
@click="toggleTexture(tex.tag)"
|
|
||||||
>{{ tex.emoji }} {{ tex.label }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Smell tolerance -->
|
|
||||||
<div class="form-group mt-sm">
|
|
||||||
<label class="form-label">
|
|
||||||
<span class="mr-xs">Smell — max I'm ok with</span>
|
|
||||||
<span class="text-xs text-muted">(tap to set your limit; tap again to clear)</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Smell tolerance">
|
|
||||||
<button
|
|
||||||
v-for="(level, idx) in SMELL_LEVELS"
|
|
||||||
:key="String(level.value)"
|
|
||||||
:class="['sensory-pill', getSmellClass(level.value, idx)]"
|
|
||||||
:aria-pressed="settingsStore.sensoryPreferences.max_smell === level.value"
|
|
||||||
@click="toggleSmell(level.value)"
|
|
||||||
>{{ level.emoji }} {{ level.label }}</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="settingsStore.sensoryPreferences.max_smell" class="text-xs text-muted mt-xs">
|
|
||||||
Recipes stronger than <strong>{{ smellLabel(settingsStore.sensoryPreferences.max_smell) }}</strong> will be hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Noise tolerance -->
|
|
||||||
<div class="form-group mt-sm">
|
|
||||||
<label class="form-label">
|
|
||||||
<span class="mr-xs">Noise — max I'm ok with</span>
|
|
||||||
<span class="text-xs text-muted">(tap to set your limit; tap again to clear)</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Noise tolerance">
|
|
||||||
<button
|
|
||||||
v-for="(level, idx) in NOISE_LEVELS"
|
|
||||||
:key="String(level.value)"
|
|
||||||
:class="['sensory-pill', getNoiseClass(level.value, idx)]"
|
|
||||||
:aria-pressed="settingsStore.sensoryPreferences.max_noise === level.value"
|
|
||||||
@click="toggleNoise(level.value)"
|
|
||||||
>{{ level.emoji }} {{ level.label }}</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="settingsStore.sensoryPreferences.max_noise" class="text-xs text-muted mt-xs">
|
|
||||||
Recipes louder than <strong>{{ noiseLabel(settingsStore.sensoryPreferences.max_noise) }}</strong> will be hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Units -->
|
<!-- Units -->
|
||||||
|
|
@ -147,93 +86,19 @@
|
||||||
Imperial (oz, cups, °F)
|
Imperial (oz, cups, °F)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="flex-start gap-sm">
|
||||||
|
<button
|
||||||
<!-- Shopping Locale -->
|
class="btn btn-primary btn-sm"
|
||||||
<section class="mt-md">
|
:disabled="settingsStore.loading"
|
||||||
<h3 class="text-lg font-semibold mb-xs">Shopping Region</h3>
|
@click="settingsStore.save()"
|
||||||
<p class="text-sm text-secondary mb-sm">
|
|
||||||
Sets your Amazon storefront and which retailers appear in shopping links.
|
|
||||||
Instacart and Walmart are US/CA only — other regions get Amazon.
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
class="form-input"
|
|
||||||
v-model="settingsStore.shoppingLocale"
|
|
||||||
aria-label="Shopping region"
|
|
||||||
style="max-width: 20rem;"
|
|
||||||
>
|
>
|
||||||
<optgroup label="North America">
|
<span v-if="settingsStore.loading">Saving…</span>
|
||||||
<option value="us">United States (USD $)</option>
|
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||||
<option value="ca">Canada (CAD CA$)</option>
|
<span v-else>Save</span>
|
||||||
<option value="mx">Mexico (MXN MX$)</option>
|
</button>
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Europe">
|
|
||||||
<option value="gb">United Kingdom (GBP £)</option>
|
|
||||||
<option value="de">Germany (EUR €)</option>
|
|
||||||
<option value="fr">France (EUR €)</option>
|
|
||||||
<option value="it">Italy (EUR €)</option>
|
|
||||||
<option value="es">Spain (EUR €)</option>
|
|
||||||
<option value="nl">Netherlands (EUR €)</option>
|
|
||||||
<option value="se">Sweden (SEK kr)</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Asia Pacific">
|
|
||||||
<option value="au">Australia (AUD A$)</option>
|
|
||||||
<option value="nz">New Zealand (NZD NZ$) — via Amazon AU</option>
|
|
||||||
<option value="jp">Japan (JPY ¥)</option>
|
|
||||||
<option value="in">India (INR ₹)</option>
|
|
||||||
<option value="sg">Singapore (SGD S$)</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="South America">
|
|
||||||
<option value="br">Brazil (BRL R$)</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Time-First Layout -->
|
|
||||||
<section class="mt-md">
|
|
||||||
<h3 class="text-lg font-semibold mb-xs">Recipe Search Layout</h3>
|
|
||||||
<p class="text-sm text-secondary mb-sm">
|
|
||||||
Choose how the Find tab looks when you search for recipes.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col gap-xs" role="radiogroup" aria-label="Recipe search layout">
|
|
||||||
<label
|
|
||||||
v-for="opt in timeFirstLayoutOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
class="flex-start gap-sm time-layout-option"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="time_first_layout"
|
|
||||||
:value="opt.value"
|
|
||||||
:checked="settingsStore.timeFirstLayout === opt.value"
|
|
||||||
@change="settingsStore.timeFirstLayout = opt.value"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>{{ opt.label }}</strong>
|
|
||||||
<span class="text-xs text-muted ml-xs">{{ opt.description }}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Data Sharing (cloud only) -->
|
|
||||||
<section v-if="isCloudMode" class="mt-md">
|
|
||||||
<h3 class="text-lg font-semibold mb-xs">Data Sharing</h3>
|
|
||||||
<label class="data-sharing-toggle flex-start gap-sm text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="magpieOptIn"
|
|
||||||
@change="setMagpieOptIn(($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
Share anonymized recipe ratings to help improve suggestions
|
|
||||||
</label>
|
|
||||||
<p class="text-xs text-muted mt-xs">
|
|
||||||
When enabled, Kiwi sends the recipe source ID, your star rating, and
|
|
||||||
style tags to CircuitForge. No personal information or pantry contents
|
|
||||||
are included.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Display Preferences -->
|
<!-- Display Preferences -->
|
||||||
<section class="mt-md">
|
<section class="mt-md">
|
||||||
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
||||||
|
|
@ -338,50 +203,19 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="autosave-fade">
|
|
||||||
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
|
|
||||||
✓ Saved
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
import { householdAPI, settingsAPI, type HouseholdStatus } from '../services/api'
|
import { householdAPI, type HouseholdStatus } from '../services/api'
|
||||||
import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api'
|
|
||||||
import type { TimeFirstLayout } from '../stores/settings'
|
|
||||||
import { useOrchUsage } from '../composables/useOrchUsage'
|
import { useOrchUsage } from '../composables/useOrchUsage'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const recipesStore = useRecipesStore()
|
const recipesStore = useRecipesStore()
|
||||||
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage()
|
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage()
|
||||||
|
|
||||||
// Cloud mode — baked in at build time via VITE_CLOUD_MODE=true in cloud builds
|
|
||||||
const isCloudMode = import.meta.env.VITE_CLOUD_MODE === 'true'
|
|
||||||
|
|
||||||
// Data sharing — magpie opt-in (cloud mode only)
|
|
||||||
const magpieOptIn = ref(false)
|
|
||||||
|
|
||||||
async function loadMagpieOptIn(): Promise<void> {
|
|
||||||
if (!isCloudMode) return
|
|
||||||
const value = await settingsAPI.getSetting('magpie_opt_in')
|
|
||||||
magpieOptIn.value = value === 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setMagpieOptIn(enabled: boolean): Promise<void> {
|
|
||||||
magpieOptIn.value = enabled
|
|
||||||
await settingsAPI.setSetting('magpie_opt_in', enabled ? 'true' : 'false')
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeFirstLayoutOptions: Array<{ value: TimeFirstLayout; label: string; description: string }> = [
|
|
||||||
{ value: 'auto', label: 'Auto', description: 'Shows a time selector when recipes are available.' },
|
|
||||||
{ value: 'time_first', label: 'Time First', description: 'Always show the time bucket selector at the top.' },
|
|
||||||
{ value: 'normal', label: 'Normal', description: 'Standard layout — no time selector shown.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const sortedCookLog = computed(() =>
|
const sortedCookLog = computed(() =>
|
||||||
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
||||||
)
|
)
|
||||||
|
|
@ -525,86 +359,7 @@ async function handleRemoveMember(userId: string) {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await settingsStore.load()
|
await settingsStore.load()
|
||||||
await loadHouseholdStatus()
|
await loadHouseholdStatus()
|
||||||
await loadMagpieOptIn()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Sensory taxonomy ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TEXTURE_OPTIONS: { tag: TextureTag; label: string; emoji: string }[] = [
|
|
||||||
{ tag: 'mushy', label: 'Mushy', emoji: '🦫' },
|
|
||||||
{ tag: 'slimy', label: 'Slimy', emoji: '🫙' },
|
|
||||||
{ tag: 'crunchy', label: 'Crunchy', emoji: '🥜' },
|
|
||||||
{ tag: 'chewy', label: 'Chewy', emoji: '🍖' },
|
|
||||||
{ tag: 'creamy', label: 'Creamy', emoji: '🥣' },
|
|
||||||
{ tag: 'chunky', label: 'Chunky', emoji: '🫕' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const SMELL_LEVELS: { value: SmellLevel; label: string; emoji: string }[] = [
|
|
||||||
{ value: 'mild', label: 'Mild', emoji: '🌿' },
|
|
||||||
{ value: 'aromatic', label: 'Aromatic', emoji: '🌸' },
|
|
||||||
{ value: 'pungent', label: 'Pungent', emoji: '🧄' },
|
|
||||||
{ value: 'fermented', label: 'Fermented', emoji: '🧀' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const NOISE_LEVELS: { value: NoiseLevel; label: string; emoji: string }[] = [
|
|
||||||
{ value: 'quiet', label: 'Quiet', emoji: '🤫' },
|
|
||||||
{ value: 'moderate', label: 'Moderate', emoji: '🍳' },
|
|
||||||
{ value: 'loud', label: 'Loud', emoji: '🔥' },
|
|
||||||
{ value: 'very_loud', label: 'Very loud', emoji: '💥' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function smellLabel(value: SmellLevel): string {
|
|
||||||
return SMELL_LEVELS.find(l => l.value === value)?.label ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function noiseLabel(value: NoiseLevel): string {
|
|
||||||
return NOISE_LEVELS.find(l => l.value === value)?.label ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTexture(tag: TextureTag) {
|
|
||||||
const current = settingsStore.sensoryPreferences.avoid_textures
|
|
||||||
const updated = current.includes(tag)
|
|
||||||
? current.filter(t => t !== tag)
|
|
||||||
: [...current, tag]
|
|
||||||
settingsStore.sensoryPreferences = {
|
|
||||||
...settingsStore.sensoryPreferences,
|
|
||||||
avoid_textures: updated,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSmell(value: SmellLevel) {
|
|
||||||
const current = settingsStore.sensoryPreferences.max_smell
|
|
||||||
settingsStore.sensoryPreferences = {
|
|
||||||
...settingsStore.sensoryPreferences,
|
|
||||||
max_smell: current === value ? null : value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleNoise(value: NoiseLevel) {
|
|
||||||
const current = settingsStore.sensoryPreferences.max_noise
|
|
||||||
settingsStore.sensoryPreferences = {
|
|
||||||
...settingsStore.sensoryPreferences,
|
|
||||||
max_noise: current === value ? null : value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSmellClass(_value: SmellLevel, idx: number): string {
|
|
||||||
const maxSmell = settingsStore.sensoryPreferences.max_smell
|
|
||||||
if (!maxSmell) return 'sensory-pill--neutral'
|
|
||||||
const maxIdx = SMELL_LEVELS.findIndex(l => l.value === maxSmell)
|
|
||||||
if (idx === maxIdx) return 'sensory-pill--limit'
|
|
||||||
if (idx < maxIdx) return 'sensory-pill--ok'
|
|
||||||
return 'sensory-pill--neutral'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNoiseClass(_value: NoiseLevel, idx: number): string {
|
|
||||||
const maxNoise = settingsStore.sensoryPreferences.max_noise
|
|
||||||
if (!maxNoise) return 'sensory-pill--neutral'
|
|
||||||
const maxIdx = NOISE_LEVELS.findIndex(l => l.value === maxNoise)
|
|
||||||
if (idx === maxIdx) return 'sensory-pill--limit'
|
|
||||||
if (idx < maxIdx) return 'sensory-pill--ok'
|
|
||||||
return 'sensory-pill--neutral'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -749,105 +504,16 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orch-pill-toggle,
|
.orch-pill-toggle {
|
||||||
.data-sharing-toggle {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orch-pill-toggle input[type="checkbox"],
|
.orch-pill-toggle input[type="checkbox"] {
|
||||||
.data-sharing-toggle input[type="checkbox"] {
|
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--color-primary);
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Time-first layout option ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.time-layout-option {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-xs, 0.25rem) 0;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-layout-option input[type="radio"] {
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sensory pills ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.sensory-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1.5px solid var(--color-border, #e0e0e0);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-secondary, #888);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sensory-pill:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sensory-pill--avoided {
|
|
||||||
background: rgba(220, 80, 60, 0.18);
|
|
||||||
border-color: rgba(220, 80, 60, 0.40);
|
|
||||||
color: #f08070;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sensory-pill--ok {
|
|
||||||
background: rgba(74, 140, 64, 0.15);
|
|
||||||
border-color: rgba(74, 140, 64, 0.35);
|
|
||||||
color: #7fc073;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sensory-pill--limit {
|
|
||||||
background: rgba(200, 140, 30, 0.18);
|
|
||||||
border-color: rgba(200, 140, 30, 0.45);
|
|
||||||
color: #c8a020;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sensory-pill--neutral {
|
|
||||||
background: transparent;
|
|
||||||
border-color: var(--color-border, #e0e0e0);
|
|
||||||
color: var(--color-text-secondary, #888);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Autosave toast ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.autosave-toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
right: 1.5rem;
|
|
||||||
background: var(--color-surface, #fff);
|
|
||||||
border: 1px solid var(--color-border, #e0e0e0);
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-success, #4a8c40);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
|
||||||
z-index: 500;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autosave-fade-enter-active,
|
|
||||||
.autosave-fade-leave-active {
|
|
||||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autosave-fade-enter-from,
|
|
||||||
.autosave-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(0.5rem);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -111,50 +111,9 @@ export interface BarcodeScanResult {
|
||||||
inventory_item: InventoryItem | null
|
inventory_item: InventoryItem | null
|
||||||
added_to_inventory: boolean
|
added_to_inventory: boolean
|
||||||
needs_manual_entry: boolean
|
needs_manual_entry: boolean
|
||||||
needs_visual_capture: boolean
|
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabelCaptureResult {
|
|
||||||
barcode: string
|
|
||||||
product_name: string | null
|
|
||||||
brand: string | null
|
|
||||||
serving_size_g: number | null
|
|
||||||
calories: number | null
|
|
||||||
fat_g: number | null
|
|
||||||
saturated_fat_g: number | null
|
|
||||||
carbs_g: number | null
|
|
||||||
sugar_g: number | null
|
|
||||||
fiber_g: number | null
|
|
||||||
protein_g: number | null
|
|
||||||
sodium_mg: number | null
|
|
||||||
ingredient_names: string[]
|
|
||||||
allergens: string[]
|
|
||||||
confidence: number
|
|
||||||
needs_review: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LabelConfirmRequest {
|
|
||||||
barcode: string
|
|
||||||
product_name?: string | null
|
|
||||||
brand?: string | null
|
|
||||||
serving_size_g?: number | null
|
|
||||||
calories?: number | null
|
|
||||||
fat_g?: number | null
|
|
||||||
saturated_fat_g?: number | null
|
|
||||||
carbs_g?: number | null
|
|
||||||
sugar_g?: number | null
|
|
||||||
fiber_g?: number | null
|
|
||||||
protein_g?: number | null
|
|
||||||
sodium_mg?: number | null
|
|
||||||
ingredient_names?: string[]
|
|
||||||
allergens?: string[]
|
|
||||||
confidence?: number
|
|
||||||
location?: string
|
|
||||||
quantity?: number
|
|
||||||
auto_add?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BarcodeScanResponse {
|
export interface BarcodeScanResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
barcodes_found: number
|
barcodes_found: number
|
||||||
|
|
@ -385,32 +344,6 @@ export const inventoryAPI = {
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a nutrition label photo for an unenriched barcode (paid tier).
|
|
||||||
* Returns extracted fields + confidence score for user review.
|
|
||||||
*/
|
|
||||||
async captureLabelPhoto(
|
|
||||||
file: File,
|
|
||||||
barcode: string
|
|
||||||
): Promise<LabelCaptureResult> {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
formData.append('barcode', barcode)
|
|
||||||
const response = await api.post('/inventory/scan/label-capture', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 60000, // vision inference can take ~5–10s
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm a user-reviewed label extraction and save to the local cache.
|
|
||||||
*/
|
|
||||||
async confirmLabelCapture(data: LabelConfirmRequest): Promise<{ ok: boolean; product_id?: number; inventory_item_id?: number; message: string }> {
|
|
||||||
const response = await api.post('/inventory/scan/label-confirm', data)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Receipts API ==========
|
// ========== Receipts API ==========
|
||||||
|
|
@ -567,7 +500,6 @@ export interface RecipeSuggestion {
|
||||||
source_url: string | null
|
source_url: string | null
|
||||||
complexity: 'easy' | 'moderate' | 'involved' | null
|
complexity: 'easy' | 'moderate' | 'involved' | null
|
||||||
estimated_time_min: number | null
|
estimated_time_min: number | null
|
||||||
time_effort: TimeEffortProfile | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NutritionFilters {
|
export interface NutritionFilters {
|
||||||
|
|
@ -592,24 +524,8 @@ export interface RecipeResult {
|
||||||
rate_limit_count: number
|
rate_limit_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamTokenResponse {
|
|
||||||
stream_url: string
|
|
||||||
token: string
|
|
||||||
expires_in_s: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RecipeJobStatusValue = 'queued' | 'running' | 'done' | 'failed'
|
|
||||||
|
|
||||||
export interface RecipeJobStatus {
|
|
||||||
job_id: string
|
|
||||||
status: RecipeJobStatusValue
|
|
||||||
result: RecipeResult | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeRequest {
|
export interface RecipeRequest {
|
||||||
pantry_items: string[]
|
pantry_items: string[]
|
||||||
secondary_pantry_items: Record<string, string>
|
|
||||||
level: number
|
level: number
|
||||||
constraints: string[]
|
constraints: string[]
|
||||||
allergies: string[]
|
allergies: string[]
|
||||||
|
|
@ -621,13 +537,10 @@ export interface RecipeRequest {
|
||||||
wildcard_confirmed: boolean
|
wildcard_confirmed: boolean
|
||||||
nutrition_filters: NutritionFilters
|
nutrition_filters: NutritionFilters
|
||||||
excluded_ids: number[]
|
excluded_ids: number[]
|
||||||
exclude_ingredients: string[]
|
|
||||||
shopping_mode: boolean
|
shopping_mode: boolean
|
||||||
pantry_match_only: boolean
|
pantry_match_only: boolean
|
||||||
complexity_filter: string | null
|
complexity_filter: string | null
|
||||||
max_time_min: number | null
|
max_time_min: number | null
|
||||||
max_total_min: number | null
|
|
||||||
max_active_min: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Staple {
|
export interface Staple {
|
||||||
|
|
@ -671,21 +584,6 @@ export interface BuildRequest {
|
||||||
role_overrides: Record<string, string>
|
role_overrides: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ask/RAG types ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AskRecipeHit {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
match_pct: number | null
|
|
||||||
category: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AskResponse {
|
|
||||||
answer: string | null
|
|
||||||
recipes: AskRecipeHit[]
|
|
||||||
tier: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Recipes API ==========
|
// ========== Recipes API ==========
|
||||||
|
|
||||||
export const recipesAPI = {
|
export const recipesAPI = {
|
||||||
|
|
@ -694,26 +592,10 @@ export const recipesAPI = {
|
||||||
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Submit an async job for L3/L4 generation. Returns job_id + initial status. */
|
|
||||||
async suggestAsync(req: RecipeRequest): Promise<{ job_id: string; status: string }> {
|
|
||||||
const response = await api.post('/recipes/suggest', req, { params: { async: 'true' }, timeout: 15000 })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Poll an async job. Returns the full status including result once done. */
|
|
||||||
async pollJob(jobId: string): Promise<RecipeJobStatus> {
|
|
||||||
const response = await api.get(`/recipes/jobs/${jobId}`, { timeout: 10000 })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
||||||
const response = await api.get(`/recipes/${id}`)
|
const response = await api.get(`/recipes/${id}`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
async getLeftovers(id: number): Promise<{ fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }> {
|
|
||||||
const response = await api.post(`/recipes/${id}/leftovers`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async listStaples(dietary?: string): Promise<Staple[]> {
|
async listStaples(dietary?: string): Promise<Staple[]> {
|
||||||
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
||||||
return response.data
|
return response.data
|
||||||
|
|
@ -740,72 +622,6 @@ export const recipesAPI = {
|
||||||
const response = await api.post('/recipes/build', req)
|
const response = await api.post('/recipes/build', req)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Issue a one-time stream token for LLM recipe generation (Paid tier / BYOK only). */
|
|
||||||
async getRecipeStreamToken(params: {
|
|
||||||
level: 3 | 4
|
|
||||||
wildcard_confirmed?: boolean
|
|
||||||
}): Promise<StreamTokenResponse> {
|
|
||||||
const response = await api.post('/recipes/stream-token', {
|
|
||||||
level: params.level,
|
|
||||||
wildcard_confirmed: params.wildcard_confirmed ?? false,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Natural-language recipe search with optional LLM synthesis (Paid tier). */
|
|
||||||
async ask(question: string, pantryItems: string[] = []): Promise<AskResponse> {
|
|
||||||
const response = await api.post('/recipes/ask', { question, pantry_items: pantryItems }, { timeout: 30000 })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
|
|
||||||
async suggestRecipeStream(
|
|
||||||
req: RecipeRequest,
|
|
||||||
onChunk: (chunk: string) => void,
|
|
||||||
onDone: () => void,
|
|
||||||
onError: (err: string) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const baseUrl = (api.defaults.baseURL ?? '') as string
|
|
||||||
let response: Response
|
|
||||||
try {
|
|
||||||
response = await fetch(`${baseUrl}/recipes/suggest?stream=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
})
|
|
||||||
} catch (err: unknown) {
|
|
||||||
onError(err instanceof Error ? err.message : 'Network error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
onError(`HTTP ${response.status}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader()
|
|
||||||
if (!reader) { onError('No response body'); return }
|
|
||||||
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) { onDone(); break }
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const parts = buffer.split('\n\n')
|
|
||||||
buffer = parts.pop() ?? ''
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part.startsWith('data: ')) continue
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(part.slice(6))
|
|
||||||
if (data.done) { onDone(); return }
|
|
||||||
else if (data.error) { onError(data.error); return }
|
|
||||||
else if (data.chunk) { onChunk(data.chunk) }
|
|
||||||
} catch { /* ignore malformed events */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Settings API ==========
|
// ========== Settings API ==========
|
||||||
|
|
@ -931,10 +747,6 @@ export const savedRecipesAPI = {
|
||||||
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
||||||
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
|
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
|
||||||
},
|
},
|
||||||
async classifyStyle(recipe_id: number): Promise<string[]> {
|
|
||||||
const response = await api.post(`/recipes/saved/${recipe_id}/classify-style`)
|
|
||||||
return response.data.suggested_tags
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Meal Plan types ---
|
// --- Meal Plan types ---
|
||||||
|
|
@ -1068,28 +880,6 @@ export interface BrowserDomain {
|
||||||
export interface BrowserCategory {
|
export interface BrowserCategory {
|
||||||
category: string
|
category: string
|
||||||
recipe_count: number
|
recipe_count: number
|
||||||
has_subcategories: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrowserSubcategory {
|
|
||||||
subcategory: string
|
|
||||||
recipe_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Time & Effort types ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface StepAnalysis {
|
|
||||||
is_passive: boolean
|
|
||||||
detected_minutes: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeEffortProfile {
|
|
||||||
active_min: number
|
|
||||||
passive_min: number
|
|
||||||
total_min: number
|
|
||||||
effort_label: 'quick' | 'moderate' | 'involved'
|
|
||||||
equipment: string[]
|
|
||||||
step_analyses: StepAnalysis[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserRecipe {
|
export interface BrowserRecipe {
|
||||||
|
|
@ -1097,8 +887,6 @@ export interface BrowserRecipe {
|
||||||
title: string
|
title: string
|
||||||
category: string | null
|
category: string | null
|
||||||
match_pct: number | null
|
match_pct: number | null
|
||||||
active_min: number | null
|
|
||||||
passive_min: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserResult {
|
export interface BrowserResult {
|
||||||
|
|
@ -1118,46 +906,14 @@ export const browserAPI = {
|
||||||
const response = await api.get(`/recipes/browse/${domain}`)
|
const response = await api.get(`/recipes/browse/${domain}`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
|
|
||||||
const response = await api.get(
|
|
||||||
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async browse(domain: string, category: string, params?: {
|
async browse(domain: string, category: string, params?: {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
pantry_items?: string
|
pantry_items?: string
|
||||||
subcategory?: string
|
|
||||||
q?: string
|
|
||||||
sort?: string
|
|
||||||
required_ingredient?: string
|
|
||||||
}): Promise<BrowserResult> {
|
}): Promise<BrowserResult> {
|
||||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitRecipeTag(body: {
|
|
||||||
recipe_id: number
|
|
||||||
domain: string
|
|
||||||
category: string
|
|
||||||
subcategory: string | null
|
|
||||||
pseudonym: string
|
|
||||||
}): Promise<void> {
|
|
||||||
await api.post('/recipes/community-tags', body)
|
|
||||||
},
|
|
||||||
|
|
||||||
async upvoteRecipeTag(tagId: number, pseudonym: string): Promise<void> {
|
|
||||||
await api.post(`/recipes/community-tags/${tagId}/upvote`, null, { params: { pseudonym } })
|
|
||||||
},
|
|
||||||
|
|
||||||
async listRecipeTags(recipeId: number): Promise<Array<{
|
|
||||||
id: number; domain: string; category: string; subcategory: string | null;
|
|
||||||
pseudonym: string; upvotes: number; accepted: boolean
|
|
||||||
}>> {
|
|
||||||
const response = await api.get(`/recipes/community-tags/${recipeId}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shopping List ─────────────────────────────────────────────────────────────
|
// ── Shopping List ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1256,145 +1012,4 @@ export async function bootstrapSession(): Promise<SessionInfo | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Sensory Preferences Types ==========
|
|
||||||
|
|
||||||
export type TextureTag = 'mushy' | 'slimy' | 'crunchy' | 'chewy' | 'creamy' | 'chunky'
|
|
||||||
export type SmellLevel = 'mild' | 'aromatic' | 'pungent' | 'fermented' | null
|
|
||||||
export type NoiseLevel = 'quiet' | 'moderate' | 'loud' | 'very_loud' | null
|
|
||||||
|
|
||||||
export interface SensoryPreferences {
|
|
||||||
avoid_textures: TextureTag[]
|
|
||||||
max_smell: SmellLevel
|
|
||||||
max_noise: NoiseLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
|
||||||
avoid_textures: [],
|
|
||||||
max_smell: null,
|
|
||||||
max_noise: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ScannedIngredient {
|
|
||||||
name: string
|
|
||||||
qty: string | null
|
|
||||||
unit: string | null
|
|
||||||
raw: string | null
|
|
||||||
in_pantry: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScannedRecipe {
|
|
||||||
title: string | null
|
|
||||||
subtitle: string | null
|
|
||||||
servings: string | null
|
|
||||||
cook_time: string | null
|
|
||||||
source_note: string | null
|
|
||||||
ingredients: ScannedIngredient[]
|
|
||||||
steps: string[]
|
|
||||||
notes: string | null
|
|
||||||
tags: string[]
|
|
||||||
pantry_match_pct: number
|
|
||||||
confidence: 'high' | 'medium' | 'low'
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserRecipe {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
subtitle: string | null
|
|
||||||
servings: string | null
|
|
||||||
cook_time: string | null
|
|
||||||
source_note: string | null
|
|
||||||
ingredients: ScannedIngredient[]
|
|
||||||
steps: string[]
|
|
||||||
notes: string | null
|
|
||||||
tags: string[]
|
|
||||||
source: string
|
|
||||||
pantry_match_pct: number | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const recipeScanAPI = {
|
|
||||||
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
|
|
||||||
scan(files: File[]): Promise<ScannedRecipe> {
|
|
||||||
const form = new FormData()
|
|
||||||
files.forEach((f) => form.append('files', f))
|
|
||||||
return api.post('/recipes/scan', form, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 120_000, // VLM can be slow on first call
|
|
||||||
}).then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Scan recipe photos with live SSE progress events.
|
|
||||||
*
|
|
||||||
* Calls onProgress(status, message) for each intermediate event
|
|
||||||
* ("allocating", "scanning", "structuring"), then resolves with the final
|
|
||||||
* ScannedRecipe on success. Rejects on error or timeout.
|
|
||||||
*/
|
|
||||||
async scanStream(
|
|
||||||
files: File[],
|
|
||||||
onProgress: (status: string, message: string) => void,
|
|
||||||
): Promise<ScannedRecipe> {
|
|
||||||
const form = new FormData()
|
|
||||||
files.forEach((f) => form.append('files', f))
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/recipes/scan/stream`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
let detail = ''
|
|
||||||
try { detail = await response.text() } catch (_) { /* ignore */ }
|
|
||||||
throw new Error(detail || `Scan failed (${response.status})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() ?? ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith('data: ')) continue
|
|
||||||
let data: Record<string, unknown>
|
|
||||||
try { data = JSON.parse(line.slice(6)) } catch { continue }
|
|
||||||
|
|
||||||
if (data.status === 'done') return data.recipe as ScannedRecipe
|
|
||||||
if (data.status === 'error') throw new Error((data.message as string) || 'Scan failed')
|
|
||||||
onProgress(data.status as string, data.message as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Stream ended without a result')
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Save a reviewed/edited scanned recipe to user_recipes. */
|
|
||||||
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
|
|
||||||
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** List all user-created recipes (scan + manual). */
|
|
||||||
listUserRecipes(): Promise<UserRecipe[]> {
|
|
||||||
return api.get('/recipes/user').then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Get a single user recipe by ID. */
|
|
||||||
getUserRecipe(id: number): Promise<UserRecipe> {
|
|
||||||
return api.get(`/recipes/user/${id}`).then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Delete a user recipe. */
|
|
||||||
deleteUserRecipe(id: number): Promise<void> {
|
|
||||||
return api.delete(`/recipes/user/${id}`).then(() => undefined)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -64,20 +64,6 @@ export interface PublishPayload {
|
||||||
recipe_id?: number
|
recipe_id?: number
|
||||||
outcome_notes?: string
|
outcome_notes?: string
|
||||||
slots?: CommunityPostSlot[]
|
slots?: CommunityPostSlot[]
|
||||||
similar_to_ref?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SimilarityTier = 'exact_recipe' | 'very_similar' | 'somewhat_similar'
|
|
||||||
|
|
||||||
export interface SimilarPost {
|
|
||||||
slug: string
|
|
||||||
title: string
|
|
||||||
recipe_name: string | null
|
|
||||||
pseudonym: string
|
|
||||||
published: string
|
|
||||||
similarity_tier: SimilarityTier
|
|
||||||
jaccard_score: number | null
|
|
||||||
tier_description: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublishResult {
|
export interface PublishResult {
|
||||||
|
|
@ -121,25 +107,6 @@ export const useCommunityStore = defineStore('community', () => {
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkSimilar(
|
|
||||||
title: string,
|
|
||||||
recipeId?: number | null,
|
|
||||||
postType?: string,
|
|
||||||
): Promise<SimilarPost[]> {
|
|
||||||
try {
|
|
||||||
const body: Record<string, unknown> = { title }
|
|
||||||
if (recipeId != null) body.recipe_id = recipeId
|
|
||||||
if (postType) body.post_type = postType
|
|
||||||
const response = await api.post<{ similar_posts: SimilarPost[] }>(
|
|
||||||
'/community/check-similar',
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
return response.data.similar_posts
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
posts,
|
posts,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -148,6 +115,5 @@ export const useCommunityStore = defineStore('community', () => {
|
||||||
fetchPosts,
|
fetchPosts,
|
||||||
forkPost,
|
forkPost,
|
||||||
publishPost,
|
publishPost,
|
||||||
checkSimilar,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,11 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await inventoryAPI.listItems({
|
items.value = await inventoryAPI.listItems({
|
||||||
item_status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
item_status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||||
location: locationFilter.value === 'all' ? undefined : locationFilter.value,
|
location: locationFilter.value === 'all' ? undefined : locationFilter.value,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
})
|
})
|
||||||
items.value = Array.isArray(result) ? result : []
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch inventory items'
|
error.value = err.response?.data?.detail || 'Failed to fetch inventory items'
|
||||||
console.error('Error fetching inventory:', err)
|
console.error('Error fetching inventory:', err)
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await mealPlanAPI.list()
|
plans.value = await mealPlanAPI.list()
|
||||||
plans.value = Array.isArray(result) ? result : []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type RecipeJobStatusValue, type NutritionFilters } from '../services/api'
|
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
|
||||||
|
|
||||||
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
||||||
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
@ -23,7 +23,6 @@ const FILTER_MODE_KEY = 'kiwi:builder_filter_mode'
|
||||||
|
|
||||||
const CONSTRAINTS_KEY = 'kiwi:constraints'
|
const CONSTRAINTS_KEY = 'kiwi:constraints'
|
||||||
const ALLERGIES_KEY = 'kiwi:allergies'
|
const ALLERGIES_KEY = 'kiwi:allergies'
|
||||||
const EXCLUDE_INGREDIENTS_KEY = 'kiwi:exclude_ingredients'
|
|
||||||
|
|
||||||
function loadConstraints(): string[] {
|
function loadConstraints(): string[] {
|
||||||
try {
|
try {
|
||||||
|
|
@ -51,19 +50,6 @@ function saveAllergies(vals: string[]) {
|
||||||
localStorage.setItem(ALLERGIES_KEY, JSON.stringify(vals))
|
localStorage.setItem(ALLERGIES_KEY, JSON.stringify(vals))
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadExcludeIngredients(): string[] {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(EXCLUDE_INGREDIENTS_KEY)
|
|
||||||
return raw ? JSON.parse(raw) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveExcludeIngredients(vals: string[]) {
|
|
||||||
localStorage.setItem(EXCLUDE_INGREDIENTS_KEY, JSON.stringify(vals))
|
|
||||||
}
|
|
||||||
|
|
||||||
type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart'
|
type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart'
|
||||||
type BuilderFilterMode = 'text' | 'tags'
|
type BuilderFilterMode = 'text' | 'tags'
|
||||||
|
|
||||||
|
|
@ -135,13 +121,11 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const result = ref<RecipeResult | null>(null)
|
const result = ref<RecipeResult | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const jobStatus = ref<RecipeJobStatusValue | null>(null)
|
|
||||||
|
|
||||||
// Request parameters
|
// Request parameters
|
||||||
const level = ref(1)
|
const level = ref(1)
|
||||||
const constraints = ref<string[]>(loadConstraints())
|
const constraints = ref<string[]>(loadConstraints())
|
||||||
const allergies = ref<string[]>(loadAllergies())
|
const allergies = ref<string[]>(loadAllergies())
|
||||||
const excludeIngredients = ref<string[]>(loadExcludeIngredients())
|
|
||||||
const hardDayMode = ref(false)
|
const hardDayMode = ref(false)
|
||||||
const maxMissing = ref<number | null>(null)
|
const maxMissing = ref<number | null>(null)
|
||||||
const styleId = ref<string | null>(null)
|
const styleId = ref<string | null>(null)
|
||||||
|
|
@ -151,8 +135,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const pantryMatchOnly = ref(false)
|
const pantryMatchOnly = ref(false)
|
||||||
const complexityFilter = ref<string | null>(null)
|
const complexityFilter = ref<string | null>(null)
|
||||||
const maxTimeMin = ref<number | null>(null)
|
const maxTimeMin = ref<number | null>(null)
|
||||||
const maxTotalMin = ref<number | null>(null)
|
|
||||||
const maxActiveMin = ref<number | null>(null)
|
|
||||||
const nutritionFilters = ref<NutritionFilters>({
|
const nutritionFilters = ref<NutritionFilters>({
|
||||||
max_calories: null,
|
max_calories: null,
|
||||||
max_sugar_g: null,
|
max_sugar_g: null,
|
||||||
|
|
@ -178,19 +160,13 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val))
|
watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val))
|
||||||
watch(constraints, (val) => saveConstraints(val), { deep: true })
|
watch(constraints, (val) => saveConstraints(val), { deep: true })
|
||||||
watch(allergies, (val) => saveAllergies(val), { deep: true })
|
watch(allergies, (val) => saveAllergies(val), { deep: true })
|
||||||
watch(excludeIngredients, (val) => saveExcludeIngredients(val), { deep: true })
|
|
||||||
|
|
||||||
const dismissedCount = computed(() => dismissedIds.value.size)
|
const dismissedCount = computed(() => dismissedIds.value.size)
|
||||||
|
|
||||||
function _buildRequest(
|
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
|
||||||
pantryItems: string[],
|
|
||||||
secondaryPantryItems: Record<string, string> = {},
|
|
||||||
extraExcluded: number[] = [],
|
|
||||||
): RecipeRequest {
|
|
||||||
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
||||||
return {
|
return {
|
||||||
pantry_items: pantryItems,
|
pantry_items: pantryItems,
|
||||||
secondary_pantry_items: secondaryPantryItems,
|
|
||||||
level: level.value,
|
level: level.value,
|
||||||
constraints: constraints.value,
|
constraints: constraints.value,
|
||||||
allergies: allergies.value,
|
allergies: allergies.value,
|
||||||
|
|
@ -202,13 +178,10 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
wildcard_confirmed: wildcardConfirmed.value,
|
wildcard_confirmed: wildcardConfirmed.value,
|
||||||
nutrition_filters: nutritionFilters.value,
|
nutrition_filters: nutritionFilters.value,
|
||||||
excluded_ids: [...excluded],
|
excluded_ids: [...excluded],
|
||||||
exclude_ingredients: excludeIngredients.value,
|
|
||||||
shopping_mode: shoppingMode.value,
|
shopping_mode: shoppingMode.value,
|
||||||
pantry_match_only: pantryMatchOnly.value,
|
pantry_match_only: pantryMatchOnly.value,
|
||||||
complexity_filter: complexityFilter.value,
|
complexity_filter: complexityFilter.value,
|
||||||
max_time_min: maxTimeMin.value,
|
max_time_min: maxTimeMin.value,
|
||||||
max_total_min: maxTotalMin.value,
|
|
||||||
max_active_min: maxActiveMin.value,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,68 +191,29 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
async function suggest(pantryItems: string[]) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
jobStatus.value = null
|
|
||||||
seenIds.value = new Set()
|
seenIds.value = new Set()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (level.value >= 3) {
|
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
||||||
await _suggestAsync(pantryItems, secondaryPantryItems)
|
|
||||||
} else {
|
|
||||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
|
|
||||||
_trackSeen(result.value.suggestions)
|
_trackSeen(result.value.suggestions)
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
jobStatus.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _suggestAsync(pantryItems: string[], secondaryPantryItems: Record<string, string>) {
|
async function loadMore(pantryItems: string[]) {
|
||||||
const queued = await recipesAPI.suggestAsync(_buildRequest(pantryItems, secondaryPantryItems))
|
|
||||||
|
|
||||||
// CLOUD_MODE or future sync fallback: server returned result directly (status 200)
|
|
||||||
if ('suggestions' in queued) {
|
|
||||||
result.value = queued as unknown as RecipeResult
|
|
||||||
_trackSeen(result.value.suggestions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobStatus.value = 'queued'
|
|
||||||
const { job_id } = queued
|
|
||||||
const deadline = Date.now() + 90_000
|
|
||||||
const POLL_MS = 2_500
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
|
|
||||||
const poll = await recipesAPI.pollJob(job_id)
|
|
||||||
jobStatus.value = poll.status
|
|
||||||
|
|
||||||
if (poll.status === 'done') {
|
|
||||||
result.value = poll.result
|
|
||||||
if (result.value) _trackSeen(result.value.suggestions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (poll.status === 'failed') {
|
|
||||||
throw new Error(poll.error ?? 'Recipe generation failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Recipe generation timed out — the model may be busy. Try again.')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
|
||||||
if (!result.value || loading.value) return
|
if (!result.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Exclude everything already shown (dismissed + all seen this session)
|
// Exclude everything already shown (dismissed + all seen this session)
|
||||||
const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value]))
|
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
|
||||||
if (more.suggestions.length === 0) {
|
if (more.suggestions.length === 0) {
|
||||||
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -320,8 +254,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
localStorage.removeItem(DISMISSED_KEY)
|
localStorage.removeItem(DISMISSED_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orbital cadence: cookedAt anchors to completion, not to a schedule.
|
|
||||||
// Days-since display measures from this timestamp — no debt accumulates.
|
|
||||||
function logCook(id: number, title: string) {
|
function logCook(id: number, title: string) {
|
||||||
const entry: CookLogEntry = { id, title, cookedAt: Date.now() }
|
const entry: CookLogEntry = { id, title, cookedAt: Date.now() }
|
||||||
cookLog.value = [...cookLog.value, entry]
|
cookLog.value = [...cookLog.value, entry]
|
||||||
|
|
@ -333,13 +265,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
localStorage.removeItem(COOK_LOG_KEY)
|
localStorage.removeItem(COOK_LOG_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastCookedDaysAgo(recipeId: number): number | null {
|
|
||||||
const entries = cookLog.value.filter((e) => e.id === recipeId)
|
|
||||||
if (entries.length === 0) return null
|
|
||||||
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
|
|
||||||
return Math.floor((Date.now() - latestMs) / 86_400_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBookmarked(id: number): boolean {
|
function isBookmarked(id: number): boolean {
|
||||||
return bookmarks.value.some((b) => b.id === id)
|
return bookmarks.value.some((b) => b.id === id)
|
||||||
}
|
}
|
||||||
|
|
@ -368,37 +293,19 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
localStorage.removeItem(ALLERGIES_KEY)
|
localStorage.removeItem(ALLERGIES_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearExcludeIngredients() {
|
|
||||||
excludeIngredients.value = []
|
|
||||||
localStorage.removeItem(EXCLUDE_INGREDIENTS_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResult() {
|
function clearResult() {
|
||||||
result.value = null
|
result.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
wildcardConfirmed.value = false
|
wildcardConfirmed.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamSuggest(
|
|
||||||
pantryItems: string[],
|
|
||||||
secondaryPantryItems: Record<string, string>,
|
|
||||||
onChunk: (chunk: string) => void,
|
|
||||||
onDone: () => void,
|
|
||||||
onError: (err: string) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const req = _buildRequest(pantryItems, secondaryPantryItems)
|
|
||||||
await recipesAPI.suggestRecipeStream(req, onChunk, onDone, onError)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
jobStatus,
|
|
||||||
level,
|
level,
|
||||||
constraints,
|
constraints,
|
||||||
allergies,
|
allergies,
|
||||||
excludeIngredients,
|
|
||||||
hardDayMode,
|
hardDayMode,
|
||||||
maxMissing,
|
maxMissing,
|
||||||
styleId,
|
styleId,
|
||||||
|
|
@ -408,26 +315,21 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
pantryMatchOnly,
|
pantryMatchOnly,
|
||||||
complexityFilter,
|
complexityFilter,
|
||||||
maxTimeMin,
|
maxTimeMin,
|
||||||
maxTotalMin,
|
|
||||||
maxActiveMin,
|
|
||||||
nutritionFilters,
|
nutritionFilters,
|
||||||
dismissedIds,
|
dismissedIds,
|
||||||
dismissedCount,
|
dismissedCount,
|
||||||
cookLog,
|
cookLog,
|
||||||
logCook,
|
logCook,
|
||||||
clearCookLog,
|
clearCookLog,
|
||||||
lastCookedDaysAgo,
|
|
||||||
bookmarks,
|
bookmarks,
|
||||||
isBookmarked,
|
isBookmarked,
|
||||||
toggleBookmark,
|
toggleBookmark,
|
||||||
clearBookmarks,
|
clearBookmarks,
|
||||||
clearConstraints,
|
clearConstraints,
|
||||||
clearAllergies,
|
clearAllergies,
|
||||||
clearExcludeIngredients,
|
|
||||||
missingIngredientMode,
|
missingIngredientMode,
|
||||||
builderFilterMode,
|
builderFilterMode,
|
||||||
suggest,
|
suggest,
|
||||||
streamSuggest,
|
|
||||||
loadMore,
|
loadMore,
|
||||||
dismiss,
|
dismiss,
|
||||||
undismiss,
|
undismiss,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const useSavedRecipesStore = defineStore('savedRecipes', () => {
|
||||||
const saved = ref<SavedRecipe[]>([])
|
const saved = ref<SavedRecipe[]>([])
|
||||||
const collections = ref<RecipeCollection[]>([])
|
const collections = ref<RecipeCollection[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const sortBy = ref<'saved_at' | 'rating' | 'title' | 'last_cooked'>('saved_at')
|
const sortBy = ref<'saved_at' | 'rating' | 'title'>('saved_at')
|
||||||
const activeCollectionId = ref<number | null>(null)
|
const activeCollectionId = ref<number | null>(null)
|
||||||
|
|
||||||
const savedIds = computed(() => new Set(saved.value.map((s) => s.recipe_id)))
|
const savedIds = computed(() => new Set(saved.value.map((s) => s.recipe_id)))
|
||||||
|
|
@ -27,15 +27,12 @@ export const useSavedRecipesStore = defineStore('savedRecipes', () => {
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Fetch independently — a collections 403 (Free tier) must not prevent
|
const [items, cols] = await Promise.all([
|
||||||
// saved recipes from loading. Backend now returns [] for Free, but guard
|
savedRecipesAPI.list({ sort_by: sortBy.value, collection_id: activeCollectionId.value ?? undefined }),
|
||||||
// here too in case an older API version is deployed.
|
|
||||||
const [itemsResult, colsResult] = await Promise.allSettled([
|
|
||||||
savedRecipesAPI.list({ sort_by: sortBy.value === 'last_cooked' ? 'saved_at' : sortBy.value, collection_id: activeCollectionId.value ?? undefined }),
|
|
||||||
savedRecipesAPI.listCollections(),
|
savedRecipesAPI.listCollections(),
|
||||||
])
|
])
|
||||||
if (itemsResult.status === 'fulfilled') saved.value = itemsResult.value
|
saved.value = items
|
||||||
if (colsResult.status === 'fulfilled') collections.value = colsResult.value
|
collections.value = cols
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Settings Store
|
||||||
|
*
|
||||||
|
* Manages user settings (cooking equipment, preferences) using Pinia.
|
||||||
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { settingsAPI } from '../services/api'
|
import { settingsAPI } from '../services/api'
|
||||||
import type { UnitSystem } from '../utils/units'
|
import type { UnitSystem } from '../utils/units'
|
||||||
import type { SensoryPreferences } from '../services/api'
|
|
||||||
import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
|
|
||||||
|
|
||||||
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
|
|
||||||
|
|
||||||
function debounce(fn: () => void, ms: number): () => void {
|
|
||||||
let t: ReturnType<typeof setTimeout>
|
|
||||||
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
// State
|
||||||
const cookingEquipment = ref<string[]>([])
|
const cookingEquipment = ref<string[]>([])
|
||||||
const unitSystem = ref<UnitSystem>('metric')
|
const unitSystem = ref<UnitSystem>('metric')
|
||||||
const shoppingLocale = ref<string>('us')
|
|
||||||
const sensoryPreferences = ref<SensoryPreferences>({ ...DEFAULT_SENSORY_PREFERENCES })
|
|
||||||
const timeFirstLayout = ref<TimeFirstLayout>('auto')
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
|
||||||
// Prevents autosave watchers from firing during initial load hydration.
|
// Actions
|
||||||
// Set to true after nextTick() at the end of load() — by that point all
|
|
||||||
// watcher jobs queued by the hydration assignments have already flushed.
|
|
||||||
let _hydrated = false
|
|
||||||
|
|
||||||
function _flash() {
|
|
||||||
saved.value = true
|
|
||||||
setTimeout(() => { saved.value = false }, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _saveKey(key: string, value: string): Promise<void> {
|
|
||||||
if (!_hydrated) return
|
|
||||||
try {
|
|
||||||
await settingsAPI.setSetting(key, value)
|
|
||||||
_flash()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('Autosave failed for key:', key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _autosave = {
|
|
||||||
equipment: debounce(() => _saveKey('cooking_equipment', JSON.stringify(cookingEquipment.value)), 600),
|
|
||||||
unit: debounce(() => _saveKey('unit_system', unitSystem.value), 600),
|
|
||||||
locale: debounce(() => _saveKey('shopping_locale', shoppingLocale.value), 600),
|
|
||||||
sensory: debounce(() => _saveKey('sensory_preferences', JSON.stringify(sensoryPreferences.value)), 600),
|
|
||||||
layout: debounce(() => _saveKey('time_first_layout', timeFirstLayout.value), 600),
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(cookingEquipment, _autosave.equipment, { deep: true })
|
|
||||||
watch(unitSystem, _autosave.unit)
|
|
||||||
watch(shoppingLocale, _autosave.locale)
|
|
||||||
watch(sensoryPreferences, _autosave.sensory, { deep: true })
|
|
||||||
watch(timeFirstLayout, _autosave.layout)
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rawEquipment, rawUnits, rawLocale, rawSensory, rawTimeFirst] = await Promise.allSettled([
|
const [rawEquipment, rawUnits] = await Promise.allSettled([
|
||||||
settingsAPI.getSetting('cooking_equipment'),
|
settingsAPI.getSetting('cooking_equipment'),
|
||||||
settingsAPI.getSetting('unit_system'),
|
settingsAPI.getSetting('unit_system'),
|
||||||
settingsAPI.getSetting('shopping_locale'),
|
|
||||||
settingsAPI.getSetting('sensory_preferences'),
|
|
||||||
settingsAPI.getSetting('time_first_layout'),
|
|
||||||
])
|
])
|
||||||
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
|
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
|
||||||
cookingEquipment.value = JSON.parse(rawEquipment.value)
|
cookingEquipment.value = JSON.parse(rawEquipment.value)
|
||||||
|
|
@ -71,44 +30,24 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
if (rawUnits.status === 'fulfilled' && rawUnits.value) {
|
if (rawUnits.status === 'fulfilled' && rawUnits.value) {
|
||||||
unitSystem.value = rawUnits.value as UnitSystem
|
unitSystem.value = rawUnits.value as UnitSystem
|
||||||
}
|
}
|
||||||
if (rawLocale.status === 'fulfilled' && rawLocale.value) {
|
|
||||||
shoppingLocale.value = rawLocale.value
|
|
||||||
}
|
|
||||||
if (rawSensory.status === 'fulfilled' && rawSensory.value) {
|
|
||||||
try {
|
|
||||||
sensoryPreferences.value = JSON.parse(rawSensory.value)
|
|
||||||
} catch {
|
|
||||||
sensoryPreferences.value = { ...DEFAULT_SENSORY_PREFERENCES }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rawTimeFirst.status === 'fulfilled' && rawTimeFirst.value) {
|
|
||||||
timeFirstLayout.value = rawTimeFirst.value as TimeFirstLayout
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load settings:', err)
|
console.error('Failed to load settings:', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
// Yield past the watcher flush triggered by hydration assignments above.
|
|
||||||
// After nextTick, any pending watcher jobs from this load() have already
|
|
||||||
// run (and been ignored by _hydrated guard), so user-driven changes from
|
|
||||||
// here forward will correctly trigger autosave.
|
|
||||||
await nextTick()
|
|
||||||
_hydrated = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept for explicit full-save scenarios (e.g. fallback, tests).
|
|
||||||
async function save() {
|
async function save() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)),
|
settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)),
|
||||||
settingsAPI.setSetting('unit_system', unitSystem.value),
|
settingsAPI.setSetting('unit_system', unitSystem.value),
|
||||||
settingsAPI.setSetting('shopping_locale', shoppingLocale.value),
|
|
||||||
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
|
||||||
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
|
||||||
])
|
])
|
||||||
_flash()
|
saved.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saved.value = false
|
||||||
|
}, 2000)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save settings:', err)
|
console.error('Failed to save settings:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -116,26 +55,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept for backward compat; autosave handles sensory changes now.
|
|
||||||
async function saveSensory() {
|
|
||||||
try {
|
|
||||||
await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
|
|
||||||
_flash()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('Failed to save sensory preferences:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// State
|
||||||
cookingEquipment,
|
cookingEquipment,
|
||||||
unitSystem,
|
unitSystem,
|
||||||
shoppingLocale,
|
|
||||||
sensoryPreferences,
|
|
||||||
timeFirstLayout,
|
|
||||||
loading,
|
loading,
|
||||||
saved,
|
saved,
|
||||||
|
|
||||||
|
// Actions
|
||||||
load,
|
load,
|
||||||
save,
|
save,
|
||||||
saveSensory,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue