feat(tasks): add background task scheduler for LLM expiry fallback #6
308 changed files with 1648 additions and 49814 deletions
44
.cliff.toml
44
.cliff.toml
|
|
@ -1,44 +0,0 @@
|
||||||
# git-cliff changelog configuration for Kiwi
|
|
||||||
# See: https://git-cliff.org/docs/configuration
|
|
||||||
|
|
||||||
[changelog]
|
|
||||||
header = """
|
|
||||||
# Changelog\n
|
|
||||||
"""
|
|
||||||
body = """
|
|
||||||
{% if version %}\
|
|
||||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
||||||
{% else %}\
|
|
||||||
## [Unreleased]
|
|
||||||
{% endif %}\
|
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
|
||||||
### {{ group | upper_first }}
|
|
||||||
{% for commit in commits %}
|
|
||||||
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}\n
|
|
||||||
"""
|
|
||||||
trim = true
|
|
||||||
|
|
||||||
[git]
|
|
||||||
conventional_commits = true
|
|
||||||
filter_unconventional = true
|
|
||||||
split_commits = false
|
|
||||||
commit_preprocessors = []
|
|
||||||
commit_parsers = [
|
|
||||||
{ message = "^feat", group = "Features" },
|
|
||||||
{ message = "^fix", group = "Bug Fixes" },
|
|
||||||
{ message = "^perf", group = "Performance" },
|
|
||||||
{ message = "^refactor", group = "Refactoring" },
|
|
||||||
{ message = "^docs", group = "Documentation" },
|
|
||||||
{ message = "^test", group = "Testing" },
|
|
||||||
{ message = "^chore", group = "Chores" },
|
|
||||||
{ message = "^ci", group = "CI/CD" },
|
|
||||||
{ message = "^revert", group = "Reverts" },
|
|
||||||
]
|
|
||||||
filter_commits = false
|
|
||||||
tag_pattern = "v[0-9].*"
|
|
||||||
skip_tags = ""
|
|
||||||
ignore_tags = ""
|
|
||||||
topo_order = false
|
|
||||||
sort_commits = "oldest"
|
|
||||||
73
.env.example
73
.env.example
|
|
@ -11,35 +11,6 @@ DATA_DIR=./data
|
||||||
# Database (defaults to DATA_DIR/kiwi.db)
|
# Database (defaults to DATA_DIR/kiwi.db)
|
||||||
# DB_PATH=./data/kiwi.db
|
# DB_PATH=./data/kiwi.db
|
||||||
|
|
||||||
# Pipeline data directory for downloaded parquets (used by download_datasets.py)
|
|
||||||
# Override to store large datasets on a separate drive or NAS
|
|
||||||
# KIWI_PIPELINE_DATA_DIR=./data/pipeline
|
|
||||||
|
|
||||||
# CF-core resource coordinator (VRAM lease management)
|
|
||||||
# Set to the coordinator URL when running alongside cf-core orchestration
|
|
||||||
# COORDINATOR_URL=http://localhost:7700
|
|
||||||
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
|
||||||
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
|
||||||
|
|
||||||
# GPU inference server (cf-orch coordinator for recipe scan, LLM generation, etc.)
|
|
||||||
# GPU_SERVER_URL: set to your local cf-orch coordinator (self-hosted rack).
|
|
||||||
# CF_ORCH_URL is the backward-compat alias — both are honoured.
|
|
||||||
# Paid+ default: when CF_LICENSE_KEY is present and neither URL is set,
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
|
||||||
# LLMRouter checks these in priority order:
|
|
||||||
# 1. Anthropic cloud — set ANTHROPIC_API_KEY
|
|
||||||
# 2. OpenAI cloud — set OPENAI_API_KEY
|
|
||||||
# 3. Local Ollama — set OLLAMA_HOST (+ optionally OLLAMA_MODEL)
|
|
||||||
# All three are optional; leave unset to rely on a local llm.yaml instead.
|
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
# OPENAI_API_KEY=sk-...
|
|
||||||
# OLLAMA_HOST=http://localhost:11434
|
|
||||||
# OLLAMA_MODEL=llama3.2
|
|
||||||
|
|
||||||
# Processing
|
# Processing
|
||||||
USE_GPU=true
|
USE_GPU=true
|
||||||
GPU_MEMORY_LIMIT=6144
|
GPU_MEMORY_LIMIT=6144
|
||||||
|
|
@ -53,56 +24,14 @@ 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
|
||||||
# KIWI_DB=data/kiwi.db # local-mode DB path override
|
# KIWI_DB=data/kiwi.db # local-mode DB path override
|
||||||
# DEV ONLY: bypass JWT auth for these IPs/CIDRs (LAN testing without Caddy in the path).
|
|
||||||
# NEVER set in production.
|
|
||||||
# IMPORTANT: Docker port mapping NATs source IPs to the bridge gateway. When hitting
|
|
||||||
# localhost:8515 (host → Docker → nginx → API), nginx sees 192.168.80.1, not 127.0.0.1.
|
|
||||||
# Include the Docker bridge CIDR to allow localhost and LAN access through nginx.
|
|
||||||
# Run: docker network inspect kiwi-cloud_kiwi-cloud-net | grep Subnet
|
|
||||||
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1,::1,192.168.80.0/20
|
|
||||||
# CLOUD_AUTH_BYPASS_IPS=
|
|
||||||
|
|
||||||
# Heimdall license server (required for cloud tier resolution)
|
# Heimdall license server (required for cloud tier resolution)
|
||||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||||
# HEIMDALL_ADMIN_TOKEN=
|
# HEIMDALL_ADMIN_TOKEN=
|
||||||
|
|
||||||
# Directus JWT (must match cf-directus SECRET env var exactly, including base64 == padding)
|
# Directus JWT (must match cf-directus SECRET env var)
|
||||||
# DIRECTUS_JWT_SECRET=
|
# DIRECTUS_JWT_SECRET=
|
||||||
|
|
||||||
# E2E test account (Directus — free tier, used by automated tests)
|
|
||||||
# E2E_TEST_EMAIL=e2e@circuitforge.tech
|
|
||||||
# E2E_TEST_PASSWORD=
|
|
||||||
# E2E_TEST_USER_ID=
|
|
||||||
|
|
||||||
# In-app feedback → Forgejo issue creation
|
|
||||||
# FORGEJO_API_TOKEN=
|
|
||||||
# FORGEJO_REPO=Circuit-Forge/kiwi
|
|
||||||
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
|
||||||
|
|
||||||
# Affiliate links (optional — plain URLs are shown if unset)
|
|
||||||
# Amazon Associates tag (circuitforge_core.affiliates, retailer="amazon")
|
|
||||||
# AMAZON_ASSOCIATES_TAG=circuitforge-20
|
|
||||||
# Instacart affiliate ID (circuitforge_core.affiliates, retailer="instacart")
|
|
||||||
# INSTACART_AFFILIATE_ID=circuitforge
|
|
||||||
# Walmart Impact network affiliate ID (inline, path-based redirect)
|
|
||||||
# WALMART_AFFILIATE_ID=
|
|
||||||
|
|
||||||
|
|
||||||
# Community PostgreSQL — shared across CF products (cloud only; leave unset for local dev)
|
|
||||||
# Points at cf-orch's cf-community-postgres container (port 5434 on the orch host).
|
|
||||||
# When unset, community write paths fail soft with a plain-language message.
|
|
||||||
# COMMUNITY_DB_URL=postgresql://cf_community:changeme@cf-orch-host:5434/cf_community
|
|
||||||
# COMMUNITY_PSEUDONYM_SALT=change-this-to-a-random-32-char-string
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Kiwi CI — lint, type-check, test on PR/push
|
|
||||||
# Full-stack: FastAPI (Python) + Vue 3 SPA (Node)
|
|
||||||
# Adapted from Circuit-Forge/cf-agents workflows/ci.yml (cf-agents#4 tracks the
|
|
||||||
# upstream ci-fullstack.yml variant; update this file when that lands).
|
|
||||||
#
|
|
||||||
# Note: frontend has no test suite yet — CI runs typecheck only.
|
|
||||||
# Add `npm run test` when vitest is wired (kiwi#XX).
|
|
||||||
#
|
|
||||||
# circuitforge-core is not on PyPI — installed from Forgejo git (public repo).
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, 'feature/**', 'fix/**']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e ".[dev]" || pip install -e . pytest pytest-asyncio httpx ruff
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Mirror push to GitHub and Codeberg on every push to main or tag.
|
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml
|
|
||||||
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
|
|
||||||
|
|
||||||
name: Mirror
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mirror:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Mirror to GitHub
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
run: |
|
|
||||||
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
|
|
||||||
git push github --mirror
|
|
||||||
|
|
||||||
- name: Mirror to Codeberg
|
|
||||||
env:
|
|
||||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
run: |
|
|
||||||
git remote add codeberg "https://CircuitForge:${CODEBERG_TOKEN}@codeberg.org/CircuitForge/${REPO}.git"
|
|
||||||
git push codeberg --mirror
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# Tag-triggered release workflow.
|
|
||||||
# Generates changelog and creates Forgejo release on v* tags.
|
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/release.yml
|
|
||||||
#
|
|
||||||
# Docker push is intentionally disabled — BSL 1.1 registry policy not yet resolved.
|
|
||||||
# Tracked in Circuit-Forge/cf-agents#3. Re-enable the Docker steps when that lands.
|
|
||||||
#
|
|
||||||
# Required secrets: FORGEJO_RELEASE_TOKEN
|
|
||||||
# (GHCR_TOKEN not needed until Docker push is enabled)
|
|
||||||
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# ── Changelog ────────────────────────────────────────────────────────────
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: orhun/git-cliff-action@v3
|
|
||||||
id: cliff
|
|
||||||
with:
|
|
||||||
config: .cliff.toml
|
|
||||||
args: --latest --strip header
|
|
||||||
env:
|
|
||||||
OUTPUT: CHANGES.md
|
|
||||||
|
|
||||||
# ── Docker (disabled — BSL registry policy pending cf-agents#3) ──────────
|
|
||||||
# - name: Set up QEMU
|
|
||||||
# uses: docker/setup-qemu-action@v3
|
|
||||||
# - name: Set up Buildx
|
|
||||||
# uses: docker/setup-buildx-action@v3
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GHCR_TOKEN }}
|
|
||||||
# - name: Build and push Docker image
|
|
||||||
# uses: docker/build-push-action@v6
|
|
||||||
# with:
|
|
||||||
# context: .
|
|
||||||
# push: true
|
|
||||||
# platforms: linux/amd64,linux/arm64
|
|
||||||
# tags: |
|
|
||||||
# ghcr.io/circuitforgellc/kiwi:${{ github.ref_name }}
|
|
||||||
# ghcr.io/circuitforgellc/kiwi:latest
|
|
||||||
# cache-from: type=gha
|
|
||||||
# cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
# ── Forgejo Release ───────────────────────────────────────────────────────
|
|
||||||
- name: Create Forgejo release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
NOTES: ${{ steps.cliff.outputs.content }}
|
|
||||||
run: |
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/${REPO}/releases" \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
|
|
||||||
'{tag_name: $tag, name: $tag, body: $body}')"
|
|
||||||
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
|
|
@ -1,59 +0,0 @@
|
||||||
# Kiwi CI — runs on GitHub mirror for public credibility badge.
|
|
||||||
# Forgejo (.forgejo/workflows/ci.yml) is the canonical CI — keep these in sync.
|
|
||||||
# No Forgejo-specific secrets used here; circuitforge-core is public on Forgejo.
|
|
||||||
#
|
|
||||||
# Note: frontend has no test suite yet — CI runs typecheck only.
|
|
||||||
# Add 'npm run test' when vitest is wired.
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e . pytest pytest-asyncio httpx ruff
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -1,7 +1,4 @@
|
||||||
|
|
||||||
# CLAUDE.md — gitignored per BSL 1.1 commercial policy
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Superpowers brainstorming artifacts
|
# Superpowers brainstorming artifacts
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
|
@ -22,12 +19,3 @@ dist/
|
||||||
|
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Local dev database
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# Test artifacts (MagicMock sqlite files from pytest)
|
|
||||||
<MagicMock*
|
|
||||||
|
|
||||||
# Playwright / debug screenshots
|
|
||||||
debug-screenshots/
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Kiwi gitleaks config — extends base CircuitForge config with local rules
|
|
||||||
|
|
||||||
[extend]
|
|
||||||
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 ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[[rules]]
|
|
||||||
id = "cf-generic-env-token"
|
|
||||||
description = "Generic KEY=<token> in env-style assignment — catches FORGEJO_API_TOKEN=hex etc."
|
|
||||||
regex = '''(?i)(token|secret|key|password|passwd|pwd|api_key)\s*[=:]\s*['"]?[A-Za-z0-9\-_]{20,}['"]?'''
|
|
||||||
[rules.allowlist]
|
|
||||||
paths = [
|
|
||||||
'.*test.*',
|
|
||||||
]
|
|
||||||
regexes = [
|
|
||||||
'api_key:\s*ollama',
|
|
||||||
'api_key:\s*any',
|
|
||||||
'your-[a-z\-]+-here',
|
|
||||||
'replace-with-',
|
|
||||||
'xxxx',
|
|
||||||
'test-fixture-',
|
|
||||||
'CFG-KIWI-TEST-',
|
|
||||||
]
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Findings suppressed here are historical false positives or already-rotated secrets.
|
|
||||||
# .env was accidentally included in the initial commit; it is now gitignored.
|
|
||||||
# Rotate DIRECTUS_JWT_SECRET if it has not been changed since 2026-03-30.
|
|
||||||
|
|
||||||
# c166e5216 (chore: initial commit) — .env included by mistake
|
|
||||||
c166e5216af532a08112ef87e8542cd51c184115:.env:generic-api-key:25
|
|
||||||
c166e5216af532a08112ef87e8542cd51c184115:.env:cf-generic-env-token:25
|
|
||||||
12
Dockerfile
12
Dockerfile
|
|
@ -11,23 +11,13 @@ 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
|
||||||
|
|
||||||
COPY kiwi/ ./kiwi/
|
COPY kiwi/ ./kiwi/
|
||||||
|
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
|
||||||
# Remove gitignored config files that may exist locally — defense-in-depth.
|
|
||||||
# The parent .dockerignore should exclude these, but an explicit rm guarantees
|
|
||||||
# they never end up in the cloud image regardless of .dockerignore placement.
|
|
||||||
RUN rm -f /app/kiwi/.env
|
|
||||||
|
|
||||||
# Install cf-core and cf-orch into the kiwi env BEFORE installing kiwi
|
|
||||||
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 .
|
||||||
|
|
||||||
|
|
|
||||||
28
LICENSE-BSL
28
LICENSE-BSL
|
|
@ -1,28 +0,0 @@
|
||||||
Business Source License 1.1
|
|
||||||
|
|
||||||
Licensor: Circuit Forge LLC
|
|
||||||
Licensed Work: Kiwi — Pantry tracking and leftover recipe suggestions
|
|
||||||
Copyright (c) 2026 Circuit Forge LLC
|
|
||||||
Additional Use Grant: You may use the Licensed Work for personal,
|
|
||||||
non-commercial pantry tracking and recipe suggestion
|
|
||||||
purposes only.
|
|
||||||
Change Date: 2030-01-01
|
|
||||||
Change License: MIT License
|
|
||||||
|
|
||||||
For the full Business Source License 1.1 text, see:
|
|
||||||
https://mariadb.com/bsl11/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This license applies to the following components of Kiwi:
|
|
||||||
|
|
||||||
- app/services/recipe/recipe_engine.py
|
|
||||||
- app/services/recipe/assembly_recipes.py
|
|
||||||
- app/services/recipe/llm_recipe.py
|
|
||||||
- app/services/expiration_predictor.py
|
|
||||||
- app/tasks/scheduler.py
|
|
||||||
- app/tasks/runner.py
|
|
||||||
- app/tiers.py
|
|
||||||
- app/cloud_session.py
|
|
||||||
- frontend/src/components/RecipesView.vue
|
|
||||||
- frontend/src/stores/recipes.ts
|
|
||||||
34
LICENSE-MIT
34
LICENSE-MIT
|
|
@ -1,34 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Circuit Forge LLC
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This license applies to the following components of Kiwi:
|
|
||||||
|
|
||||||
- app/api/endpoints/inventory.py
|
|
||||||
- app/api/endpoints/ocr.py
|
|
||||||
- app/db/store.py
|
|
||||||
- app/db/migrations/
|
|
||||||
- app/core/config.py
|
|
||||||
- scripts/pipeline/
|
|
||||||
- scripts/download_datasets.py
|
|
||||||
- scripts/backfill_texture_profiles.py
|
|
||||||
126
README.md
126
README.md
|
|
@ -1,58 +1,33 @@
|
||||||
<!-- 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)
|
**Status:** Pre-alpha · CircuitForge LLC
|
||||||
|
|
||||||
*Part of the CircuitForge LLC suite — "AI for the tasks the system made hard on purpose."*
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **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
|
||||||
|
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier)
|
||||||
|
- **Recipe suggestions** — LLM-powered ideas based on what's expiring (Paid tier, BYOK-unlockable)
|
||||||
|
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
|
||||||
|
|
||||||
## 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 +35,32 @@ 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 |
|
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||||
| Save recipes + notes + star rating | Yes | Yes | Yes |
|
| Recipe suggestions | BYOK | ✓ | ✓ |
|
||||||
| Style tags (manual, free-text) | Yes | Yes | Yes |
|
| Meal planning | — | ✓ | ✓ |
|
||||||
| Leftover mode (5/day) | Yes | Yes | Yes |
|
| Multi-household | — | — | ✓ |
|
||||||
| Receipt OCR | BYOK | Yes | Yes |
|
| Leftover mode | — | — | ✓ |
|
||||||
| Recipe suggestions (L1–L4) | BYOK | Yes | Yes |
|
|
||||||
| Named recipe collections | — | Yes | Yes |
|
|
||||||
| LLM style auto-classifier | — | BYOK | Yes |
|
|
||||||
| Meal planning | — | Yes | Yes |
|
|
||||||
| Multi-household | — | — | Yes |
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@
|
||||||
Kiwi: Pantry tracking and leftover recipe suggestions.
|
Kiwi: Pantry tracking and leftover recipe suggestions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.1.0"
|
||||||
__author__ = "Alan 'pyr0ball' Weinstock"
|
__author__ = "Alan 'pyr0ball' Weinstock"
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,444 +0,0 @@
|
||||||
# app/api/endpoints/community.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.services.community.feed import posts_to_rss
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/community", tags=["community"])
|
|
||||||
|
|
||||||
_community_store = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_community_store():
|
|
||||||
return _community_store
|
|
||||||
|
|
||||||
|
|
||||||
def init_community_store(community_db_url: str | None) -> None:
|
|
||||||
global _community_store
|
|
||||||
if not community_db_url:
|
|
||||||
logger.info(
|
|
||||||
"COMMUNITY_DB_URL not set — community write features disabled. "
|
|
||||||
"Browse still works via cloud feed."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
from circuitforge_core.community import CommunityDB
|
|
||||||
from app.services.community.community_store import KiwiCommunityStore
|
|
||||||
db = CommunityDB(dsn=community_db_url)
|
|
||||||
db.run_migrations()
|
|
||||||
_community_store = KiwiCommunityStore(db)
|
|
||||||
logger.info("Community store initialized.")
|
|
||||||
|
|
||||||
|
|
||||||
def _visible(post, session=None) -> bool:
|
|
||||||
"""Return False for premium-tier posts when the session is not paid/premium."""
|
|
||||||
tier = getattr(post, "tier", None)
|
|
||||||
if tier == "premium":
|
|
||||||
if session is None or getattr(session, "tier", None) not in ("paid", "premium"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts")
|
|
||||||
async def list_posts(
|
|
||||||
post_type: str | None = None,
|
|
||||||
dietary_tags: str | None = None,
|
|
||||||
allergen_exclude: str | None = None,
|
|
||||||
page: int = 1,
|
|
||||||
page_size: int = 20,
|
|
||||||
):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return {
|
|
||||||
"posts": [],
|
|
||||||
"total": 0,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"note": "Community DB not available on this instance.",
|
|
||||||
}
|
|
||||||
|
|
||||||
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
|
|
||||||
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
|
|
||||||
offset = (page - 1) * min(page_size, 100)
|
|
||||||
|
|
||||||
posts = await asyncio.to_thread(
|
|
||||||
store.list_posts,
|
|
||||||
limit=min(page_size, 100),
|
|
||||||
offset=offset,
|
|
||||||
post_type=post_type,
|
|
||||||
dietary_tags=dietary,
|
|
||||||
allergen_exclude=allergen_ex,
|
|
||||||
)
|
|
||||||
visible = [_post_to_dict(p) for p in posts if _visible(p)]
|
|
||||||
return {"posts": visible, "total": len(visible), "page": page, "page_size": page_size}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts/{slug}")
|
|
||||||
async def get_post(slug: str, request: Request):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community DB not available on this instance.")
|
|
||||||
|
|
||||||
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
|
||||||
if post is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found.")
|
|
||||||
|
|
||||||
accept = request.headers.get("accept", "")
|
|
||||||
if "application/activity+json" in accept or "application/ld+json" in accept:
|
|
||||||
from app.services.community.ap_compat import post_to_ap_json_ld
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url)
|
|
||||||
|
|
||||||
return _post_to_dict(post)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/feed.rss")
|
|
||||||
async def get_rss_feed(request: Request):
|
|
||||||
store = _get_community_store()
|
|
||||||
posts_data: list[dict] = []
|
|
||||||
if store is not None:
|
|
||||||
posts = await asyncio.to_thread(store.list_posts, limit=50)
|
|
||||||
posts_data = [_post_to_dict(p) for p in posts]
|
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
rss = posts_to_rss(posts_data, base_url=base_url)
|
|
||||||
return Response(content=rss, media_type="application/rss+xml; charset=utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/local-feed")
|
|
||||||
async def local_feed():
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return []
|
|
||||||
posts = await asyncio.to_thread(store.list_posts, limit=50)
|
|
||||||
return [_post_to_dict(p) for p in posts]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hall-of-chaos")
|
|
||||||
async def hall_of_chaos():
|
|
||||||
"""Hidden easter egg endpoint -- returns the 10 most chaotic bloopers."""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return {"posts": [], "chaos_level": 0}
|
|
||||||
posts = await asyncio.to_thread(
|
|
||||||
store.list_posts, limit=10, post_type="recipe_blooper"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"posts": [_post_to_dict(p) for p in posts],
|
|
||||||
"chaos_level": len(posts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"}
|
|
||||||
_MAX_TITLE_LEN = 200
|
|
||||||
_MAX_TEXT_LEN = 2000
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_publish_body(body: dict) -> None:
|
|
||||||
"""Raise HTTPException(422) for any invalid fields in a publish request."""
|
|
||||||
post_type = body.get("post_type", "plan")
|
|
||||||
if post_type not in _VALID_POST_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}",
|
|
||||||
)
|
|
||||||
title = body.get("title") or ""
|
|
||||||
if len(title) > _MAX_TITLE_LEN:
|
|
||||||
raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.")
|
|
||||||
for field in ("description", "outcome_notes", "recipe_name"):
|
|
||||||
value = body.get(field)
|
|
||||||
if value and len(str(value)) > _MAX_TEXT_LEN:
|
|
||||||
raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.")
|
|
||||||
photo_url = body.get("photo_url")
|
|
||||||
if photo_url and not str(photo_url).startswith("https://"):
|
|
||||||
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)
|
|
||||||
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
|
||||||
from app.tiers import can_use
|
|
||||||
if not can_use("community_publish", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
|
|
||||||
|
|
||||||
_validate_publish_body(body)
|
|
||||||
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="This Kiwi instance is not connected to a community database. "
|
|
||||||
"Publishing is only available on cloud instances.",
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.services.community.community_store import get_or_create_pseudonym
|
|
||||||
def _get_pseudonym():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
return get_or_create_pseudonym(
|
|
||||||
store=s,
|
|
||||||
directus_user_id=session.user_id,
|
|
||||||
requested_name=body.get("pseudonym_name"),
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
try:
|
|
||||||
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
|
|
||||||
from app.services.community.element_snapshot import compute_snapshot
|
|
||||||
def _snapshot():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
return compute_snapshot(recipe_ids=recipe_ids, store=s)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
snapshot = await asyncio.to_thread(_snapshot)
|
|
||||||
|
|
||||||
post_type = body.get("post_type", "plan")
|
|
||||||
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
|
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
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
|
|
||||||
post = CommunityPost(
|
|
||||||
slug=slug,
|
|
||||||
pseudonym=pseudonym,
|
|
||||||
post_type=post_type,
|
|
||||||
published=datetime.now(timezone.utc),
|
|
||||||
title=(body.get("title") or "Untitled")[:_MAX_TITLE_LEN],
|
|
||||||
description=body.get("description"),
|
|
||||||
photo_url=body.get("photo_url"),
|
|
||||||
slots=body.get("slots", []),
|
|
||||||
recipe_id=body.get("recipe_id"),
|
|
||||||
recipe_name=body.get("recipe_name"),
|
|
||||||
level=body.get("level"),
|
|
||||||
outcome_notes=body.get("outcome_notes"),
|
|
||||||
seasoning_score=snapshot.seasoning_score,
|
|
||||||
richness_score=snapshot.richness_score,
|
|
||||||
brightness_score=snapshot.brightness_score,
|
|
||||||
depth_score=snapshot.depth_score,
|
|
||||||
aroma_score=snapshot.aroma_score,
|
|
||||||
structure_score=snapshot.structure_score,
|
|
||||||
texture_profile=snapshot.texture_profile,
|
|
||||||
dietary_tags=list(snapshot.dietary_tags),
|
|
||||||
allergen_flags=list(snapshot.allergen_flags),
|
|
||||||
flavor_molecules=list(snapshot.flavor_molecules),
|
|
||||||
fat_pct=snapshot.fat_pct,
|
|
||||||
protein_pct=snapshot.protein_pct,
|
|
||||||
moisture_pct=snapshot.moisture_pct,
|
|
||||||
similar_to_ref=similar_to_ref,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
inserted = await asyncio.to_thread(store.insert_post, post)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="A post with this title already exists today. Try a different title.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
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)
|
|
||||||
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community DB not available.")
|
|
||||||
|
|
||||||
def _get_pseudonym():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
return s.get_current_pseudonym(session.user_id)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
|
||||||
if not pseudonym:
|
|
||||||
raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.")
|
|
||||||
|
|
||||||
deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found or you are not the author.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts/{slug}/fork", status_code=201)
|
|
||||||
async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, 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.")
|
|
||||||
if post.post_type != "plan":
|
|
||||||
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
|
|
||||||
|
|
||||||
required_slot_keys = {"day", "meal_type", "recipe_id"}
|
|
||||||
if any(not required_slot_keys.issubset(slot) for slot in post.slots):
|
|
||||||
raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.")
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
week_start = date.today().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
def _create_plan():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
meal_types = list({slot["meal_type"] for slot in post.slots})
|
|
||||||
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types or ["dinner"])
|
|
||||||
for slot in post.slots:
|
|
||||||
s.assign_recipe_to_slot(
|
|
||||||
plan_id=plan["id"],
|
|
||||||
day_of_week=slot["day"],
|
|
||||||
meal_type=slot["meal_type"],
|
|
||||||
recipe_id=slot["recipe_id"],
|
|
||||||
)
|
|
||||||
return plan
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
plan = await asyncio.to_thread(_create_plan)
|
|
||||||
return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts/{slug}/fork-adapt", status_code=201)
|
|
||||||
async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)):
|
|
||||||
from app.tiers import can_use
|
|
||||||
if not can_use("community_fork_adapt", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.")
|
|
||||||
# Stub: full LLM adaptation deferred
|
|
||||||
raise HTTPException(status_code=501, detail="Fork-adapt not yet implemented.")
|
|
||||||
|
|
||||||
|
|
||||||
def _post_to_dict(post) -> dict:
|
|
||||||
return {
|
|
||||||
"slug": post.slug,
|
|
||||||
"pseudonym": post.pseudonym,
|
|
||||||
"post_type": post.post_type,
|
|
||||||
"published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published),
|
|
||||||
"title": post.title,
|
|
||||||
"description": post.description,
|
|
||||||
"photo_url": post.photo_url,
|
|
||||||
"slots": list(post.slots),
|
|
||||||
"recipe_id": post.recipe_id,
|
|
||||||
"recipe_name": post.recipe_name,
|
|
||||||
"level": post.level,
|
|
||||||
"outcome_notes": post.outcome_notes,
|
|
||||||
"element_profiles": {
|
|
||||||
"seasoning_score": post.seasoning_score,
|
|
||||||
"richness_score": post.richness_score,
|
|
||||||
"brightness_score": post.brightness_score,
|
|
||||||
"depth_score": post.depth_score,
|
|
||||||
"aroma_score": post.aroma_score,
|
|
||||||
"structure_score": post.structure_score,
|
|
||||||
"texture_profile": post.texture_profile,
|
|
||||||
},
|
|
||||||
"dietary_tags": list(post.dietary_tags),
|
|
||||||
"allergen_flags": list(post.allergen_flags),
|
|
||||||
"flavor_molecules": list(post.flavor_molecules),
|
|
||||||
"fat_pct": post.fat_pct,
|
|
||||||
"protein_pct": post.protein_pct,
|
|
||||||
"moisture_pct": post.moisture_pct,
|
|
||||||
"similar_to_ref": getattr(post, "similar_to_ref", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _post_type_prefix(post_type: str) -> str:
|
|
||||||
return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
"""Export endpoints — CSV and JSON export of user data."""
|
"""Export endpoints — CSV/Excel of receipt and inventory data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
@ -47,33 +45,3 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/json")
|
|
||||||
async def export_full_json(store: Store = Depends(get_store)):
|
|
||||||
"""Export full pantry inventory + saved recipes as a single JSON file.
|
|
||||||
|
|
||||||
Intended for data portability — users can import this into another
|
|
||||||
Kiwi instance or keep it as an offline backup.
|
|
||||||
"""
|
|
||||||
inventory, saved = await asyncio.gather(
|
|
||||||
asyncio.to_thread(store.list_inventory),
|
|
||||||
asyncio.to_thread(store.get_saved_recipes),
|
|
||||||
)
|
|
||||||
|
|
||||||
export_doc = {
|
|
||||||
"kiwi_export": {
|
|
||||||
"version": "1.0",
|
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"inventory": [dict(row) for row in inventory],
|
|
||||||
"saved_recipes": [dict(row) for row in saved],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body = json.dumps(export_doc, default=str, indent=2)
|
|
||||||
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([body]),
|
|
||||||
media_type="application/json",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
"""Feedback router — provided by circuitforge-core."""
|
|
||||||
from circuitforge_core.api import make_feedback_router
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = make_feedback_router(
|
|
||||||
repo="Circuit-Forge/kiwi",
|
|
||||||
product="kiwi",
|
|
||||||
demo_mode_fn=lambda: settings.DEMO_MODE,
|
|
||||||
)
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""Screenshot attachment endpoint for in-app feedback.
|
|
||||||
|
|
||||||
After the cf-core feedback router creates a Forgejo issue, the frontend
|
|
||||||
can call POST /feedback/attach to upload a screenshot and pin it as a
|
|
||||||
comment on that issue.
|
|
||||||
|
|
||||||
The endpoint is separate from the cf-core router so Kiwi owns it
|
|
||||||
without modifying shared infrastructure.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_FORGEJO_BASE = os.environ.get(
|
|
||||||
"FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1"
|
|
||||||
)
|
|
||||||
_REPO = "Circuit-Forge/kiwi"
|
|
||||||
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
||||||
|
|
||||||
|
|
||||||
class AttachRequest(BaseModel):
|
|
||||||
issue_number: int
|
|
||||||
filename: str = Field(default="screenshot.png", max_length=80)
|
|
||||||
image_b64: str # data URI or raw base64
|
|
||||||
|
|
||||||
|
|
||||||
class AttachResponse(BaseModel):
|
|
||||||
comment_url: str
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_headers() -> dict[str, str]:
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
return {"Authorization": f"token {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_image(image_b64: str) -> tuple[bytes, str]:
|
|
||||||
"""Return (raw_bytes, mime_type) from a base64 string or data URI."""
|
|
||||||
if image_b64.startswith("data:"):
|
|
||||||
header, _, data = image_b64.partition(",")
|
|
||||||
mime = header.split(";")[0].split(":")[1] if ":" in header else "image/png"
|
|
||||||
else:
|
|
||||||
data = image_b64
|
|
||||||
mime = "image/png"
|
|
||||||
return base64.b64decode(data), mime
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attach", response_model=AttachResponse)
|
|
||||||
def attach_screenshot(payload: AttachRequest) -> AttachResponse:
|
|
||||||
"""Upload a screenshot to a Forgejo issue as a comment with embedded image.
|
|
||||||
|
|
||||||
The image is uploaded as an issue asset, then referenced in a comment
|
|
||||||
so it is visible inline when the issue is viewed.
|
|
||||||
"""
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=503, detail="Feedback not configured.")
|
|
||||||
|
|
||||||
raw_bytes, mime = _decode_image(payload.image_b64)
|
|
||||||
|
|
||||||
if len(raw_bytes) > _MAX_BYTES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"Screenshot exceeds 5 MB limit ({len(raw_bytes) // 1024} KB received).",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Upload image as issue asset
|
|
||||||
asset_resp = requests.post(
|
|
||||||
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/assets",
|
|
||||||
headers=_forgejo_headers(),
|
|
||||||
files={"attachment": (payload.filename, raw_bytes, mime)},
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
if not asset_resp.ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"Forgejo asset upload failed: {asset_resp.text[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
asset_url = asset_resp.json().get("browser_download_url", "")
|
|
||||||
|
|
||||||
# Pin as a comment so the image is visible inline
|
|
||||||
comment_body = f"**Screenshot attached by reporter:**\n\n"
|
|
||||||
comment_resp = requests.post(
|
|
||||||
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/comments",
|
|
||||||
headers={**_forgejo_headers(), "Content-Type": "application/json"},
|
|
||||||
json={"body": comment_body},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if not comment_resp.ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"Forgejo comment failed: {comment_resp.text[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
comment_url = comment_resp.json().get("html_url", "")
|
|
||||||
return AttachResponse(comment_url=comment_url)
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
"""Household management endpoints — shared pantry for Premium users."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, get_session
|
|
||||||
from app.services.heimdall_orch import HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.household import (
|
|
||||||
HouseholdAcceptRequest,
|
|
||||||
HouseholdAcceptResponse,
|
|
||||||
HouseholdCreateResponse,
|
|
||||||
HouseholdInviteResponse,
|
|
||||||
HouseholdMember,
|
|
||||||
HouseholdRemoveMemberRequest,
|
|
||||||
HouseholdStatusResponse,
|
|
||||||
MessageResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_INVITE_TTL_DAYS = 7
|
|
||||||
_KIWI_BASE_URL = os.environ.get("KIWI_BASE_URL", "https://menagerie.circuitforge.tech/kiwi")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_premium(session: CloudUser = Depends(get_session)) -> CloudUser:
|
|
||||||
if session.tier not in ("premium", "ultra", "local"):
|
|
||||||
raise HTTPException(status_code=403, detail="Household features require Premium tier.")
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _require_household_owner(session: CloudUser = Depends(_require_premium)) -> CloudUser:
|
|
||||||
if not session.is_household_owner or not session.household_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Only the household owner can perform this action.")
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _household_store(household_id: str) -> Store:
|
|
||||||
"""Open the household DB directly (used during invite acceptance).
|
|
||||||
Sets row_factory so dict-style column access works on raw conn queries.
|
|
||||||
"""
|
|
||||||
db_path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
store = Store(db_path)
|
|
||||||
store.conn.row_factory = sqlite3.Row
|
|
||||||
return store
|
|
||||||
|
|
||||||
|
|
||||||
def _heimdall_post(path: str, body: dict) -> dict:
|
|
||||||
"""Call Heimdall admin API. Returns response dict or raises HTTPException."""
|
|
||||||
if not HEIMDALL_ADMIN_TOKEN:
|
|
||||||
log.warning("HEIMDALL_ADMIN_TOKEN not set — household Heimdall call skipped")
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"{HEIMDALL_URL}{path}",
|
|
||||||
json=body,
|
|
||||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if not resp.ok:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Heimdall error: {resp.text}")
|
|
||||||
return resp.json()
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Heimdall unreachable: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create", response_model=HouseholdCreateResponse)
|
|
||||||
async def create_household(session: CloudUser = Depends(_require_premium)):
|
|
||||||
"""Create a new household. The calling user becomes owner."""
|
|
||||||
if session.household_id:
|
|
||||||
raise HTTPException(status_code=409, detail="You are already in a household.")
|
|
||||||
data = _heimdall_post("/admin/household/create", {"owner_user_id": session.user_id})
|
|
||||||
household_id = data.get("household_id")
|
|
||||||
if not household_id:
|
|
||||||
# Heimdall returned OK but without a household_id — treat as server error.
|
|
||||||
# Fall back to a local stub only when HEIMDALL_ADMIN_TOKEN is unset (dev mode).
|
|
||||||
if HEIMDALL_ADMIN_TOKEN:
|
|
||||||
raise HTTPException(status_code=500, detail="Heimdall did not return a household_id.")
|
|
||||||
household_id = "local-household"
|
|
||||||
return HouseholdCreateResponse(
|
|
||||||
household_id=household_id,
|
|
||||||
message="Household created. Share an invite link to add members.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=HouseholdStatusResponse)
|
|
||||||
async def household_status(session: CloudUser = Depends(_require_premium)):
|
|
||||||
"""Return current user's household membership status."""
|
|
||||||
if not session.household_id:
|
|
||||||
return HouseholdStatusResponse(in_household=False)
|
|
||||||
|
|
||||||
members: list[HouseholdMember] = []
|
|
||||||
if HEIMDALL_ADMIN_TOKEN:
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
f"{HEIMDALL_URL}/admin/household/{session.household_id}",
|
|
||||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
raw = resp.json()
|
|
||||||
for m in raw.get("members", []):
|
|
||||||
members.append(HouseholdMember(
|
|
||||||
user_id=m["user_id"],
|
|
||||||
joined_at=m.get("joined_at", ""),
|
|
||||||
is_owner=m["user_id"] == raw.get("owner_user_id"),
|
|
||||||
))
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Could not fetch household members: %s", exc)
|
|
||||||
|
|
||||||
return HouseholdStatusResponse(
|
|
||||||
in_household=True,
|
|
||||||
household_id=session.household_id,
|
|
||||||
is_owner=session.is_household_owner,
|
|
||||||
members=members,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/invite", response_model=HouseholdInviteResponse)
|
|
||||||
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
|
|
||||||
"""Generate a one-time invite token valid for 7 days."""
|
|
||||||
token = secrets.token_hex(32)
|
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
|
|
||||||
store = Store(session.db)
|
|
||||||
try:
|
|
||||||
store.conn.execute(
|
|
||||||
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
|
||||||
VALUES (?, ?, ?, ?)""",
|
|
||||||
(token, session.household_id, session.user_id, expires_at),
|
|
||||||
)
|
|
||||||
store.conn.commit()
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
invite_url = f"{_KIWI_BASE_URL}/#/join?household_id={session.household_id}&token={token}"
|
|
||||||
return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accept", response_model=HouseholdAcceptResponse)
|
|
||||||
async def accept_invite(
|
|
||||||
body: HouseholdAcceptRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Accept a household invite. Opens the household DB directly to validate the token."""
|
|
||||||
if session.household_id:
|
|
||||||
raise HTTPException(status_code=409, detail="You are already in a household.")
|
|
||||||
|
|
||||||
hh_store = _household_store(body.household_id)
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
try:
|
|
||||||
row = hh_store.conn.execute(
|
|
||||||
"""SELECT token, expires_at, used_at FROM household_invites
|
|
||||||
WHERE token = ? AND household_id = ?""",
|
|
||||||
(body.token, body.household_id),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Invite not found.")
|
|
||||||
if row["used_at"] is not None:
|
|
||||||
raise HTTPException(status_code=410, detail="Invite already used.")
|
|
||||||
if row["expires_at"] < now:
|
|
||||||
raise HTTPException(status_code=410, detail="Invite has expired.")
|
|
||||||
|
|
||||||
hh_store.conn.execute(
|
|
||||||
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
|
|
||||||
(now, session.user_id, body.token),
|
|
||||||
)
|
|
||||||
hh_store.conn.commit()
|
|
||||||
finally:
|
|
||||||
hh_store.close()
|
|
||||||
|
|
||||||
_heimdall_post("/admin/household/add-member", {
|
|
||||||
"household_id": body.household_id,
|
|
||||||
"user_id": session.user_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return HouseholdAcceptResponse(
|
|
||||||
message="You have joined the household. Reload the app to switch to the shared pantry.",
|
|
||||||
household_id=body.household_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/leave", response_model=MessageResponse)
|
|
||||||
async def leave_household(session: CloudUser = Depends(_require_premium)) -> MessageResponse:
|
|
||||||
"""Leave the current household (non-owners only)."""
|
|
||||||
if not session.household_id:
|
|
||||||
raise HTTPException(status_code=400, detail="You are not in a household.")
|
|
||||||
if session.is_household_owner:
|
|
||||||
raise HTTPException(status_code=400, detail="The household owner cannot leave. Delete the household instead.")
|
|
||||||
_heimdall_post("/admin/household/remove-member", {
|
|
||||||
"household_id": session.household_id,
|
|
||||||
"user_id": session.user_id,
|
|
||||||
})
|
|
||||||
return MessageResponse(message="You have left the household. Reload the app to return to your personal pantry.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/remove-member", response_model=MessageResponse)
|
|
||||||
async def remove_member(
|
|
||||||
body: HouseholdRemoveMemberRequest,
|
|
||||||
session: CloudUser = Depends(_require_household_owner),
|
|
||||||
) -> MessageResponse:
|
|
||||||
"""Remove a member from the household (owner only)."""
|
|
||||||
if body.user_id == session.user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Use /leave to remove yourself.")
|
|
||||||
_heimdall_post("/admin/household/remove-member", {
|
|
||||||
"household_id": session.household_id,
|
|
||||||
"user_id": body.user_id,
|
|
||||||
})
|
|
||||||
return MessageResponse(message=f"Member {body.user_id} removed from household.")
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
"""Kiwi — /api/v1/imitate/samples endpoint for Avocet Imitate tab.
|
|
||||||
|
|
||||||
Returns the actual assembled prompt Kiwi sends to its LLM for recipe generation,
|
|
||||||
including the full pantry context (expiry-first ordering), dietary constraints
|
|
||||||
(from user_settings if present), and the Level 3 format instructions.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import get_session, CloudUser
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_LEVEL3_FORMAT = [
|
|
||||||
"",
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
|
||||||
"Title: <name of the dish>",
|
|
||||||
"Ingredients: <comma-separated list>",
|
|
||||||
"Directions:",
|
|
||||||
"1. <first step>",
|
|
||||||
"2. <second step>",
|
|
||||||
"3. <continue for each step>",
|
|
||||||
"Notes: <optional tips>",
|
|
||||||
]
|
|
||||||
|
|
||||||
_LEVEL4_FORMAT = [
|
|
||||||
"",
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
|
||||||
"Title: <name of the dish>",
|
|
||||||
"Ingredients: <comma-separated list>",
|
|
||||||
"Directions:",
|
|
||||||
"1. <first step>",
|
|
||||||
"2. <second step>",
|
|
||||||
"Notes: <optional tips>",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_user_settings(store: Store) -> dict:
|
|
||||||
"""Read all key/value pairs from user_settings table."""
|
|
||||||
try:
|
|
||||||
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
|
|
||||||
return {r["key"]: r["value"] for r in rows}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_recipe_prompt(
|
|
||||||
pantry_names: list[str],
|
|
||||||
expiring_names: list[str],
|
|
||||||
constraints: list[str],
|
|
||||||
allergies: list[str],
|
|
||||||
level: int = 3,
|
|
||||||
) -> str:
|
|
||||||
"""Assemble the recipe generation prompt matching Kiwi's Level 3/4 format."""
|
|
||||||
# Expiring items first, then remaining pantry items (deduped)
|
|
||||||
expiring_set = set(expiring_names)
|
|
||||||
ordered = list(expiring_names) + [n for n in pantry_names if n not in expiring_set]
|
|
||||||
|
|
||||||
if not ordered:
|
|
||||||
ordered = pantry_names
|
|
||||||
|
|
||||||
if level == 4:
|
|
||||||
lines = [
|
|
||||||
"Surprise me with a creative, unexpected recipe.",
|
|
||||||
"Only use ingredients that make culinary sense together. "
|
|
||||||
"Do not force flavoured/sweetened items (vanilla yoghurt, flavoured syrups, jam) into savoury dishes.",
|
|
||||||
f"Ingredients available: {', '.join(ordered)}",
|
|
||||||
]
|
|
||||||
if constraints:
|
|
||||||
lines.append(f"Constraints: {', '.join(constraints)}")
|
|
||||||
if allergies:
|
|
||||||
lines.append(f"Must NOT contain: {', '.join(allergies)}")
|
|
||||||
lines.append("Treat any mystery ingredient as a wildcard — use your imagination.")
|
|
||||||
lines += _LEVEL4_FORMAT
|
|
||||||
else:
|
|
||||||
lines = [
|
|
||||||
"You are a creative chef. Generate a recipe using the ingredients below.",
|
|
||||||
"IMPORTANT: When you use a pantry item, list it in Ingredients using its exact name "
|
|
||||||
"from the pantry list. Do not add adjectives, quantities, or cooking states "
|
|
||||||
"(e.g. use 'butter', not 'unsalted butter' or '2 tbsp butter').",
|
|
||||||
"IMPORTANT: Only use pantry items that make culinary sense for the dish. "
|
|
||||||
"Do NOT force flavoured/sweetened items (vanilla yoghurt, fruit yoghurt, jam, "
|
|
||||||
"dessert sauces, flavoured syrups) into savoury dishes.",
|
|
||||||
"IMPORTANT: Do not default to the same ingredient repeatedly across dishes. "
|
|
||||||
"If a pantry item does not genuinely improve this specific dish, leave it out.",
|
|
||||||
"",
|
|
||||||
f"Pantry items: {', '.join(ordered)}",
|
|
||||||
]
|
|
||||||
if expiring_names:
|
|
||||||
lines.append(
|
|
||||||
f"Priority — use these soon (expiring): {', '.join(expiring_names)}"
|
|
||||||
)
|
|
||||||
if constraints:
|
|
||||||
lines.append(f"Dietary constraints: {', '.join(constraints)}")
|
|
||||||
if allergies:
|
|
||||||
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergies)}")
|
|
||||||
lines += _LEVEL3_FORMAT
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/samples")
|
|
||||||
async def imitate_samples(
|
|
||||||
limit: int = 5,
|
|
||||||
level: int = 3,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Return assembled recipe generation prompts for Avocet's Imitate tab.
|
|
||||||
|
|
||||||
Each sample includes:
|
|
||||||
system_prompt empty (Kiwi uses no system context)
|
|
||||||
input_text full Level 3/4 prompt with pantry items, expiring items,
|
|
||||||
dietary constraints, and format instructions
|
|
||||||
output_text empty (no prior LLM output stored per-request)
|
|
||||||
|
|
||||||
level: 3 (structured with element biasing context) or 4 (wildcard creative)
|
|
||||||
limit: max number of distinct prompt variants to return (varies by pantry state)
|
|
||||||
"""
|
|
||||||
limit = max(1, min(limit, 10))
|
|
||||||
store = Store(session.db)
|
|
||||||
|
|
||||||
# Full pantry for context
|
|
||||||
all_items = store.list_inventory()
|
|
||||||
pantry_names = [r["product_name"] for r in all_items if r.get("product_name")]
|
|
||||||
|
|
||||||
# Expiring items as priority ingredients
|
|
||||||
expiring = store.expiring_soon(days=14)
|
|
||||||
expiring_names = [r["product_name"] for r in expiring if r.get("product_name")]
|
|
||||||
|
|
||||||
# Dietary constraints from user_settings (keys: constraints, allergies)
|
|
||||||
settings = _read_user_settings(store)
|
|
||||||
import json as _json
|
|
||||||
try:
|
|
||||||
constraints = _json.loads(settings.get("dietary_constraints", "[]")) or []
|
|
||||||
except Exception:
|
|
||||||
constraints = []
|
|
||||||
try:
|
|
||||||
allergies = _json.loads(settings.get("dietary_allergies", "[]")) or []
|
|
||||||
except Exception:
|
|
||||||
allergies = []
|
|
||||||
|
|
||||||
if not pantry_names:
|
|
||||||
return {"samples": [], "total": 0, "type": f"recipe_level{level}"}
|
|
||||||
|
|
||||||
# Build prompt variants: one per expiring item as the "anchor" ingredient,
|
|
||||||
# plus one general pantry prompt. Cap at limit.
|
|
||||||
samples = []
|
|
||||||
seen_anchors: set[str] = set()
|
|
||||||
|
|
||||||
for item in (expiring[:limit - 1] if expiring else []):
|
|
||||||
anchor = item.get("product_name", "")
|
|
||||||
if not anchor or anchor in seen_anchors:
|
|
||||||
continue
|
|
||||||
seen_anchors.add(anchor)
|
|
||||||
|
|
||||||
# Put this item first in the list for the prompt
|
|
||||||
ordered_expiring = [anchor] + [n for n in expiring_names if n != anchor]
|
|
||||||
prompt = _build_recipe_prompt(pantry_names, ordered_expiring, constraints, allergies, level)
|
|
||||||
|
|
||||||
samples.append({
|
|
||||||
"id": item.get("id", 0),
|
|
||||||
"anchor_item": anchor,
|
|
||||||
"expiring_count": len(expiring_names),
|
|
||||||
"pantry_count": len(pantry_names),
|
|
||||||
"system_prompt": "",
|
|
||||||
"input_text": prompt,
|
|
||||||
"output_text": "",
|
|
||||||
})
|
|
||||||
|
|
||||||
# One general prompt using all expiring as priority
|
|
||||||
if len(samples) < limit:
|
|
||||||
prompt = _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
|
|
||||||
samples.append({
|
|
||||||
"id": 0,
|
|
||||||
"anchor_item": "full pantry",
|
|
||||||
"expiring_count": len(expiring_names),
|
|
||||||
"pantry_count": len(pantry_names),
|
|
||||||
"system_prompt": "",
|
|
||||||
"input_text": prompt,
|
|
||||||
"output_text": "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"samples": samples, "total": len(samples), "type": f"recipe_level{level}"}
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
@ -12,73 +11,25 @@ import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
|
|
||||||
_predictor = ExpirationPredictor()
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.inventory import (
|
from app.models.schemas.inventory import (
|
||||||
BarcodeScanResponse,
|
BarcodeScanResponse,
|
||||||
BulkAddByNameRequest,
|
|
||||||
BulkAddByNameResponse,
|
|
||||||
BulkAddItemResult,
|
|
||||||
DiscardRequest,
|
|
||||||
InventoryItemCreate,
|
InventoryItemCreate,
|
||||||
InventoryItemResponse,
|
InventoryItemResponse,
|
||||||
InventoryItemUpdate,
|
InventoryItemUpdate,
|
||||||
InventoryStats,
|
InventoryStats,
|
||||||
PartialConsumeRequest,
|
|
||||||
ProductCreate,
|
ProductCreate,
|
||||||
ProductResponse,
|
ProductResponse,
|
||||||
ProductUpdate,
|
ProductUpdate,
|
||||||
TagCreate,
|
TagCreate,
|
||||||
TagResponse,
|
TagResponse,
|
||||||
)
|
)
|
||||||
from app.models.schemas.label_capture import LabelConfirmRequest
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _user_constraints(store) -> list[str]:
|
|
||||||
"""Load active dietary constraints from user settings (comma-separated string)."""
|
|
||||||
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
|
|
||||||
opened = item.get("opened_date")
|
|
||||||
if opened:
|
|
||||||
days = _predictor.days_after_opening(item.get("category"))
|
|
||||||
if days is not None:
|
|
||||||
try:
|
|
||||||
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
|
|
||||||
item = {**item, "opened_expiry_date": str(opened_expiry)}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if "opened_expiry_date" not in item:
|
|
||||||
item = {**item, "opened_expiry_date": None}
|
|
||||||
|
|
||||||
# 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.filter_secondary_by_constraints(sec, user_constraints or [])
|
|
||||||
item = {
|
|
||||||
**item,
|
|
||||||
"secondary_state": sec["label"] if sec else None,
|
|
||||||
"secondary_uses": sec["uses"] if sec else None,
|
|
||||||
"secondary_warning": sec["warning"] if sec else None,
|
|
||||||
"secondary_discard_signs": sec["discard_signs"] if sec else None,
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
# ── Products ──────────────────────────────────────────────────────────────────
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|
@ -163,12 +114,7 @@ async def delete_product(product_id: int, store: Store = Depends(get_store)):
|
||||||
# ── Inventory items ───────────────────────────────────────────────────────────
|
# ── Inventory items ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_inventory_item(
|
async def create_inventory_item(body: InventoryItemCreate, store: Store = Depends(get_store)):
|
||||||
body: InventoryItemCreate,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
log.info("add_item auth=%s tier=%s product_id=%s", _auth_label(session.user_id), session.tier, body.product_id)
|
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
body.product_id,
|
body.product_id,
|
||||||
|
|
@ -181,38 +127,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)
|
|
||||||
async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depends(get_store)):
|
|
||||||
"""Create pantry items from a list of ingredient names (no barcode required).
|
|
||||||
|
|
||||||
Uses get_or_create_product so re-adding an existing product is idempotent.
|
|
||||||
"""
|
|
||||||
results: list[BulkAddItemResult] = []
|
|
||||||
for entry in body.items:
|
|
||||||
try:
|
|
||||||
product, _ = await asyncio.to_thread(
|
|
||||||
store.get_or_create_product, entry.name, None, source="manual"
|
|
||||||
)
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.add_inventory_item,
|
|
||||||
product["id"],
|
|
||||||
entry.location,
|
|
||||||
quantity=entry.quantity,
|
|
||||||
unit=entry.unit,
|
|
||||||
source="manual",
|
|
||||||
)
|
|
||||||
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
|
|
||||||
except Exception as exc:
|
|
||||||
results.append(BulkAddItemResult(name=entry.name, ok=False, error=str(exc)))
|
|
||||||
|
|
||||||
added = sum(1 for r in results if r.ok)
|
|
||||||
return BulkAddByNameResponse(added=added, failed=len(results) - added, results=results)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items", response_model=List[InventoryItemResponse])
|
@router.get("/items", response_model=List[InventoryItemResponse])
|
||||||
|
|
@ -222,15 +137,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(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(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 +151,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(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)
|
||||||
|
|
@ -251,83 +163,24 @@ async def update_inventory_item(
|
||||||
updates["purchase_date"] = str(updates["purchase_date"])
|
updates["purchase_date"] = str(updates["purchase_date"])
|
||||||
if "expiration_date" in updates and updates["expiration_date"]:
|
if "expiration_date" in updates and updates["expiration_date"]:
|
||||||
updates["expiration_date"] = str(updates["expiration_date"])
|
updates["expiration_date"] = str(updates["expiration_date"])
|
||||||
if "opened_date" in updates and updates["opened_date"]:
|
|
||||||
updates["opened_date"] = str(updates["opened_date"])
|
|
||||||
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(item)
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
|
||||||
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
|
||||||
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
|
|
||||||
from datetime import date
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.update_inventory_item,
|
|
||||||
item_id,
|
|
||||||
opened_date=str(date.today()),
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
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, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||||
async def consume_item(
|
async def consume_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item_id: int,
|
|
||||||
body: Optional[PartialConsumeRequest] = None,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Consume an inventory item fully or partially.
|
|
||||||
|
|
||||||
When body.quantity is provided, decrements by that amount and only marks
|
|
||||||
status=consumed when quantity reaches zero. Omit body to consume all.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
if body is not None:
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.partial_consume_item, item_id, body.quantity, now
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.update_inventory_item,
|
store.update_inventory_item,
|
||||||
item_id,
|
item_id,
|
||||||
status="consumed",
|
status="consumed",
|
||||||
consumed_at=now,
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
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, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
|
||||||
async def discard_item(
|
|
||||||
item_id: int,
|
|
||||||
body: DiscardRequest = DiscardRequest(),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Mark an item as discarded (not used, spoiled, etc).
|
|
||||||
|
|
||||||
Optional reason field accepts free text or a preset label
|
|
||||||
('not used', 'spoiled', 'excess', 'other').
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.update_inventory_item,
|
|
||||||
item_id,
|
|
||||||
status="discarded",
|
|
||||||
consumed_at=datetime.now(timezone.utc).isoformat(),
|
consumed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
disposal_reason=body.reason,
|
|
||||||
)
|
)
|
||||||
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(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 +203,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,
|
||||||
|
|
@ -382,24 +210,12 @@ async def scan_barcode_text(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
||||||
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 +226,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(
|
||||||
|
|
@ -420,14 +236,10 @@ async def scan_barcode_text(
|
||||||
tier=session.tier,
|
tier=session.tier,
|
||||||
has_byok=session.has_byok,
|
has_byok=session.has_byok,
|
||||||
)
|
)
|
||||||
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
|
|
||||||
resolved_qty = product_info.get("pack_quantity") or body.quantity
|
|
||||||
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,
|
||||||
product["id"], body.location,
|
product["id"], body.location,
|
||||||
quantity=resolved_qty,
|
quantity=body.quantity,
|
||||||
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",
|
||||||
)
|
)
|
||||||
|
|
@ -435,8 +247,6 @@ async def scan_barcode_text(
|
||||||
else:
|
else:
|
||||||
result_product = None
|
result_product = 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 +256,7 @@ 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,
|
"message": "Added to inventory" if inventory_item else "Product not found in database",
|
||||||
"needs_visual_capture": needs_capture,
|
|
||||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
|
||||||
}],
|
}],
|
||||||
message="Barcode processed",
|
message="Barcode processed",
|
||||||
)
|
)
|
||||||
|
|
@ -464,10 +272,6 @@ async def scan_barcode_image(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""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)
|
|
||||||
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 +282,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 +294,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,
|
||||||
|
|
@ -522,27 +314,20 @@ async def scan_barcode_image(
|
||||||
tier=session.tier,
|
tier=session.tier,
|
||||||
has_byok=session.has_byok,
|
has_byok=session.has_byok,
|
||||||
)
|
)
|
||||||
resolved_qty = product_info.get("pack_quantity") or quantity
|
|
||||||
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=quantity,
|
||||||
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 +338,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)
|
||||||
|
|
@ -721,23 +369,6 @@ async def list_tags(
|
||||||
|
|
||||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/recalculate-expiry")
|
|
||||||
async def recalculate_expiry(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> dict:
|
|
||||||
"""Re-run the expiration predictor over all available inventory items.
|
|
||||||
|
|
||||||
Uses each item's stored purchase_date and current location. Safe to call
|
|
||||||
multiple times — idempotent per session.
|
|
||||||
"""
|
|
||||||
def _run(s: Store) -> tuple[int, int]:
|
|
||||||
return s.recalculate_expiry(tier=session.tier, has_byok=session.has_byok)
|
|
||||||
|
|
||||||
updated, skipped = await asyncio.to_thread(_run, store)
|
|
||||||
return {"updated": updated, "skipped": skipped}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=InventoryStats)
|
@router.get("/stats", response_model=InventoryStats)
|
||||||
async def get_inventory_stats(store: Store = Depends(get_store)):
|
async def get_inventory_stats(store: Store = Depends(get_store)):
|
||||||
def _stats():
|
def _stats():
|
||||||
|
|
|
||||||
|
|
@ -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,325 +0,0 @@
|
||||||
# app/api/endpoints/meal_plans.py
|
|
||||||
"""Meal plan CRUD, shopping list, and prep session endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.meal_plan import (
|
|
||||||
CreatePlanRequest,
|
|
||||||
GapItem,
|
|
||||||
PlanSummary,
|
|
||||||
PrepSessionSummary,
|
|
||||||
PrepTaskSummary,
|
|
||||||
ShoppingListResponse,
|
|
||||||
SlotSummary,
|
|
||||||
UpdatePlanRequest,
|
|
||||||
UpdatePrepTaskRequest,
|
|
||||||
UpsertSlotRequest,
|
|
||||||
VALID_MEAL_TYPES,
|
|
||||||
)
|
|
||||||
from app.services.meal_plan.affiliates import get_retailer_links
|
|
||||||
from app.services.meal_plan.prep_scheduler import build_prep_tasks
|
|
||||||
from app.services.meal_plan.shopping_list import compute_shopping_list
|
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _slot_summary(row: dict) -> SlotSummary:
|
|
||||||
return SlotSummary(
|
|
||||||
id=row["id"],
|
|
||||||
plan_id=row["plan_id"],
|
|
||||||
day_of_week=row["day_of_week"],
|
|
||||||
meal_type=row["meal_type"],
|
|
||||||
recipe_id=row.get("recipe_id"),
|
|
||||||
recipe_title=row.get("recipe_title"),
|
|
||||||
servings=row["servings"],
|
|
||||||
custom_label=row.get("custom_label"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary:
|
|
||||||
meal_types = plan.get("meal_types") or ["dinner"]
|
|
||||||
if isinstance(meal_types, str):
|
|
||||||
meal_types = json.loads(meal_types)
|
|
||||||
return PlanSummary(
|
|
||||||
id=plan["id"],
|
|
||||||
week_start=plan["week_start"],
|
|
||||||
meal_types=meal_types,
|
|
||||||
slots=[_slot_summary(s) for s in slots],
|
|
||||||
created_at=plan["created_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prep_task_summary(row: dict) -> PrepTaskSummary:
|
|
||||||
return PrepTaskSummary(
|
|
||||||
id=row["id"],
|
|
||||||
recipe_id=row.get("recipe_id"),
|
|
||||||
task_label=row["task_label"],
|
|
||||||
duration_minutes=row.get("duration_minutes"),
|
|
||||||
sequence_order=row["sequence_order"],
|
|
||||||
equipment=row.get("equipment"),
|
|
||||||
is_parallel=bool(row.get("is_parallel", False)),
|
|
||||||
notes=row.get("notes"),
|
|
||||||
user_edited=bool(row.get("user_edited", False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── plan CRUD ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/", response_model=PlanSummary)
|
|
||||||
async def create_plan(
|
|
||||||
req: CreatePlanRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# Free tier is locked to dinner-only; paid+ may configure meal types
|
|
||||||
if can_use("meal_plan_config", session.tier):
|
|
||||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
|
||||||
else:
|
|
||||||
meal_types = ["dinner"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=f"A meal plan for the week of {req.week_start} already exists.",
|
|
||||||
)
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
|
||||||
return _plan_summary(plan, slots)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[PlanSummary])
|
|
||||||
async def list_plans(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> list[PlanSummary]:
|
|
||||||
plans = await asyncio.to_thread(store.list_meal_plans)
|
|
||||||
result = []
|
|
||||||
for p in plans:
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, p["id"])
|
|
||||||
result.append(_plan_summary(p, slots))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{plan_id}", response_model=PlanSummary)
|
|
||||||
async def update_plan(
|
|
||||||
plan_id: int,
|
|
||||||
req: UpdatePlanRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
# Free tier stays dinner-only; paid+ may add meal types
|
|
||||||
if can_use("meal_plan_config", session.tier):
|
|
||||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
|
||||||
else:
|
|
||||||
meal_types = ["dinner"]
|
|
||||||
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
|
|
||||||
if updated is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
return _plan_summary(updated, slots)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plan_id}", response_model=PlanSummary)
|
|
||||||
async def get_plan(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
return _plan_summary(plan, slots)
|
|
||||||
|
|
||||||
|
|
||||||
# ── slots ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.put("/{plan_id}/slots/{day_of_week}/{meal_type}", response_model=SlotSummary)
|
|
||||||
async def upsert_slot(
|
|
||||||
plan_id: int,
|
|
||||||
day_of_week: int,
|
|
||||||
meal_type: str,
|
|
||||||
req: UpsertSlotRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> SlotSummary:
|
|
||||||
if day_of_week < 0 or day_of_week > 6:
|
|
||||||
raise HTTPException(status_code=422, detail="day_of_week must be 0-6.")
|
|
||||||
if meal_type not in VALID_MEAL_TYPES:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
row = await asyncio.to_thread(
|
|
||||||
store.upsert_slot,
|
|
||||||
plan_id, day_of_week, meal_type,
|
|
||||||
req.recipe_id, req.servings, req.custom_label,
|
|
||||||
)
|
|
||||||
return _slot_summary(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{plan_id}/slots/{slot_id}", status_code=204)
|
|
||||||
async def delete_slot(
|
|
||||||
plan_id: int,
|
|
||||||
slot_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> None:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
await asyncio.to_thread(store.delete_slot, slot_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── shopping list ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{plan_id}/shopping-list", response_model=ShoppingListResponse)
|
|
||||||
async def get_shopping_list(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> ShoppingListResponse:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
|
|
||||||
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
|
|
||||||
inventory = await asyncio.to_thread(store.list_inventory)
|
|
||||||
|
|
||||||
gaps, covered = compute_shopping_list(recipes, inventory)
|
|
||||||
|
|
||||||
# Enrich gap items with retailer links
|
|
||||||
def _to_schema(item, enrich: bool) -> GapItem:
|
|
||||||
links = get_retailer_links(item.ingredient_name) if enrich else []
|
|
||||||
return GapItem(
|
|
||||||
ingredient_name=item.ingredient_name,
|
|
||||||
needed_raw=item.needed_raw,
|
|
||||||
have_quantity=item.have_quantity,
|
|
||||||
have_unit=item.have_unit,
|
|
||||||
covered=item.covered,
|
|
||||||
retailer_links=links,
|
|
||||||
)
|
|
||||||
|
|
||||||
gap_items = [_to_schema(g, enrich=True) for g in gaps]
|
|
||||||
covered_items = [_to_schema(c, enrich=False) for c in covered]
|
|
||||||
|
|
||||||
disclosure = (
|
|
||||||
"Some links may be affiliate links. Purchases through them support Kiwi development."
|
|
||||||
if gap_items else None
|
|
||||||
)
|
|
||||||
|
|
||||||
return ShoppingListResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
gap_items=gap_items,
|
|
||||||
covered_items=covered_items,
|
|
||||||
disclosure=disclosure,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── prep session ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
|
||||||
async def get_prep_session(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PrepSessionSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id)
|
|
||||||
if prep_session is None:
|
|
||||||
raise HTTPException(status_code=404, detail="No prep session for this plan.")
|
|
||||||
raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"])
|
|
||||||
return PrepSessionSummary(
|
|
||||||
id=prep_session["id"],
|
|
||||||
plan_id=plan_id,
|
|
||||||
scheduled_date=prep_session["scheduled_date"],
|
|
||||||
status=prep_session["status"],
|
|
||||||
tasks=[_prep_task_summary(t) for t in raw_tasks],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
|
||||||
async def create_prep_session(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PrepSessionSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
|
|
||||||
prep_tasks = build_prep_tasks(slots=slots, recipes=recipes)
|
|
||||||
|
|
||||||
scheduled_date = date.today().isoformat()
|
|
||||||
prep_session = await asyncio.to_thread(
|
|
||||||
store.create_prep_session, plan_id, scheduled_date
|
|
||||||
)
|
|
||||||
session_id = prep_session["id"]
|
|
||||||
|
|
||||||
task_dicts = [
|
|
||||||
{
|
|
||||||
"recipe_id": t.recipe_id,
|
|
||||||
"slot_id": t.slot_id,
|
|
||||||
"task_label": t.task_label,
|
|
||||||
"duration_minutes": t.duration_minutes,
|
|
||||||
"sequence_order": t.sequence_order,
|
|
||||||
"equipment": t.equipment,
|
|
||||||
"is_parallel": t.is_parallel,
|
|
||||||
"notes": t.notes,
|
|
||||||
}
|
|
||||||
for t in prep_tasks
|
|
||||||
]
|
|
||||||
inserted = await asyncio.to_thread(store.bulk_insert_prep_tasks, session_id, task_dicts)
|
|
||||||
|
|
||||||
return PrepSessionSummary(
|
|
||||||
id=prep_session["id"],
|
|
||||||
plan_id=prep_session["plan_id"],
|
|
||||||
scheduled_date=prep_session["scheduled_date"],
|
|
||||||
status=prep_session["status"],
|
|
||||||
tasks=[_prep_task_summary(r) for r in inserted],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{plan_id}/prep-session/tasks/{task_id}",
|
|
||||||
response_model=PrepTaskSummary,
|
|
||||||
)
|
|
||||||
async def update_prep_task(
|
|
||||||
plan_id: int,
|
|
||||||
task_id: int,
|
|
||||||
req: UpdatePrepTaskRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PrepTaskSummary:
|
|
||||||
updated = await asyncio.to_thread(
|
|
||||||
store.update_prep_task,
|
|
||||||
task_id,
|
|
||||||
duration_minutes=req.duration_minutes,
|
|
||||||
sequence_order=req.sequence_order,
|
|
||||||
notes=req.notes,
|
|
||||||
equipment=req.equipment,
|
|
||||||
)
|
|
||||||
if updated is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Task not found.")
|
|
||||||
return _prep_task_summary(updated)
|
|
||||||
|
|
@ -219,7 +219,7 @@ def _commit_items(
|
||||||
receipt_id=receipt_id,
|
receipt_id=receipt_id,
|
||||||
purchase_date=str(purchase_date) if purchase_date else None,
|
purchase_date=str(purchase_date) if purchase_date else None,
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="receipt",
|
source="receipt_ocr",
|
||||||
)
|
)
|
||||||
|
|
||||||
created.append(ApprovedInventoryItem(
|
created.append(ApprovedInventoryItem(
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Proxy endpoint: exposes cf-orch call budget to the Kiwi frontend.
|
|
||||||
|
|
||||||
Only lifetime/founders users have a license_key — subscription and free
|
|
||||||
users receive null (no budget UI shown).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.services.heimdall_orch import get_orch_usage
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def orch_usage_endpoint(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> dict | None:
|
|
||||||
"""Return the current period's orch usage for the authenticated user.
|
|
||||||
|
|
||||||
Returns null if the user has no lifetime/founders license key (i.e. they
|
|
||||||
are on a subscription or free plan — no budget cap applies to them).
|
|
||||||
"""
|
|
||||||
if session.license_key is None:
|
|
||||||
return None
|
|
||||||
return get_orch_usage(session.license_key, "kiwi")
|
|
||||||
|
|
@ -42,11 +42,9 @@ async def upload_receipt(
|
||||||
)
|
)
|
||||||
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
|
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
|
||||||
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
|
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
|
||||||
# Pass session.db (a Path) rather than store — the store dependency closes before
|
|
||||||
# background tasks run, so the task opens its own store from the DB path.
|
|
||||||
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
|
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
|
||||||
if ocr_allowed:
|
if ocr_allowed:
|
||||||
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
|
||||||
return ReceiptResponse.model_validate(receipt)
|
return ReceiptResponse.model_validate(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +64,7 @@ async def upload_receipts_batch(
|
||||||
store.create_receipt, file.filename, str(saved)
|
store.create_receipt, file.filename, str(saved)
|
||||||
)
|
)
|
||||||
if ocr_allowed:
|
if ocr_allowed:
|
||||||
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
|
||||||
results.append(ReceiptResponse.model_validate(receipt))
|
results.append(ReceiptResponse.model_validate(receipt))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -99,13 +97,8 @@ async def get_receipt_quality(receipt_id: int, store: Store = Depends(get_store)
|
||||||
return QualityAssessment.model_validate(qa)
|
return QualityAssessment.model_validate(qa)
|
||||||
|
|
||||||
|
|
||||||
async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path) -> None:
|
async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store) -> None:
|
||||||
"""Background task: run OCR pipeline on an uploaded receipt.
|
"""Background task: run OCR pipeline on an uploaded receipt."""
|
||||||
|
|
||||||
Accepts db_path (not a Store instance) because FastAPI closes the request-scoped
|
|
||||||
store before background tasks execute. This task owns its store lifecycle.
|
|
||||||
"""
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
|
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
|
||||||
from app.services.receipt_service import ReceiptService
|
from app.services.receipt_service import ReceiptService
|
||||||
|
|
@ -115,5 +108,3 @@ async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
store.update_receipt_status, receipt_id, "error", str(exc)
|
store.update_receipt_status, receipt_id, "error", str(exc)
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,853 +0,0 @@
|
||||||
"""Recipe suggestion and browser endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import json as _json_mod
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.recipe import (
|
|
||||||
AskRequest,
|
|
||||||
AskResponse,
|
|
||||||
AskRecipeHit,
|
|
||||||
AssemblyTemplateOut,
|
|
||||||
BuildRequest,
|
|
||||||
LeftoversResponse,
|
|
||||||
RecipeJobStatus,
|
|
||||||
RecipeRequest,
|
|
||||||
RecipeResult,
|
|
||||||
RecipeSuggestion,
|
|
||||||
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 (
|
|
||||||
build_from_selection,
|
|
||||||
get_role_candidates,
|
|
||||||
get_templates_for_api,
|
|
||||||
)
|
|
||||||
from app.services.recipe.browser_domains import (
|
|
||||||
DOMAINS,
|
|
||||||
category_has_subcategories,
|
|
||||||
get_category_names,
|
|
||||||
get_domain_labels,
|
|
||||||
get_keywords_for_category,
|
|
||||||
get_keywords_for_subcategory,
|
|
||||||
get_subcategory_names,
|
|
||||||
)
|
|
||||||
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.tiers import can_use
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
|
||||||
"""Run recipe suggestion in a worker thread with its own Store connection.
|
|
||||||
|
|
||||||
SQLite connections cannot be shared across threads. This function creates
|
|
||||||
a fresh Store (and therefore a fresh sqlite3.Connection) in the same thread
|
|
||||||
where it will be used, avoiding ProgrammingError: SQLite objects created in
|
|
||||||
a thread can only be used in that same thread.
|
|
||||||
"""
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return RecipeEngine(store).suggest(req)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_stream_prompt(db_path: Path, level: int) -> str:
|
|
||||||
"""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(
|
|
||||||
req: RecipeRequest,
|
|
||||||
async_mode: bool = Query(default=False, alias="async"),
|
|
||||||
stream: bool = Query(default=False),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
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.
|
|
||||||
# Also read stored unit_system preference; default to metric if not set.
|
|
||||||
unit_system = store.get_setting("unit_system") or "metric"
|
|
||||||
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok, "unit_system": unit_system})
|
|
||||||
if req.level == 4 and not req.wildcard_confirmed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Level 4 (Wildcard) requires wildcard_confirmed=true.",
|
|
||||||
)
|
|
||||||
if req.level in (3, 4) and not can_use("recipe_suggestions", req.tier, req.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="LLM recipe levels require Paid tier or a configured LLM backend.",
|
|
||||||
)
|
|
||||||
if req.style_id and not can_use("style_picker", req.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
|
||||||
|
|
||||||
# Orch budget check for lifetime/founders keys — downgrade to L2 (local) if exhausted.
|
|
||||||
# Subscription and local/BYOK users skip this check entirely.
|
|
||||||
orch_fallback = False
|
|
||||||
if (
|
|
||||||
req.level in (3, 4)
|
|
||||||
and session.license_key is not None
|
|
||||||
and not session.has_byok
|
|
||||||
and session.tier != "local"
|
|
||||||
):
|
|
||||||
budget = check_orch_budget(session.license_key, "kiwi")
|
|
||||||
if not budget.get("allowed", True):
|
|
||||||
req = req.model_copy(update={"level": 2})
|
|
||||||
orch_fallback = True
|
|
||||||
|
|
||||||
if stream and req.level in (3, 4):
|
|
||||||
return StreamingResponse(
|
|
||||||
_stream_recipe_sse(session.db, req),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if req.level in (3, 4) and async_mode:
|
|
||||||
return await _enqueue_recipe_job(session, req)
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
||||||
if orch_fallback:
|
|
||||||
result = result.model_copy(update={"orch_fallback": True})
|
|
||||||
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")
|
|
||||||
async def list_browse_domains(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return available domain schemas for the recipe browser."""
|
|
||||||
return get_domain_labels()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/{domain}")
|
|
||||||
async def list_browse_categories(
|
|
||||||
domain: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return categories with recipe counts for a given domain."""
|
|
||||||
if domain not in DOMAINS:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
|
||||||
|
|
||||||
cat_names = get_category_names(domain)
|
|
||||||
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
|
||||||
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
|
|
||||||
|
|
||||||
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]:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/{domain}/{category}")
|
|
||||||
async def browse_recipes(
|
|
||||||
domain: str,
|
|
||||||
category: str,
|
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
|
||||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
||||||
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),
|
|
||||||
) -> dict:
|
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
|
||||||
Pass subcategory to narrow within a category that has subcategories.
|
|
||||||
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:
|
|
||||||
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)
|
|
||||||
if not keywords:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
pantry_list = (
|
|
||||||
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
|
||||||
if pantry_items
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _browse(db_path: Path) -> dict:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
# Load sensory preferences
|
|
||||||
sensory_prefs_json = store.get_setting("sensory_preferences")
|
|
||||||
sensory_exclude = build_sensory_exclude(sensory_prefs_json)
|
|
||||||
|
|
||||||
result = store.browse_recipes(
|
|
||||||
keywords=keywords,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
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(
|
|
||||||
domain=domain,
|
|
||||||
category=category,
|
|
||||||
page=page,
|
|
||||||
result_count=result["total"],
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_browse, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates", response_model=list[AssemblyTemplateOut])
|
|
||||||
async def list_assembly_templates() -> list[dict]:
|
|
||||||
"""Return all 13 assembly templates with ordered role sequences.
|
|
||||||
|
|
||||||
Cache-friendly: static data, no per-user state.
|
|
||||||
"""
|
|
||||||
return get_templates_for_api()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/template-candidates", response_model=RoleCandidatesResponse)
|
|
||||||
async def get_template_role_candidates(
|
|
||||||
template_id: str = Query(..., description="Template slug, e.g. 'burrito_taco'"),
|
|
||||||
role: str = Query(..., description="Role display name, e.g. 'protein'"),
|
|
||||||
prior_picks: str = Query(default="", description="Comma-separated prior selections"),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Return pantry-matched candidates for one wizard step."""
|
|
||||||
def _get(db_path: Path) -> dict:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
items = store.list_inventory(status="available")
|
|
||||||
pantry_set = {
|
|
||||||
item["product_name"]
|
|
||||||
for item in items
|
|
||||||
if item.get("product_name")
|
|
||||||
}
|
|
||||||
pantry_list = list(pantry_set)
|
|
||||||
prior = [p.strip() for p in prior_picks.split(",") if p.strip()]
|
|
||||||
profile_index = store.get_element_profiles(pantry_list + prior)
|
|
||||||
return get_role_candidates(
|
|
||||||
template_slug=template_id,
|
|
||||||
role_display=role,
|
|
||||||
pantry_set=pantry_set,
|
|
||||||
prior_picks=prior,
|
|
||||||
profile_index=profile_index,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/build", response_model=RecipeSuggestion)
|
|
||||||
async def build_recipe(
|
|
||||||
req: BuildRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> RecipeSuggestion:
|
|
||||||
"""Build a recipe from explicit role selections."""
|
|
||||||
def _build(db_path: Path) -> RecipeSuggestion | None:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
items = store.list_inventory(status="available")
|
|
||||||
pantry_set = {
|
|
||||||
item["product_name"]
|
|
||||||
for item in items
|
|
||||||
if item.get("product_name")
|
|
||||||
}
|
|
||||||
suggestion = build_from_selection(
|
|
||||||
template_slug=req.template_id,
|
|
||||||
role_overrides=req.role_overrides,
|
|
||||||
pantry_set=pantry_set,
|
|
||||||
)
|
|
||||||
if suggestion is None:
|
|
||||||
return None
|
|
||||||
# Persist to recipes table so the result can be saved/bookmarked.
|
|
||||||
# external_id encodes template + selections for stable dedup.
|
|
||||||
import hashlib as _hl, json as _js
|
|
||||||
sel_hash = _hl.md5(
|
|
||||||
_js.dumps(req.role_overrides, sort_keys=True).encode()
|
|
||||||
).hexdigest()[:8]
|
|
||||||
external_id = f"assembly:{req.template_id}:{sel_hash}"
|
|
||||||
real_id = store.upsert_built_recipe(
|
|
||||||
external_id=external_id,
|
|
||||||
title=suggestion.title,
|
|
||||||
ingredients=suggestion.matched_ingredients,
|
|
||||||
directions=suggestion.directions,
|
|
||||||
)
|
|
||||||
return suggestion.model_copy(update={"id": real_id})
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(_build, session.db)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Template not found or required ingredient missing.",
|
|
||||||
)
|
|
||||||
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}")
|
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_recipe(rid)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
|
||||||
if not recipe:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
"""Saved recipe bookmark endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.saved_recipe import (
|
|
||||||
CollectionMemberRequest,
|
|
||||||
CollectionRequest,
|
|
||||||
CollectionSummary,
|
|
||||||
SavedRecipeSummary,
|
|
||||||
SaveRecipeRequest,
|
|
||||||
UpdateSavedRecipeRequest,
|
|
||||||
)
|
|
||||||
from app.services.magpie_hook import fire_recipe_signal
|
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
|
|
||||||
class StyleClassifyResponse(BaseModel):
|
|
||||||
suggested_tags: list[str]
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _in_thread(db_path: Path, fn):
|
|
||||||
"""Run a Store operation in a worker thread with its own connection."""
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return fn(store)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
|
|
||||||
collection_ids = store.get_saved_recipe_collection_ids(row["id"])
|
|
||||||
return SavedRecipeSummary(
|
|
||||||
id=row["id"],
|
|
||||||
recipe_id=row["recipe_id"],
|
|
||||||
title=row.get("title") or "",
|
|
||||||
saved_at=row["saved_at"],
|
|
||||||
notes=row.get("notes"),
|
|
||||||
rating=row.get("rating"),
|
|
||||||
style_tags=row.get("style_tags") or [],
|
|
||||||
collection_ids=collection_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── save / unsave ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("", response_model=SavedRecipeSummary)
|
|
||||||
async def save_recipe(
|
|
||||||
req: SaveRecipeRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> SavedRecipeSummary:
|
|
||||||
def _run(store: Store) -> SavedRecipeSummary:
|
|
||||||
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
|
||||||
return _to_summary(row, store)
|
|
||||||
|
|
||||||
result = 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)
|
|
||||||
async def unsave_recipe(
|
|
||||||
recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> None:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.unsave_recipe(recipe_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{recipe_id}", response_model=SavedRecipeSummary)
|
|
||||||
async def update_saved_recipe(
|
|
||||||
recipe_id: int,
|
|
||||||
req: UpdateSavedRecipeRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> SavedRecipeSummary:
|
|
||||||
def _run(store: Store) -> SavedRecipeSummary:
|
|
||||||
if not store.is_recipe_saved(recipe_id):
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not saved.")
|
|
||||||
row = store.update_saved_recipe(
|
|
||||||
recipe_id, req.notes, req.rating, req.style_tags
|
|
||||||
)
|
|
||||||
return _to_summary(row, store)
|
|
||||||
|
|
||||||
result = 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])
|
|
||||||
async def list_saved_recipes(
|
|
||||||
sort_by: str = "saved_at",
|
|
||||||
collection_id: int | None = None,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[SavedRecipeSummary]:
|
|
||||||
def _run(store: Store) -> list[SavedRecipeSummary]:
|
|
||||||
rows = store.get_saved_recipes(sort_by=sort_by, collection_id=collection_id)
|
|
||||||
return [_to_summary(r, store) for r in rows]
|
|
||||||
|
|
||||||
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) ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/collections", response_model=list[CollectionSummary])
|
|
||||||
async def list_collections(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> 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):
|
|
||||||
return []
|
|
||||||
rows = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.get_collections()
|
|
||||||
)
|
|
||||||
return [CollectionSummary(**r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/collections", response_model=CollectionSummary)
|
|
||||||
async def create_collection(
|
|
||||||
req: CollectionRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> CollectionSummary:
|
|
||||||
if not can_use("recipe_collections", session.tier):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Collections require Paid tier.",
|
|
||||||
)
|
|
||||||
row = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db,
|
|
||||||
lambda s: s.create_collection(req.name, req.description),
|
|
||||||
)
|
|
||||||
return CollectionSummary(**row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/collections/{collection_id}", status_code=204)
|
|
||||||
async def delete_collection(
|
|
||||||
collection_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> None:
|
|
||||||
if not can_use("recipe_collections", session.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.delete_collection(collection_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/collections/{collection_id}", response_model=CollectionSummary)
|
|
||||||
async def rename_collection(
|
|
||||||
collection_id: int,
|
|
||||||
req: CollectionRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> CollectionSummary:
|
|
||||||
if not can_use("recipe_collections", session.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
|
||||||
row = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db,
|
|
||||||
lambda s: s.rename_collection(collection_id, req.name, req.description),
|
|
||||||
)
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Collection not found.")
|
|
||||||
return CollectionSummary(**row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/collections/{collection_id}/members", status_code=204)
|
|
||||||
async def add_to_collection(
|
|
||||||
collection_id: int,
|
|
||||||
req: CollectionMemberRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> None:
|
|
||||||
if not can_use("recipe_collections", session.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db,
|
|
||||||
lambda s: s.add_to_collection(collection_id, req.saved_recipe_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/collections/{collection_id}/members/{saved_recipe_id}", status_code=204
|
|
||||||
)
|
|
||||||
async def remove_from_collection(
|
|
||||||
collection_id: int,
|
|
||||||
saved_recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> None:
|
|
||||||
if not can_use("recipe_collections", session.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db,
|
|
||||||
lambda s: s.remove_from_collection(collection_id, saved_recipe_id),
|
|
||||||
)
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""Session bootstrap endpoint — called once per app load by the frontend.
|
|
||||||
|
|
||||||
Logs auth= + tier= for log-based analytics without client-side tracking.
|
|
||||||
See Circuit-Forge/kiwi#86.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bootstrap")
|
|
||||||
def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
|
|
||||||
"""Record auth type and tier for log-based analytics.
|
|
||||||
|
|
||||||
Expected log output:
|
|
||||||
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
|
||||||
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)
|
|
||||||
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 {
|
|
||||||
"auth": _auth_label(session.user_id),
|
|
||||||
"tier": session.tier,
|
|
||||||
"has_byok": session.has_byok,
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""User settings endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"})
|
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key}")
|
|
||||||
async def get_setting(
|
|
||||||
key: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> dict:
|
|
||||||
"""Return the stored value for a settings key."""
|
|
||||||
if key not in _ALLOWED_KEYS:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown settings key: '{key}'.")
|
|
||||||
value = store.get_setting(key)
|
|
||||||
if value is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found.")
|
|
||||||
return {"key": key, "value": value}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{key}")
|
|
||||||
async def set_setting(
|
|
||||||
key: str,
|
|
||||||
body: SettingBody,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> dict:
|
|
||||||
"""Upsert a settings key-value pair."""
|
|
||||||
if key not in _ALLOWED_KEYS:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown settings key: '{key}'.")
|
|
||||||
store.set_setting(key, body.value)
|
|
||||||
return {"key": key, "value": body.value}
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
"""Shopping list endpoints.
|
|
||||||
|
|
||||||
Free tier for all users (anonymous guests included — shopping list is the
|
|
||||||
primary affiliate revenue surface). Confirm-purchase action is also Free:
|
|
||||||
it moves a checked item into pantry inventory without a tier gate so the
|
|
||||||
flow works for anyone who signs up or browses without an account.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /shopping — list items (with affiliate links)
|
|
||||||
POST /shopping — add item manually
|
|
||||||
PATCH /shopping/{id} — update (check/uncheck, rename, qty)
|
|
||||||
DELETE /shopping/{id} — remove single item
|
|
||||||
DELETE /shopping/checked — clear all checked items
|
|
||||||
DELETE /shopping/all — clear entire list
|
|
||||||
POST /shopping/from-recipe — bulk add gaps from a recipe
|
|
||||||
POST /shopping/{id}/confirm — confirm purchase → add to pantry inventory
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.shopping import (
|
|
||||||
BulkAddFromRecipeRequest,
|
|
||||||
ConfirmPurchaseRequest,
|
|
||||||
ShoppingItemCreate,
|
|
||||||
ShoppingItemResponse,
|
|
||||||
ShoppingItemUpdate,
|
|
||||||
)
|
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
|
|
||||||
"""Attach live affiliate links to a raw store row."""
|
|
||||||
links = builder.build_links(item["name"])
|
|
||||||
return ShoppingItemResponse(
|
|
||||||
**{**item, "checked": bool(item.get("checked", 0))},
|
|
||||||
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _in_thread(db_path, fn):
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return fn(store)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ── List ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _locale_from_store(store: Store) -> str:
|
|
||||||
return store.get_setting("shopping_locale") or "us"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ShoppingItemResponse])
|
|
||||||
async def list_shopping_items(
|
|
||||||
include_checked: bool = True,
|
|
||||||
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, locale=locale)
|
|
||||||
items = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
|
|
||||||
)
|
|
||||||
return [_enrich(i, builder) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Add manually ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("", response_model=ShoppingItemResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_shopping_item(
|
|
||||||
body: ShoppingItemCreate,
|
|
||||||
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))
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
_in_thread,
|
|
||||||
session.db,
|
|
||||||
lambda s: s.add_shopping_item(
|
|
||||||
name=body.name,
|
|
||||||
quantity=body.quantity,
|
|
||||||
unit=body.unit,
|
|
||||||
category=body.category,
|
|
||||||
notes=body.notes,
|
|
||||||
source=body.source,
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
sort_order=body.sort_order,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return _enrich(item, builder)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bulk add from recipe ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/from-recipe", response_model=list[ShoppingItemResponse], status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_from_recipe(
|
|
||||||
body: BulkAddFromRecipeRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Add missing ingredients from a recipe to the shopping list.
|
|
||||||
|
|
||||||
Runs pantry gap analysis and adds only the items the user doesn't have
|
|
||||||
(unless include_covered=True). Skips duplicates already on the list.
|
|
||||||
"""
|
|
||||||
from app.services.meal_plan.shopping_list import compute_shopping_list
|
|
||||||
|
|
||||||
def _run(store: Store):
|
|
||||||
recipe = store.get_recipe(body.recipe_id)
|
|
||||||
if not recipe:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
|
||||||
inventory = store.list_inventory()
|
|
||||||
gaps, covered = compute_shopping_list([recipe], inventory)
|
|
||||||
targets = (gaps + covered) if body.include_covered else gaps
|
|
||||||
|
|
||||||
# Avoid duplicates already on the list
|
|
||||||
existing = {i["name"].lower() for i in store.list_shopping_items()}
|
|
||||||
added = []
|
|
||||||
for gap in targets:
|
|
||||||
if gap.ingredient_name.lower() in existing:
|
|
||||||
continue
|
|
||||||
item = store.add_shopping_item(
|
|
||||||
name=gap.ingredient_name,
|
|
||||||
quantity=None,
|
|
||||||
unit=gap.have_unit,
|
|
||||||
source="recipe",
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
)
|
|
||||||
added.append(item)
|
|
||||||
return added
|
|
||||||
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
|
||||||
items = await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
return [_enrich(i, builder) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Update ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.patch("/{item_id}", response_model=ShoppingItemResponse)
|
|
||||||
async def update_shopping_item(
|
|
||||||
item_id: int,
|
|
||||||
body: ShoppingItemUpdate,
|
|
||||||
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))
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
_in_thread,
|
|
||||||
session.db,
|
|
||||||
lambda s: s.update_shopping_item(item_id, **body.model_dump(exclude_none=True)),
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
return _enrich(item, builder)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Confirm purchase → pantry ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{item_id}/confirm", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def confirm_purchase(
|
|
||||||
item_id: int,
|
|
||||||
body: ConfirmPurchaseRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Confirm a checked item was purchased and add it to pantry inventory.
|
|
||||||
|
|
||||||
Human approval step: the user explicitly confirms what they actually bought
|
|
||||||
before it lands in their pantry. Returns the new inventory item.
|
|
||||||
"""
|
|
||||||
def _run(store: Store):
|
|
||||||
shopping_item = store.get_shopping_item(item_id)
|
|
||||||
if not shopping_item:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
|
|
||||||
qty = body.quantity if body.quantity is not None else (shopping_item.get("quantity") or 1.0)
|
|
||||||
unit = body.unit or shopping_item.get("unit") or "count"
|
|
||||||
category = shopping_item.get("category")
|
|
||||||
|
|
||||||
product = store.get_or_create_product(
|
|
||||||
name=shopping_item["name"],
|
|
||||||
category=category,
|
|
||||||
)
|
|
||||||
inv_item = store.add_inventory_item(
|
|
||||||
product_id=product["id"],
|
|
||||||
location=body.location,
|
|
||||||
quantity=qty,
|
|
||||||
unit=unit,
|
|
||||||
source="manual",
|
|
||||||
)
|
|
||||||
# Mark the shopping item checked and leave it for the user to clear
|
|
||||||
store.update_shopping_item(item_id, checked=True)
|
|
||||||
return inv_item
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Delete ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_shopping_item(
|
|
||||||
item_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
deleted = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.delete_shopping_item(item_id)
|
|
||||||
)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/checked", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def clear_checked(session: CloudUser = Depends(get_session)):
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.clear_checked_shopping_items()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/all", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def clear_all(session: CloudUser = Depends(get_session)):
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.clear_all_shopping_items()
|
|
||||||
)
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
"""Staple library endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from app.services.recipe.staple_library import StapleLibrary
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
_lib = StapleLibrary()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def list_staples(dietary: str | None = None) -> list[dict]:
|
|
||||||
staples = _lib.filter_by_dietary(dietary) if dietary else _lib.list_all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"slug": s.slug,
|
|
||||||
"name": s.name,
|
|
||||||
"description": s.description,
|
|
||||||
"dietary_labels": s.dietary_labels,
|
|
||||||
"yield_formats": list(s.yield_formats.keys()),
|
|
||||||
}
|
|
||||||
for s in staples
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}")
|
|
||||||
async def get_staple(slug: str) -> dict:
|
|
||||||
staple = _lib.get(slug)
|
|
||||||
if not staple:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Staple '{slug}' not found.")
|
|
||||||
return {
|
|
||||||
"slug": staple.slug,
|
|
||||||
"name": staple.name,
|
|
||||||
"description": staple.description,
|
|
||||||
"dietary_labels": staple.dietary_labels,
|
|
||||||
"base_ingredients": staple.base_ingredients,
|
|
||||||
"base_method": staple.base_method,
|
|
||||||
"base_time_minutes": staple.base_time_minutes,
|
|
||||||
"yield_formats": staple.yield_formats,
|
|
||||||
"compatible_styles": staple.compatible_styles,
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +1,10 @@
|
||||||
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
|
||||||
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()
|
||||||
|
|
||||||
api_router.include_router(session.router, prefix="/session", tags=["session"])
|
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
|
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
|
||||||
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
|
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) # OCR endpoints under /receipts
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"]) # No prefix, uses /export in the router
|
||||||
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"])
|
|
||||||
# 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(settings.router, prefix="/settings", tags=["settings"])
|
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
|
||||||
api_router.include_router(feedback_attach.router, prefix="/feedback", tags=["feedback"])
|
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
|
||||||
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
|
||||||
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
|
|
||||||
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(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,11 +17,15 @@ 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 jwt as pyjwt
|
||||||
from fastapi import Depends, HTTPException, Request, Response
|
import requests
|
||||||
|
import yaml
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -27,22 +33,17 @@ 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", "")
|
||||||
|
|
||||||
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
||||||
|
|
||||||
|
_TIER_CACHE: dict[str, tuple[str, float]] = {}
|
||||||
|
_TIER_CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
TIERS = ["free", "paid", "premium", "ultra"]
|
TIERS = ["free", "paid", "premium", "ultra"]
|
||||||
|
|
||||||
_core = _CoreFactory(product="kiwi", byok_detector=detect_byok)
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_label(user_id: str) -> str:
|
|
||||||
"""Classify a user_id into a short tag for structured log lines. No PII emitted."""
|
|
||||||
if user_id in ("local", "local-dev"):
|
|
||||||
return "local"
|
|
||||||
if user_id.startswith("anon-"):
|
|
||||||
return "anon"
|
|
||||||
return "authed"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Domain ────────────────────────────────────────────────────────────────────
|
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -52,74 +53,127 @@ class CloudUser:
|
||||||
tier: str # free | paid | premium | ultra | local
|
tier: str # free | paid | premium | ultra | local
|
||||||
db: Path # per-user SQLite DB path
|
db: Path # per-user SQLite DB path
|
||||||
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
||||||
household_id: str | None = None
|
|
||||||
is_household_owner: bool = False
|
|
||||||
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
|
||||||
|
|
||||||
|
|
||||||
# ── DB path helpers ───────────────────────────────────────────────────────────
|
# ── JWT validation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
def _extract_session_token(header_value: str) -> str:
|
||||||
if household_id:
|
m = re.search(r'(?:^|;)\s*cf_session=([^;]+)', header_value)
|
||||||
path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
|
return m.group(1).strip() if m else header_value.strip()
|
||||||
else:
|
|
||||||
|
|
||||||
|
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) -> str:
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _TIER_CACHE.get(user_id)
|
||||||
|
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
if not HEIMDALL_ADMIN_TOKEN:
|
||||||
|
return "free"
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
tier = resp.json().get("tier", "free") if resp.ok else "free"
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
||||||
|
tier = "free"
|
||||||
|
|
||||||
|
_TIER_CACHE[user_id] = (tier, now)
|
||||||
|
return tier
|
||||||
|
|
||||||
|
|
||||||
|
def _user_db_path(user_id: str) -> Path:
|
||||||
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
|
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _anon_guest_db_path(guest_id: str) -> Path:
|
# ── BYOK detection ────────────────────────────────────────────────────────────
|
||||||
"""Per-session DB for unauthenticated guest visitors.
|
|
||||||
|
|
||||||
Each anonymous visitor gets an isolated SQLite DB keyed by their guest UUID
|
_LLM_CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
||||||
cookie, so shopping lists and affiliate interactions never bleed across sessions.
|
|
||||||
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
path = CLOUD_DATA_ROOT / f"anon-{guest_id}" / "kiwi.db"
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
with open(config_path) as f:
|
||||||
return path
|
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:
|
def get_session(request: Request) -> CloudUser:
|
||||||
"""FastAPI dependency — resolves the current user from the request.
|
"""FastAPI dependency — resolves the current user from the request.
|
||||||
|
|
||||||
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.
|
Local mode: fully-privileged "local" user pointing at local DB.
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
||||||
Dev bypass: CLOUD_AUTH_BYPASS_IPS match returns a "local-dev" session.
|
|
||||||
Anonymous: per-session UUID cookie (cf_guest_id) isolates each guest's data.
|
|
||||||
"""
|
"""
|
||||||
core_user = _core.resolve(request, response)
|
has_byok = _detect_byok()
|
||||||
uid, tier, has_byok = core_user.user_id, core_user.tier, core_user.has_byok
|
|
||||||
|
|
||||||
if not CLOUD_MODE or uid in ("local", "local-dev"):
|
if not CLOUD_MODE:
|
||||||
# local-dev gets a writable path under CLOUD_DATA_ROOT; local uses KIWI_DB
|
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
||||||
db = _user_db_path(uid) if uid == "local-dev" else _LOCAL_KIWI_DB
|
|
||||||
return CloudUser(user_id=uid, tier=tier, db=db, has_byok=has_byok)
|
|
||||||
|
|
||||||
if uid.startswith("anon-"):
|
raw_header = (
|
||||||
guest_id = uid[len("anon-"):]
|
request.headers.get("x-cf-session", "")
|
||||||
return CloudUser(
|
or request.headers.get("cookie", "")
|
||||||
user_id=uid, tier=tier,
|
|
||||||
db=_anon_guest_db_path(guest_id),
|
|
||||||
has_byok=has_byok,
|
|
||||||
)
|
)
|
||||||
|
if not raw_header:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
household_id = core_user.meta.get("household_id")
|
token = _extract_session_token(raw_header)
|
||||||
is_owner = core_user.meta.get("is_household_owner", False)
|
if not token:
|
||||||
license_key = core_user.meta.get("license_key")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
log.debug("Resolved %s session uid=%s tier=%s household=%s", _auth_label(uid), uid[:8], tier, household_id)
|
|
||||||
return CloudUser(
|
user_id = validate_session_jwt(token)
|
||||||
user_id=uid, tier=tier,
|
_ensure_provisioned(user_id)
|
||||||
db=_user_db_path(uid, household_id=household_id),
|
tier = _fetch_cloud_tier(user_id)
|
||||||
has_byok=has_byok,
|
return CloudUser(user_id=user_id, tier=tier, db=_user_db_path(user_id), has_byok=has_byok)
|
||||||
household_id=household_id,
|
|
||||||
is_household_owner=is_owner,
|
|
||||||
license_key=license_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_tier(min_tier: str):
|
def require_tier(min_tier: str):
|
||||||
|
|
|
||||||
|
|
@ -35,28 +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_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
|
||||||
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
|
||||||
"COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod"
|
|
||||||
)
|
|
||||||
COMMUNITY_CLOUD_FEED_URL: str = os.environ.get(
|
|
||||||
"COMMUNITY_CLOUD_FEED_URL",
|
|
||||||
"https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Processing
|
# Processing
|
||||||
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
|
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
|
||||||
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")
|
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")
|
||||||
|
|
@ -65,52 +43,8 @@ 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)
|
|
||||||
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+)
|
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
|
||||||
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 +57,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
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ CREATE TABLE receipts_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
original_path TEXT NOT NULL,
|
original_path TEXT NOT NULL,
|
||||||
processed_path TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'uploaded'
|
status TEXT NOT NULL DEFAULT 'uploaded'
|
||||||
CHECK (status IN (
|
CHECK (status IN (
|
||||||
'uploaded',
|
'uploaded',
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
-- Migration 006: Ingredient element profiles + FlavorGraph molecule index.
|
|
||||||
|
|
||||||
CREATE TABLE ingredient_profiles (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
name_variants TEXT NOT NULL DEFAULT '[]', -- JSON array of aliases/alternate spellings
|
|
||||||
elements TEXT NOT NULL DEFAULT '[]', -- JSON array: ["Richness","Depth"]
|
|
||||||
-- Functional submetadata (from USDA FDC)
|
|
||||||
fat_pct REAL DEFAULT 0.0,
|
|
||||||
fat_saturated_pct REAL DEFAULT 0.0,
|
|
||||||
moisture_pct REAL DEFAULT 0.0,
|
|
||||||
protein_pct REAL DEFAULT 0.0,
|
|
||||||
starch_pct REAL DEFAULT 0.0,
|
|
||||||
binding_score INTEGER DEFAULT 0 CHECK (binding_score BETWEEN 0 AND 3),
|
|
||||||
glutamate_mg REAL DEFAULT 0.0,
|
|
||||||
ph_estimate REAL,
|
|
||||||
sodium_mg_per_100g REAL DEFAULT 0.0,
|
|
||||||
smoke_point_c REAL,
|
|
||||||
is_fermented INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_emulsifier INTEGER NOT NULL DEFAULT 0,
|
|
||||||
-- Aroma submetadata
|
|
||||||
flavor_molecule_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of FlavorGraph compound IDs
|
|
||||||
heat_stable INTEGER NOT NULL DEFAULT 1,
|
|
||||||
add_timing TEXT NOT NULL DEFAULT 'any'
|
|
||||||
CHECK (add_timing IN ('early','finish','any')),
|
|
||||||
-- Brightness submetadata
|
|
||||||
acid_type TEXT CHECK (acid_type IN ('citric','acetic','lactic',NULL)),
|
|
||||||
-- Texture submetadata
|
|
||||||
texture_profile TEXT NOT NULL DEFAULT 'neutral',
|
|
||||||
water_activity REAL,
|
|
||||||
-- Source
|
|
||||||
usda_fdc_id TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'usda',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_ingredient_profiles_name ON ingredient_profiles (name);
|
|
||||||
CREATE INDEX idx_ingredient_profiles_elements ON ingredient_profiles (elements);
|
|
||||||
|
|
||||||
CREATE TABLE flavor_molecules (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
compound_id TEXT NOT NULL UNIQUE, -- FlavorGraph node ID
|
|
||||||
compound_name TEXT NOT NULL,
|
|
||||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array of ingredient names
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_flavor_molecules_compound_id ON flavor_molecules (compound_id);
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Migration 007: Recipe corpus index (food.com dataset).
|
|
||||||
|
|
||||||
CREATE TABLE recipes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
external_id TEXT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
ingredients TEXT NOT NULL DEFAULT '[]', -- JSON array of raw ingredient strings
|
|
||||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array of normalized names
|
|
||||||
directions TEXT NOT NULL DEFAULT '[]', -- JSON array of step strings
|
|
||||||
category TEXT,
|
|
||||||
keywords TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
||||||
calories REAL,
|
|
||||||
fat_g REAL,
|
|
||||||
protein_g REAL,
|
|
||||||
sodium_mg REAL,
|
|
||||||
-- Element coverage scores computed at import time
|
|
||||||
element_coverage TEXT NOT NULL DEFAULT '{}', -- JSON {element: 0.0-1.0}
|
|
||||||
source TEXT NOT NULL DEFAULT 'foodcom',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_recipes_title ON recipes (title);
|
|
||||||
CREATE INDEX idx_recipes_category ON recipes (category);
|
|
||||||
CREATE UNIQUE INDEX idx_recipes_external_id ON recipes (external_id);
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- Migration 008: Derived substitution pairs.
|
|
||||||
-- Source: diff of lishuyang/recipepairs (GPL-3.0 derivation — raw data not shipped).
|
|
||||||
|
|
||||||
CREATE TABLE substitution_pairs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
original_name TEXT NOT NULL,
|
|
||||||
substitute_name TEXT NOT NULL,
|
|
||||||
constraint_label TEXT NOT NULL, -- 'vegan'|'vegetarian'|'dairy_free'|'gluten_free'|'low_fat'|'low_sodium'
|
|
||||||
fat_delta REAL DEFAULT 0.0,
|
|
||||||
moisture_delta REAL DEFAULT 0.0,
|
|
||||||
glutamate_delta REAL DEFAULT 0.0,
|
|
||||||
protein_delta REAL DEFAULT 0.0,
|
|
||||||
occurrence_count INTEGER DEFAULT 1,
|
|
||||||
compensation_hints TEXT NOT NULL DEFAULT '[]', -- JSON [{ingredient, reason, element}]
|
|
||||||
source TEXT NOT NULL DEFAULT 'derived',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_substitution_pairs_original ON substitution_pairs (original_name);
|
|
||||||
CREATE INDEX idx_substitution_pairs_constraint ON substitution_pairs (constraint_label);
|
|
||||||
CREATE UNIQUE INDEX idx_substitution_pairs_pair
|
|
||||||
ON substitution_pairs (original_name, substitute_name, constraint_label);
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
-- Migration 009: Staple library (bulk-preparable base components).
|
|
||||||
|
|
||||||
CREATE TABLE staples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
base_ingredients TEXT NOT NULL DEFAULT '[]', -- JSON array of ingredient strings
|
|
||||||
base_method TEXT,
|
|
||||||
base_time_minutes INTEGER,
|
|
||||||
yield_formats TEXT NOT NULL DEFAULT '{}', -- JSON {format_name: {elements, shelf_days, methods, texture}}
|
|
||||||
dietary_labels TEXT NOT NULL DEFAULT '[]', -- JSON ['vegan','high-protein']
|
|
||||||
compatible_styles TEXT NOT NULL DEFAULT '[]', -- JSON [style_id]
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_staples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
staple_slug TEXT NOT NULL REFERENCES staples(slug) ON DELETE CASCADE,
|
|
||||||
active_format TEXT NOT NULL,
|
|
||||||
quantity_g REAL,
|
|
||||||
prepared_at TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_user_staples_slug ON user_staples (staple_slug);
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-- Migration 010: User substitution approval log (opt-in dataset moat).
|
|
||||||
|
|
||||||
CREATE TABLE substitution_feedback (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
original_name TEXT NOT NULL,
|
|
||||||
substitute_name TEXT NOT NULL,
|
|
||||||
constraint_label TEXT,
|
|
||||||
compensation_used TEXT NOT NULL DEFAULT '[]', -- JSON array of compensation ingredient names
|
|
||||||
approved INTEGER NOT NULL DEFAULT 0,
|
|
||||||
opted_in INTEGER NOT NULL DEFAULT 0, -- user consented to anonymized sharing
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_substitution_feedback_original ON substitution_feedback (original_name);
|
|
||||||
CREATE INDEX idx_substitution_feedback_opted_in ON substitution_feedback (opted_in);
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- Migration 011: Daily rate limits (leftover mode: 5/day free tier).
|
|
||||||
|
|
||||||
CREATE TABLE rate_limits (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
feature TEXT NOT NULL,
|
|
||||||
window_date TEXT NOT NULL, -- YYYY-MM-DD
|
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE (feature, window_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_rate_limits_feature_date ON rate_limits (feature, window_date);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Migration 012: User settings key-value store.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
-- Migration 014: Add macro nutrition columns to recipes and ingredient_profiles.
|
|
||||||
--
|
|
||||||
-- recipes: sugar, carbs, fiber, servings, and an estimated flag.
|
|
||||||
-- ingredient_profiles: carbs, fiber, calories, sugar per 100g (for estimation fallback).
|
|
||||||
|
|
||||||
ALTER TABLE recipes ADD COLUMN sugar_g REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN carbs_g REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN fiber_g REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN servings REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN nutrition_estimated INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN carbs_g_per_100g REAL DEFAULT 0.0;
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN fiber_g_per_100g REAL DEFAULT 0.0;
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN calories_per_100g REAL DEFAULT 0.0;
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN sugar_g_per_100g REAL DEFAULT 0.0;
|
|
||||||
|
|
||||||
CREATE INDEX idx_recipes_sugar_g ON recipes (sugar_g);
|
|
||||||
CREATE INDEX idx_recipes_carbs_g ON recipes (carbs_g);
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
-- Migration 015: FTS5 inverted index for recipe ingredient lookup.
|
|
||||||
--
|
|
||||||
-- Content table backed by `recipes` — stores only the inverted index, no text duplication.
|
|
||||||
-- MATCH queries replace O(N) LIKE scans with O(log N) token lookups.
|
|
||||||
--
|
|
||||||
-- One-time rebuild cost on 3.2M rows: ~15-30 seconds at startup.
|
|
||||||
-- Subsequent startups skip this migration entirely.
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS recipes_fts USING fts5(
|
|
||||||
ingredient_names,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id,
|
|
||||||
tokenize="unicode61"
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO recipes_fts(recipes_fts) VALUES('rebuild');
|
|
||||||
|
|
||||||
-- Triggers to keep the FTS index in sync with the recipes table.
|
|
||||||
-- Without these, rows inserted after the initial rebuild are invisible to FTS queries.
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
-- Migration 016: Add FTS5 sync triggers for the recipes_fts content table.
|
|
||||||
--
|
|
||||||
-- Migration 015 created recipes_fts and did a one-time rebuild, but omitted
|
|
||||||
-- triggers. Without them, INSERT/UPDATE/DELETE on recipes does not update the
|
|
||||||
-- FTS index, so new rows are invisible to MATCH queries.
|
|
||||||
--
|
|
||||||
-- CREATE TRIGGER IF NOT EXISTS is idempotent — safe to re-run.
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- 017_household_invites.sql
|
|
||||||
CREATE TABLE IF NOT EXISTS household_invites (
|
|
||||||
token TEXT PRIMARY KEY,
|
|
||||||
household_id TEXT NOT NULL,
|
|
||||||
created_by TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
used_at TEXT,
|
|
||||||
used_by TEXT
|
|
||||||
);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- Migration 018: saved recipes bookmarks.
|
|
||||||
|
|
||||||
CREATE TABLE saved_recipes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes (saved_at DESC);
|
|
||||||
CREATE INDEX idx_saved_recipes_rating ON saved_recipes (rating);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- Migration 019: recipe collections (Paid tier organisation).
|
|
||||||
|
|
||||||
CREATE TABLE recipe_collections (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE recipe_collection_members (
|
|
||||||
collection_id INTEGER NOT NULL REFERENCES recipe_collections(id) ON DELETE CASCADE,
|
|
||||||
saved_recipe_id INTEGER NOT NULL REFERENCES saved_recipes(id) ON DELETE CASCADE,
|
|
||||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (collection_id, saved_recipe_id)
|
|
||||||
);
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
-- Migration 020: recipe browser navigation telemetry.
|
|
||||||
-- Used to determine whether category nesting depth needs increasing.
|
|
||||||
-- Review: if any category has page > 5 and result_count > 100 consistently,
|
|
||||||
-- consider adding a third nesting level for that category.
|
|
||||||
|
|
||||||
CREATE TABLE browser_telemetry (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
page INTEGER NOT NULL,
|
|
||||||
result_count INTEGER NOT NULL,
|
|
||||||
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
-- Migration 021: FTS5 inverted index for the recipe browser (category + keywords).
|
|
||||||
--
|
|
||||||
-- The browser domain queries were using LIKE '%keyword%' against category and
|
|
||||||
-- keywords columns — a leading wildcard prevents any B-tree index use, so every
|
|
||||||
-- query was a full sequential scan of 3.1M rows. This FTS5 index replaces those
|
|
||||||
-- scans with O(log N) token lookups.
|
|
||||||
--
|
|
||||||
-- Content-table backed: stores only the inverted index, no text duplication.
|
|
||||||
-- The keywords column is a JSON array; FTS5 tokenises it as plain text, stripping
|
|
||||||
-- the punctuation, which gives correct per-word matching.
|
|
||||||
--
|
|
||||||
-- One-time rebuild cost on 3.1M rows: ~20-40 seconds at first startup.
|
|
||||||
-- Subsequent startups skip this migration (IF NOT EXISTS guard).
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS recipe_browser_fts USING fts5(
|
|
||||||
category,
|
|
||||||
keywords,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id,
|
|
||||||
tokenize="unicode61"
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords)
|
|
||||||
VALUES (new.id, new.category, new.keywords);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords);
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords)
|
|
||||||
VALUES (new.id, new.category, new.keywords);
|
|
||||||
END;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- 022_meal_plans.sql
|
|
||||||
CREATE TABLE meal_plans (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
week_start TEXT NOT NULL,
|
|
||||||
meal_types TEXT NOT NULL DEFAULT '["dinner"]',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Migration 022: Add is_generic flag to recipes
|
|
||||||
-- Generic recipes are catch-all/dump recipes with loose ingredient lists
|
|
||||||
-- that should not appear in Level 1 (deterministic "use what I have") results.
|
|
||||||
-- Admins can mark recipes via the recipe editor or a bulk backfill script.
|
|
||||||
ALTER TABLE recipes ADD COLUMN is_generic INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- 023_meal_plan_slots.sql
|
|
||||||
CREATE TABLE meal_plan_slots (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
|
||||||
day_of_week INTEGER NOT NULL CHECK(day_of_week BETWEEN 0 AND 6),
|
|
||||||
meal_type TEXT NOT NULL,
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id),
|
|
||||||
servings REAL NOT NULL DEFAULT 2.0,
|
|
||||||
custom_label TEXT,
|
|
||||||
UNIQUE(plan_id, day_of_week, meal_type)
|
|
||||||
);
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- 024_prep_sessions.sql
|
|
||||||
CREATE TABLE prep_sessions (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
|
||||||
scheduled_date TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'draft'
|
|
||||||
CHECK(status IN ('draft','reviewed','done')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-- 025_prep_tasks.sql
|
|
||||||
CREATE TABLE prep_tasks (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
session_id INTEGER NOT NULL REFERENCES prep_sessions(id) ON DELETE CASCADE,
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id),
|
|
||||||
slot_id INTEGER REFERENCES meal_plan_slots(id),
|
|
||||||
task_label TEXT NOT NULL,
|
|
||||||
duration_minutes INTEGER,
|
|
||||||
sequence_order INTEGER NOT NULL,
|
|
||||||
equipment TEXT,
|
|
||||||
is_parallel INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notes TEXT,
|
|
||||||
user_edited INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- 028_community_pseudonyms.sql
|
|
||||||
-- Per-user pseudonym store: maps the user's chosen community display name
|
|
||||||
-- to their Directus user ID. This table lives in per-user kiwi.db only.
|
|
||||||
-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design.
|
|
||||||
--
|
|
||||||
-- A user may have one active pseudonym. Old pseudonyms are retained for reference
|
|
||||||
-- (posts published under them keep their pseudonym attribution) but only one is
|
|
||||||
-- flagged as current (is_current = 1).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS community_pseudonyms (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
pseudonym TEXT NOT NULL,
|
|
||||||
directus_user_id TEXT NOT NULL,
|
|
||||||
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Only one pseudonym can be current at a time per user
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current
|
|
||||||
ON community_pseudonyms (directus_user_id)
|
|
||||||
WHERE is_current = 1;
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
-- Migration 029: Add inferred_tags column and update FTS index to include it.
|
|
||||||
--
|
|
||||||
-- inferred_tags holds a JSON array of normalized tag strings derived by
|
|
||||||
-- scripts/pipeline/infer_recipe_tags.py (e.g. ["cuisine:Italian",
|
|
||||||
-- "dietary:Low-Carb", "flavor:Umami", "can_be:Gluten-Free"]).
|
|
||||||
--
|
|
||||||
-- The FTS5 browser table is rebuilt to index inferred_tags alongside
|
|
||||||
-- category and keywords so browse domain queries match against all signals.
|
|
||||||
|
|
||||||
-- 1. Add inferred_tags column (empty array default; populated by pipeline run)
|
|
||||||
ALTER TABLE recipes ADD COLUMN inferred_tags TEXT NOT NULL DEFAULT '[]';
|
|
||||||
|
|
||||||
-- 2. Drop old FTS table and triggers that only covered category + keywords
|
|
||||||
DROP TRIGGER IF EXISTS recipes_ai;
|
|
||||||
DROP TRIGGER IF EXISTS recipes_ad;
|
|
||||||
DROP TRIGGER IF EXISTS recipes_au;
|
|
||||||
DROP TABLE IF EXISTS recipe_browser_fts;
|
|
||||||
|
|
||||||
-- 3. Recreate FTS5 table: now indexes category, keywords, AND inferred_tags
|
|
||||||
CREATE VIRTUAL TABLE recipe_browser_fts USING fts5(
|
|
||||||
category,
|
|
||||||
keywords,
|
|
||||||
inferred_tags,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 4. Triggers to keep FTS in sync with recipes table changes
|
|
||||||
CREATE TRIGGER recipes_ai AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER recipes_ad AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER recipes_au AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- 5. Populate FTS from current table state
|
|
||||||
-- (inferred_tags is '[]' for all rows at this point; run infer_recipe_tags.py
|
|
||||||
-- to populate, then the FTS will be rebuilt as part of that script.)
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Migration 030: open-package tracking
|
|
||||||
-- Adds opened_date to track when a multi-use item was first opened,
|
|
||||||
-- enabling secondary shelf-life windows (e.g. salsa: 1 year sealed → 2 weeks opened).
|
|
||||||
|
|
||||||
ALTER TABLE inventory_items ADD COLUMN opened_date TEXT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- Migration 031: add disposal_reason for waste logging (#60)
|
|
||||||
-- status='discarded' already exists in the CHECK constraint from migration 002.
|
|
||||||
-- This column stores free-text reason (optional) and calm-framing presets.
|
|
||||||
ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- 032_meal_plan_unique_week.sql
|
|
||||||
-- Prevent duplicate plans for the same week.
|
|
||||||
-- Existing duplicates must be resolved before applying (keep MIN(id) per week_start).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_meal_plans_week_start ON meal_plans (week_start);
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- Migration 033: standalone shopping list
|
|
||||||
-- Items can be added manually, from recipe gap analysis, or from the recipe browser.
|
|
||||||
-- Affiliate links are computed at query time by the API layer (never stored).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_list_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
quantity REAL,
|
|
||||||
unit TEXT,
|
|
||||||
category TEXT,
|
|
||||||
checked INTEGER NOT NULL DEFAULT 0, -- 0=want, 1=in-cart/checked off
|
|
||||||
notes TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | recipe | meal_plan
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_checked
|
|
||||||
ON shopping_list_items (checked, sort_order);
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
1599
app/db/store.py
1599
app/db/store.py
File diff suppressed because it is too large
Load diff
72
app/main.py
72
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
|
||||||
|
|
@ -11,83 +9,20 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.meal_plan.affiliates import register_kiwi_programs
|
|
||||||
|
|
||||||
# Structured key=value log lines — grep/awk-friendly for log-based analytics.
|
|
||||||
# Without basicConfig, app-level INFO logs are silently dropped.
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
from app.tasks.scheduler import get_scheduler
|
from app.tasks.scheduler import get_scheduler
|
||||||
get_scheduler(settings.DB_PATH)
|
get_scheduler(settings.DB_PATH)
|
||||||
logger.info("Task scheduler started.")
|
logger.info("Task scheduler started.")
|
||||||
|
|
||||||
# Initialize community store (no-op if COMMUNITY_DB_URL is not set)
|
|
||||||
from app.api.endpoints.community import init_community_store
|
|
||||||
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
|
||||||
|
|
@ -100,7 +35,7 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
description="Pantry tracking + leftover recipe suggestions",
|
description="Pantry tracking + leftover recipe suggestions",
|
||||||
version="0.2.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -114,11 +49,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())
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""Pydantic schemas for household management endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdCreateResponse(BaseModel):
|
|
||||||
household_id: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdMember(BaseModel):
|
|
||||||
user_id: str
|
|
||||||
joined_at: str
|
|
||||||
is_owner: bool
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdStatusResponse(BaseModel):
|
|
||||||
in_household: bool
|
|
||||||
household_id: str | None = None
|
|
||||||
is_owner: bool = False
|
|
||||||
members: list[HouseholdMember] = Field(default_factory=list)
|
|
||||||
max_seats: int = 4
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdInviteResponse(BaseModel):
|
|
||||||
invite_url: str
|
|
||||||
token: str
|
|
||||||
expires_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdAcceptRequest(BaseModel):
|
|
||||||
household_id: str
|
|
||||||
token: str
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdAcceptResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
household_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class HouseholdRemoveMemberRequest(BaseModel):
|
|
||||||
user_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
@ -89,20 +89,9 @@ class InventoryItemUpdate(BaseModel):
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
sublocation: Optional[str] = None
|
sublocation: Optional[str] = None
|
||||||
purchase_date: Optional[date] = None
|
|
||||||
expiration_date: Optional[date] = None
|
expiration_date: Optional[date] = None
|
||||||
opened_date: Optional[date] = None
|
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
disposal_reason: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PartialConsumeRequest(BaseModel):
|
|
||||||
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
|
|
||||||
|
|
||||||
|
|
||||||
class DiscardRequest(BaseModel):
|
|
||||||
reason: Optional[str] = Field(None, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemResponse(BaseModel):
|
class InventoryItemResponse(BaseModel):
|
||||||
|
|
@ -117,15 +106,8 @@ class InventoryItemResponse(BaseModel):
|
||||||
sublocation: Optional[str]
|
sublocation: Optional[str]
|
||||||
purchase_date: Optional[str]
|
purchase_date: Optional[str]
|
||||||
expiration_date: Optional[str]
|
expiration_date: Optional[str]
|
||||||
opened_date: Optional[str] = None
|
|
||||||
opened_expiry_date: Optional[str] = None
|
|
||||||
secondary_state: Optional[str] = None
|
|
||||||
secondary_uses: Optional[List[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
|
|
||||||
source: str
|
source: str
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
@ -141,8 +123,6 @@ class BarcodeScanResult(BaseModel):
|
||||||
product: Optional[ProductResponse]
|
product: Optional[ProductResponse]
|
||||||
inventory_item: Optional[InventoryItemResponse]
|
inventory_item: Optional[InventoryItemResponse]
|
||||||
added_to_inventory: bool
|
added_to_inventory: bool
|
||||||
needs_manual_entry: bool = False
|
|
||||||
needs_visual_capture: bool = False # Paid tier offer when no product data found
|
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -153,32 +133,6 @@ class BarcodeScanResponse(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# ── Bulk add by name ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class BulkAddItem(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
|
||||||
quantity: float = Field(default=1.0, gt=0)
|
|
||||||
unit: str = "count"
|
|
||||||
location: str = "pantry"
|
|
||||||
|
|
||||||
|
|
||||||
class BulkAddByNameRequest(BaseModel):
|
|
||||||
items: List[BulkAddItem] = Field(..., min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class BulkAddItemResult(BaseModel):
|
|
||||||
name: str
|
|
||||||
ok: bool
|
|
||||||
item_id: Optional[int] = None
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BulkAddByNameResponse(BaseModel):
|
|
||||||
added: int
|
|
||||||
failed: int
|
|
||||||
results: List[BulkAddItemResult]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class InventoryStats(BaseModel):
|
class InventoryStats(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# app/models/schemas/meal_plan.py
|
|
||||||
"""Pydantic schemas for meal planning endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date as _date
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
|
|
||||||
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePlanRequest(BaseModel):
|
|
||||||
week_start: _date
|
|
||||||
meal_types: list[str] = Field(default_factory=lambda: ["dinner"])
|
|
||||||
|
|
||||||
@field_validator("week_start")
|
|
||||||
@classmethod
|
|
||||||
def must_be_monday(cls, v: _date) -> _date:
|
|
||||||
if v.weekday() != 0:
|
|
||||||
raise ValueError("week_start must be a Monday (weekday 0)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePlanRequest(BaseModel):
|
|
||||||
meal_types: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class UpsertSlotRequest(BaseModel):
|
|
||||||
recipe_id: int | None = None
|
|
||||||
servings: float = Field(2.0, gt=0)
|
|
||||||
custom_label: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SlotSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
plan_id: int
|
|
||||||
day_of_week: int
|
|
||||||
meal_type: str
|
|
||||||
recipe_id: int | None
|
|
||||||
recipe_title: str | None
|
|
||||||
servings: float
|
|
||||||
custom_label: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class PlanSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
week_start: str
|
|
||||||
meal_types: list[str]
|
|
||||||
slots: list[SlotSummary]
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class RetailerLink(BaseModel):
|
|
||||||
retailer: str
|
|
||||||
label: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class GapItem(BaseModel):
|
|
||||||
ingredient_name: str
|
|
||||||
needed_raw: str | None # e.g. "2 cups" from recipe text
|
|
||||||
have_quantity: float | None # from pantry
|
|
||||||
have_unit: str | None
|
|
||||||
covered: bool # True = pantry has it
|
|
||||||
retailer_links: list[RetailerLink] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListResponse(BaseModel):
|
|
||||||
plan_id: int
|
|
||||||
gap_items: list[GapItem]
|
|
||||||
covered_items: list[GapItem]
|
|
||||||
disclosure: str | None = None # affiliate disclosure text when links present
|
|
||||||
|
|
||||||
|
|
||||||
class PrepTaskSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
recipe_id: int | None
|
|
||||||
task_label: str
|
|
||||||
duration_minutes: int | None
|
|
||||||
sequence_order: int
|
|
||||||
equipment: str | None
|
|
||||||
is_parallel: bool
|
|
||||||
notes: str | None
|
|
||||||
user_edited: bool
|
|
||||||
|
|
||||||
|
|
||||||
class PrepSessionSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
plan_id: int
|
|
||||||
scheduled_date: str
|
|
||||||
status: str
|
|
||||||
tasks: list[PrepTaskSummary]
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePrepTaskRequest(BaseModel):
|
|
||||||
duration_minutes: int | None = None
|
|
||||||
sequence_order: int | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
equipment: str | None = None
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
"""Pydantic schemas for the recipe engine API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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):
|
|
||||||
original_name: str
|
|
||||||
substitute_name: str
|
|
||||||
constraint_label: str
|
|
||||||
explanation: str
|
|
||||||
compensation_hints: list[dict] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionPanel(BaseModel):
|
|
||||||
"""Per-recipe macro summary. All values are per-serving when servings is known,
|
|
||||||
otherwise for the full recipe. None means data is unavailable."""
|
|
||||||
calories: float | None = None
|
|
||||||
fat_g: float | None = None
|
|
||||||
protein_g: float | None = None
|
|
||||||
carbs_g: float | None = None
|
|
||||||
fiber_g: float | None = None
|
|
||||||
sugar_g: float | None = None
|
|
||||||
sodium_mg: float | None = None
|
|
||||||
servings: float | None = None
|
|
||||||
estimated: bool = False # True when nutrition was inferred from ingredient profiles
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeSuggestion(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
match_count: int
|
|
||||||
element_coverage: dict[str, float] = Field(default_factory=dict)
|
|
||||||
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
|
|
||||||
matched_ingredients: list[str] = Field(default_factory=list)
|
|
||||||
missing_ingredients: list[str] = Field(default_factory=list)
|
|
||||||
directions: list[str] = Field(default_factory=list)
|
|
||||||
prep_notes: list[str] = Field(default_factory=list)
|
|
||||||
notes: str = ""
|
|
||||||
level: int = 1
|
|
||||||
is_wildcard: bool = False
|
|
||||||
nutrition: NutritionPanel | None = None
|
|
||||||
source_url: str | None = None
|
|
||||||
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
|
||||||
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):
|
|
||||||
ingredient: str
|
|
||||||
retailer: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeResult(BaseModel):
|
|
||||||
suggestions: list[RecipeSuggestion]
|
|
||||||
element_gaps: list[str]
|
|
||||||
grocery_list: list[str] = Field(default_factory=list)
|
|
||||||
grocery_links: list[GroceryLink] = Field(default_factory=list)
|
|
||||||
rate_limited: bool = False
|
|
||||||
rate_limit_count: int = 0
|
|
||||||
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):
|
|
||||||
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
|
||||||
max_calories: float | None = None
|
|
||||||
max_sugar_g: float | None = None
|
|
||||||
max_carbs_g: float | None = None
|
|
||||||
max_sodium_mg: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
|
||||||
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)
|
|
||||||
constraints: list[str] = Field(default_factory=list)
|
|
||||||
expiry_first: bool = False
|
|
||||||
hard_day_mode: bool = False
|
|
||||||
max_missing: int | None = None
|
|
||||||
style_id: str | None = None
|
|
||||||
category: str | None = None
|
|
||||||
tier: str = "free"
|
|
||||||
has_byok: bool = False
|
|
||||||
wildcard_confirmed: bool = False
|
|
||||||
allergies: list[str] = Field(default_factory=list)
|
|
||||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
|
||||||
exclude_ingredients: list[str] = Field(default_factory=list)
|
|
||||||
shopping_mode: bool = False
|
|
||||||
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
|
||||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AssemblyRoleOut(BaseModel):
|
|
||||||
"""One role slot in a template, as returned by GET /api/recipes/templates."""
|
|
||||||
|
|
||||||
display: str
|
|
||||||
required: bool
|
|
||||||
keywords: list[str]
|
|
||||||
hint: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class AssemblyTemplateOut(BaseModel):
|
|
||||||
"""One assembly template, as returned by GET /api/recipes/templates."""
|
|
||||||
|
|
||||||
id: str # slug, e.g. "burrito_taco"
|
|
||||||
title: str
|
|
||||||
icon: str
|
|
||||||
descriptor: str
|
|
||||||
role_sequence: list[AssemblyRoleOut]
|
|
||||||
|
|
||||||
|
|
||||||
class RoleCandidateItem(BaseModel):
|
|
||||||
"""One candidate ingredient for a wizard picker step."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
in_pantry: bool
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class RoleCandidatesResponse(BaseModel):
|
|
||||||
"""Response from GET /api/recipes/template-candidates."""
|
|
||||||
|
|
||||||
compatible: list[RoleCandidateItem] = Field(default_factory=list)
|
|
||||||
other: list[RoleCandidateItem] = Field(default_factory=list)
|
|
||||||
available_tags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class BuildRequest(BaseModel):
|
|
||||||
"""Request body for POST /api/recipes/build."""
|
|
||||||
|
|
||||||
template_id: str
|
|
||||||
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
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""Pydantic schemas for saved recipes and collections."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class SaveRecipeRequest(BaseModel):
|
|
||||||
recipe_id: int
|
|
||||||
notes: str | None = None
|
|
||||||
rating: int | None = Field(None, ge=0, le=5)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateSavedRecipeRequest(BaseModel):
|
|
||||||
notes: str | None = None
|
|
||||||
rating: int | None = Field(None, ge=0, le=5)
|
|
||||||
style_tags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class SavedRecipeSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
recipe_id: int
|
|
||||||
title: str
|
|
||||||
saved_at: str
|
|
||||||
notes: str | None
|
|
||||||
rating: int | None
|
|
||||||
style_tags: list[str]
|
|
||||||
collection_ids: list[int] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
member_count: int
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionMemberRequest(BaseModel):
|
|
||||||
saved_recipe_id: int
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""Pydantic schemas for the shopping list endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemCreate(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
|
||||||
quantity: Optional[float] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
source: str = "manual"
|
|
||||||
recipe_id: Optional[int] = None
|
|
||||||
sort_order: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemUpdate(BaseModel):
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
||||||
quantity: Optional[float] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
checked: Optional[bool] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkOut(BaseModel):
|
|
||||||
ingredient: str
|
|
||||||
retailer: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
quantity: Optional[float]
|
|
||||||
unit: Optional[str]
|
|
||||||
category: Optional[str]
|
|
||||||
checked: bool
|
|
||||||
notes: Optional[str]
|
|
||||||
source: str
|
|
||||||
recipe_id: Optional[int]
|
|
||||||
sort_order: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
grocery_links: list[GroceryLinkOut] = []
|
|
||||||
|
|
||||||
|
|
||||||
class BulkAddFromRecipeRequest(BaseModel):
|
|
||||||
recipe_id: int
|
|
||||||
include_covered: bool = False # if True, add pantry-covered items too
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmPurchaseRequest(BaseModel):
|
|
||||||
"""Move a checked item into pantry inventory."""
|
|
||||||
location: str = "pantry"
|
|
||||||
quantity: Optional[float] = None # override the list quantity
|
|
||||||
unit: Optional[str] = None
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -5,8 +5,6 @@ This module provides functionality to detect and decode barcodes
|
||||||
from images (UPC, EAN, QR codes, etc.).
|
from images (UPC, EAN, QR codes, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pyzbar import pyzbar
|
from pyzbar import pyzbar
|
||||||
|
|
@ -14,12 +12,6 @@ from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image as _PILImage
|
|
||||||
_HAS_PIL = True
|
|
||||||
except ImportError:
|
|
||||||
_HAS_PIL = False
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +76,9 @@ class BarcodeScanner:
|
||||||
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
|
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
logger.info("No barcodes found in standard orientation, trying rotations...")
|
logger.info("No barcodes found in standard orientation, trying rotations...")
|
||||||
for angle in [90, 180, 270, 45, 135]:
|
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
|
||||||
|
# 0° already tried, 180° is functionally same as 0°, 90°/270° are same axis
|
||||||
|
for angle in [30, 60, 90]:
|
||||||
rotated_gray = self._rotate_image(gray, angle)
|
rotated_gray = self._rotate_image(gray, angle)
|
||||||
rotated_color = self._rotate_image(image, angle)
|
rotated_color = self._rotate_image(image, angle)
|
||||||
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
||||||
|
|
@ -270,26 +264,6 @@ class BarcodeScanner:
|
||||||
|
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|
||||||
def _fix_exif_orientation(self, image_bytes: bytes) -> bytes:
|
|
||||||
"""Apply EXIF orientation correction so cv2 sees an upright image.
|
|
||||||
|
|
||||||
Phone cameras embed rotation in EXIF; cv2.imdecode ignores it,
|
|
||||||
so a photo taken in portrait may arrive physically sideways in memory.
|
|
||||||
"""
|
|
||||||
if not _HAS_PIL:
|
|
||||||
return image_bytes
|
|
||||||
try:
|
|
||||||
pil = _PILImage.open(io.BytesIO(image_bytes))
|
|
||||||
pil = _PILImage.fromarray(np.array(pil)) # strips EXIF but applies orientation via PIL
|
|
||||||
# Use ImageOps.exif_transpose for proper EXIF-aware rotation
|
|
||||||
import PIL.ImageOps
|
|
||||||
pil = PIL.ImageOps.exif_transpose(pil)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
pil.save(buf, format="JPEG")
|
|
||||||
return buf.getvalue()
|
|
||||||
except Exception:
|
|
||||||
return image_bytes
|
|
||||||
|
|
||||||
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
|
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Scan barcodes from image bytes (uploaded file).
|
Scan barcodes from image bytes (uploaded file).
|
||||||
|
|
@ -301,10 +275,6 @@ class BarcodeScanner:
|
||||||
List of detected barcodes
|
List of detected barcodes
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Apply EXIF orientation correction first (phone cameras embed rotation in EXIF;
|
|
||||||
# cv2.imdecode ignores it, causing sideways barcodes to appear rotated in memory).
|
|
||||||
image_bytes = self._fix_exif_orientation(image_bytes)
|
|
||||||
|
|
||||||
# Convert bytes to numpy array
|
# Convert bytes to numpy array
|
||||||
nparr = np.frombuffer(image_bytes, np.uint8)
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
||||||
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
|
@ -330,12 +300,11 @@ class BarcodeScanner:
|
||||||
)
|
)
|
||||||
barcodes.extend(self._detect_barcodes(thresh, image))
|
barcodes.extend(self._detect_barcodes(thresh, image))
|
||||||
|
|
||||||
# 3. Try all 90° rotations + common tilt angles
|
# 3. Try rotations if still no barcodes found
|
||||||
# 90/270 catches truly sideways barcodes; 180 catches upside-down;
|
|
||||||
# 45/135 catches tilted barcodes on flat surfaces.
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
logger.info("No barcodes found in uploaded image, trying rotations...")
|
logger.info("No barcodes found in uploaded image, trying rotations...")
|
||||||
for angle in [90, 180, 270, 45, 135]:
|
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
|
||||||
|
for angle in [30, 60, 90]:
|
||||||
rotated_gray = self._rotate_image(gray, angle)
|
rotated_gray = self._rotate_image(gray, angle)
|
||||||
rotated_color = self._rotate_image(image, angle)
|
rotated_color = self._rotate_image(image, angle)
|
||||||
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# app/services/community/ap_compat.py
|
|
||||||
# MIT License — AP scaffold only (no actor, inbox, outbox)
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def post_to_ap_json_ld(post: dict, base_url: str) -> dict:
|
|
||||||
"""Serialize a community post dict to an ActivityPub-compatible JSON-LD Note.
|
|
||||||
|
|
||||||
This is a read-only scaffold. No AP actor, inbox, or outbox.
|
|
||||||
The slug URI is stable so a future full AP implementation can reuse posts
|
|
||||||
without a DB migration.
|
|
||||||
"""
|
|
||||||
slug = post["slug"]
|
|
||||||
published = post.get("published")
|
|
||||||
if isinstance(published, datetime):
|
|
||||||
published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
else:
|
|
||||||
published_str = str(published)
|
|
||||||
|
|
||||||
dietary_tags: list[str] = post.get("dietary_tags") or []
|
|
||||||
tags = [{"type": "Hashtag", "name": "#kiwi"}]
|
|
||||||
for tag in dietary_tags:
|
|
||||||
tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "Note",
|
|
||||||
"id": f"{base_url}/api/v1/community/posts/{slug}",
|
|
||||||
"attributedTo": post.get("pseudonym", "anonymous"),
|
|
||||||
"content": _build_content(post),
|
|
||||||
"published": published_str,
|
|
||||||
"tag": tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_content(post: dict) -> str:
|
|
||||||
title = post.get("title") or "Untitled"
|
|
||||||
desc = post.get("description")
|
|
||||||
if desc:
|
|
||||||
return f"{title} — {desc}"
|
|
||||||
return title
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
# app/services/community/community_store.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from circuitforge_core.community import CommunityPost, SharedStore
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class KiwiCommunityStore(SharedStore):
|
|
||||||
"""Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore."""
|
|
||||||
|
|
||||||
def list_meal_plans(
|
|
||||||
self,
|
|
||||||
limit: int = 20,
|
|
||||||
offset: int = 0,
|
|
||||||
dietary_tags: list[str] | None = None,
|
|
||||||
allergen_exclude: list[str] | None = None,
|
|
||||||
) -> list[CommunityPost]:
|
|
||||||
return self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
post_type="plan",
|
|
||||||
dietary_tags=dietary_tags,
|
|
||||||
allergen_exclude=allergen_exclude,
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_outcomes(
|
|
||||||
self,
|
|
||||||
limit: int = 20,
|
|
||||||
offset: int = 0,
|
|
||||||
post_type: str | None = None,
|
|
||||||
) -> list[CommunityPost]:
|
|
||||||
if post_type in ("recipe_success", "recipe_blooper"):
|
|
||||||
return self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
post_type=post_type,
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
success = self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=0,
|
|
||||||
post_type="recipe_success",
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
bloopers = self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=0,
|
|
||||||
post_type="recipe_blooper",
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True)
|
|
||||||
return merged[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_pseudonym(
|
|
||||||
store,
|
|
||||||
directus_user_id: str,
|
|
||||||
requested_name: str | None,
|
|
||||||
) -> str:
|
|
||||||
"""Return the user's current pseudonym, creating it if it doesn't exist.
|
|
||||||
|
|
||||||
If the user has an existing pseudonym, return it (ignore requested_name).
|
|
||||||
If not, create using requested_name (must be provided for first-time setup).
|
|
||||||
|
|
||||||
Raises ValueError if no existing pseudonym and requested_name is None or blank.
|
|
||||||
"""
|
|
||||||
existing = store.get_current_pseudonym(directus_user_id)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
if not requested_name or not requested_name.strip():
|
|
||||||
raise ValueError(
|
|
||||||
"A pseudonym is required for first publish. "
|
|
||||||
"Pass requested_name with the user's chosen display name."
|
|
||||||
)
|
|
||||||
|
|
||||||
name = requested_name.strip()
|
|
||||||
if "@" in name:
|
|
||||||
raise ValueError(
|
|
||||||
"Pseudonym must not contain '@' — use a display name, not an email address."
|
|
||||||
)
|
|
||||||
|
|
||||||
store.set_pseudonym(directus_user_id, name)
|
|
||||||
return name
|
|
||||||
|
|
@ -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,138 +0,0 @@
|
||||||
# app/services/community/element_snapshot.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
# Ingredient name substrings → allergen flag
|
|
||||||
_ALLERGEN_MAP: dict[str, str] = {
|
|
||||||
"milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy",
|
|
||||||
"yogurt": "dairy", "whey": "dairy",
|
|
||||||
"egg": "eggs",
|
|
||||||
"wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten",
|
|
||||||
"barley": "gluten", "rye": "gluten",
|
|
||||||
"peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts",
|
|
||||||
"pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts",
|
|
||||||
"soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy",
|
|
||||||
"shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish",
|
|
||||||
"clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish",
|
|
||||||
"fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish",
|
|
||||||
"tilapia": "fish", "halibut": "fish",
|
|
||||||
"sesame": "sesame",
|
|
||||||
}
|
|
||||||
|
|
||||||
_MEAT_KEYWORDS = frozenset([
|
|
||||||
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage",
|
|
||||||
"salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat",
|
|
||||||
"mince", "veal", "duck", "venison", "bison", "lard",
|
|
||||||
])
|
|
||||||
_SEAFOOD_KEYWORDS = frozenset([
|
|
||||||
"fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel",
|
|
||||||
"scallop", "anchovy", "sardine", "cod", "tilapia",
|
|
||||||
])
|
|
||||||
_ANIMAL_PRODUCT_KEYWORDS = frozenset([
|
|
||||||
"milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_allergens(ingredient_names: list[str]) -> list[str]:
|
|
||||||
found: set[str] = set()
|
|
||||||
lowered = [n.lower() for n in ingredient_names]
|
|
||||||
for ingredient in lowered:
|
|
||||||
for keyword, flag in _ALLERGEN_MAP.items():
|
|
||||||
if keyword in ingredient:
|
|
||||||
found.add(flag)
|
|
||||||
return sorted(found)
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]:
|
|
||||||
lowered = [n.lower() for n in ingredient_names]
|
|
||||||
all_text = " ".join(lowered)
|
|
||||||
|
|
||||||
has_meat = any(k in all_text for k in _MEAT_KEYWORDS)
|
|
||||||
has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS)
|
|
||||||
has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS)
|
|
||||||
|
|
||||||
tags: list[str] = []
|
|
||||||
if not has_meat and not has_seafood:
|
|
||||||
tags.append("vegetarian")
|
|
||||||
if not has_meat and not has_seafood and not has_animal_products:
|
|
||||||
tags.append("vegan")
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ElementSnapshot:
|
|
||||||
seasoning_score: float
|
|
||||||
richness_score: float
|
|
||||||
brightness_score: float
|
|
||||||
depth_score: float
|
|
||||||
aroma_score: float
|
|
||||||
structure_score: float
|
|
||||||
texture_profile: str
|
|
||||||
dietary_tags: tuple
|
|
||||||
allergen_flags: tuple
|
|
||||||
flavor_molecules: tuple
|
|
||||||
fat_pct: float | None
|
|
||||||
protein_pct: float | None
|
|
||||||
moisture_pct: float | None
|
|
||||||
|
|
||||||
|
|
||||||
def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot:
|
|
||||||
"""Compute an element snapshot from a list of recipe IDs.
|
|
||||||
|
|
||||||
Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus.
|
|
||||||
Averages numeric scores across all recipes. Unions allergen flags and dietary tags.
|
|
||||||
Call at publish time only — snapshot is stored denormalized in community_posts.
|
|
||||||
"""
|
|
||||||
if not recipe_ids:
|
|
||||||
return ElementSnapshot(
|
|
||||||
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
|
|
||||||
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
|
|
||||||
texture_profile="", dietary_tags=(), allergen_flags=(),
|
|
||||||
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = store.get_recipes_by_ids(recipe_ids)
|
|
||||||
if not rows:
|
|
||||||
return ElementSnapshot(
|
|
||||||
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
|
|
||||||
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
|
|
||||||
texture_profile="", dietary_tags=(), allergen_flags=(),
|
|
||||||
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _avg(field: str) -> float:
|
|
||||||
vals = [r.get(field) or 0.0 for r in rows]
|
|
||||||
return sum(vals) / len(vals)
|
|
||||||
|
|
||||||
all_ingredients: list[str] = []
|
|
||||||
for r in rows:
|
|
||||||
names = r.get("ingredient_names") or []
|
|
||||||
all_ingredients.extend(names if isinstance(names, list) else [])
|
|
||||||
|
|
||||||
allergens = _detect_allergens(all_ingredients)
|
|
||||||
dietary = _detect_dietary_tags(all_ingredients)
|
|
||||||
|
|
||||||
texture = rows[0].get("texture_profile") or ""
|
|
||||||
|
|
||||||
fat_vals = [r.get("fat") for r in rows if r.get("fat") is not None]
|
|
||||||
prot_vals = [r.get("protein") for r in rows if r.get("protein") is not None]
|
|
||||||
moist_vals = [r.get("moisture") for r in rows if r.get("moisture") is not None]
|
|
||||||
|
|
||||||
return ElementSnapshot(
|
|
||||||
seasoning_score=_avg("seasoning_score"),
|
|
||||||
richness_score=_avg("richness_score"),
|
|
||||||
brightness_score=_avg("brightness_score"),
|
|
||||||
depth_score=_avg("depth_score"),
|
|
||||||
aroma_score=_avg("aroma_score"),
|
|
||||||
structure_score=_avg("structure_score"),
|
|
||||||
texture_profile=texture,
|
|
||||||
dietary_tags=tuple(dietary),
|
|
||||||
allergen_flags=tuple(allergens),
|
|
||||||
flavor_molecules=(),
|
|
||||||
fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None,
|
|
||||||
protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None,
|
|
||||||
moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None,
|
|
||||||
)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue