Compare commits
125 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf807179f5 | |||
| 0c200f3148 | |||
| 21a0664961 | |||
| a9ab996bcc | |||
| 56f942b3fd | |||
| 84636bcdaf | |||
| 51a48a430b | |||
| b326d4aa6e | |||
| 7cad503b35 | |||
| 430600c1af | |||
| 21a9b85067 | |||
| c72b4415db | |||
| 2df17ec719 | |||
| 4ac24e7920 | |||
| cdbc24240a | |||
| dd39418bc8 | |||
| 02abc8e734 | |||
| 61c428baf0 | |||
| 6e954c5c6e | |||
| ef04064728 | |||
| 59b183a898 | |||
| b4624fba84 | |||
| 667daf939e | |||
| 4e50661483 | |||
| ac4eda2047 | |||
| 3f4b756fc6 | |||
| 973c76a4c8 | |||
| 92fab94ae0 | |||
| 30f5620fd5 | |||
| 0ef57618bf | |||
| 8c765b7da2 | |||
| e57f46f4b6 | |||
| 04dbdddbad | |||
| e83bb0415a | |||
| e62d69d099 | |||
| 7498995092 | |||
| 640fcefa9e | |||
| d5a4b14400 | |||
| 7fd92d5179 | |||
| 6f097cd43d | |||
| 46778d62e3 | |||
| 896b4e048c | |||
| c9fcfde694 | |||
| e05bfe86f5 | |||
| 95e76edaea | |||
| 12ab63e2fb | |||
| 9350719516 | |||
| 9c4d8b7883 | |||
| ed04b655be | |||
| f6b29693c8 | |||
| b86b7732dc | |||
| 7e0722cc23 | |||
| e2c358c90a | |||
| 0bac494ecd | |||
| 17e62c451f | |||
| 3463aa1e17 | |||
| e45b07c203 | |||
| b5eb8e4772 | |||
| 91867f15f4 | |||
| 1182c6cffb | |||
| 7292c5e7fc | |||
| 63517d135b | |||
| 2547f80893 | |||
| 0996ea8c7a | |||
| c3e7dc1ea4 | |||
| 521cb419bc | |||
| 302285a1a5 | |||
| b1e187c779 | |||
| 70205ebb25 | |||
| 9697c7b64f | |||
| f962748073 | |||
| a507deddbf | |||
| 7a7eae4666 | |||
| b223325d77 | |||
| f1d35dd1ac | |||
| 1ac7e3d76a | |||
| 1a7a94a344 | |||
| 5d0ee2493e | |||
| 69e2ca7914 | |||
| 0bef082ff0 | |||
| c6f45be1ba | |||
| be050f5492 | |||
| e2658f743f | |||
| dbc4aa3c68 | |||
| ed4595d960 | |||
| eba536070c | |||
| 79f345aae6 | |||
| 5385adc52a | |||
| e7ba305e63 | |||
| b2c546e86a | |||
| 8fd77bd1f2 | |||
| 22a3da61c3 | |||
| bea61054fa | |||
| 38382a4fc9 | |||
| 01aae2eec8 | |||
| 890216a1f0 | |||
| 8483b9ae5f | |||
| 23a2e8fe38 | |||
| 6aa63cf2f0 | |||
| e745ce4375 | |||
| de0008f5c7 | |||
| dbaf2b6ac8 | |||
| 9a277f9b42 | |||
| 200a6ef87b | |||
| c8fdc21c29 | |||
| 2ad71f2636 | |||
| 0de6182f48 | |||
| fb18a9c78c | |||
| 443e68ba3f | |||
| 64a0abebe3 | |||
| 4423373750 | |||
| 76516abd62 | |||
| 757f779030 | |||
| c984e6162e | |||
| ab97af73f7 | |||
| 6741c6981d | |||
| 5c135d0860 | |||
| bc04739447 | |||
| ceb03f8b5b | |||
| 9de42c3088 | |||
| 41837f348c | |||
| 4de4f63614 | |||
| 391e79ac86 | |||
| 91724caf96 | |||
| fdc477b395 |
186 changed files with 27894 additions and 971 deletions
44
.cliff.toml
Normal file
44
.cliff.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 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"
|
||||||
26
.env.example
26
.env.example
|
|
@ -21,10 +21,12 @@ DATA_DIR=./data
|
||||||
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
||||||
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
||||||
|
|
||||||
# CF-core hosted coordinator (managed cloud GPU inference — Paid+ tier)
|
# GPU inference server (cf-orch coordinator for recipe scan, LLM generation, etc.)
|
||||||
# Set CF_ORCH_URL to use a hosted cf-orch coordinator instead of self-hosting.
|
# GPU_SERVER_URL: set to your local cf-orch coordinator (self-hosted rack).
|
||||||
# CF_LICENSE_KEY is read automatically by CFOrchClient for bearer auth.
|
# CF_ORCH_URL is the backward-compat alias — both are honoured.
|
||||||
# CF_ORCH_URL=https://orch.circuitforge.tech
|
# 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
|
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
||||||
|
|
||||||
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
||||||
|
|
@ -51,6 +53,15 @@ 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
|
||||||
|
|
@ -68,9 +79,14 @@ DEMO_MODE=false
|
||||||
# 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)
|
# Directus JWT (must match cf-directus SECRET env var exactly, including base64 == padding)
|
||||||
# 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
|
# In-app feedback → Forgejo issue creation
|
||||||
# FORGEJO_API_TOKEN=
|
# FORGEJO_API_TOKEN=
|
||||||
# FORGEJO_REPO=Circuit-Forge/kiwi
|
# FORGEJO_REPO=Circuit-Forge/kiwi
|
||||||
|
|
|
||||||
62
.forgejo/workflows/ci.yml
Normal file
62
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# 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
|
||||||
34
.forgejo/workflows/mirror.yml
Normal file
34
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# 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
|
||||||
71
.forgejo/workflows/release.yml
Normal file
71
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# 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
Normal file
59
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,6 +23,9 @@ dist/
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Local dev database
|
||||||
|
*.db
|
||||||
|
|
||||||
# Test artifacts (MagicMock sqlite files from pytest)
|
# Test artifacts (MagicMock sqlite files from pytest)
|
||||||
<MagicMock*
|
<MagicMock*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@
|
||||||
[extend]
|
[extend]
|
||||||
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
||||||
|
|
||||||
|
# ── Global allowlist ──────────────────────────────────────────────────────────
|
||||||
|
# Amazon grocery department IDs (rh=n:<10-digit>) false-positive as phone
|
||||||
|
# numbers. locale_config.py is a static lookup table with no secrets.
|
||||||
|
|
||||||
|
[allowlist]
|
||||||
|
# Amazon grocery dept IDs (rh=n:<digits>) false-positive as phone numbers.
|
||||||
|
regexes = [
|
||||||
|
'''rh=n:\d{8,12}''',
|
||||||
|
]
|
||||||
|
|
||||||
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
||||||
|
|
||||||
[[rules]]
|
[[rules]]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ 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
|
||||||
|
|
@ -22,8 +25,9 @@ COPY kiwi/ ./kiwi/
|
||||||
# they never end up in the cloud image regardless of .dockerignore placement.
|
# they never end up in the cloud image regardless of .dockerignore placement.
|
||||||
RUN rm -f /app/kiwi/.env
|
RUN rm -f /app/kiwi/.env
|
||||||
|
|
||||||
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
|
# 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 .
|
||||||
|
|
||||||
|
|
|
||||||
142
README.md
142
README.md
|
|
@ -1,80 +1,118 @@
|
||||||
# 🥝 Kiwi
|
<!-- Logo coming soon — replace docs/kiwi-logo.svg when final icon ships -->
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/kiwi-logo.svg" alt="Kiwi logo" width="96" height="96" />
|
||||||
|
|
||||||
> *Part of the CircuitForge LLC "AI for the tasks the system made hard on purpose" suite.*
|
# Kiwi
|
||||||
|
|
||||||
**Pantry tracking and leftover recipe suggestions.**
|
**Pantry tracking and recipe suggestions — with or without an LLM.**
|
||||||
|
|
||||||
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
[](#license)
|
||||||
|
[](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/actions)
|
||||||
|
[](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/releases)
|
||||||
|
|
||||||
**LLM support is optional.** Inventory tracking, barcode scanning, expiry alerts, CSV export, and receipt upload all work without any LLM configured. AI features (receipt OCR, recipe suggestions, meal planning) activate when a backend is available and are BYOK-unlockable at any tier.
|
[Documentation](https://docs.circuitforge.tech/kiwi) · [Live demo](https://menagerie.circuitforge.tech/kiwi) · [circuitforge.tech](https://circuitforge.tech)
|
||||||
|
|
||||||
**Status:** Beta · CircuitForge LLC
|
*Part of the CircuitForge LLC suite — "AI for the tasks the system made hard on purpose."*
|
||||||
|
</div>
|
||||||
**[Documentation](https://docs.circuitforge.tech/kiwi/)** · [circuitforge.tech](https://circuitforge.tech)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does
|
> **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.
|
||||||
|
|
||||||
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
---
|
||||||
- **Expiry alerts** — know what's about to go bad
|
|
||||||
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
|
|
||||||
- **Saved recipes** — bookmark any recipe with notes, a 0–5 star rating, and free-text style tags (Free); organize into named collections (Paid)
|
|
||||||
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
|
|
||||||
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
|
|
||||||
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
|
|
||||||
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Free, 5/day; unlimited at Paid+)
|
|
||||||
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
|
|
||||||
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
|
|
||||||
|
|
||||||
## Stack
|
## What Kiwi does
|
||||||
|
|
||||||
- **Frontend:** Vue 3 SPA (Vite + TypeScript)
|
| Feature | Notes |
|
||||||
- **Backend:** FastAPI + SQLite (via `circuitforge-core`)
|
|---|---|
|
||||||
- **Auth:** CF session cookie → Directus JWT (cloud mode)
|
| **Inventory tracking** | Add items by barcode scan, receipt upload, or manually |
|
||||||
- **Licensing:** Heimdall (free tier auto-provisioned at signup)
|
| **Expiry alerts** | Know what is about to go bad before it does |
|
||||||
|
| **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
|
||||||
# Web: http://localhost:8511
|
# Web: http://localhost:8511
|
||||||
# API: http://localhost:8512
|
# API: http://localhost:8512
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cloud instance
|
**Live cloud instance** (free account required):
|
||||||
|
[menagerie.circuitforge.tech/kiwi](https://menagerie.circuitforge.tech/kiwi)
|
||||||
|
|
||||||
```bash
|
Full setup and configuration guide: [docs.circuitforge.tech/kiwi](https://docs.circuitforge.tech/kiwi)
|
||||||
./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 | ✓ | ✓ | ✓ |
|
| Inventory CRUD | Yes | Yes | Yes |
|
||||||
| Barcode scan | ✓ | ✓ | ✓ |
|
| Barcode scan | Yes | Yes | Yes |
|
||||||
| Receipt upload | ✓ | ✓ | ✓ |
|
| Receipt upload | Yes | Yes | Yes |
|
||||||
| Expiry alerts | ✓ | ✓ | ✓ |
|
| Expiry alerts | Yes | Yes | Yes |
|
||||||
| CSV export | ✓ | ✓ | ✓ |
|
| CSV export | Yes | Yes | Yes |
|
||||||
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
|
| Recipe browser (200k+ recipes) | Yes | Yes | Yes |
|
||||||
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
|
| Save recipes + notes + star rating | Yes | Yes | Yes |
|
||||||
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
|
| Style tags (manual, free-text) | Yes | Yes | Yes |
|
||||||
| Receipt OCR | BYOK | ✓ | ✓ |
|
| Leftover mode (5/day) | Yes | Yes | Yes |
|
||||||
| Recipe suggestions (L1–L4) | BYOK | ✓ | ✓ |
|
| Receipt OCR | BYOK | Yes | Yes |
|
||||||
| Named recipe collections | — | ✓ | ✓ |
|
| Recipe suggestions (L1–L4) | BYOK | Yes | Yes |
|
||||||
| LLM style auto-classifier | — | BYOK | ✓ |
|
| Named recipe collections | — | Yes | Yes |
|
||||||
| Meal planning | — | ✓ | ✓ |
|
| LLM style auto-classifier | — | BYOK | Yes |
|
||||||
| Multi-household | — | — | ✓ |
|
| Meal planning | — | Yes | Yes |
|
||||||
| Leftover mode (5/day) | ✓ | ✓ | ✓ |
|
| Multi-household | — | — | Yes |
|
||||||
|
|
||||||
BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`)
|
**BYOK** = bring your own LLM backend. Configure `~/.config/circuitforge/llm.yaml` to unlock AI features at any tier without a paid subscription.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
Discovery/pipeline layer: MIT
|
Kiwi uses a split license:
|
||||||
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.
|
||||||
|
|
|
||||||
332
app/api/endpoints/activitypub.py
Normal file
332
app/api/endpoints/activitypub.py
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
# 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
|
||||||
|
|
@ -62,7 +62,13 @@ async def list_posts(
|
||||||
):
|
):
|
||||||
store = _get_community_store()
|
store = _get_community_store()
|
||||||
if store is None:
|
if store is None:
|
||||||
return {"posts": [], "total": 0, "note": "Community DB not available on this instance."}
|
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
|
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
|
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
|
||||||
|
|
@ -76,7 +82,8 @@ async def list_posts(
|
||||||
dietary_tags=dietary,
|
dietary_tags=dietary,
|
||||||
allergen_exclude=allergen_ex,
|
allergen_exclude=allergen_ex,
|
||||||
)
|
)
|
||||||
return {"posts": [_post_to_dict(p) for p in posts if _visible(p)], "page": page, "page_size": page_size}
|
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}")
|
@router.get("/posts/{slug}")
|
||||||
|
|
@ -160,6 +167,54 @@ def _validate_publish_body(body: dict) -> None:
|
||||||
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
|
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check-similar")
|
||||||
|
async def check_similar(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
|
"""Pre-submission dedup check: return similar existing posts for the given title/recipe_id.
|
||||||
|
|
||||||
|
Safe to call with no community store configured — returns empty list rather than 503.
|
||||||
|
"""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
return {"similar_posts": []}
|
||||||
|
|
||||||
|
title = (body.get("title") or "").strip()
|
||||||
|
recipe_id = body.get("recipe_id")
|
||||||
|
post_type = body.get("post_type")
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
return {"similar_posts": []}
|
||||||
|
|
||||||
|
candidates = await asyncio.to_thread(
|
||||||
|
store.search_similar_posts,
|
||||||
|
title,
|
||||||
|
recipe_id,
|
||||||
|
post_type,
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return {"similar_posts": []}
|
||||||
|
|
||||||
|
from app.services.community.dedup import build_similar_post_result, fetch_recipe_ingredients
|
||||||
|
incoming_ingredients = await asyncio.to_thread(
|
||||||
|
fetch_recipe_ingredients, session.db, recipe_id
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for post in candidates:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
build_similar_post_result,
|
||||||
|
post,
|
||||||
|
recipe_id,
|
||||||
|
incoming_ingredients,
|
||||||
|
session.db,
|
||||||
|
)
|
||||||
|
if result["similarity_tier"] != "different":
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return {"similar_posts": results[:5]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts", status_code=201)
|
@router.post("/posts", status_code=201)
|
||||||
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
@ -207,6 +262,8 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
||||||
|
|
||||||
|
similar_to_ref = body.get("similar_to_ref") or None
|
||||||
|
|
||||||
from circuitforge_core.community.models import CommunityPost
|
from circuitforge_core.community.models import CommunityPost
|
||||||
post = CommunityPost(
|
post = CommunityPost(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|
@ -234,6 +291,7 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
fat_pct=snapshot.fat_pct,
|
fat_pct=snapshot.fat_pct,
|
||||||
protein_pct=snapshot.protein_pct,
|
protein_pct=snapshot.protein_pct,
|
||||||
moisture_pct=snapshot.moisture_pct,
|
moisture_pct=snapshot.moisture_pct,
|
||||||
|
similar_to_ref=similar_to_ref,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -243,7 +301,41 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail="A post with this title already exists today. Try a different title.",
|
detail="A post with this title already exists today. Try a different title.",
|
||||||
) from exc
|
) from exc
|
||||||
return _post_to_dict(inserted)
|
|
||||||
|
post_dict = _post_to_dict(inserted)
|
||||||
|
|
||||||
|
# AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in)
|
||||||
|
from app.core.config import settings as _settings
|
||||||
|
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
|
||||||
|
from circuitforge_core.activitypub import make_create, make_note, PUBLIC
|
||||||
|
from app.services.ap.keys import get_actor
|
||||||
|
from app.services.ap.delivery import deliver_to_followers
|
||||||
|
_ap_actor = get_actor()
|
||||||
|
if _ap_actor is not None:
|
||||||
|
base = f"https://{_settings.AP_HOST}"
|
||||||
|
from app.api.endpoints.activitypub import _post_to_ap_note
|
||||||
|
_note = _post_to_ap_note(inserted, _ap_actor, base)
|
||||||
|
_activity = make_create(_ap_actor, _note)
|
||||||
|
asyncio.create_task(
|
||||||
|
asyncio.to_thread(
|
||||||
|
deliver_to_followers, inserted.slug, _activity, session.db
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mastodon post if user has connected account and opted in
|
||||||
|
if body.get("post_to_mastodon"):
|
||||||
|
from app.services.ap.mastodon import build_post_content, get_token, post_status
|
||||||
|
_masto = await asyncio.to_thread(
|
||||||
|
get_token, session.db, session.user_id, _settings.AP_TOKEN_ENCRYPTION_KEY
|
||||||
|
)
|
||||||
|
if _masto:
|
||||||
|
_masto_url, _masto_token = _masto
|
||||||
|
_content = build_post_content(post_dict)
|
||||||
|
asyncio.create_task(
|
||||||
|
asyncio.to_thread(post_status, _masto_url, _masto_token, _content)
|
||||||
|
)
|
||||||
|
|
||||||
|
return post_dict
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/posts/{slug}", status_code=204)
|
@router.delete("/posts/{slug}", status_code=204)
|
||||||
|
|
@ -344,6 +436,7 @@ def _post_to_dict(post) -> dict:
|
||||||
"fat_pct": post.fat_pct,
|
"fat_pct": post.fat_pct,
|
||||||
"protein_pct": post.protein_pct,
|
"protein_pct": post.protein_pct,
|
||||||
"moisture_pct": post.moisture_pct,
|
"moisture_pct": post.moisture_pct,
|
||||||
|
"similar_to_ref": getattr(post, "similar_to_ref", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
5
app/api/endpoints/corrections.py
Normal file
5
app/api/endpoints/corrections.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# 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,9 +1,11 @@
|
||||||
"""Export endpoints — CSV/Excel of receipt and inventory data."""
|
"""Export endpoints — CSV and JSON export of user 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
|
||||||
|
|
@ -45,3 +47,33 @@ 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}"},
|
||||||
|
)
|
||||||
|
|
|
||||||
103
app/api/endpoints/feedback_attach.py
Normal file
103
app/api/endpoints/feedback_attach.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -11,7 +11,8 @@ import sqlite3
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN, get_session
|
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.db.store import Store
|
||||||
from app.models.schemas.household import (
|
from app.models.schemas.household import (
|
||||||
HouseholdAcceptRequest,
|
HouseholdAcceptRequest,
|
||||||
|
|
@ -128,15 +129,18 @@ async def household_status(session: CloudUser = Depends(_require_premium)):
|
||||||
@router.post("/invite", response_model=HouseholdInviteResponse)
|
@router.post("/invite", response_model=HouseholdInviteResponse)
|
||||||
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
|
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
|
||||||
"""Generate a one-time invite token valid for 7 days."""
|
"""Generate a one-time invite token valid for 7 days."""
|
||||||
store = Store(session.db)
|
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
|
||||||
store.conn.execute(
|
store = Store(session.db)
|
||||||
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
try:
|
||||||
VALUES (?, ?, ?, ?)""",
|
store.conn.execute(
|
||||||
(token, session.household_id, session.user_id, expires_at),
|
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
||||||
)
|
VALUES (?, ?, ?, ?)""",
|
||||||
store.conn.commit()
|
(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}"
|
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)
|
return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at)
|
||||||
|
|
||||||
|
|
@ -152,24 +156,27 @@ async def accept_invite(
|
||||||
|
|
||||||
hh_store = _household_store(body.household_id)
|
hh_store = _household_store(body.household_id)
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
row = hh_store.conn.execute(
|
try:
|
||||||
"""SELECT token, expires_at, used_at FROM household_invites
|
row = hh_store.conn.execute(
|
||||||
WHERE token = ? AND household_id = ?""",
|
"""SELECT token, expires_at, used_at FROM household_invites
|
||||||
(body.token, body.household_id),
|
WHERE token = ? AND household_id = ?""",
|
||||||
).fetchone()
|
(body.token, body.household_id),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Invite not found.")
|
raise HTTPException(status_code=404, detail="Invite not found.")
|
||||||
if row["used_at"] is not None:
|
if row["used_at"] is not None:
|
||||||
raise HTTPException(status_code=410, detail="Invite already used.")
|
raise HTTPException(status_code=410, detail="Invite already used.")
|
||||||
if row["expires_at"] < now:
|
if row["expires_at"] < now:
|
||||||
raise HTTPException(status_code=410, detail="Invite has expired.")
|
raise HTTPException(status_code=410, detail="Invite has expired.")
|
||||||
|
|
||||||
hh_store.conn.execute(
|
hh_store.conn.execute(
|
||||||
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
|
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
|
||||||
(now, session.user_id, body.token),
|
(now, session.user_id, body.token),
|
||||||
)
|
)
|
||||||
hh_store.conn.commit()
|
hh_store.conn.commit()
|
||||||
|
finally:
|
||||||
|
hh_store.close()
|
||||||
|
|
||||||
_heimdall_post("/admin/household/add-member", {
|
_heimdall_post("/admin/household/add-member", {
|
||||||
"household_id": body.household_id,
|
"household_id": body.household_id,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
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
|
||||||
|
|
@ -11,28 +12,73 @@ 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, get_session
|
from app.cloud_session import CloudUser, _auth_label, 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,
|
BulkAddByNameRequest,
|
||||||
BulkAddByNameResponse,
|
BulkAddByNameResponse,
|
||||||
BulkAddItemResult,
|
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)
|
||||||
|
|
@ -117,7 +163,12 @@ 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(body: InventoryItemCreate, store: Store = Depends(get_store)):
|
async def create_inventory_item(
|
||||||
|
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,
|
||||||
|
|
@ -130,7 +181,10 @@ async def create_inventory_item(body: InventoryItemCreate, store: Store = Depend
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
source=body.source,
|
source=body.source,
|
||||||
)
|
)
|
||||||
return InventoryItemResponse.model_validate(item)
|
# RETURNING * omits joined columns (product_name, barcode, category).
|
||||||
|
# Re-fetch with the products JOIN so the response is fully populated (#99).
|
||||||
|
full_item = await asyncio.to_thread(store.get_inventory_item, item["id"])
|
||||||
|
return InventoryItemResponse.model_validate(full_item)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
||||||
|
|
@ -143,7 +197,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe
|
||||||
for entry in body.items:
|
for entry in body.items:
|
||||||
try:
|
try:
|
||||||
product, _ = await asyncio.to_thread(
|
product, _ = await asyncio.to_thread(
|
||||||
store.get_or_create_product, entry.name, None, source="shopping"
|
store.get_or_create_product, entry.name, None, source="manual"
|
||||||
)
|
)
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
|
|
@ -151,7 +205,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe
|
||||||
entry.location,
|
entry.location,
|
||||||
quantity=entry.quantity,
|
quantity=entry.quantity,
|
||||||
unit=entry.unit,
|
unit=entry.unit,
|
||||||
source="shopping",
|
source="manual",
|
||||||
)
|
)
|
||||||
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
|
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -168,13 +222,15 @@ 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)
|
||||||
return [InventoryItemResponse.model_validate(i) for i in items]
|
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||||
|
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)
|
||||||
return [InventoryItemResponse.model_validate(i) for i in items]
|
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||||
|
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)
|
||||||
|
|
@ -182,7 +238,8 @@ 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")
|
||||||
return InventoryItemResponse.model_validate(item)
|
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||||
|
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -194,24 +251,83 @@ 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")
|
||||||
return InventoryItemResponse.model_validate(item)
|
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||||
|
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(item_id: int, store: Store = Depends(get_store)):
|
async def consume_item(
|
||||||
|
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
|
||||||
|
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(
|
||||||
|
store.update_inventory_item,
|
||||||
|
item_id,
|
||||||
|
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
|
from datetime import datetime, timezone
|
||||||
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="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")
|
||||||
return InventoryItemResponse.model_validate(item)
|
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||||
|
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)
|
||||||
|
|
@ -234,6 +350,31 @@ 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,
|
||||||
|
|
@ -241,12 +382,24 @@ 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
|
||||||
|
|
||||||
off = OpenFoodFactsService()
|
|
||||||
predictor = ExpirationPredictor()
|
predictor = ExpirationPredictor()
|
||||||
product_info = await off.lookup_product(body.barcode)
|
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()
|
||||||
|
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:
|
||||||
|
|
@ -257,7 +410,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="openfoodfacts",
|
source=product_source,
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
exp = predictor.predict_expiration(
|
exp = predictor.predict_expiration(
|
||||||
|
|
@ -267,10 +420,14 @@ 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=body.quantity,
|
quantity=resolved_qty,
|
||||||
|
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",
|
||||||
)
|
)
|
||||||
|
|
@ -278,6 +435,8 @@ 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,
|
||||||
|
|
@ -287,7 +446,9 @@ 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,
|
||||||
"message": "Added to inventory" if inventory_item else "Product not found in database",
|
"needs_manual_entry": not product_found and not needs_capture,
|
||||||
|
"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",
|
||||||
)
|
)
|
||||||
|
|
@ -303,6 +464,10 @@ 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}"
|
||||||
|
|
@ -313,7 +478,8 @@ 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
|
||||||
|
|
||||||
barcodes = await asyncio.to_thread(BarcodeScanner().scan_image, temp_file)
|
image_bytes = temp_file.read_bytes()
|
||||||
|
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=[],
|
||||||
|
|
@ -325,40 +491,58 @@ async def scan_barcode_image(
|
||||||
results = []
|
results = []
|
||||||
for bc in barcodes:
|
for bc in barcodes:
|
||||||
code = bc["data"]
|
code = bc["data"]
|
||||||
product_info = await off.lookup_product(code)
|
|
||||||
|
# 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_source = "openfoodfacts"
|
||||||
|
|
||||||
|
db_product = None
|
||||||
inventory_item = None
|
inventory_item = None
|
||||||
if product_info and auto_add_to_inventory:
|
if product_info:
|
||||||
product, _ = await asyncio.to_thread(
|
db_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="openfoodfacts",
|
source=product_source,
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
exp = predictor.predict_expiration(
|
if auto_add_to_inventory:
|
||||||
product_info.get("category", ""),
|
exp = predictor.predict_expiration(
|
||||||
location,
|
product_info.get("category", ""),
|
||||||
product_name=product_info.get("name", code),
|
location,
|
||||||
tier=session.tier,
|
product_name=product_info.get("name", code),
|
||||||
has_byok=session.has_byok,
|
tier=session.tier,
|
||||||
)
|
has_byok=session.has_byok,
|
||||||
inventory_item = await asyncio.to_thread(
|
)
|
||||||
store.add_inventory_item,
|
resolved_qty = product_info.get("pack_quantity") or quantity
|
||||||
product["id"], location,
|
resolved_unit = product_info.get("pack_unit") or "count"
|
||||||
quantity=quantity,
|
inventory_item = await asyncio.to_thread(
|
||||||
expiration_date=str(exp) if exp else None,
|
store.add_inventory_item,
|
||||||
source="barcode_scan",
|
db_product["id"], location,
|
||||||
)
|
quantity=resolved_qty,
|
||||||
|
unit=resolved_unit,
|
||||||
|
expiration_date=str(exp) if exp else None,
|
||||||
|
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(product) if product_info else None,
|
"product": ProductResponse.model_validate(db_product) if db_product 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,
|
||||||
"message": "Added to inventory" if inventory_item else "Barcode scanned",
|
"needs_manual_entry": not product_found and not needs_capture,
|
||||||
|
"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,
|
||||||
|
|
@ -369,6 +553,143 @@ 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)
|
||||||
|
|
|
||||||
133
app/api/endpoints/mastodon_oauth.py
Normal file
133
app/api/endpoints/mastodon_oauth.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# 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}
|
||||||
|
|
@ -19,6 +19,7 @@ from app.models.schemas.meal_plan import (
|
||||||
PrepTaskSummary,
|
PrepTaskSummary,
|
||||||
ShoppingListResponse,
|
ShoppingListResponse,
|
||||||
SlotSummary,
|
SlotSummary,
|
||||||
|
UpdatePlanRequest,
|
||||||
UpdatePrepTaskRequest,
|
UpdatePrepTaskRequest,
|
||||||
UpsertSlotRequest,
|
UpsertSlotRequest,
|
||||||
VALID_MEAL_TYPES,
|
VALID_MEAL_TYPES,
|
||||||
|
|
@ -81,13 +82,21 @@ async def create_plan(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
) -> PlanSummary:
|
) -> PlanSummary:
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
# Free tier is locked to dinner-only; paid+ may configure meal types
|
# Free tier is locked to dinner-only; paid+ may configure meal types
|
||||||
if can_use("meal_plan_config", session.tier):
|
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"]
|
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
||||||
else:
|
else:
|
||||||
meal_types = ["dinner"]
|
meal_types = ["dinner"]
|
||||||
|
|
||||||
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
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"])
|
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
||||||
return _plan_summary(plan, slots)
|
return _plan_summary(plan, slots)
|
||||||
|
|
||||||
|
|
@ -105,6 +114,28 @@ async def list_plans(
|
||||||
return result
|
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)
|
@router.get("/{plan_id}", response_model=PlanSummary)
|
||||||
async def get_plan(
|
async def get_plan(
|
||||||
plan_id: int,
|
plan_id: int,
|
||||||
|
|
|
||||||
|
|
@ -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_ocr",
|
source="receipt",
|
||||||
)
|
)
|
||||||
|
|
||||||
created.append(ApprovedInventoryItem(
|
created.append(ApprovedInventoryItem(
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,11 @@ 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, store)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
||||||
return ReceiptResponse.model_validate(receipt)
|
return ReceiptResponse.model_validate(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,7 +66,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, store)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
||||||
results.append(ReceiptResponse.model_validate(receipt))
|
results.append(ReceiptResponse.model_validate(receipt))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -97,8 +99,13 @@ 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, store: Store) -> None:
|
async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path) -> 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
|
||||||
|
|
@ -108,3 +115,5 @@ async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store)
|
||||||
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()
|
||||||
|
|
|
||||||
371
app/api/endpoints/recipe_scan.py
Normal file
371
app/api/endpoints/recipe_scan.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""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)
|
||||||
166
app/api/endpoints/recipe_tags.py
Normal file
166
app/api/endpoints/recipe_tags.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# 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)
|
||||||
|
|
@ -2,21 +2,36 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
import json as _json_mod
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
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.db.store import Store
|
||||||
from app.models.schemas.recipe import (
|
from app.models.schemas.recipe import (
|
||||||
|
AskRequest,
|
||||||
|
AskResponse,
|
||||||
|
AskRecipeHit,
|
||||||
AssemblyTemplateOut,
|
AssemblyTemplateOut,
|
||||||
BuildRequest,
|
BuildRequest,
|
||||||
|
LeftoversResponse,
|
||||||
|
RecipeJobStatus,
|
||||||
RecipeRequest,
|
RecipeRequest,
|
||||||
RecipeResult,
|
RecipeResult,
|
||||||
RecipeSuggestion,
|
RecipeSuggestion,
|
||||||
RoleCandidatesResponse,
|
RoleCandidatesResponse,
|
||||||
|
StreamTokenRequest,
|
||||||
|
StreamTokenResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
|
||||||
|
from app.api.endpoints.imitate import _build_recipe_prompt
|
||||||
from app.services.recipe.assembly_recipes import (
|
from app.services.recipe.assembly_recipes import (
|
||||||
build_from_selection,
|
build_from_selection,
|
||||||
get_role_candidates,
|
get_role_candidates,
|
||||||
|
|
@ -24,11 +39,16 @@ from app.services.recipe.assembly_recipes import (
|
||||||
)
|
)
|
||||||
from app.services.recipe.browser_domains import (
|
from app.services.recipe.browser_domains import (
|
||||||
DOMAINS,
|
DOMAINS,
|
||||||
|
category_has_subcategories,
|
||||||
get_category_names,
|
get_category_names,
|
||||||
get_domain_labels,
|
get_domain_labels,
|
||||||
get_keywords_for_category,
|
get_keywords_for_category,
|
||||||
|
get_keywords_for_subcategory,
|
||||||
|
get_subcategory_names,
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
|
from app.services.recipe.time_effort import parse_time_effort
|
||||||
|
from app.services.recipe.sensory import build_sensory_exclude
|
||||||
from app.services.heimdall_orch import check_orch_budget
|
from app.services.heimdall_orch import check_orch_budget
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
@ -50,13 +70,127 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest", response_model=RecipeResult)
|
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(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
|
async_mode: bool = Query(default=False, alias="async"),
|
||||||
|
stream: bool = Query(default=False),
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> RecipeResult:
|
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.
|
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
||||||
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
# 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:
|
if req.level == 4 and not req.wildcard_confirmed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -84,12 +218,92 @@ async def suggest_recipes(
|
||||||
req = req.model_copy(update={"level": 2})
|
req = req.model_copy(update={"level": 2})
|
||||||
orch_fallback = True
|
orch_fallback = True
|
||||||
|
|
||||||
|
if stream and req.level in (3, 4):
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream_recipe_sse(session.db, req),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if req.level in (3, 4) and async_mode:
|
||||||
|
return await _enqueue_recipe_job(session, req)
|
||||||
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
||||||
if orch_fallback:
|
if orch_fallback:
|
||||||
result = result.model_copy(update={"orch_fallback": True})
|
result = result.model_copy(update={"orch_fallback": True})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream-token", response_model=StreamTokenResponse)
|
||||||
|
async def get_stream_token(
|
||||||
|
req: StreamTokenRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> StreamTokenResponse:
|
||||||
|
"""Issue a one-time stream token for LLM recipe generation.
|
||||||
|
|
||||||
|
Tier-gated (Paid or BYOK). Builds the prompt from pantry + user settings,
|
||||||
|
then calls the cf-orch coordinator to obtain a stream URL. Returns
|
||||||
|
immediately — the frontend opens EventSource to the stream URL directly.
|
||||||
|
"""
|
||||||
|
if not can_use("recipe_suggestions", session.tier, session.has_byok):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Streaming recipe generation requires Paid tier or a configured LLM backend.",
|
||||||
|
)
|
||||||
|
if req.level == 4 and not req.wildcard_confirmed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Level 4 (Wildcard) streaming requires wildcard_confirmed=true.",
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = await asyncio.to_thread(_build_stream_prompt, session.db, req.level)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await coordinator_authorize(prompt=prompt, caller="kiwi-recipe", ttl_s=300)
|
||||||
|
except CoordinatorError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||||
|
|
||||||
|
return StreamTokenResponse(
|
||||||
|
stream_url=result.stream_url,
|
||||||
|
token=result.token,
|
||||||
|
expires_in_s=result.expires_in_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", response_model=RecipeJobStatus)
|
||||||
|
async def get_recipe_job_status(
|
||||||
|
job_id: str,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> RecipeJobStatus:
|
||||||
|
"""Poll the status of an async recipe generation job.
|
||||||
|
|
||||||
|
Returns 404 when job_id is unknown or belongs to a different user.
|
||||||
|
On status='done' with suggestions=[], the LLM returned empty — client
|
||||||
|
should show a 'no recipe generated, try again' message.
|
||||||
|
"""
|
||||||
|
def _get(db_path: Path) -> dict | None:
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
return store.get_recipe_job(job_id, session.user_id)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
row = await asyncio.to_thread(_get, session.db)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found.")
|
||||||
|
|
||||||
|
result = None
|
||||||
|
if row["status"] == "done" and row["result"]:
|
||||||
|
result = RecipeResult.model_validate_json(row["result"])
|
||||||
|
|
||||||
|
return RecipeJobStatus(
|
||||||
|
job_id=row["job_id"],
|
||||||
|
status=row["status"],
|
||||||
|
result=result,
|
||||||
|
error=row["error"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/domains")
|
@router.get("/browse/domains")
|
||||||
async def list_browse_domains(
|
async def list_browse_domains(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
|
|
@ -107,15 +321,42 @@ async def list_browse_categories(
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
keywords_by_category = {
|
cat_names = get_category_names(domain)
|
||||||
cat: get_keywords_for_category(domain, cat)
|
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
||||||
for cat in get_category_names(domain)
|
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]:
|
def _get(db_path: Path) -> list[dict]:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
return store.get_browser_categories(domain, keywords_by_category)
|
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
@ -129,22 +370,39 @@ async def browse_recipes(
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
|
subcategory: Annotated[str | None, Query()] = None,
|
||||||
|
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||||
|
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc|match)$")] = "default",
|
||||||
|
required_ingredient: Annotated[str | None, Query(max_length=100)] = None,
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
"""Return a paginated list of recipes for a domain/category.
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct
|
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||||
badges on each result.
|
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:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
keywords = get_keywords_for_category(domain, category)
|
if category == "_all":
|
||||||
if not keywords:
|
keywords = None # unfiltered browse
|
||||||
raise HTTPException(
|
elif subcategory:
|
||||||
status_code=404,
|
keywords = get_keywords_for_subcategory(domain, category, subcategory)
|
||||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
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 = (
|
pantry_list = (
|
||||||
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
||||||
|
|
@ -155,12 +413,90 @@ async def browse_recipes(
|
||||||
def _browse(db_path: Path) -> dict:
|
def _browse(db_path: Path) -> dict:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
|
# Load sensory preferences
|
||||||
|
sensory_prefs_json = store.get_setting("sensory_preferences")
|
||||||
|
sensory_exclude = build_sensory_exclude(sensory_prefs_json)
|
||||||
|
|
||||||
result = store.browse_recipes(
|
result = store.browse_recipes(
|
||||||
keywords=keywords,
|
keywords=keywords,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
pantry_items=pantry_list,
|
pantry_items=pantry_list,
|
||||||
|
q=q or None,
|
||||||
|
sort=sort,
|
||||||
|
sensory_exclude=sensory_exclude,
|
||||||
|
required_ingredient=required_ingredient or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Attach time/effort signals to each browse result ────────────────
|
||||||
|
import json as _json
|
||||||
|
for recipe_row in result.get("recipes", []):
|
||||||
|
directions_raw = recipe_row.get("directions") or []
|
||||||
|
if isinstance(directions_raw, str):
|
||||||
|
try:
|
||||||
|
directions_raw = _json.loads(directions_raw)
|
||||||
|
except Exception:
|
||||||
|
directions_raw = []
|
||||||
|
if directions_raw:
|
||||||
|
_profile = parse_time_effort(
|
||||||
|
directions_raw,
|
||||||
|
ingredients=recipe_row.get("ingredients") or [],
|
||||||
|
ingredient_names=recipe_row.get("ingredient_names") or [],
|
||||||
|
)
|
||||||
|
recipe_row["active_min"] = _profile.active_min
|
||||||
|
recipe_row["passive_min"] = _profile.passive_min
|
||||||
|
else:
|
||||||
|
recipe_row["active_min"] = None
|
||||||
|
recipe_row["passive_min"] = None
|
||||||
|
# Remove directions from browse payload — not needed by the card UI
|
||||||
|
recipe_row.pop("directions", None)
|
||||||
|
|
||||||
|
# Community tag fallback: if FTS returned nothing for a subcategory,
|
||||||
|
# check whether accepted community tags exist for this location and
|
||||||
|
# fetch those corpus recipes directly by ID.
|
||||||
|
if result["total"] == 0 and subcategory and keywords:
|
||||||
|
try:
|
||||||
|
from app.api.endpoints.community import _get_community_store
|
||||||
|
cs = _get_community_store()
|
||||||
|
if cs is not None:
|
||||||
|
community_ids = cs.get_accepted_recipe_ids_for_subcategory(
|
||||||
|
domain=domain,
|
||||||
|
category=category,
|
||||||
|
subcategory=subcategory,
|
||||||
|
)
|
||||||
|
if community_ids:
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
paged_ids = community_ids[offset: offset + page_size]
|
||||||
|
recipes = store.fetch_recipes_by_ids(paged_ids, pantry_list)
|
||||||
|
import json as _json_c
|
||||||
|
for recipe_row in recipes:
|
||||||
|
directions_raw = recipe_row.get("directions") or []
|
||||||
|
if isinstance(directions_raw, str):
|
||||||
|
try:
|
||||||
|
directions_raw = _json_c.loads(directions_raw)
|
||||||
|
except Exception:
|
||||||
|
directions_raw = []
|
||||||
|
if directions_raw:
|
||||||
|
_profile = parse_time_effort(
|
||||||
|
directions_raw,
|
||||||
|
ingredients=recipe_row.get("ingredients") or [],
|
||||||
|
ingredient_names=recipe_row.get("ingredient_names") or [],
|
||||||
|
)
|
||||||
|
recipe_row["active_min"] = _profile.active_min
|
||||||
|
recipe_row["passive_min"] = _profile.passive_min
|
||||||
|
else:
|
||||||
|
recipe_row["active_min"] = None
|
||||||
|
recipe_row["passive_min"] = None
|
||||||
|
recipe_row.pop("directions", None)
|
||||||
|
result = {
|
||||||
|
"recipes": recipes,
|
||||||
|
"total": len(community_ids),
|
||||||
|
"page": page,
|
||||||
|
"community_tagged": True,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("community tag fallback failed: %s", exc)
|
||||||
|
|
||||||
store.log_browser_telemetry(
|
store.log_browser_telemetry(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
category=category,
|
category=category,
|
||||||
|
|
@ -264,6 +600,137 @@ async def build_recipe(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_ASK_STOPWORDS: frozenset[str] = frozenset({
|
||||||
|
"what", "can", "make", "with", "have", "some", "the", "and", "for",
|
||||||
|
"that", "this", "these", "those", "how", "about", "are", "there",
|
||||||
|
"give", "show", "find", "want", "need", "like", "any", "good",
|
||||||
|
"quick", "easy", "simple", "fast", "using", "use", "from", "into",
|
||||||
|
"more", "much", "just", "only", "my", "please", "could", "would",
|
||||||
|
"should", "something", "anything", "everything", "ideas", "idea",
|
||||||
|
"suggest", "meal", "food", "dish", "dishes", "today", "tonight",
|
||||||
|
"tomorrow", "now", "here", "there", "recipes", "recipe", "dinner",
|
||||||
|
"lunch", "breakfast", "snack", "under", "minutes", "hours", "time",
|
||||||
|
"left", "over", "also", "some", "make", "cook", "made", "cooked",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ask_keywords(question: str) -> list[str]:
|
||||||
|
"""Extract food-relevant keywords from a natural language question."""
|
||||||
|
tokens = _re.findall(r"[a-zA-Z]+", question.lower())
|
||||||
|
return [t for t in tokens if len(t) > 3 and t not in _ASK_STOPWORDS]
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_in_thread(db_path: Path, question: str, pantry_items: list[str]) -> AskResponse:
|
||||||
|
"""Run Ask logic in a worker thread.
|
||||||
|
|
||||||
|
Free tier: keyword extraction + FTS ingredient search.
|
||||||
|
Paid tier path: same search, then LLM synthesis over results.
|
||||||
|
The caller handles tier gating and LLM synthesis outside this thread
|
||||||
|
to avoid importing LLMRouter in a sync context.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
keywords = _extract_ask_keywords(question)
|
||||||
|
ingredient_hits: list[dict] = []
|
||||||
|
if keywords:
|
||||||
|
ingredient_hits = store.search_recipes_by_ingredients(keywords, limit=15)
|
||||||
|
|
||||||
|
# Also search by title using the full question text as a substring hint.
|
||||||
|
# browse_recipes q= does title LIKE %q%. Extract the longest keyword
|
||||||
|
# from the question as the title probe (most likely to appear in a title).
|
||||||
|
title_hits: list[dict] = []
|
||||||
|
title_probe = max(keywords, key=len) if keywords else None
|
||||||
|
if title_probe:
|
||||||
|
browse_result = store.browse_recipes(
|
||||||
|
keywords=None,
|
||||||
|
page=1,
|
||||||
|
page_size=12,
|
||||||
|
pantry_items=pantry_items or None,
|
||||||
|
q=title_probe,
|
||||||
|
sort="match" if pantry_items else "default",
|
||||||
|
)
|
||||||
|
title_hits = browse_result.get("recipes", [])
|
||||||
|
|
||||||
|
# Merge by ID; ingredient hits come first (more semantically relevant).
|
||||||
|
seen: set[int] = set()
|
||||||
|
merged: list[dict] = []
|
||||||
|
for row in ingredient_hits + title_hits:
|
||||||
|
rid = row.get("id")
|
||||||
|
if rid is not None and rid not in seen:
|
||||||
|
seen.add(rid)
|
||||||
|
merged.append(row)
|
||||||
|
|
||||||
|
# Compute pantry match_pct if caller sent pantry items.
|
||||||
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else set()
|
||||||
|
|
||||||
|
hits: list[AskRecipeHit] = []
|
||||||
|
for row in merged[:12]:
|
||||||
|
match_pct: float | None = None
|
||||||
|
if pantry_set:
|
||||||
|
raw_names = row.get("ingredient_names") or []
|
||||||
|
if isinstance(raw_names, str):
|
||||||
|
try:
|
||||||
|
raw_names = _json.loads(raw_names)
|
||||||
|
except Exception:
|
||||||
|
raw_names = []
|
||||||
|
if raw_names:
|
||||||
|
covered = sum(
|
||||||
|
1 for n in raw_names
|
||||||
|
if any(p in n.lower() for p in pantry_set)
|
||||||
|
)
|
||||||
|
match_pct = round(covered / len(raw_names), 2)
|
||||||
|
hits.append(AskRecipeHit(
|
||||||
|
id=row["id"],
|
||||||
|
title=row.get("title", ""),
|
||||||
|
category=row.get("category"),
|
||||||
|
match_pct=match_pct,
|
||||||
|
))
|
||||||
|
|
||||||
|
return AskResponse(answer=None, recipes=hits, tier="free")
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask", response_model=AskResponse)
|
||||||
|
async def ask_recipes(
|
||||||
|
req: AskRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> AskResponse:
|
||||||
|
"""Natural-language recipe search with optional LLM synthesis.
|
||||||
|
|
||||||
|
Free tier: keyword extraction from question → FTS ingredient + title search.
|
||||||
|
Paid tier / BYOK: same search, then LLM synthesizes a short conversational answer.
|
||||||
|
"""
|
||||||
|
result = await asyncio.to_thread(_ask_in_thread, session.db, req.question, req.pantry_items)
|
||||||
|
|
||||||
|
# LLM synthesis: only for paid/premium/ultra tiers, not "local" dev tier.
|
||||||
|
# Wrapped in wait_for so an unresponsive model degrades gracefully to recipe list only.
|
||||||
|
paid_tier = session.tier in ("paid", "premium", "ultra")
|
||||||
|
if (paid_tier or session.has_byok) and result.recipes:
|
||||||
|
recipe_titles = ", ".join(r.title for r in result.recipes[:6])
|
||||||
|
prompt = (
|
||||||
|
f'You are a helpful kitchen assistant. The user asked: "{req.question}"\n\n'
|
||||||
|
f"Matching recipes: {recipe_titles}\n\n"
|
||||||
|
f"Write a brief, friendly 1–2 sentence response suggesting which of these "
|
||||||
|
f"recipes might best fit the question. Be specific and natural."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
answer = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(LLMRouter().complete, prompt),
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
|
result = result.model_copy(update={"answer": answer.strip() or None, "tier": "paid"})
|
||||||
|
except (Exception, asyncio.TimeoutError) as exc:
|
||||||
|
log.warning("Ask LLM synthesis skipped: %s", exc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
|
@ -276,4 +743,111 @@ async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session))
|
||||||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
||||||
if not recipe:
|
if not recipe:
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||||
return recipe
|
|
||||||
|
# Normalize corpus record into RecipeSuggestion shape so RecipeDetailPanel
|
||||||
|
# can render it without knowing it came from a direct DB lookup.
|
||||||
|
ingredient_names = recipe.get("ingredient_names") or []
|
||||||
|
if isinstance(ingredient_names, str):
|
||||||
|
import json as _json
|
||||||
|
try:
|
||||||
|
ingredient_names = _json.loads(ingredient_names)
|
||||||
|
except Exception:
|
||||||
|
ingredient_names = []
|
||||||
|
|
||||||
|
_directions_for_te = recipe.get("directions") or []
|
||||||
|
if isinstance(_directions_for_te, str):
|
||||||
|
import json as _json2
|
||||||
|
try:
|
||||||
|
_directions_for_te = _json2.loads(_directions_for_te)
|
||||||
|
except Exception:
|
||||||
|
_directions_for_te = []
|
||||||
|
|
||||||
|
_ingredients_for_te = recipe.get("ingredients") or []
|
||||||
|
if isinstance(_ingredients_for_te, str):
|
||||||
|
import json as _json3
|
||||||
|
try:
|
||||||
|
_ingredients_for_te = _json3.loads(_ingredients_for_te)
|
||||||
|
except Exception:
|
||||||
|
_ingredients_for_te = []
|
||||||
|
|
||||||
|
_ingredient_names_for_te = recipe.get("ingredient_names") or []
|
||||||
|
if isinstance(_ingredient_names_for_te, str):
|
||||||
|
import json as _json4
|
||||||
|
try:
|
||||||
|
_ingredient_names_for_te = _json4.loads(_ingredient_names_for_te)
|
||||||
|
except Exception:
|
||||||
|
_ingredient_names_for_te = []
|
||||||
|
|
||||||
|
if _directions_for_te:
|
||||||
|
_te = parse_time_effort(
|
||||||
|
_directions_for_te,
|
||||||
|
ingredients=_ingredients_for_te,
|
||||||
|
ingredient_names=_ingredient_names_for_te,
|
||||||
|
)
|
||||||
|
_time_effort_out: dict | None = {
|
||||||
|
"active_min": _te.active_min,
|
||||||
|
"passive_min": _te.passive_min,
|
||||||
|
"total_min": _te.total_min,
|
||||||
|
"effort_label": _te.effort_label,
|
||||||
|
"equipment": _te.equipment,
|
||||||
|
"step_analyses": [
|
||||||
|
{
|
||||||
|
"is_passive": sa.is_passive,
|
||||||
|
"detected_minutes": sa.detected_minutes,
|
||||||
|
"prep_min": sa.prep_min,
|
||||||
|
}
|
||||||
|
for sa in _te.step_analyses
|
||||||
|
],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
_time_effort_out = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": recipe.get("id"),
|
||||||
|
"title": recipe.get("title", ""),
|
||||||
|
"match_count": 0,
|
||||||
|
"matched_ingredients": ingredient_names,
|
||||||
|
"missing_ingredients": [],
|
||||||
|
"directions": recipe.get("directions") or [],
|
||||||
|
"prep_notes": [],
|
||||||
|
"swap_candidates": [],
|
||||||
|
"element_coverage": {},
|
||||||
|
"notes": recipe.get("notes") or "",
|
||||||
|
"level": 1,
|
||||||
|
"is_wildcard": False,
|
||||||
|
"nutrition": None,
|
||||||
|
"source_url": recipe.get("source_url") or None,
|
||||||
|
"complexity": None,
|
||||||
|
"estimated_time_min": None,
|
||||||
|
"time_effort": _time_effort_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/leftovers", response_model=LeftoversResponse)
|
||||||
|
async def get_leftovers_shelf_life(
|
||||||
|
recipe_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> LeftoversResponse:
|
||||||
|
"""Return cooked-leftover shelf-life estimate for a recipe.
|
||||||
|
|
||||||
|
Free tier: deterministic lookup (FDA/USDA table).
|
||||||
|
Deterministic path always runs; no tier gate needed.
|
||||||
|
"""
|
||||||
|
def _get(db_path: Path, rid: int) -> LeftoversResponse:
|
||||||
|
from app.services.leftovers_predictor import predict_leftovers_from_row
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
recipe = store.get_recipe(rid)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||||
|
result = predict_leftovers_from_row(recipe)
|
||||||
|
return LeftoversResponse(
|
||||||
|
fridge_days=result.fridge_days,
|
||||||
|
freeze_days=result.freeze_days,
|
||||||
|
freeze_by_day=result.freeze_by_day,
|
||||||
|
storage_advice=result.storage_advice,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get, session.db, recipe_id)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
@ -16,8 +17,13 @@ from app.models.schemas.saved_recipe import (
|
||||||
SaveRecipeRequest,
|
SaveRecipeRequest,
|
||||||
UpdateSavedRecipeRequest,
|
UpdateSavedRecipeRequest,
|
||||||
)
|
)
|
||||||
|
from app.services.magpie_hook import fire_recipe_signal
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
||||||
|
class StyleClassifyResponse(BaseModel):
|
||||||
|
suggested_tags: list[str]
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,7 +41,7 @@ def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
|
||||||
return SavedRecipeSummary(
|
return SavedRecipeSummary(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
recipe_id=row["recipe_id"],
|
recipe_id=row["recipe_id"],
|
||||||
title=row.get("title", ""),
|
title=row.get("title") or "",
|
||||||
saved_at=row["saved_at"],
|
saved_at=row["saved_at"],
|
||||||
notes=row.get("notes"),
|
notes=row.get("notes"),
|
||||||
rating=row.get("rating"),
|
rating=row.get("rating"),
|
||||||
|
|
@ -55,7 +61,9 @@ async def save_recipe(
|
||||||
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
||||||
return _to_summary(row, store)
|
return _to_summary(row, store)
|
||||||
|
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
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)
|
@router.delete("/{recipe_id}", status_code=204)
|
||||||
|
|
@ -82,7 +90,11 @@ async def update_saved_recipe(
|
||||||
)
|
)
|
||||||
return _to_summary(row, store)
|
return _to_summary(row, store)
|
||||||
|
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
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])
|
@router.get("", response_model=list[SavedRecipeSummary])
|
||||||
|
|
@ -98,12 +110,37 @@ async def list_saved_recipes(
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
|
|
||||||
|
|
||||||
|
# ── style classifier (Paid / BYOK) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/classify-style", response_model=StyleClassifyResponse)
|
||||||
|
async def classify_style(
|
||||||
|
recipe_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> StyleClassifyResponse:
|
||||||
|
if not can_use("style_classifier", session.tier, getattr(session, "has_byok", False)):
|
||||||
|
raise HTTPException(status_code=403, detail="Style classifier requires Paid tier or BYOK.")
|
||||||
|
|
||||||
|
def _run(store: Store) -> StyleClassifyResponse:
|
||||||
|
recipe = store.get_recipe(recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||||
|
from app.services.recipe.style_classifier import classify_style as _classify
|
||||||
|
tags = _classify(recipe)
|
||||||
|
return StyleClassifyResponse(suggested_tags=tags)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
|
|
||||||
|
|
||||||
# ── collections (Paid) ────────────────────────────────────────────────────────
|
# ── collections (Paid) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/collections", response_model=list[CollectionSummary])
|
@router.get("/collections", response_model=list[CollectionSummary])
|
||||||
async def list_collections(
|
async def list_collections(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> list[CollectionSummary]:
|
) -> list[CollectionSummary]:
|
||||||
|
# Free users can list (they'll always have zero — creating requires Paid).
|
||||||
|
# Returning 403 here breaks savedStore.load() via Promise.all for non-Paid users.
|
||||||
|
if not can_use("recipe_collections", session.tier):
|
||||||
|
return []
|
||||||
rows = await asyncio.to_thread(
|
rows = await asyncio.to_thread(
|
||||||
_in_thread, session.db, lambda s: s.get_collections()
|
_in_thread, session.db, lambda s: s.get_collections()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
37
app/api/endpoints/session.py
Normal file
37
app/api/endpoints/session.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""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,
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
|
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"})
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
class SettingBody(BaseModel):
|
||||||
|
|
|
||||||
233
app/api/endpoints/shopping.py
Normal file
233
app/api/endpoints/shopping.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""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,21 +1,34 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate, meal_plans, orch_usage
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
||||||
from app.api.endpoints.community import router as community_router
|
from app.api.endpoints.community import router as community_router
|
||||||
|
from app.api.endpoints.corrections import router as corrections_router
|
||||||
|
from app.api.endpoints.mastodon_oauth import router as mastodon_router
|
||||||
|
from app.api.endpoints.recipe_scan import router as recipe_scan_router
|
||||||
|
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
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"])
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
|
# recipe_scan_router registered BEFORE recipes.router so /recipes/scan and /recipes/user
|
||||||
|
# take priority over /recipes/{recipe_id} (which would otherwise match them as int IDs).
|
||||||
|
api_router.include_router(recipe_scan_router, prefix="/recipes", tags=["recipe-scan"])
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
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(household.router, prefix="/household", tags=["household"])
|
||||||
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
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(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(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(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,11 +1,9 @@
|
||||||
"""Cloud session resolution for Kiwi FastAPI.
|
"""Cloud session resolution for Kiwi FastAPI.
|
||||||
|
|
||||||
Local mode (CLOUD_MODE unset/false): returns a local CloudUser with no auth
|
Delegates JWT validation, Heimdall provisioning, tier resolution, and guest
|
||||||
checks, full tier access, and DB path pointing to settings.DB_PATH.
|
session management to circuitforge_core.CloudSessionFactory. Kiwi-specific
|
||||||
|
CloudUser (per-user DB path, household data, BYOK flag) and DB helpers are
|
||||||
Cloud mode (CLOUD_MODE=true): validates the cf_session JWT injected by Caddy
|
kept here.
|
||||||
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")
|
||||||
|
|
@ -17,15 +15,11 @@ 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
|
||||||
|
|
||||||
import jwt as pyjwt
|
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory, detect_byok
|
||||||
import requests
|
from fastapi import Depends, HTTPException, Request, Response
|
||||||
import yaml
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -33,54 +27,22 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
|
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
|
||||||
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
||||||
DIRECTUS_JWT_SECRET: str = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
|
||||||
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
|
||||||
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
|
||||||
|
|
||||||
# Dev bypass: comma-separated IPs or CIDR ranges that skip JWT auth.
|
|
||||||
# NEVER set this in production. Intended only for LAN developer testing when
|
|
||||||
# the request doesn't pass through Caddy (which normally injects X-CF-Session).
|
|
||||||
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1
|
|
||||||
import ipaddress as _ipaddress
|
|
||||||
|
|
||||||
_BYPASS_RAW: list[str] = [
|
|
||||||
e.strip()
|
|
||||||
for e in os.environ.get("CLOUD_AUTH_BYPASS_IPS", "").split(",")
|
|
||||||
if e.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
_BYPASS_NETS: list[_ipaddress.IPv4Network | _ipaddress.IPv6Network] = []
|
|
||||||
_BYPASS_IPS: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
if _BYPASS_RAW:
|
|
||||||
_nets, _ips = [], set()
|
|
||||||
for entry in _BYPASS_RAW:
|
|
||||||
try:
|
|
||||||
_nets.append(_ipaddress.ip_network(entry, strict=False))
|
|
||||||
except ValueError:
|
|
||||||
_ips.add(entry) # treat non-parseable entries as bare IPs
|
|
||||||
_BYPASS_NETS = _nets
|
|
||||||
_BYPASS_IPS = frozenset(_ips)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_bypass_ip(ip: str) -> bool:
|
|
||||||
if not ip:
|
|
||||||
return False
|
|
||||||
if ip in _BYPASS_IPS:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
addr = _ipaddress.ip_address(ip)
|
|
||||||
return any(addr in net for net in _BYPASS_NETS)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
||||||
|
|
||||||
_TIER_CACHE: dict[str, tuple[dict, 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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -95,73 +57,7 @@ class CloudUser:
|
||||||
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
||||||
|
|
||||||
|
|
||||||
# ── JWT validation ─────────────────────────────────────────────────────────────
|
# ── DB path helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _extract_session_token(header_value: str) -> str:
|
|
||||||
m = re.search(r'(?:^|;)\s*cf_session=([^;]+)', header_value)
|
|
||||||
return m.group(1).strip() if m else header_value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def validate_session_jwt(token: str) -> str:
|
|
||||||
"""Validate cf_session JWT and return the Directus user_id."""
|
|
||||||
try:
|
|
||||||
payload = pyjwt.decode(
|
|
||||||
token,
|
|
||||||
DIRECTUS_JWT_SECRET,
|
|
||||||
algorithms=["HS256"],
|
|
||||||
options={"require": ["id", "exp"]},
|
|
||||||
)
|
|
||||||
return payload["id"]
|
|
||||||
except Exception as exc:
|
|
||||||
log.debug("JWT validation failed: %s", exc)
|
|
||||||
raise HTTPException(status_code=401, detail="Session invalid or expired")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Heimdall integration ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _ensure_provisioned(user_id: str) -> None:
|
|
||||||
if not HEIMDALL_ADMIN_TOKEN:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
requests.post(
|
|
||||||
f"{HEIMDALL_URL}/admin/provision",
|
|
||||||
json={"directus_user_id": user_id, "product": "kiwi", "tier": "free"},
|
|
||||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool, str | None]:
|
|
||||||
"""Returns (tier, household_id | None, is_household_owner, license_key | None)."""
|
|
||||||
now = time.monotonic()
|
|
||||||
cached = _TIER_CACHE.get(user_id)
|
|
||||||
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
|
||||||
entry = cached[0]
|
|
||||||
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False), entry.get("license_key")
|
|
||||||
|
|
||||||
if not HEIMDALL_ADMIN_TOKEN:
|
|
||||||
return "free", None, False, None
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
|
||||||
json={"directus_user_id": user_id, "product": "kiwi"},
|
|
||||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
data = resp.json() if resp.ok else {}
|
|
||||||
tier = data.get("tier", "free")
|
|
||||||
household_id = data.get("household_id")
|
|
||||||
is_owner = data.get("is_household_owner", False)
|
|
||||||
license_key = data.get("key_display")
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
|
||||||
tier, household_id, is_owner, license_key = "free", None, False, None
|
|
||||||
|
|
||||||
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner, "license_key": license_key}, now)
|
|
||||||
return tier, household_id, is_owner, license_key
|
|
||||||
|
|
||||||
|
|
||||||
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
||||||
if household_id:
|
if household_id:
|
||||||
|
|
@ -172,94 +68,56 @@ def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _anon_db_path() -> Path:
|
def _anon_guest_db_path(guest_id: str) -> Path:
|
||||||
"""Ephemeral DB for unauthenticated guest visitors (Free tier, no persistence)."""
|
"""Per-session DB for unauthenticated guest visitors.
|
||||||
path = CLOUD_DATA_ROOT / "anonymous" / "kiwi.db"
|
|
||||||
|
Each anonymous visitor gets an isolated SQLite DB keyed by their guest UUID
|
||||||
|
cookie, so shopping lists and affiliate interactions never bleed across sessions.
|
||||||
|
"""
|
||||||
|
path = CLOUD_DATA_ROOT / f"anon-{guest_id}" / "kiwi.db"
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
# ── BYOK detection ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_LLM_CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_byok(config_path: Path = _LLM_CONFIG_PATH) -> bool:
|
|
||||||
"""Return True if at least one enabled non-vision LLM backend is configured.
|
|
||||||
|
|
||||||
Reads the same llm.yaml that LLMRouter uses. Local (Ollama, vLLM) and
|
|
||||||
API-key backends both count — the policy is "user is supplying compute",
|
|
||||||
regardless of where that compute lives.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(config_path) as f:
|
|
||||||
cfg = yaml.safe_load(f) or {}
|
|
||||||
return any(
|
|
||||||
b.get("enabled", True) and b.get("type") != "vision_service"
|
|
||||||
for b in cfg.get("backends", {}).values()
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_session(request: Request) -> CloudUser:
|
def get_session(request: Request, response: Response) -> 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: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
|
Dev bypass: CLOUD_AUTH_BYPASS_IPS match returns a "local-dev" session.
|
||||||
returns a "local" session without JWT validation (dev/LAN use only).
|
Anonymous: per-session UUID cookie (cf_guest_id) isolates each guest's data.
|
||||||
"""
|
"""
|
||||||
has_byok = _detect_byok()
|
core_user = _core.resolve(request, response)
|
||||||
|
uid, tier, has_byok = core_user.user_id, core_user.tier, core_user.has_byok
|
||||||
|
|
||||||
if not CLOUD_MODE:
|
if not CLOUD_MODE or uid in ("local", "local-dev"):
|
||||||
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
# local-dev gets a writable path under CLOUD_DATA_ROOT; local uses KIWI_DB
|
||||||
|
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)
|
||||||
|
|
||||||
# Prefer X-Real-IP (set by nginx from the actual client address) over the
|
if uid.startswith("anon-"):
|
||||||
# TCP peer address (which is nginx's container IP when behind the proxy).
|
guest_id = uid[len("anon-"):]
|
||||||
client_ip = (
|
|
||||||
request.headers.get("x-real-ip", "")
|
|
||||||
or (request.client.host if request.client else "")
|
|
||||||
)
|
|
||||||
if (_BYPASS_IPS or _BYPASS_NETS) and _is_bypass_ip(client_ip):
|
|
||||||
log.debug("CLOUD_AUTH_BYPASS_IPS match for %s — returning local session", client_ip)
|
|
||||||
# Use a dev DB under CLOUD_DATA_ROOT so the container has a writable path.
|
|
||||||
dev_db = _user_db_path("local-dev")
|
|
||||||
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
|
|
||||||
|
|
||||||
raw_header = (
|
|
||||||
request.headers.get("x-cf-session", "")
|
|
||||||
or request.headers.get("cookie", "")
|
|
||||||
)
|
|
||||||
if not raw_header:
|
|
||||||
return CloudUser(
|
return CloudUser(
|
||||||
user_id="anonymous",
|
user_id=uid, tier=tier,
|
||||||
tier="free",
|
db=_anon_guest_db_path(guest_id),
|
||||||
db=_anon_db_path(),
|
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
)
|
)
|
||||||
|
|
||||||
token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret
|
household_id = core_user.meta.get("household_id")
|
||||||
if not token:
|
is_owner = core_user.meta.get("is_household_owner", False)
|
||||||
return CloudUser(
|
license_key = core_user.meta.get("license_key")
|
||||||
user_id="anonymous",
|
log.debug("Resolved %s session uid=%s tier=%s household=%s", _auth_label(uid), uid[:8], tier, household_id)
|
||||||
tier="free",
|
|
||||||
db=_anon_db_path(),
|
|
||||||
has_byok=has_byok,
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id = validate_session_jwt(token)
|
|
||||||
_ensure_provisioned(user_id)
|
|
||||||
tier, household_id, is_household_owner, license_key = _fetch_cloud_tier(user_id)
|
|
||||||
return CloudUser(
|
return CloudUser(
|
||||||
user_id=user_id,
|
user_id=uid, tier=tier,
|
||||||
tier=tier,
|
db=_user_db_path(uid, household_id=household_id),
|
||||||
db=_user_db_path(user_id, household_id=household_id),
|
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
household_id=household_id,
|
household_id=household_id,
|
||||||
is_household_owner=is_household_owner,
|
is_household_owner=is_owner,
|
||||||
license_key=license_key,
|
license_key=license_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ class Settings:
|
||||||
# Database
|
# Database
|
||||||
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
||||||
|
|
||||||
|
# Pre-computed browse counts cache (small SQLite, separate from corpus).
|
||||||
|
# Written by the nightly refresh task and by infer_recipe_tags.py.
|
||||||
|
# Set BROWSE_COUNTS_PATH to a bind-mounted path if you want the host
|
||||||
|
# pipeline to share counts with the container without re-running FTS.
|
||||||
|
BROWSE_COUNTS_PATH: Path = Path(
|
||||||
|
os.environ.get("BROWSE_COUNTS_PATH", str(DATA_DIR / "browse_counts.db"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Magpie data flywheel — ingest endpoint for anonymized recipe signals
|
||||||
|
# Set MAGPIE_INGEST_URL to enable; leave unset (or None) to disable silently.
|
||||||
|
MAGPIE_INGEST_URL: str | None = os.environ.get("MAGPIE_INGEST_URL") or None
|
||||||
|
|
||||||
# Community feature settings
|
# Community feature settings
|
||||||
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
||||||
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
||||||
|
|
@ -53,15 +65,52 @@ 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)
|
# CF-core resource coordinator (VRAM lease management — lease broker, not inference)
|
||||||
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
||||||
|
|
||||||
|
# GPU inference server URL
|
||||||
|
# Priority: GPU_SERVER_URL env var → CF_ORCH_URL env var (backward compat)
|
||||||
|
# → https://orch.circuitforge.tech when CF_LICENSE_KEY is present (Paid+)
|
||||||
|
# Resolved value is written back to os.environ["CF_ORCH_URL"] at startup so
|
||||||
|
# all service-layer callers that read CF_ORCH_URL directly see the right URL.
|
||||||
|
GPU_SERVER_URL: str | None = (
|
||||||
|
os.environ.get("GPU_SERVER_URL")
|
||||||
|
or os.environ.get("CF_ORCH_URL")
|
||||||
|
or (
|
||||||
|
"https://orch.circuitforge.tech"
|
||||||
|
if os.environ.get("CF_LICENSE_KEY")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||||
|
|
||||||
|
# E2E test account — analytics logging is suppressed for this user_id so test
|
||||||
|
# runs don't pollute session counts. Set to the Directus UUID of the test user.
|
||||||
|
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
|
||||||
|
|
||||||
|
# ActivityPub federation (optional; disabled by default)
|
||||||
|
AP_ENABLED: bool = os.environ.get("AP_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||||
|
AP_HOST: str = os.environ.get("AP_HOST", "") # e.g. kiwi.circuitforge.tech
|
||||||
|
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
||||||
|
AP_KEY_PATH: Path = Path(
|
||||||
|
os.environ.get("AP_KEY_PATH", str(CLOUD_DATA_ROOT / "ap_keys" / "instance.pem"))
|
||||||
|
)
|
||||||
|
# Fernet key for Mastodon access token encryption (base64-urlsafe, 32 bytes)
|
||||||
|
# Leave unset to skip encryption (dev only)
|
||||||
|
AP_TOKEN_ENCRYPTION_KEY: str | None = os.environ.get("AP_TOKEN_ENCRYPTION_KEY") or None
|
||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||||
|
# Use OrchestratedScheduler (coordinator-aware, multi-GPU fan-out) instead of
|
||||||
|
# LocalScheduler. Defaults to true in CLOUD_MODE; can be set independently
|
||||||
|
# for multi-GPU local rigs that don't need full cloud auth.
|
||||||
|
USE_ORCH_SCHEDULER: bool | None = (
|
||||||
|
None if os.environ.get("USE_ORCH_SCHEDULER") is None
|
||||||
|
else os.environ.get("USE_ORCH_SCHEDULER", "").lower() in ("1", "true", "yes")
|
||||||
|
)
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
@ -74,3 +123,9 @@ 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
|
||||||
|
|
|
||||||
5
app/db/migrations/030_opened_date.sql
Normal file
5
app/db/migrations/030_opened_date.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- 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;
|
||||||
4
app/db/migrations/031_disposal_reason.sql
Normal file
4
app/db/migrations/031_disposal_reason.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- 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;
|
||||||
4
app/db/migrations/032_meal_plan_unique_week.sql
Normal file
4
app/db/migrations/032_meal_plan_unique_week.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- 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);
|
||||||
21
app/db/migrations/033_shopping_list.sql
Normal file
21
app/db/migrations/033_shopping_list.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- 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);
|
||||||
14
app/db/migrations/034_recipe_jobs.sql
Normal file
14
app/db/migrations/034_recipe_jobs.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- 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);
|
||||||
12
app/db/migrations/035_sensory_tags.sql
Normal file
12
app/db/migrations/035_sensory_tags.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 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 '{}';
|
||||||
26
app/db/migrations/036_captured_products.sql
Normal file
26
app/db/migrations/036_captured_products.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 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
|
||||||
|
);
|
||||||
34
app/db/migrations/037_products_source_visual_capture.sql
Normal file
34
app/db/migrations/037_products_source_visual_capture.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- 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;
|
||||||
31
app/db/migrations/039_saved_recipes_drop_fk.sql
Normal file
31
app/db/migrations/039_saved_recipes_drop_fk.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- 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;
|
||||||
21
app/db/migrations/040_corrections.sql
Normal file
21
app/db/migrations/040_corrections.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- 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);
|
||||||
23
app/db/migrations/041_user_recipes.sql
Normal file
23
app/db/migrations/041_user_recipes.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- 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);
|
||||||
47
app/db/migrations/042_activitypub.sql
Normal file
47
app/db/migrations/042_activitypub.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
-- 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,6 +6,8 @@ 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
|
||||||
|
|
@ -21,3 +23,16 @@ 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()
|
||||||
|
|
|
||||||
734
app/db/store.py
734
app/db/store.py
|
|
@ -11,6 +11,7 @@ from typing import Any
|
||||||
|
|
||||||
from circuitforge_core.db.base import get_connection
|
from circuitforge_core.db.base import get_connection
|
||||||
from circuitforge_core.db.migrations import run_migrations
|
from circuitforge_core.db.migrations import run_migrations
|
||||||
|
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
@ -23,12 +24,25 @@ _COUNT_CACHE: dict[tuple[str, ...], int] = {}
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, db_path: Path, key: str = "") -> None:
|
def __init__(self, db_path: Path, key: str = "") -> None:
|
||||||
|
import os
|
||||||
self._db_path = str(db_path)
|
self._db_path = str(db_path)
|
||||||
self.conn: sqlite3.Connection = get_connection(db_path, key)
|
self.conn: sqlite3.Connection = get_connection(db_path, key)
|
||||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||||
run_migrations(self.conn, MIGRATIONS_DIR)
|
run_migrations(self.conn, MIGRATIONS_DIR)
|
||||||
|
|
||||||
|
# When RECIPE_DB_PATH is set (cloud mode), attach the shared read-only
|
||||||
|
# corpus DB as the "corpus" schema so per-user DBs can access recipe data.
|
||||||
|
# _cp (corpus prefix) is "corpus." in cloud mode, "" in local mode.
|
||||||
|
corpus_path = os.environ.get("RECIPE_DB_PATH", "")
|
||||||
|
if corpus_path:
|
||||||
|
self.conn.execute("ATTACH DATABASE ? AS corpus", (corpus_path,))
|
||||||
|
self._cp = "corpus."
|
||||||
|
self._corpus_path = corpus_path
|
||||||
|
else:
|
||||||
|
self._cp = ""
|
||||||
|
self._corpus_path = self._db_path
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
@ -46,7 +60,11 @@ class Store:
|
||||||
# saved recipe columns
|
# saved recipe columns
|
||||||
"style_tags",
|
"style_tags",
|
||||||
# meal plan columns
|
# meal plan columns
|
||||||
"meal_types"):
|
"meal_types",
|
||||||
|
# user_recipes columns
|
||||||
|
"steps", "tags",
|
||||||
|
# captured_products columns
|
||||||
|
"allergens"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
|
|
@ -218,7 +236,8 @@ class Store:
|
||||||
|
|
||||||
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
||||||
allowed = {"quantity", "unit", "location", "sublocation",
|
allowed = {"quantity", "unit", "location", "sublocation",
|
||||||
"expiration_date", "status", "notes", "consumed_at"}
|
"purchase_date", "expiration_date", "opened_date",
|
||||||
|
"status", "notes", "consumed_at", "disposal_reason"}
|
||||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return self.get_inventory_item(item_id)
|
return self.get_inventory_item(item_id)
|
||||||
|
|
@ -231,6 +250,32 @@ class Store:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return self.get_inventory_item(item_id)
|
return self.get_inventory_item(item_id)
|
||||||
|
|
||||||
|
def partial_consume_item(
|
||||||
|
self,
|
||||||
|
item_id: int,
|
||||||
|
consume_qty: float,
|
||||||
|
consumed_at: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Decrement quantity by consume_qty. Mark consumed when quantity reaches 0."""
|
||||||
|
row = self.get_inventory_item(item_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
remaining = max(0.0, round(row["quantity"] - consume_qty, 6))
|
||||||
|
if remaining <= 0:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE inventory_items SET quantity = 0, status = 'consumed',"
|
||||||
|
" consumed_at = ?, updated_at = datetime('now') WHERE id = ?",
|
||||||
|
(consumed_at, item_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')"
|
||||||
|
" WHERE id = ?",
|
||||||
|
(remaining, item_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return self.get_inventory_item(item_id)
|
||||||
|
|
||||||
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
|
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
"""SELECT i.*, p.name as product_name, p.category
|
"""SELECT i.*, p.name as product_name, p.category
|
||||||
|
|
@ -345,8 +390,9 @@ class Store:
|
||||||
|
|
||||||
def _fts_ready(self) -> bool:
|
def _fts_ready(self) -> bool:
|
||||||
"""Return True if the recipes_fts virtual table exists."""
|
"""Return True if the recipes_fts virtual table exists."""
|
||||||
|
schema = "corpus" if self._cp else "main"
|
||||||
row = self._fetch_one(
|
row = self._fetch_one(
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes_fts'"
|
f"SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name='recipes_fts'"
|
||||||
)
|
)
|
||||||
return row is not None
|
return row is not None
|
||||||
|
|
||||||
|
|
@ -637,10 +683,12 @@ class Store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Pull up to 10× limit candidates so ranking has enough headroom.
|
# Pull up to 10× limit candidates so ranking has enough headroom.
|
||||||
|
# FTS5 pseudo-column in WHERE uses bare table name, not schema-qualified.
|
||||||
|
c = self._cp
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT r.*
|
SELECT r.*
|
||||||
FROM recipes_fts
|
FROM {c}recipes_fts
|
||||||
JOIN recipes r ON r.id = recipes_fts.rowid
|
JOIN {c}recipes r ON r.id = {c}recipes_fts.rowid
|
||||||
WHERE recipes_fts MATCH ?
|
WHERE recipes_fts MATCH ?
|
||||||
{where_extra}
|
{where_extra}
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
|
|
@ -674,9 +722,10 @@ class Store:
|
||||||
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
|
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
|
||||||
for _ in ingredient_names
|
for _ in ingredient_names
|
||||||
)
|
)
|
||||||
|
c = self._cp
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT r.*, ({match_score}) AS match_count
|
SELECT r.*, ({match_score}) AS match_count
|
||||||
FROM recipes r
|
FROM {c}recipes r
|
||||||
WHERE ({like_clauses})
|
WHERE ({like_clauses})
|
||||||
{where_extra}
|
{where_extra}
|
||||||
ORDER BY match_count DESC, r.id ASC
|
ORDER BY match_count DESC, r.id ASC
|
||||||
|
|
@ -686,7 +735,46 @@ class Store:
|
||||||
return self._fetch_all(sql, tuple(all_params))
|
return self._fetch_all(sql, tuple(all_params))
|
||||||
|
|
||||||
def get_recipe(self, recipe_id: int) -> dict | None:
|
def get_recipe(self, recipe_id: int) -> dict | None:
|
||||||
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
row = self._fetch_one(f"SELECT * FROM {self._cp}recipes WHERE id = ?", (recipe_id,))
|
||||||
|
if row is None and self._cp:
|
||||||
|
# Fall back to user's own assembled recipes in main schema
|
||||||
|
row = self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
||||||
|
return row
|
||||||
|
|
||||||
|
# --- Async recipe jobs ---
|
||||||
|
|
||||||
|
def create_recipe_job(self, job_id: str, user_id: str, request_json: str) -> sqlite3.Row:
|
||||||
|
return self._insert_returning(
|
||||||
|
"INSERT INTO recipe_jobs (job_id, user_id, status, request) VALUES (?,?,?,?) RETURNING *",
|
||||||
|
(job_id, user_id, "queued", request_json),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_recipe_job(self, job_id: str, user_id: str) -> sqlite3.Row | None:
|
||||||
|
return self._fetch_one(
|
||||||
|
"SELECT * FROM recipe_jobs WHERE job_id=? AND user_id=?",
|
||||||
|
(job_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_recipe_job_running(self, job_id: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE recipe_jobs SET status='running', updated_at=datetime('now') WHERE job_id=?",
|
||||||
|
(job_id,),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def complete_recipe_job(self, job_id: str, result_json: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE recipe_jobs SET status='done', result=?, updated_at=datetime('now') WHERE job_id=?",
|
||||||
|
(result_json, job_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def fail_recipe_job(self, job_id: str, error: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE recipe_jobs SET status='failed', error=?, updated_at=datetime('now') WHERE job_id=?",
|
||||||
|
(error, job_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
def upsert_built_recipe(
|
def upsert_built_recipe(
|
||||||
self,
|
self,
|
||||||
|
|
@ -737,7 +825,7 @@ class Store:
|
||||||
return {}
|
return {}
|
||||||
placeholders = ",".join("?" * len(names))
|
placeholders = ",".join("?" * len(names))
|
||||||
rows = self._fetch_all(
|
rows = self._fetch_all(
|
||||||
f"SELECT name, elements FROM ingredient_profiles WHERE name IN ({placeholders})",
|
f"SELECT name, elements FROM {self._cp}ingredient_profiles WHERE name IN ({placeholders})",
|
||||||
tuple(names),
|
tuple(names),
|
||||||
)
|
)
|
||||||
result: dict[str, list[str]] = {}
|
result: dict[str, list[str]] = {}
|
||||||
|
|
@ -878,12 +966,25 @@ class Store:
|
||||||
"title": "r.title ASC",
|
"title": "r.title ASC",
|
||||||
}.get(sort_by, "sr.saved_at DESC")
|
}.get(sort_by, "sr.saved_at DESC")
|
||||||
|
|
||||||
|
c = self._cp
|
||||||
|
# In corpus-attached (cloud) mode: try corpus recipes first, fall back
|
||||||
|
# to user's own assembled recipes. In local mode: single join suffices.
|
||||||
|
if c:
|
||||||
|
recipe_join = (
|
||||||
|
f"LEFT JOIN {c}recipes rc ON rc.id = sr.recipe_id "
|
||||||
|
"LEFT JOIN recipes rm ON rm.id = sr.recipe_id"
|
||||||
|
)
|
||||||
|
title_col = "COALESCE(rc.title, rm.title) AS title"
|
||||||
|
else:
|
||||||
|
recipe_join = "JOIN recipes rc ON rc.id = sr.recipe_id"
|
||||||
|
title_col = "rc.title"
|
||||||
|
|
||||||
if collection_id is not None:
|
if collection_id is not None:
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
f"""
|
f"""
|
||||||
SELECT sr.*, r.title
|
SELECT sr.*, {title_col}
|
||||||
FROM saved_recipes sr
|
FROM saved_recipes sr
|
||||||
JOIN recipes r ON r.id = sr.recipe_id
|
{recipe_join}
|
||||||
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
|
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
|
||||||
WHERE rcm.collection_id = ?
|
WHERE rcm.collection_id = ?
|
||||||
ORDER BY {order}
|
ORDER BY {order}
|
||||||
|
|
@ -892,9 +993,9 @@ class Store:
|
||||||
)
|
)
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
f"""
|
f"""
|
||||||
SELECT sr.*, r.title
|
SELECT sr.*, {title_col}
|
||||||
FROM saved_recipes sr
|
FROM saved_recipes sr
|
||||||
JOIN recipes r ON r.id = sr.recipe_id
|
{recipe_join}
|
||||||
ORDER BY {order}
|
ORDER BY {order}
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
@ -909,10 +1010,26 @@ class Store:
|
||||||
# ── recipe collections ────────────────────────────────────────────────
|
# ── recipe collections ────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_collection(self, name: str, description: str | None) -> dict:
|
def create_collection(self, name: str, description: str | None) -> dict:
|
||||||
return self._insert_returning(
|
# INSERT RETURNING * omits aggregate columns (e.g. member_count); re-query
|
||||||
"INSERT INTO recipe_collections (name, description) VALUES (?, ?) RETURNING *",
|
# with the same SELECT used by get_collections() so the response shape is consistent.
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"INSERT INTO recipe_collections (name, description) VALUES (?, ?)",
|
||||||
(name, description),
|
(name, description),
|
||||||
)
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
new_id = cur.lastrowid
|
||||||
|
row = self._fetch_one(
|
||||||
|
"""
|
||||||
|
SELECT rc.*,
|
||||||
|
COUNT(rcm.saved_recipe_id) AS member_count
|
||||||
|
FROM recipe_collections rc
|
||||||
|
LEFT JOIN recipe_collection_members rcm ON rcm.collection_id = rc.id
|
||||||
|
WHERE rc.id = ?
|
||||||
|
GROUP BY rc.id
|
||||||
|
""",
|
||||||
|
(new_id,),
|
||||||
|
)
|
||||||
|
return row # type: ignore[return-value]
|
||||||
|
|
||||||
def delete_collection(self, collection_id: int) -> None:
|
def delete_collection(self, collection_id: int) -> None:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
|
|
@ -974,17 +1091,38 @@ class Store:
|
||||||
# ── recipe browser ────────────────────────────────────────────────────
|
# ── recipe browser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_browser_categories(
|
def get_browser_categories(
|
||||||
self, domain: str, keywords_by_category: dict[str, list[str]]
|
self,
|
||||||
|
domain: str,
|
||||||
|
keywords_by_category: dict[str, list[str]],
|
||||||
|
has_subcategories_by_category: dict[str, bool] | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return [{category, recipe_count}] for each category in the domain.
|
"""Return [{category, recipe_count, has_subcategories}] for each category.
|
||||||
|
|
||||||
keywords_by_category maps category name to the keyword list used to
|
keywords_by_category maps category name → keyword list for counting.
|
||||||
match against recipes.category and recipes.keywords.
|
has_subcategories_by_category maps category name → bool (optional;
|
||||||
|
defaults to False for all categories when omitted).
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for category, keywords in keywords_by_category.items():
|
for category, keywords in keywords_by_category.items():
|
||||||
count = self._count_recipes_for_keywords(keywords)
|
count = self._count_recipes_for_keywords(keywords)
|
||||||
results.append({"category": category, "recipe_count": count})
|
results.append({
|
||||||
|
"category": category,
|
||||||
|
"recipe_count": count,
|
||||||
|
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_browser_subcategories(
|
||||||
|
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return [{subcategory, recipe_count}] for each subcategory.
|
||||||
|
|
||||||
|
Mirrors get_browser_categories but for the second level.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for subcat, keywords in keywords_by_subcategory.items():
|
||||||
|
count = self._count_recipes_for_keywords(keywords)
|
||||||
|
results.append({"subcategory": subcat, "recipe_count": count})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -993,15 +1131,32 @@ class Store:
|
||||||
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
||||||
return " OR ".join(phrases)
|
return " OR ".join(phrases)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ingredient_fts_term(ingredient: str) -> str:
|
||||||
|
"""Build an FTS5 ingredient_names column prefix-filter.
|
||||||
|
|
||||||
|
Returns e.g. 'ingredient_names : "potato"*' which matches any recipe whose
|
||||||
|
ingredient_names column contains a token starting with that word. Prefix
|
||||||
|
matching (*) means "potato" also matches "potatoes", "sweet potato", etc.
|
||||||
|
Apostrophes are stripped because the FTS5 tokenizer drops them.
|
||||||
|
"""
|
||||||
|
cleaned = ingredient.replace("'", "").strip()
|
||||||
|
escaped = cleaned.replace('"', '""')
|
||||||
|
return f'ingredient_names : "{escaped}"*'
|
||||||
|
|
||||||
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
||||||
if not keywords:
|
if not keywords:
|
||||||
return 0
|
return 0
|
||||||
cache_key = (self._db_path, *sorted(keywords))
|
# Use corpus path as cache key so all cloud users share the same counts.
|
||||||
|
cache_key = (self._corpus_path, *sorted(keywords))
|
||||||
if cache_key in _COUNT_CACHE:
|
if cache_key in _COUNT_CACHE:
|
||||||
return _COUNT_CACHE[cache_key]
|
return _COUNT_CACHE[cache_key]
|
||||||
match_expr = self._browser_fts_query(keywords)
|
match_expr = self._browser_fts_query(keywords)
|
||||||
|
c = self._cp
|
||||||
|
# FTS5 pseudo-column in WHERE is always the bare (unqualified) table name,
|
||||||
|
# even when the table is accessed through an ATTACHed schema.
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(
|
||||||
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
f"SELECT count(*) FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
||||||
(match_expr,),
|
(match_expr,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
count = row[0] if row else 0
|
count = row[0] if row else 0
|
||||||
|
|
@ -1010,61 +1165,341 @@ class Store:
|
||||||
|
|
||||||
def browse_recipes(
|
def browse_recipes(
|
||||||
self,
|
self,
|
||||||
keywords: list[str],
|
keywords: list[str] | None,
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
pantry_items: list[str] | None = None,
|
pantry_items: list[str] | None = None,
|
||||||
|
q: str | None = None,
|
||||||
|
sort: str = "default",
|
||||||
|
sensory_exclude: SensoryExclude | None = None,
|
||||||
|
required_ingredient: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a page of recipes matching the keyword set.
|
"""Return a page of recipes matching the keyword set.
|
||||||
|
|
||||||
|
Pass keywords=None to browse all recipes without category filtering.
|
||||||
Each recipe row includes match_pct (float | None) when pantry_items
|
Each recipe row includes match_pct (float | None) when pantry_items
|
||||||
is provided. match_pct is the fraction of ingredient_names covered by
|
is provided. match_pct is the fraction of ingredient_names covered by
|
||||||
the pantry set — computed deterministically, no LLM needed.
|
the pantry set — computed deterministically, no LLM needed.
|
||||||
|
|
||||||
|
q: optional title substring filter (case-insensitive LIKE).
|
||||||
|
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A)
|
||||||
|
| "match" (pantry coverage DESC — falls back to default when no pantry).
|
||||||
|
required_ingredient: when set, only return recipes whose ingredient_names contain
|
||||||
|
this substring (case-insensitive). "must include" filter.
|
||||||
"""
|
"""
|
||||||
if not keywords:
|
if keywords is not None and not keywords:
|
||||||
return {"recipes": [], "total": 0, "page": page}
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
||||||
match_expr = self._browser_fts_query(keywords)
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
c = self._cp
|
||||||
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||||
|
|
||||||
# Reuse cached count — avoids a second index scan on every page turn.
|
# "match" sort requires pantry items; fall back gracefully when absent.
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
effective_sort = sort if (sort != "match" or pantry_set) else "default"
|
||||||
|
|
||||||
rows = self._fetch_all(
|
order_clause = {
|
||||||
"""
|
"alpha": "ORDER BY title ASC",
|
||||||
SELECT id, title, category, keywords, ingredient_names,
|
"alpha_desc": "ORDER BY title DESC",
|
||||||
calories, fat_g, protein_g, sodium_mg
|
}.get(effective_sort, "ORDER BY id ASC")
|
||||||
FROM recipes
|
|
||||||
WHERE id IN (
|
q_param = f"%{q.strip()}%" if q and q.strip() else None
|
||||||
SELECT rowid FROM recipe_browser_fts
|
|
||||||
WHERE recipe_browser_fts MATCH ?
|
# ── required-ingredient FTS filter (must-include) ─────────────────────
|
||||||
)
|
# FTS5 column prefix-filter avoids the full table scan that LIKE '%X%' would do.
|
||||||
ORDER BY id ASC
|
req_fts_term = (
|
||||||
LIMIT ? OFFSET ?
|
self._ingredient_fts_term(required_ingredient) if required_ingredient else ""
|
||||||
""",
|
|
||||||
(match_expr, page_size, offset),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
# ── match sort: push match_pct computation into SQL so ORDER BY works ──
|
||||||
|
if effective_sort == "match" and pantry_set:
|
||||||
|
return self._browse_by_match(
|
||||||
|
keywords, page, page_size, offset, pantry_set, q_param, c,
|
||||||
|
sensory_exclude=sensory_exclude,
|
||||||
|
required_ingredient=required_ingredient,
|
||||||
|
)
|
||||||
|
|
||||||
|
cols = (
|
||||||
|
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||||
|
f" calories, fat_g, protein_g, sodium_mg, directions, sensory_tags FROM {c}recipes"
|
||||||
|
)
|
||||||
|
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
||||||
|
|
||||||
|
if keywords is None:
|
||||||
|
if req_fts_term:
|
||||||
|
# Ingredient filter: use FTS index — much faster than LIKE on full table
|
||||||
|
if q_param:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
||||||
|
(req_fts_term, q_param),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(req_fts_term, q_param, page_size, offset),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub}",
|
||||||
|
(req_fts_term,),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(req_fts_term, page_size, offset),
|
||||||
|
)
|
||||||
|
elif q_param:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
||||||
|
(q_param,),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(q_param, page_size, offset),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"{cols} {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(page_size, offset),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
keywords_expr = self._browser_fts_query(keywords)
|
||||||
|
# Combine keywords + ingredient into one FTS MATCH to use a single index pass
|
||||||
|
combined_match = (
|
||||||
|
f"({keywords_expr}) AND {req_fts_term}" if req_fts_term else keywords_expr
|
||||||
|
)
|
||||||
|
if q_param:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
||||||
|
(combined_match, q_param),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(combined_match, q_param, page_size, offset),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if required_ingredient:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub}",
|
||||||
|
(combined_match,),
|
||||||
|
).fetchone()[0]
|
||||||
|
else:
|
||||||
|
# Reuse cached count — avoids a second index scan on every page turn.
|
||||||
|
total = self._count_recipes_for_keywords(keywords)
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(combined_match, page_size, offset),
|
||||||
|
)
|
||||||
|
# Community tag fallback: if FTS found nothing, check whether
|
||||||
|
# community-tagged recipe IDs exist for this keyword context.
|
||||||
|
# browse_recipes doesn't know domain/category directly, so the
|
||||||
|
# fallback is triggered by the caller via community_ids= when needed.
|
||||||
|
# (See browse_recipes_with_community_fallback in the endpoint layer.)
|
||||||
|
|
||||||
recipes = []
|
recipes = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
# Apply sensory filter -- untagged recipes (empty {}) always pass
|
||||||
|
if sensory_exclude and not sensory_exclude.is_empty():
|
||||||
|
if not passes_sensory_filter(r.get("sensory_tags"), sensory_exclude):
|
||||||
|
continue
|
||||||
entry = {
|
entry = {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"title": r["title"],
|
"title": r["title"],
|
||||||
"category": r["category"],
|
"category": r["category"],
|
||||||
"match_pct": None,
|
"match_pct": None,
|
||||||
}
|
}
|
||||||
if pantry_set:
|
if pantry_set:
|
||||||
names = r.get("ingredient_names") or []
|
names = r.get("ingredient_names") or []
|
||||||
if names:
|
if names:
|
||||||
matched = sum(
|
matched = sum(1 for n in names if n.lower() in pantry_set)
|
||||||
1 for n in names if n.lower() in pantry_set
|
|
||||||
)
|
|
||||||
entry["match_pct"] = round(matched / len(names), 3)
|
entry["match_pct"] = round(matched / len(names), 3)
|
||||||
recipes.append(entry)
|
recipes.append(entry)
|
||||||
|
|
||||||
return {"recipes": recipes, "total": total, "page": page}
|
return {"recipes": recipes, "total": total, "page": page}
|
||||||
|
|
||||||
|
def fetch_recipes_by_ids(
|
||||||
|
self,
|
||||||
|
recipe_ids: list[int],
|
||||||
|
pantry_items: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch a specific set of corpus recipes by ID for community tag fallback.
|
||||||
|
|
||||||
|
Returns recipes in the same shape as browse_recipes rows, with match_pct
|
||||||
|
populated when pantry_items are provided.
|
||||||
|
"""
|
||||||
|
if not recipe_ids:
|
||||||
|
return []
|
||||||
|
c = self._cp
|
||||||
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||||
|
ph = ",".join("?" * len(recipe_ids))
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||||
|
f" calories, fat_g, protein_g, sodium_mg, directions"
|
||||||
|
f" FROM {c}recipes WHERE id IN ({ph}) ORDER BY id ASC",
|
||||||
|
tuple(recipe_ids),
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
entry: dict = {
|
||||||
|
"id": r["id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"category": r["category"],
|
||||||
|
"match_pct": None,
|
||||||
|
}
|
||||||
|
entry["directions"] = r.get("directions")
|
||||||
|
if pantry_set:
|
||||||
|
names = r.get("ingredient_names") or []
|
||||||
|
if names:
|
||||||
|
matched = sum(1 for n in names if n.lower() in pantry_set)
|
||||||
|
entry["match_pct"] = round(matched / len(names), 3)
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# How many FTS candidates to fetch before Python-scoring for match sort.
|
||||||
|
# Large enough to cover several pages with good diversity; small enough
|
||||||
|
# that json-parsing + dict-lookup stays sub-second even for big categories.
|
||||||
|
_MATCH_POOL_SIZE = 800
|
||||||
|
|
||||||
|
def _browse_by_match(
|
||||||
|
self,
|
||||||
|
keywords: list[str] | None,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
offset: int,
|
||||||
|
pantry_set: set[str],
|
||||||
|
q_param: str | None,
|
||||||
|
c: str,
|
||||||
|
sensory_exclude: SensoryExclude | None = None,
|
||||||
|
required_ingredient: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Browse recipes sorted by pantry match percentage.
|
||||||
|
|
||||||
|
Fetches up to _MATCH_POOL_SIZE FTS candidates, scores each against the
|
||||||
|
pantry set in Python (fast dict lookup on a bounded list), then sorts
|
||||||
|
and paginates in-memory. This avoids correlated json_each() subqueries
|
||||||
|
that are prohibitively slow over 50k+ row result sets.
|
||||||
|
|
||||||
|
The reported total is the full FTS count (from cache), not pool size.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
pantry_lower = {p.lower() for p in pantry_set}
|
||||||
|
|
||||||
|
# ── required-ingredient FTS filter (must-include) ─────────────────────
|
||||||
|
req_fts_term = (
|
||||||
|
self._ingredient_fts_term(required_ingredient) if required_ingredient else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fetch candidate pool from FTS ────────────────────────────────────
|
||||||
|
base_cols = (
|
||||||
|
f"SELECT r.id, r.title, r.category, r.ingredient_names, r.directions, r.sensory_tags"
|
||||||
|
f" FROM {c}recipes r"
|
||||||
|
)
|
||||||
|
fts_sub = (
|
||||||
|
f"r.id IN (SELECT rowid FROM {c}recipe_browser_fts"
|
||||||
|
f" WHERE recipe_browser_fts MATCH ?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if keywords is None:
|
||||||
|
if req_fts_term:
|
||||||
|
if q_param:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE id IN"
|
||||||
|
f" (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
||||||
|
f" AND LOWER(title) LIKE LOWER(?)",
|
||||||
|
(req_fts_term, q_param),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"{base_cols} WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)"
|
||||||
|
f" ORDER BY r.id ASC LIMIT ?",
|
||||||
|
(req_fts_term, q_param, self._MATCH_POOL_SIZE),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE id IN"
|
||||||
|
f" (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)",
|
||||||
|
(req_fts_term,),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"{base_cols} WHERE {fts_sub} ORDER BY r.id ASC LIMIT ?",
|
||||||
|
(req_fts_term, self._MATCH_POOL_SIZE),
|
||||||
|
).fetchall()
|
||||||
|
elif q_param:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
||||||
|
(q_param,),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"{base_cols} WHERE LOWER(r.title) LIKE LOWER(?)"
|
||||||
|
f" ORDER BY r.id ASC LIMIT ?",
|
||||||
|
(q_param, self._MATCH_POOL_SIZE),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes"
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"{base_cols} ORDER BY r.id ASC LIMIT ?",
|
||||||
|
(self._MATCH_POOL_SIZE,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
keywords_expr = self._browser_fts_query(keywords)
|
||||||
|
combined_match = (
|
||||||
|
f"({keywords_expr}) AND {req_fts_term}" if req_fts_term else keywords_expr
|
||||||
|
)
|
||||||
|
if q_param:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes r"
|
||||||
|
f" WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)",
|
||||||
|
(combined_match, q_param),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"{base_cols} WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)"
|
||||||
|
f" ORDER BY r.id ASC LIMIT ?",
|
||||||
|
(combined_match, q_param, self._MATCH_POOL_SIZE),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
if required_ingredient:
|
||||||
|
total = self.conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {c}recipes r WHERE {fts_sub}",
|
||||||
|
(combined_match,),
|
||||||
|
).fetchone()[0]
|
||||||
|
else:
|
||||||
|
total = self._count_recipes_for_keywords(keywords)
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"{base_cols} WHERE {fts_sub} ORDER BY r.id ASC LIMIT ?",
|
||||||
|
(combined_match, self._MATCH_POOL_SIZE),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# ── Score in Python, sort, paginate ──────────────────────────────────
|
||||||
|
scored = []
|
||||||
|
for r in rows:
|
||||||
|
row = dict(r)
|
||||||
|
# Sensory filter applied before scoring to keep hot path clean
|
||||||
|
if sensory_exclude and not sensory_exclude.is_empty():
|
||||||
|
if not passes_sensory_filter(row.get("sensory_tags"), sensory_exclude):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
names = _json.loads(row["ingredient_names"] or "[]")
|
||||||
|
except Exception:
|
||||||
|
names = []
|
||||||
|
if names:
|
||||||
|
matched = sum(1 for n in names if n.lower() in pantry_lower)
|
||||||
|
match_pct = round(matched / len(names), 3)
|
||||||
|
else:
|
||||||
|
match_pct = None
|
||||||
|
scored.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"category": row["category"],
|
||||||
|
"match_pct": match_pct,
|
||||||
|
"directions": row.get("directions"),
|
||||||
|
})
|
||||||
|
|
||||||
|
scored.sort(key=lambda r: (-(r["match_pct"] or 0), r["id"]))
|
||||||
|
page_slice = scored[offset: offset + page_size]
|
||||||
|
return {"recipes": page_slice, "total": total, "page": page}
|
||||||
|
|
||||||
def log_browser_telemetry(
|
def log_browser_telemetry(
|
||||||
self,
|
self,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
|
@ -1092,6 +1527,12 @@ class Store:
|
||||||
def get_meal_plan(self, plan_id: int) -> dict | None:
|
def get_meal_plan(self, plan_id: int) -> dict | None:
|
||||||
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
|
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
|
||||||
|
|
||||||
|
def update_meal_plan_types(self, plan_id: int, meal_types: list[str]) -> dict | None:
|
||||||
|
return self._fetch_one(
|
||||||
|
"UPDATE meal_plans SET meal_types = ? WHERE id = ? RETURNING *",
|
||||||
|
(json.dumps(meal_types), plan_id),
|
||||||
|
)
|
||||||
|
|
||||||
def list_meal_plans(self) -> list[dict]:
|
def list_meal_plans(self) -> list[dict]:
|
||||||
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
||||||
|
|
||||||
|
|
@ -1121,10 +1562,11 @@ class Store:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
||||||
|
c = self._cp
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
"""SELECT s.*, r.name AS recipe_title
|
f"""SELECT s.*, r.title AS recipe_title
|
||||||
FROM meal_plan_slots s
|
FROM meal_plan_slots s
|
||||||
LEFT JOIN recipes r ON r.id = s.recipe_id
|
LEFT JOIN {c}recipes r ON r.id = s.recipe_id
|
||||||
WHERE s.plan_id = ?
|
WHERE s.plan_id = ?
|
||||||
ORDER BY s.day_of_week, s.meal_type""",
|
ORDER BY s.day_of_week, s.meal_type""",
|
||||||
(plan_id,),
|
(plan_id,),
|
||||||
|
|
@ -1132,10 +1574,11 @@ class Store:
|
||||||
|
|
||||||
def get_plan_recipes(self, plan_id: int) -> list[dict]:
|
def get_plan_recipes(self, plan_id: int) -> list[dict]:
|
||||||
"""Return full recipe rows for all recipes assigned to a plan."""
|
"""Return full recipe rows for all recipes assigned to a plan."""
|
||||||
|
c = self._cp
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
"""SELECT DISTINCT r.*
|
f"""SELECT DISTINCT r.*
|
||||||
FROM meal_plan_slots s
|
FROM meal_plan_slots s
|
||||||
JOIN recipes r ON r.id = s.recipe_id
|
JOIN {c}recipes r ON r.id = s.recipe_id
|
||||||
WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""",
|
WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""",
|
||||||
(plan_id,),
|
(plan_id,),
|
||||||
)
|
)
|
||||||
|
|
@ -1223,3 +1666,192 @@ class Store:
|
||||||
(pseudonym, directus_user_id),
|
(pseudonym, directus_user_id),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── Shopping list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_shopping_item(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
quantity: float | None = None,
|
||||||
|
unit: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
source: str = "manual",
|
||||||
|
recipe_id: int | None = None,
|
||||||
|
sort_order: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
return self._insert_returning(
|
||||||
|
"""INSERT INTO shopping_list_items
|
||||||
|
(name, quantity, unit, category, notes, source, recipe_id, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""",
|
||||||
|
(name, quantity, unit, category, notes, source, recipe_id, sort_order),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_shopping_items(self, include_checked: bool = True) -> list[dict]:
|
||||||
|
where = "" if include_checked else "WHERE checked = 0"
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"SELECT * FROM shopping_list_items {where} ORDER BY checked, sort_order, id",
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def get_shopping_item(self, item_id: int) -> dict | None:
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM shopping_list_items WHERE id = ?", (item_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
def update_shopping_item(self, item_id: int, **kwargs) -> dict | None:
|
||||||
|
allowed = {"name", "quantity", "unit", "category", "checked", "notes", "sort_order"}
|
||||||
|
fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||||
|
if not fields:
|
||||||
|
return self.get_shopping_item(item_id)
|
||||||
|
if "checked" in fields:
|
||||||
|
fields["checked"] = 1 if fields["checked"] else 0
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
||||||
|
values = list(fields.values()) + [item_id]
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE shopping_list_items SET {set_clause}, updated_at = datetime('now') WHERE id = ?",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return self.get_shopping_item(item_id)
|
||||||
|
|
||||||
|
def delete_shopping_item(self, item_id: int) -> bool:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"DELETE FROM shopping_list_items WHERE id = ?", (item_id,)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def clear_checked_shopping_items(self) -> int:
|
||||||
|
cur = self.conn.execute("DELETE FROM shopping_list_items WHERE checked = 1")
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
def clear_all_shopping_items(self) -> int:
|
||||||
|
cur = self.conn.execute("DELETE FROM shopping_list_items")
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
# ── Captured products (visual label cache) ────────────────────────────────
|
||||||
|
|
||||||
|
def get_captured_product(self, barcode: str) -> dict | None:
|
||||||
|
"""Look up a locally-captured product by barcode.
|
||||||
|
|
||||||
|
Returns the row dict (ingredient_names and allergens already decoded as
|
||||||
|
lists) or None if the barcode has not been captured yet.
|
||||||
|
"""
|
||||||
|
return self._fetch_one(
|
||||||
|
"SELECT * FROM captured_products WHERE barcode = ?", (barcode,)
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_captured_product(
|
||||||
|
self,
|
||||||
|
barcode: str,
|
||||||
|
*,
|
||||||
|
product_name: str | None = None,
|
||||||
|
brand: str | None = None,
|
||||||
|
serving_size_g: float | None = None,
|
||||||
|
calories: float | None = None,
|
||||||
|
fat_g: float | None = None,
|
||||||
|
saturated_fat_g: float | None = None,
|
||||||
|
carbs_g: float | None = None,
|
||||||
|
sugar_g: float | None = None,
|
||||||
|
fiber_g: float | None = None,
|
||||||
|
protein_g: float | None = None,
|
||||||
|
sodium_mg: float | None = None,
|
||||||
|
ingredient_names: list[str] | None = None,
|
||||||
|
allergens: list[str] | None = None,
|
||||||
|
confidence: float | None = None,
|
||||||
|
confirmed_by_user: bool = True,
|
||||||
|
source: str = "visual_capture",
|
||||||
|
) -> dict:
|
||||||
|
"""Insert or replace a captured product row, returning the saved dict."""
|
||||||
|
return self._insert_returning(
|
||||||
|
"""INSERT INTO captured_products
|
||||||
|
(barcode, product_name, brand, serving_size_g, calories,
|
||||||
|
fat_g, saturated_fat_g, carbs_g, sugar_g, fiber_g,
|
||||||
|
protein_g, sodium_mg, ingredient_names, allergens,
|
||||||
|
confidence, confirmed_by_user, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(barcode) DO UPDATE SET
|
||||||
|
product_name = excluded.product_name,
|
||||||
|
brand = excluded.brand,
|
||||||
|
serving_size_g = excluded.serving_size_g,
|
||||||
|
calories = excluded.calories,
|
||||||
|
fat_g = excluded.fat_g,
|
||||||
|
saturated_fat_g = excluded.saturated_fat_g,
|
||||||
|
carbs_g = excluded.carbs_g,
|
||||||
|
sugar_g = excluded.sugar_g,
|
||||||
|
fiber_g = excluded.fiber_g,
|
||||||
|
protein_g = excluded.protein_g,
|
||||||
|
sodium_mg = excluded.sodium_mg,
|
||||||
|
ingredient_names = excluded.ingredient_names,
|
||||||
|
allergens = excluded.allergens,
|
||||||
|
confidence = excluded.confidence,
|
||||||
|
confirmed_by_user = excluded.confirmed_by_user,
|
||||||
|
source = excluded.source,
|
||||||
|
captured_at = datetime('now')
|
||||||
|
RETURNING *""",
|
||||||
|
(
|
||||||
|
barcode, product_name, brand, serving_size_g, calories,
|
||||||
|
fat_g, saturated_fat_g, carbs_g, sugar_g, fiber_g,
|
||||||
|
protein_g, sodium_mg,
|
||||||
|
self._dump(ingredient_names or []),
|
||||||
|
self._dump(allergens or []),
|
||||||
|
confidence, 1 if confirmed_by_user else 0, source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── User Recipes (kiwi#9) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_user_recipe(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
ingredients: list[dict],
|
||||||
|
steps: list[str],
|
||||||
|
subtitle: str | None = None,
|
||||||
|
servings: str | None = None,
|
||||||
|
cook_time: str | None = None,
|
||||||
|
source_note: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
source: str = "manual",
|
||||||
|
pantry_match_pct: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self._insert_returning(
|
||||||
|
"""INSERT INTO user_recipes
|
||||||
|
(title, subtitle, servings, cook_time, source_note,
|
||||||
|
ingredients, steps, notes, tags, source, pantry_match_pct)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING *""",
|
||||||
|
(
|
||||||
|
title, subtitle, servings, cook_time, source_note,
|
||||||
|
self._dump(ingredients),
|
||||||
|
self._dump(steps),
|
||||||
|
notes,
|
||||||
|
self._dump(tags or []),
|
||||||
|
source,
|
||||||
|
pantry_match_pct,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_recipe(self, recipe_id: int) -> dict[str, Any] | None:
|
||||||
|
return self._fetch_one(
|
||||||
|
"SELECT * FROM user_recipes WHERE id = ?",
|
||||||
|
(recipe_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_user_recipes(self) -> list[dict[str, Any]]:
|
||||||
|
return self._fetch_all(
|
||||||
|
"SELECT * FROM user_recipes ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_user_recipe(self, recipe_id: int) -> bool:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"DELETE FROM user_recipes WHERE id = ?", (recipe_id,)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
|
||||||
64
app/main.py
64
app/main.py
|
|
@ -1,7 +1,9 @@
|
||||||
#!/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,13 +13,41 @@ 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
|
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()
|
register_kiwi_programs()
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
|
|
@ -29,6 +59,35 @@ async def lifespan(app: FastAPI):
|
||||||
from app.api.endpoints.community import init_community_store
|
from app.api.endpoints.community import init_community_store
|
||||||
init_community_store(settings.COMMUNITY_DB_URL)
|
init_community_store(settings.COMMUNITY_DB_URL)
|
||||||
|
|
||||||
|
# Initialize ActivityPub instance actor (no-op when AP_ENABLED=false)
|
||||||
|
if settings.AP_ENABLED and settings.AP_HOST:
|
||||||
|
try:
|
||||||
|
from app.services.ap.keys import init_actor
|
||||||
|
init_actor(host=settings.AP_HOST, key_path=settings.AP_KEY_PATH)
|
||||||
|
except Exception as _ap_exc:
|
||||||
|
logger.warning("AP init failed (AP features disabled): %s", _ap_exc)
|
||||||
|
|
||||||
|
# Browse counts cache — warm in-memory cache from disk, refresh if stale.
|
||||||
|
# Uses the corpus path the store will attach to at request time.
|
||||||
|
corpus_path = os.environ.get("RECIPE_DB_PATH", str(settings.DB_PATH))
|
||||||
|
try:
|
||||||
|
from app.db.store import _COUNT_CACHE
|
||||||
|
from app.services.recipe.browse_counts_cache import (
|
||||||
|
is_stale, load_into_memory, refresh,
|
||||||
|
)
|
||||||
|
if is_stale(settings.BROWSE_COUNTS_PATH):
|
||||||
|
logger.info("browse_counts: cache stale — refreshing in background...")
|
||||||
|
asyncio.create_task(
|
||||||
|
asyncio.to_thread(refresh, corpus_path, settings.BROWSE_COUNTS_PATH)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("browse_counts: startup init failed (live FTS fallback active): %s", exc)
|
||||||
|
|
||||||
|
# Nightly background refresh loop
|
||||||
|
asyncio.create_task(_browse_counts_refresh_loop(corpus_path))
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Graceful scheduler shutdown
|
# Graceful scheduler shutdown
|
||||||
|
|
@ -55,6 +114,11 @@ 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():
|
||||||
|
|
|
||||||
0
app/mcp/__init__.py
Normal file
0
app/mcp/__init__.py
Normal file
306
app/mcp/server.py
Normal file
306
app/mcp/server.py
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
"""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())
|
||||||
|
|
@ -89,9 +89,20 @@ 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):
|
||||||
|
|
@ -106,8 +117,15 @@ 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
|
||||||
|
|
@ -123,6 +141,8 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
59
app/models/schemas/label_capture.py
Normal file
59
app/models/schemas/label_capture.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""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
|
||||||
|
|
@ -22,6 +22,10 @@ class CreatePlanRequest(BaseModel):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePlanRequest(BaseModel):
|
||||||
|
meal_types: list[str]
|
||||||
|
|
||||||
|
|
||||||
class UpsertSlotRequest(BaseModel):
|
class UpsertSlotRequest(BaseModel):
|
||||||
recipe_id: int | None = None
|
recipe_id: int | None = None
|
||||||
servings: float = Field(2.0, gt=0)
|
servings: float = Field(2.0, gt=0)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,36 @@ from __future__ import annotations
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LeftoversResponse(BaseModel):
|
||||||
|
"""Cooked-leftover shelf-life estimate returned by POST /recipes/{id}/leftovers."""
|
||||||
|
fridge_days: int
|
||||||
|
freeze_days: int | None = None # None = not recommended
|
||||||
|
freeze_by_day: int | None = None # day number from cook date to freeze by
|
||||||
|
storage_advice: str
|
||||||
|
|
||||||
|
|
||||||
|
class StepAnalysis(BaseModel):
|
||||||
|
"""Active/passive classification for one direction step."""
|
||||||
|
is_passive: bool
|
||||||
|
detected_minutes: int | None = None
|
||||||
|
prep_min: int | None = None # estimated physical prep time (action detection)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEffortProfile(BaseModel):
|
||||||
|
"""Parsed time and effort profile for a recipe.
|
||||||
|
|
||||||
|
Mirrors app.services.recipe.time_effort.TimeEffortProfile (dataclass).
|
||||||
|
Serialised into RecipeSuggestion so the frontend can render the effort
|
||||||
|
summary without a second round-trip.
|
||||||
|
"""
|
||||||
|
active_min: int = 0
|
||||||
|
passive_min: int = 0
|
||||||
|
total_min: int = 0
|
||||||
|
effort_label: str = "moderate" # "quick" | "moderate" | "involved"
|
||||||
|
equipment: list[str] = Field(default_factory=list)
|
||||||
|
step_analyses: list[StepAnalysis] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SwapCandidate(BaseModel):
|
class SwapCandidate(BaseModel):
|
||||||
original_name: str
|
original_name: str
|
||||||
substitute_name: str
|
substitute_name: str
|
||||||
|
|
@ -41,6 +71,10 @@ class RecipeSuggestion(BaseModel):
|
||||||
is_wildcard: bool = False
|
is_wildcard: bool = False
|
||||||
nutrition: NutritionPanel | None = None
|
nutrition: NutritionPanel | None = None
|
||||||
source_url: str | 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):
|
class GroceryLink(BaseModel):
|
||||||
|
|
@ -59,6 +93,18 @@ class RecipeResult(BaseModel):
|
||||||
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeJobQueued(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str = "queued"
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeJobStatus(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str
|
||||||
|
result: RecipeResult | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NutritionFilters(BaseModel):
|
class NutritionFilters(BaseModel):
|
||||||
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
||||||
max_calories: float | None = None
|
max_calories: float | None = None
|
||||||
|
|
@ -69,6 +115,10 @@ class NutritionFilters(BaseModel):
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
class RecipeRequest(BaseModel):
|
||||||
pantry_items: list[str]
|
pantry_items: list[str]
|
||||||
|
# Maps product name → secondary state label for items past nominal expiry
|
||||||
|
# but still within their secondary use window (e.g. {"Bread": "stale"}).
|
||||||
|
# Used by the recipe engine to boost recipes suited to those specific states.
|
||||||
|
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
|
||||||
level: int = Field(default=1, ge=1, le=4)
|
level: int = Field(default=1, ge=1, le=4)
|
||||||
constraints: list[str] = Field(default_factory=list)
|
constraints: list[str] = Field(default_factory=list)
|
||||||
expiry_first: bool = False
|
expiry_first: bool = False
|
||||||
|
|
@ -82,7 +132,14 @@ class RecipeRequest(BaseModel):
|
||||||
allergies: list[str] = Field(default_factory=list)
|
allergies: list[str] = Field(default_factory=list)
|
||||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
excluded_ids: list[int] = Field(default_factory=list)
|
||||||
|
exclude_ingredients: list[str] = Field(default_factory=list)
|
||||||
shopping_mode: bool = False
|
shopping_mode: bool = False
|
||||||
|
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
||||||
|
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 ──────────────────────────────────────────────────
|
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
||||||
|
|
@ -128,3 +185,45 @@ class BuildRequest(BaseModel):
|
||||||
|
|
||||||
template_id: str
|
template_id: str
|
||||||
role_overrides: dict[str, str] = Field(default_factory=dict)
|
role_overrides: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamTokenRequest(BaseModel):
|
||||||
|
"""Request body for POST /recipes/stream-token.
|
||||||
|
|
||||||
|
Pantry items and dietary constraints are fetched from the DB at request
|
||||||
|
time — the client does not supply them here.
|
||||||
|
"""
|
||||||
|
level: int = Field(4, ge=3, le=4, description="Recipe level: 3=styled, 4=wildcard")
|
||||||
|
wildcard_confirmed: bool = Field(False, description="Required true for level 4")
|
||||||
|
|
||||||
|
|
||||||
|
class StreamTokenResponse(BaseModel):
|
||||||
|
"""Response from POST /recipes/stream-token.
|
||||||
|
|
||||||
|
The frontend opens EventSource at stream_url?token=<token> to receive
|
||||||
|
SSE chunks directly from the coordinator.
|
||||||
|
"""
|
||||||
|
stream_url: str
|
||||||
|
token: str
|
||||||
|
expires_in_s: int
|
||||||
|
|
||||||
|
|
||||||
|
class AskRequest(BaseModel):
|
||||||
|
"""Request body for POST /recipes/ask."""
|
||||||
|
question: str = Field(min_length=1, max_length=500)
|
||||||
|
pantry_items: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AskRecipeHit(BaseModel):
|
||||||
|
"""A single recipe result from the Ask endpoint."""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
match_pct: float | None = None
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AskResponse(BaseModel):
|
||||||
|
"""Response from POST /recipes/ask."""
|
||||||
|
answer: str | None = None # LLM-synthesized response (Paid tier only)
|
||||||
|
recipes: list[AskRecipeHit]
|
||||||
|
tier: str
|
||||||
|
|
|
||||||
74
app/models/schemas/recipe_scan.py
Normal file
74
app/models/schemas/recipe_scan.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""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
|
||||||
60
app/models/schemas/shopping.py
Normal file
60
app/models/schemas/shopping.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""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,6 +3,11 @@
|
||||||
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}")
|
||||||
|
|
|
||||||
0
app/services/ap/__init__.py
Normal file
0
app/services/ap/__init__.py
Normal file
115
app/services/ap/delivery.py
Normal file
115
app/services/ap/delivery.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# 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()
|
||||||
48
app/services/ap/keys.py
Normal file
48
app/services/ap/keys.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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
|
||||||
194
app/services/ap/mastodon.py
Normal file
194
app/services/ap/mastodon.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# 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
|
||||||
111
app/services/community/dedup.py
Normal file
111
app/services/community/dedup.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# 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, ""),
|
||||||
|
}
|
||||||
94
app/services/coordinator_proxy.py
Normal file
94
app/services/coordinator_proxy.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""cf-orch coordinator proxy client.
|
||||||
|
|
||||||
|
Calls the coordinator's /proxy/authorize endpoint to obtain a one-time
|
||||||
|
stream URL + token for LLM streaming. Always raises CoordinatorError on
|
||||||
|
failure — callers decide how to handle it (stream-token endpoint returns
|
||||||
|
503 or 403 as appropriate).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinatorError(Exception):
|
||||||
|
"""Raised when the coordinator returns an error or is unreachable."""
|
||||||
|
def __init__(self, message: str, status_code: int = 503):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StreamTokenResult:
|
||||||
|
stream_url: str
|
||||||
|
token: str
|
||||||
|
expires_in_s: int
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinator_url() -> str:
|
||||||
|
return os.environ.get("COORDINATOR_URL", "http://10.1.10.71:7700")
|
||||||
|
|
||||||
|
|
||||||
|
def _product_key() -> str:
|
||||||
|
return os.environ.get("COORDINATOR_KIWI_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def coordinator_authorize(
|
||||||
|
prompt: str,
|
||||||
|
caller: str = "kiwi-recipe",
|
||||||
|
ttl_s: int = 300,
|
||||||
|
) -> StreamTokenResult:
|
||||||
|
"""Call POST /proxy/authorize on the coordinator.
|
||||||
|
|
||||||
|
Returns a StreamTokenResult with the stream URL and one-time token.
|
||||||
|
Raises CoordinatorError on any failure (network, auth, capacity).
|
||||||
|
"""
|
||||||
|
url = f"{_coordinator_url()}/proxy/authorize"
|
||||||
|
key = _product_key()
|
||||||
|
if not key:
|
||||||
|
raise CoordinatorError(
|
||||||
|
"COORDINATOR_KIWI_KEY env var is not set — streaming unavailable",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"product": "kiwi",
|
||||||
|
"product_key": key,
|
||||||
|
"caller": caller,
|
||||||
|
"prompt": prompt,
|
||||||
|
"params": {},
|
||||||
|
"ttl_s": ttl_s,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
log.warning("coordinator_authorize network error: %s", exc)
|
||||||
|
raise CoordinatorError(f"Coordinator unreachable: {exc}", status_code=503)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise CoordinatorError("Invalid product key", status_code=401)
|
||||||
|
if resp.status_code == 429:
|
||||||
|
raise CoordinatorError("Too many concurrent streams", status_code=429)
|
||||||
|
if resp.status_code == 503:
|
||||||
|
raise CoordinatorError("No GPU available for streaming", status_code=503)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise CoordinatorError(
|
||||||
|
f"Coordinator error {resp.status_code}: {resp.text[:200]}",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# Use public_stream_url if coordinator provides it (cloud mode), else stream_url
|
||||||
|
stream_url = data.get("public_stream_url") or data["stream_url"]
|
||||||
|
return StreamTokenResult(
|
||||||
|
stream_url=stream_url,
|
||||||
|
token=data["token"],
|
||||||
|
expires_in_s=data["expires_in_s"],
|
||||||
|
)
|
||||||
|
|
@ -116,6 +116,270 @@ class ExpirationPredictor:
|
||||||
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Secondary shelf life in days after a package is opened.
|
||||||
|
# Sources: USDA FoodKeeper app, FDA consumer guides.
|
||||||
|
# Only categories where opening significantly shortens shelf life are listed.
|
||||||
|
# Items not listed default to None (no secondary window tracked).
|
||||||
|
SHELF_LIFE_AFTER_OPENING: dict[str, int] = {
|
||||||
|
# Dairy — once opened, clock ticks fast
|
||||||
|
'dairy': 5,
|
||||||
|
'milk': 5,
|
||||||
|
'cream': 3,
|
||||||
|
'yogurt': 7,
|
||||||
|
'cheese': 14,
|
||||||
|
'butter': 30,
|
||||||
|
# Condiments — refrigerated after opening
|
||||||
|
'condiments': 30,
|
||||||
|
'ketchup': 30,
|
||||||
|
'mustard': 30,
|
||||||
|
'mayo': 14,
|
||||||
|
'salad_dressing': 30,
|
||||||
|
'soy_sauce': 90,
|
||||||
|
# Canned goods — once opened, very short
|
||||||
|
'canned_goods': 4,
|
||||||
|
# Beverages
|
||||||
|
'juice': 7,
|
||||||
|
'soda': 4,
|
||||||
|
# Bread / Bakery
|
||||||
|
'bread': 5,
|
||||||
|
'bakery': 3,
|
||||||
|
# Produce
|
||||||
|
'leafy_greens': 3,
|
||||||
|
'berries': 3,
|
||||||
|
# Pantry staples (open bag)
|
||||||
|
'chips': 14,
|
||||||
|
'cookies': 14,
|
||||||
|
'cereal': 30,
|
||||||
|
'flour': 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post-expiry secondary use window.
|
||||||
|
# These are NOT spoilage extensions — they describe a qualitative state
|
||||||
|
# change where the ingredient is specifically suited for certain preparations.
|
||||||
|
# Sources: USDA FoodKeeper, food science, culinary tradition.
|
||||||
|
#
|
||||||
|
# Fields:
|
||||||
|
# window_days — days past nominal expiry still usable in secondary state
|
||||||
|
# label — short UI label for the state
|
||||||
|
# uses — recipe contexts suited to this state (shown in UI)
|
||||||
|
# warning — safety note, calm tone, None if none needed
|
||||||
|
# discard_signs — qualitative signs the item has gone past the secondary window
|
||||||
|
# constraints_exclude — dietary constraint labels that suppress this entry entirely
|
||||||
|
# (e.g. alcohol-containing items suppressed for halal/alcohol-free)
|
||||||
|
SECONDARY_WINDOW: dict[str, dict] = {
|
||||||
|
'bread': {
|
||||||
|
'window_days': 5,
|
||||||
|
'label': 'stale',
|
||||||
|
'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'],
|
||||||
|
'warning': 'Check for mold before use — discard if any is visible.',
|
||||||
|
'discard_signs': 'Visible mold (any colour), or unpleasant smell beyond dry/yeasty.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'bakery': {
|
||||||
|
'window_days': 3,
|
||||||
|
'label': 'day-old',
|
||||||
|
'uses': ['French toast', 'bread pudding', 'crumbles', 'trifle base', 'cake pops', 'streusel topping', 'bread crumbs'],
|
||||||
|
'warning': 'Check for mold before use — discard if any is visible.',
|
||||||
|
'discard_signs': 'Visible mold, sliminess, or strong sour smell.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'bananas': {
|
||||||
|
'window_days': 5,
|
||||||
|
'label': 'overripe',
|
||||||
|
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Leaking liquid, fermented smell, or mold on skin.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'milk': {
|
||||||
|
'window_days': 3,
|
||||||
|
'label': 'sour',
|
||||||
|
'uses': ['pancakes', 'scones', 'waffles', 'muffins', 'quick breads', 'béchamel', 'baked mac and cheese'],
|
||||||
|
'warning': 'Use only in cooked recipes — do not drink.',
|
||||||
|
'discard_signs': 'Chunky texture, strong unpleasant smell beyond tangy, or visible separation with grey colour.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'dairy': {
|
||||||
|
'window_days': 2,
|
||||||
|
'label': 'sour',
|
||||||
|
'uses': ['pancakes', 'scones', 'quick breads', 'muffins', 'waffles'],
|
||||||
|
'warning': 'Use only in cooked recipes — do not drink.',
|
||||||
|
'discard_signs': 'Strong unpleasant smell, unusual colour, or chunky texture.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'cheese': {
|
||||||
|
'window_days': 14,
|
||||||
|
'label': 'rind-ready',
|
||||||
|
'uses': ['parmesan broth', 'minestrone', 'ribollita', 'risotto', 'polenta', 'bean soups', 'gratins'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Soft or wet texture on hard cheese, pink or black mold (white/green surface mold on hard cheese can be cut off with 1cm margin).',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'rice': {
|
||||||
|
'window_days': 2,
|
||||||
|
'label': 'day-old',
|
||||||
|
'uses': ['fried rice', 'onigiri', 'rice porridge', 'congee', 'arancini', 'stuffed peppers', 'rice fritters'],
|
||||||
|
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
|
||||||
|
'discard_signs': 'Slimy texture, unusual smell, or more than 4 days since cooking.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'tortillas': {
|
||||||
|
'window_days': 5,
|
||||||
|
'label': 'stale',
|
||||||
|
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
|
||||||
|
'warning': 'Check for mold, especially if stored in a sealed bag — discard if any is visible.',
|
||||||
|
'discard_signs': 'Visible mold (check seams and edges), or strong sour smell.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
# ── New entries ──────────────────────────────────────────────────────
|
||||||
|
'apples': {
|
||||||
|
'window_days': 7,
|
||||||
|
'label': 'soft',
|
||||||
|
'uses': ['applesauce', 'apple butter', 'baked apples', 'apple crisp', 'smoothies', 'chutney'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Large bruised areas with fermented smell, visible mold, or liquid leaking from skin.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'leafy_greens': {
|
||||||
|
'window_days': 2,
|
||||||
|
'label': 'wilting',
|
||||||
|
'uses': ['sautéed greens', 'soups', 'smoothies', 'frittata', 'pasta add-in', 'stir fry'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Slimy texture, strong unpleasant smell, or yellowed and mushy leaves.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'tomatoes': {
|
||||||
|
'window_days': 4,
|
||||||
|
'label': 'soft',
|
||||||
|
'uses': ['roasted tomatoes', 'tomato sauce', 'shakshuka', 'bruschetta', 'soup', 'salsa'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Broken skin with liquid pooling, mold, or fermented smell.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'cooked_pasta': {
|
||||||
|
'window_days': 3,
|
||||||
|
'label': 'day-old',
|
||||||
|
'uses': ['pasta frittata', 'pasta salad', 'baked pasta', 'soup add-in', 'fried pasta cakes'],
|
||||||
|
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||||
|
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'cooked_potatoes': {
|
||||||
|
'window_days': 3,
|
||||||
|
'label': 'day-old',
|
||||||
|
'uses': ['potato pancakes', 'hash browns', 'potato soup', 'gnocchi', 'twice-baked potatoes', 'croquettes'],
|
||||||
|
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||||
|
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'yogurt': {
|
||||||
|
'window_days': 7,
|
||||||
|
'label': 'tangy',
|
||||||
|
'uses': ['marinades', 'flatbreads', 'smoothies', 'tzatziki', 'baked goods', 'salad dressings'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Pink or orange discolouration, visible mold, or strongly unpleasant smell (not just tangy).',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'cream': {
|
||||||
|
'window_days': 2,
|
||||||
|
'label': 'sour',
|
||||||
|
'uses': ['soups', 'sauces', 'scones', 'quick breads', 'mashed potatoes'],
|
||||||
|
'warning': 'Use in cooked recipes only. Discard if the smell is strongly unpleasant rather than tangy.',
|
||||||
|
'discard_signs': 'Strong unpleasant smell beyond tangy, unusual colour, or chunky texture.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'wine': {
|
||||||
|
'window_days': 4,
|
||||||
|
'label': 'open',
|
||||||
|
'uses': ['pan sauces', 'braises', 'risotto', 'marinades', 'poaching liquid', 'wine reduction'],
|
||||||
|
'warning': None,
|
||||||
|
'discard_signs': 'Strong vinegar smell (still usable in braises/marinades), or visible cloudiness with off-smell.',
|
||||||
|
'constraints_exclude': ['halal', 'alcohol-free'],
|
||||||
|
},
|
||||||
|
'cooked_beans': {
|
||||||
|
'window_days': 3,
|
||||||
|
'label': 'day-old',
|
||||||
|
'uses': ['refried beans', 'bean soup', 'bean fritters', 'hummus', 'bean dip', 'grain bowls'],
|
||||||
|
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||||
|
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
'cooked_meat': {
|
||||||
|
'window_days': 2,
|
||||||
|
'label': 'leftover',
|
||||||
|
'uses': ['grain bowls', 'tacos', 'soups', 'fried rice', 'sandwiches', 'hash', 'pasta add-in'],
|
||||||
|
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||||
|
'discard_signs': 'Off smell, slimy texture, or more than 3–4 days since cooking.',
|
||||||
|
'constraints_exclude': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def days_after_opening(self, category: str | None) -> int | None:
|
||||||
|
"""Return days of shelf life remaining once a package is opened.
|
||||||
|
|
||||||
|
Returns None if the category is unknown or not tracked after opening
|
||||||
|
(e.g. frozen items, raw meat — category check irrelevant once opened).
|
||||||
|
"""
|
||||||
|
if not category:
|
||||||
|
return None
|
||||||
|
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
|
||||||
|
|
||||||
|
def secondary_state(
|
||||||
|
self, category: str | None, expiry_date: str | None
|
||||||
|
) -> dict | None:
|
||||||
|
"""Return secondary use info if the item is in its post-expiry secondary window.
|
||||||
|
|
||||||
|
Returns a dict with label, uses, warning, discard_signs, constraints_exclude,
|
||||||
|
days_past, and window_days when the item is past its nominal expiry date but
|
||||||
|
still within the secondary use window.
|
||||||
|
Returns None in all other cases (unknown category, no window defined, not yet
|
||||||
|
expired, or past the secondary window).
|
||||||
|
|
||||||
|
Callers should apply constraints_exclude against user dietary constraints
|
||||||
|
and suppress the result entirely if any excluded constraint is active.
|
||||||
|
See filter_secondary_by_constraints().
|
||||||
|
"""
|
||||||
|
if not category or not expiry_date:
|
||||||
|
return None
|
||||||
|
entry = self.SECONDARY_WINDOW.get(category.lower())
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from datetime import date
|
||||||
|
today = date.today()
|
||||||
|
exp = date.fromisoformat(expiry_date)
|
||||||
|
days_past = (today - exp).days
|
||||||
|
if 0 <= days_past <= entry['window_days']:
|
||||||
|
return {
|
||||||
|
'label': entry['label'],
|
||||||
|
'uses': list(entry['uses']),
|
||||||
|
'warning': entry['warning'],
|
||||||
|
'discard_signs': entry.get('discard_signs'),
|
||||||
|
'constraints_exclude': list(entry.get('constraints_exclude') or []),
|
||||||
|
'days_past': days_past,
|
||||||
|
'window_days': entry['window_days'],
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_secondary_by_constraints(
|
||||||
|
sec: dict | None,
|
||||||
|
user_constraints: list[str],
|
||||||
|
) -> dict | None:
|
||||||
|
"""Suppress secondary state entirely if any excluded constraint is active.
|
||||||
|
|
||||||
|
Call after secondary_state() when user dietary constraints are available.
|
||||||
|
Returns sec unchanged when no constraints match, or None when suppressed.
|
||||||
|
"""
|
||||||
|
if sec is None:
|
||||||
|
return None
|
||||||
|
excluded = sec.get('constraints_exclude') or []
|
||||||
|
if any(c.lower() in [e.lower() for e in excluded] for c in user_constraints):
|
||||||
|
return None
|
||||||
|
return sec
|
||||||
|
|
||||||
# Keyword lists are checked in declaration order — most specific first.
|
# Keyword lists are checked in declaration order — most specific first.
|
||||||
# Rules:
|
# Rules:
|
||||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
||||||
|
|
|
||||||
140
app/services/label_capture.py
Normal file
140
app/services/label_capture.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Visual label capture service for unenriched products (kiwi#79).
|
||||||
|
|
||||||
|
Wraps the cf-core VisionRouter to extract structured nutrition data from a
|
||||||
|
photographed nutrition facts panel. When the VisionRouter is not yet wired
|
||||||
|
(NotImplementedError) the service falls back to a mock extraction so the
|
||||||
|
barcode scan flow can be exercised end-to-end in development.
|
||||||
|
|
||||||
|
JSON contract returned by the vision model (and mock):
|
||||||
|
{
|
||||||
|
"product_name": str | null,
|
||||||
|
"brand": str | null,
|
||||||
|
"serving_size_g": number | null,
|
||||||
|
"calories": number | null,
|
||||||
|
"fat_g": number | null,
|
||||||
|
"saturated_fat_g": number | null,
|
||||||
|
"carbs_g": number | null,
|
||||||
|
"sugar_g": number | null,
|
||||||
|
"fiber_g": number | null,
|
||||||
|
"protein_g": number | null,
|
||||||
|
"sodium_mg": number | null,
|
||||||
|
"ingredient_names": [str],
|
||||||
|
"allergens": [str],
|
||||||
|
"confidence": number (0.0–1.0)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Confidence below this threshold surfaces amber highlights in the UI.
|
||||||
|
REVIEW_THRESHOLD = 0.7
|
||||||
|
|
||||||
|
_MOCK_EXTRACTION: dict[str, Any] = {
|
||||||
|
"product_name": "Unknown Product",
|
||||||
|
"brand": None,
|
||||||
|
"serving_size_g": None,
|
||||||
|
"calories": None,
|
||||||
|
"fat_g": None,
|
||||||
|
"saturated_fat_g": None,
|
||||||
|
"carbs_g": None,
|
||||||
|
"sugar_g": None,
|
||||||
|
"fiber_g": None,
|
||||||
|
"protein_g": None,
|
||||||
|
"sodium_mg": None,
|
||||||
|
"ingredient_names": [],
|
||||||
|
"allergens": [],
|
||||||
|
"confidence": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_EXTRACTION_PROMPT = """You are reading a nutrition facts label photograph.
|
||||||
|
Extract the following fields as a JSON object with no extra text:
|
||||||
|
|
||||||
|
{
|
||||||
|
"product_name": <product name or null>,
|
||||||
|
"brand": <brand name or null>,
|
||||||
|
"serving_size_g": <serving size in grams as a number or null>,
|
||||||
|
"calories": <calories per serving as a number or null>,
|
||||||
|
"fat_g": <total fat grams or null>,
|
||||||
|
"saturated_fat_g": <saturated fat grams or null>,
|
||||||
|
"carbs_g": <total carbohydrates grams or null>,
|
||||||
|
"sugar_g": <sugars grams or null>,
|
||||||
|
"fiber_g": <dietary fiber grams or null>,
|
||||||
|
"protein_g": <protein grams or null>,
|
||||||
|
"sodium_mg": <sodium milligrams or null>,
|
||||||
|
"ingredient_names": [list of individual ingredients as strings],
|
||||||
|
"allergens": [list of allergens explicitly stated on label],
|
||||||
|
"confidence": <your confidence this extraction is correct, 0.0 to 1.0>
|
||||||
|
}
|
||||||
|
|
||||||
|
Use null for any field you cannot read clearly. Do not guess values.
|
||||||
|
Respond with JSON only."""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_label(image_bytes: bytes) -> dict[str, Any]:
|
||||||
|
"""Run vision model extraction on raw label image bytes.
|
||||||
|
|
||||||
|
Returns a dict matching the nutrition JSON contract above.
|
||||||
|
Falls back to a zero-confidence mock if the VisionRouter is not yet
|
||||||
|
implemented (stub) or if the model returns unparseable output.
|
||||||
|
"""
|
||||||
|
# Allow unit tests to bypass the vision model entirely.
|
||||||
|
if os.environ.get("KIWI_LABEL_CAPTURE_MOCK") == "1":
|
||||||
|
log.debug("label_capture: mock mode active")
|
||||||
|
return dict(_MOCK_EXTRACTION)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from circuitforge_core.vision import caption as vision_caption
|
||||||
|
result = vision_caption(image_bytes, prompt=_EXTRACTION_PROMPT)
|
||||||
|
raw = result.caption or ""
|
||||||
|
return _parse_extraction(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("label_capture: extraction failed (%s) — returning mock extraction", exc)
|
||||||
|
return dict(_MOCK_EXTRACTION)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_extraction(raw: str) -> dict[str, Any]:
|
||||||
|
"""Parse the JSON string returned by the vision model.
|
||||||
|
|
||||||
|
Strips markdown code fences if present. Validates required shape.
|
||||||
|
Returns the mock on any parse error.
|
||||||
|
"""
|
||||||
|
text = raw.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
# Strip ```json ... ``` fences
|
||||||
|
lines = text.splitlines()
|
||||||
|
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
log.warning("label_capture: could not parse vision response: %s", exc)
|
||||||
|
return dict(_MOCK_EXTRACTION)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
log.warning("label_capture: vision response is not a dict")
|
||||||
|
return dict(_MOCK_EXTRACTION)
|
||||||
|
|
||||||
|
# Normalise list fields — model may return None instead of []
|
||||||
|
for list_key in ("ingredient_names", "allergens"):
|
||||||
|
if not isinstance(data.get(list_key), list):
|
||||||
|
data[list_key] = []
|
||||||
|
|
||||||
|
# Clamp confidence to [0, 1]
|
||||||
|
confidence = data.get("confidence")
|
||||||
|
if not isinstance(confidence, (int, float)):
|
||||||
|
confidence = 0.0
|
||||||
|
data["confidence"] = max(0.0, min(1.0, float(confidence)))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def needs_review(extraction: dict[str, Any]) -> bool:
|
||||||
|
"""Return True when the extraction confidence is below REVIEW_THRESHOLD."""
|
||||||
|
return float(extraction.get("confidence", 0.0)) < REVIEW_THRESHOLD
|
||||||
233
app/services/leftovers_predictor.py
Normal file
233
app/services/leftovers_predictor.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
# app/services/leftovers_predictor.py
|
||||||
|
"""Cooked-leftovers shelf-life predictor.
|
||||||
|
|
||||||
|
Fast path: deterministic lookup anchored to FDA/USDA safe food handling.
|
||||||
|
Fallback: LLM for unclassifiable edge cases (same gate as expiry_llm_matching).
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- shortest-component-wins for proteins: a fish taco is bounded by the fish.
|
||||||
|
- category/keyword signals override ingredient signals for assembled dishes
|
||||||
|
(soup, stew, casserole) where the cooking method matters more than the
|
||||||
|
dominant protein.
|
||||||
|
- no urgency/panic framing — see feedback_kiwi_no_panic.md.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LeftoversResult:
|
||||||
|
fridge_days: int
|
||||||
|
freeze_days: int | None # None = "not recommended"
|
||||||
|
freeze_by_day: int | None # day number from cook date to freeze by; None = no need
|
||||||
|
storage_advice: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Protein priority table — shorter shelf life wins when multiple match.
|
||||||
|
# Values: (fridge_days, freeze_days). All fridge values are conservative.
|
||||||
|
# Sources: USDA FoodKeeper, FDA Safe Food Handling.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PROTEIN_SIGNALS: list[tuple[list[str], int, int | None]] = [
|
||||||
|
# (keyword_list, fridge_days, freeze_days)
|
||||||
|
(["fish", "salmon", "tuna", "cod", "tilapia", "halibut", "trout", "bass",
|
||||||
|
"mahi", "snapper", "flounder", "catfish", "swordfish", "sardine", "anchovy"],
|
||||||
|
2, 90),
|
||||||
|
(["shrimp", "prawn", "scallop", "crab", "lobster", "clam", "mussel",
|
||||||
|
"oyster", "squid", "octopus", "seafood"],
|
||||||
|
2, 90),
|
||||||
|
(["ground beef", "ground turkey", "ground pork", "ground chicken",
|
||||||
|
"ground meat", "hamburger", "mince"],
|
||||||
|
3, 90),
|
||||||
|
(["chicken", "turkey", "poultry", "duck", "hen"],
|
||||||
|
3, 90),
|
||||||
|
(["pork", "ham", "bacon", "sausage", "chorizo", "bratwurst", "kielbasa",
|
||||||
|
"salami", "pepperoni"],
|
||||||
|
4, 120),
|
||||||
|
(["beef", "steak", "brisket", "roast", "lamb", "veal", "venison"],
|
||||||
|
4, 180),
|
||||||
|
(["egg", "eggs", "frittata", "quiche", "omelette"],
|
||||||
|
3, None),
|
||||||
|
(["tofu", "tempeh", "seitan"],
|
||||||
|
4, 90),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dish-type signals — override protein signal when a structural match fires.
|
||||||
|
# Ordered from most-perishable to least.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DISH_SIGNALS: list[tuple[list[str], int, int | None, str]] = [
|
||||||
|
# (keywords, fridge_days, freeze_days, storage_advice_fragment)
|
||||||
|
|
||||||
|
# Ceviche: acid denatures proteins but does not kill pathogens.
|
||||||
|
# FDA/USDA classify it as raw seafood — 2-day fridge max, do not freeze.
|
||||||
|
(["ceviche", "tiradito", "leche de tigre"],
|
||||||
|
2, None,
|
||||||
|
"Acid marination is not the same as heat cooking — treat as raw seafood. "
|
||||||
|
"Best eaten the day it's made; 2 days maximum in the fridge."),
|
||||||
|
|
||||||
|
# Fermented / salt-cured dishes — preservation extends shelf life significantly.
|
||||||
|
# This matches dish names, not just presence of the ingredient (lardo in a pasta
|
||||||
|
# follows normal pasta rules, not this entry).
|
||||||
|
(["kimchi", "sauerkraut", "preserved lemon"],
|
||||||
|
14, None,
|
||||||
|
"Fermented and salt-preserved dishes keep well. Store submerged in their brine."),
|
||||||
|
|
||||||
|
(["confit", "gravlax", "gravad lax", "lardo"],
|
||||||
|
7, 60,
|
||||||
|
"Store covered in its fat or cure. Keep cold and away from strong-smelling foods."),
|
||||||
|
|
||||||
|
(["soup", "stew", "broth", "chowder", "bisque", "gumbo", "chili"],
|
||||||
|
4, 120,
|
||||||
|
"Soups and stews keep well in the fridge. Cool to room temperature before covering."),
|
||||||
|
(["curry"],
|
||||||
|
4, 90,
|
||||||
|
"Store curry in an airtight container. The flavours deepen overnight."),
|
||||||
|
(["casserole", "bake", "gratin", "lasagna", "lasagne", "moussaka",
|
||||||
|
"shepherd's pie", "pot pie"],
|
||||||
|
5, 90,
|
||||||
|
"Cover tightly. Reheat individual portions rather than the whole dish."),
|
||||||
|
(["pasta", "noodle", "spaghetti", "penne", "linguine", "fettuccine",
|
||||||
|
"macaroni", "risotto"],
|
||||||
|
4, 60,
|
||||||
|
"Store pasta and sauce separately if possible to prevent sogginess."),
|
||||||
|
(["rice", "fried rice", "pilaf", "biryani"],
|
||||||
|
3, 90,
|
||||||
|
"Cool rice quickly — spread on a tray if needed. Don't leave at room temperature for more than 1 hour."),
|
||||||
|
(["salad"],
|
||||||
|
2, None,
|
||||||
|
"Keep dressing separate. Once dressed, best eaten the same day."),
|
||||||
|
(["stir fry", "stir-fry"],
|
||||||
|
3, 60,
|
||||||
|
"Reheat in a hot pan or wok rather than a microwave to keep texture."),
|
||||||
|
(["sandwich", "wrap", "taco", "burrito"],
|
||||||
|
2, None,
|
||||||
|
"Assemble fresh when possible. Fillings keep better stored separately."),
|
||||||
|
(["pizza"],
|
||||||
|
4, 60,
|
||||||
|
"Reheat in a dry skillet for a crisp base rather than a microwave."),
|
||||||
|
(["muffin", "bread", "biscuit", "scone", "roll"],
|
||||||
|
3, 90,
|
||||||
|
"Wrap tightly or seal in a bag to prevent drying out."),
|
||||||
|
(["cake", "pie", "cookie", "brownie", "dessert", "pudding"],
|
||||||
|
5, 90,
|
||||||
|
"Store covered at room temperature or in the fridge depending on fillings."),
|
||||||
|
(["smoothie", "juice", "shake"],
|
||||||
|
1, 7,
|
||||||
|
"Best consumed fresh. Stir or shake well before drinking."),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Default when no signals match.
|
||||||
|
_DEFAULT_FRIDGE = 4
|
||||||
|
_DEFAULT_FREEZE = 90
|
||||||
|
_DEFAULT_ADVICE = "Store in an airtight container in the fridge. Reheat until piping hot before eating."
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_any(text: str, keywords: list[str]) -> bool:
|
||||||
|
for kw in keywords:
|
||||||
|
if re.search(rf"\b{re.escape(kw)}\b", text, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_ingredients(ingredients: list[str]) -> tuple[int, int | None] | None:
|
||||||
|
"""Return (fridge_days, freeze_days) for the most-perishable protein found."""
|
||||||
|
joined = " ".join(str(i) for i in ingredients).lower()
|
||||||
|
best: tuple[int, int | None] | None = None
|
||||||
|
for keywords, fridge, freeze in _PROTEIN_SIGNALS:
|
||||||
|
if _contains_any(joined, keywords):
|
||||||
|
if best is None or fridge < best[0]:
|
||||||
|
best = (fridge, freeze)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_dish_type(text: str) -> tuple[int, int | None, str] | None:
|
||||||
|
"""Return (fridge_days, freeze_days, advice) for the first matching dish type."""
|
||||||
|
for keywords, fridge, freeze, advice in _DISH_SIGNALS:
|
||||||
|
if _contains_any(text, keywords):
|
||||||
|
return fridge, freeze, advice
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def predict_leftovers(
|
||||||
|
title: str,
|
||||||
|
ingredients: list[str],
|
||||||
|
category: str | None = None,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
) -> LeftoversResult:
|
||||||
|
"""Predict cooked-leftover shelf life deterministically.
|
||||||
|
|
||||||
|
Falls back gracefully — always returns a result even for unknown recipes.
|
||||||
|
"""
|
||||||
|
# Build a combined text blob for dish-type scanning.
|
||||||
|
search_text = " ".join(filter(None, [
|
||||||
|
title,
|
||||||
|
category or "",
|
||||||
|
" ".join(keywords or []),
|
||||||
|
]))
|
||||||
|
|
||||||
|
# Dish-type match takes structural priority over raw ingredient protein signal.
|
||||||
|
dish = _scan_dish_type(search_text)
|
||||||
|
protein = _scan_ingredients(ingredients)
|
||||||
|
|
||||||
|
if dish:
|
||||||
|
fridge_days, freeze_days, base_advice = dish
|
||||||
|
# Still apply shortest-protein-wins if protein is more perishable than dish default.
|
||||||
|
if protein and protein[0] < fridge_days:
|
||||||
|
fridge_days = protein[0]
|
||||||
|
if protein[1] is not None and (freeze_days is None or protein[1] < freeze_days):
|
||||||
|
freeze_days = protein[1]
|
||||||
|
advice = base_advice
|
||||||
|
elif protein:
|
||||||
|
fridge_days, freeze_days = protein
|
||||||
|
advice = _DEFAULT_ADVICE
|
||||||
|
else:
|
||||||
|
fridge_days = _DEFAULT_FRIDGE
|
||||||
|
freeze_days = _DEFAULT_FREEZE
|
||||||
|
advice = _DEFAULT_ADVICE
|
||||||
|
|
||||||
|
# freeze_by_day: recommend freezing on day 2 if fridge window is tight (≤3 days).
|
||||||
|
freeze_by_day: int | None = None
|
||||||
|
if freeze_days is not None and fridge_days <= 3:
|
||||||
|
freeze_by_day = 2
|
||||||
|
|
||||||
|
return LeftoversResult(
|
||||||
|
fridge_days=fridge_days,
|
||||||
|
freeze_days=freeze_days,
|
||||||
|
freeze_by_day=freeze_by_day,
|
||||||
|
storage_advice=advice,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def predict_leftovers_from_row(recipe: dict[str, Any]) -> LeftoversResult:
|
||||||
|
"""Convenience wrapper that accepts a Store row dict directly."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
title = recipe.get("title") or ""
|
||||||
|
|
||||||
|
raw_ingredients = recipe.get("ingredient_names") or []
|
||||||
|
if isinstance(raw_ingredients, str):
|
||||||
|
try:
|
||||||
|
raw_ingredients = _json.loads(raw_ingredients)
|
||||||
|
except Exception:
|
||||||
|
raw_ingredients = [raw_ingredients]
|
||||||
|
|
||||||
|
raw_keywords = recipe.get("keywords") or []
|
||||||
|
if isinstance(raw_keywords, str):
|
||||||
|
try:
|
||||||
|
raw_keywords = _json.loads(raw_keywords)
|
||||||
|
except Exception:
|
||||||
|
raw_keywords = [raw_keywords]
|
||||||
|
|
||||||
|
return predict_leftovers(
|
||||||
|
title=title,
|
||||||
|
ingredients=[str(i) for i in raw_ingredients],
|
||||||
|
category=recipe.get("category"),
|
||||||
|
keywords=[str(k) for k in raw_keywords],
|
||||||
|
)
|
||||||
97
app/services/magpie_hook.py
Normal file
97
app/services/magpie_hook.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Magpie data-flywheel hook.
|
||||||
|
|
||||||
|
Fires anonymized recipe-signal events to the Magpie ingest endpoint when a
|
||||||
|
user saves or rates a recipe. This is the Kiwi side of the flywheel — Magpie
|
||||||
|
does not have a receiver endpoint yet, so the hook stubs out gracefully: if
|
||||||
|
``MAGPIE_INGEST_URL`` is unset, or the request fails for any reason, it logs
|
||||||
|
at DEBUG level and returns without raising.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_INGEST_PATH = "/api/v1/ingest/recipe-signal"
|
||||||
|
|
||||||
|
|
||||||
|
async def fire_recipe_signal(
|
||||||
|
db_path: Path,
|
||||||
|
recipe_id: int,
|
||||||
|
rating: int | None,
|
||||||
|
style_tags: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Post an anonymized recipe signal to Magpie if the user has opted in.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the user's SQLite database.
|
||||||
|
recipe_id: Internal Kiwi recipe ID being rated/saved.
|
||||||
|
rating: Star rating (0–5) or None if not yet rated.
|
||||||
|
style_tags: Style tags applied to the saved recipe.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
if not settings.MAGPIE_INGEST_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check per-user opt-in via a short-lived Store (own connection, own thread
|
||||||
|
# context is fine — this runs in the async event loop as a background task
|
||||||
|
# so we open and close the connection immediately).
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
opt_in = store.get_setting("magpie_opt_in")
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("magpie_hook: could not read magpie_opt_in setting: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if opt_in != "true":
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch the recipe to get its external_id (source URL slug / corpus key).
|
||||||
|
try:
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
recipe = store.get_recipe(recipe_id)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("magpie_hook: could not fetch recipe %d: %s", recipe_id, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if recipe is None:
|
||||||
|
logger.debug("magpie_hook: recipe %d not found, skipping", recipe_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
external_id: str | None = recipe.get("external_id") if isinstance(recipe, dict) else getattr(recipe, "external_id", None)
|
||||||
|
if not external_id:
|
||||||
|
# Corpus recipe not yet enriched with a source identifier — skip quietly.
|
||||||
|
logger.debug("magpie_hook: recipe %d has no external_id, skipping", recipe_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"product": "kiwi",
|
||||||
|
"signal": "recipe_rating",
|
||||||
|
"external_id": external_id,
|
||||||
|
"rating": rating,
|
||||||
|
"style_tags": style_tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
url = settings.MAGPIE_INGEST_URL.rstrip("/") + _INGEST_PATH
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
logger.debug(
|
||||||
|
"magpie_hook: POST %s → %d", url, response.status_code
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Magpie may not have a receiver yet — log and swallow.
|
||||||
|
logger.debug("magpie_hook: ingest request failed (stub): %s", exc)
|
||||||
|
|
@ -2,17 +2,20 @@
|
||||||
# BSL 1.1 — LLM feature
|
# BSL 1.1 — LLM feature
|
||||||
"""Provide a router-compatible LLM client for meal plan generation tasks.
|
"""Provide a router-compatible LLM client for meal plan generation tasks.
|
||||||
|
|
||||||
Cloud (CF_ORCH_URL set):
|
Cloud (CF_ORCH_URL set), tier 1 — task-based routing (preferred):
|
||||||
Allocates a cf-text service via cf-orch (3B-7B GGUF, ~2GB VRAM).
|
Calls /api/inference/task with product=kiwi, task=meal_plan.
|
||||||
Returns an _OrchTextRouter that wraps the cf-text HTTP endpoint
|
The coordinator resolves the model from assignments.yaml.
|
||||||
with a .complete(system, user, **kwargs) interface.
|
|
||||||
|
Cloud (CF_ORCH_URL set), tier 2 — direct allocation (fallback):
|
||||||
|
Allocates cf-text directly via client.allocate(). Used when the task
|
||||||
|
is not yet registered in the coordinator (cf-orch#61 not deployed).
|
||||||
|
|
||||||
Local / self-hosted (no CF_ORCH_URL):
|
Local / self-hosted (no CF_ORCH_URL):
|
||||||
Returns an LLMRouter instance which tries ollama, vllm, or any
|
Returns an LLMRouter instance which tries ollama, vllm, or any
|
||||||
backend configured in ~/.config/circuitforge/llm.yaml.
|
backend configured in ~/.config/circuitforge/llm.yaml.
|
||||||
|
|
||||||
Both paths expose the same interface so llm_timing.py and llm_planner.py
|
All paths expose the same (router, ctx) interface so llm_planner.py
|
||||||
need no knowledge of the backend.
|
needs no knowledge of the backend.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -22,8 +25,7 @@ from contextlib import nullcontext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# cf-orch service name and VRAM budget for meal plan LLM tasks.
|
# cf-orch service name and TTL for direct-allocate fallback path.
|
||||||
# These are lighter than recipe_llm (4.0 GB) — cf-text handles them.
|
|
||||||
_SERVICE_TYPE = "cf-text"
|
_SERVICE_TYPE = "cf-text"
|
||||||
_TTL_S = 120.0
|
_TTL_S = 120.0
|
||||||
_CALLER = "kiwi-meal-plan"
|
_CALLER = "kiwi-meal-plan"
|
||||||
|
|
@ -62,35 +64,79 @@ class _OrchTextRouter:
|
||||||
return resp.choices[0].message.content or ""
|
return resp.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
|
# Imported at module level so tests can patch the names in this module's namespace.
|
||||||
|
# app.services.task_inference.task_allocate — patch target for task routing tests.
|
||||||
|
try:
|
||||||
|
from app.services.task_inference import TaskNotRegistered, task_allocate
|
||||||
|
_HAS_TASK_INFERENCE = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_TASK_INFERENCE = False
|
||||||
|
|
||||||
|
# circuitforge_orch.client.CFOrchClient — patch target for direct-allocate fallback tests.
|
||||||
|
try:
|
||||||
|
from circuitforge_orch.client import CFOrchClient
|
||||||
|
except ImportError:
|
||||||
|
CFOrchClient = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
# circuitforge_core.llm.router.LLMRouter — patch target for local-inference tests.
|
||||||
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
except (ImportError, FileNotFoundError):
|
||||||
|
LLMRouter = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
|
||||||
def get_meal_plan_router():
|
def get_meal_plan_router():
|
||||||
"""Return an LLM client for meal plan tasks.
|
"""Return an LLM client for meal plan tasks.
|
||||||
|
|
||||||
Tries cf-orch cf-text allocation first (cloud); falls back to LLMRouter
|
Returns (router, ctx) where ctx is a context manager the caller holds
|
||||||
(local ollama/vllm). Returns None if no backend is available.
|
open for the duration of the LLM call. Returns (None, nullcontext(None))
|
||||||
|
if no backend is available.
|
||||||
"""
|
"""
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
if cf_orch_url:
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.client import CFOrchClient
|
|
||||||
client = CFOrchClient(cf_orch_url)
|
|
||||||
ctx = client.allocate(
|
|
||||||
service=_SERVICE_TYPE,
|
|
||||||
ttl_s=_TTL_S,
|
|
||||||
caller=_CALLER,
|
|
||||||
)
|
|
||||||
alloc = ctx.__enter__()
|
|
||||||
if alloc is not None:
|
|
||||||
return _OrchTextRouter(alloc.url), ctx
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc)
|
|
||||||
|
|
||||||
# Local fallback: LLMRouter (ollama / vllm / openai-compat)
|
if cf_orch_url:
|
||||||
try:
|
# Tier 1: task-based routing — coordinator owns model selection.
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
if _HAS_TASK_INFERENCE:
|
||||||
return LLMRouter(), nullcontext(None)
|
try:
|
||||||
except FileNotFoundError:
|
ctx = task_allocate(
|
||||||
logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled")
|
"kiwi", "meal_plan",
|
||||||
return None, nullcontext(None)
|
service_hint=_SERVICE_TYPE,
|
||||||
except Exception as exc:
|
ttl_s=_TTL_S,
|
||||||
logger.debug("LLMRouter init failed: %s", exc)
|
)
|
||||||
return None, nullcontext(None)
|
alloc = ctx.__enter__()
|
||||||
|
return _OrchTextRouter(alloc.url), ctx
|
||||||
|
except TaskNotRegistered:
|
||||||
|
logger.debug(
|
||||||
|
"kiwi.meal_plan not in coordinator assignments — "
|
||||||
|
"falling back to direct cf-text allocation"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("task allocation failed, trying direct allocate: %s", exc)
|
||||||
|
|
||||||
|
# Tier 2: direct allocation — hardcoded service type.
|
||||||
|
if CFOrchClient is not None:
|
||||||
|
try:
|
||||||
|
client = CFOrchClient(cf_orch_url)
|
||||||
|
ctx = client.allocate(
|
||||||
|
service=_SERVICE_TYPE,
|
||||||
|
ttl_s=_TTL_S,
|
||||||
|
caller=_CALLER,
|
||||||
|
)
|
||||||
|
alloc = ctx.__enter__()
|
||||||
|
if alloc is not None:
|
||||||
|
return _OrchTextRouter(alloc.url), ctx
|
||||||
|
ctx.__exit__(None, None, None) # release allocation before falling through
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
|
|
||||||
|
# Tier 3: local inference — ollama / vllm / openai-compat.
|
||||||
|
if LLMRouter is not None:
|
||||||
|
try:
|
||||||
|
return LLMRouter(), nullcontext(None)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled")
|
||||||
|
return None, nullcontext(None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("LLMRouter init failed: %s", exc)
|
||||||
|
return None, nullcontext(None)
|
||||||
|
return None, nullcontext(None)
|
||||||
|
|
|
||||||
|
|
@ -18,43 +18,51 @@ class DocuvisionResult:
|
||||||
class DocuvisionClient:
|
class DocuvisionClient:
|
||||||
"""Thin client for the cf-docuvision service."""
|
"""Thin client for the cf-docuvision service."""
|
||||||
|
|
||||||
def __init__(self, base_url: str) -> None:
|
def __init__(self, base_url: str, timeout: float = 120.0) -> None:
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
def extract_text(self, image_path: str | Path) -> DocuvisionResult:
|
def extract_text(self, image_path: str | Path, hint: str = "text") -> DocuvisionResult:
|
||||||
"""Send an image to docuvision and return extracted text."""
|
"""Send an image to docuvision and return extracted text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file.
|
||||||
|
hint: Docuvision extraction hint — "text" for dense prose (recipes),
|
||||||
|
"table" for tabular data, "form" for form fields, "auto" for
|
||||||
|
automatic detection.
|
||||||
|
"""
|
||||||
image_bytes = Path(image_path).read_bytes()
|
image_bytes = Path(image_path).read_bytes()
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
with httpx.Client(timeout=self._timeout) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{self._base_url}/extract",
|
f"{self._base_url}/extract",
|
||||||
json={"image": b64},
|
json={"image_b64": b64, "hint": hint},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
return DocuvisionResult(
|
return DocuvisionResult(
|
||||||
text=data.get("text", ""),
|
text=data.get("raw_text", ""),
|
||||||
confidence=data.get("confidence"),
|
confidence=data.get("metadata", {}).get("confidence"),
|
||||||
raw=data,
|
raw=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def extract_text_async(self, image_path: str | Path) -> DocuvisionResult:
|
async def extract_text_async(self, image_path: str | Path, hint: str = "text") -> DocuvisionResult:
|
||||||
"""Async version."""
|
"""Async version."""
|
||||||
image_bytes = Path(image_path).read_bytes()
|
image_bytes = Path(image_path).read_bytes()
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{self._base_url}/extract",
|
f"{self._base_url}/extract",
|
||||||
json={"image": b64},
|
json={"image_b64": b64, "hint": hint},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
return DocuvisionResult(
|
return DocuvisionResult(
|
||||||
text=data.get("text", ""),
|
text=data.get("raw_text", ""),
|
||||||
confidence=data.get("confidence"),
|
confidence=data.get("metadata", {}).get("confidence"),
|
||||||
raw=data,
|
raw=data,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,29 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
if not cf_orch_url:
|
if not cf_orch_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Tier 1: task-based routing — coordinator owns model selection.
|
||||||
|
try:
|
||||||
|
from app.services.task_inference import task_allocate, TaskNotRegistered
|
||||||
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
try:
|
||||||
|
with task_allocate(
|
||||||
|
"kiwi", "ocr",
|
||||||
|
service_hint="cf-docuvision",
|
||||||
|
ttl_s=60.0,
|
||||||
|
) as alloc:
|
||||||
|
doc_client = DocuvisionClient(alloc.url)
|
||||||
|
result = doc_client.extract_text(image_path)
|
||||||
|
return result.text if result.text else None
|
||||||
|
except TaskNotRegistered:
|
||||||
|
logger.debug(
|
||||||
|
"kiwi.ocr not in coordinator assignments — "
|
||||||
|
"falling back to direct cf-docuvision allocation"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("task allocation path failed, trying direct allocate: %s", exc)
|
||||||
|
|
||||||
|
# Tier 2: direct allocation — hardcoded service type.
|
||||||
try:
|
try:
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
|
@ -49,7 +72,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
result = doc_client.extract_text(image_path)
|
result = doc_client.extract_text(image_path)
|
||||||
return result.text if result.text else None
|
return result.text if result.text else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("cf-docuvision fast-path failed, falling back: %s", exc)
|
logger.debug("cf-docuvision fast-path failed, falling back to local VLM: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,64 +15,73 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class OpenFoodFactsService:
|
class OpenFoodFactsService:
|
||||||
"""
|
"""
|
||||||
Service for interacting with the OpenFoodFacts API.
|
Service for interacting with the Open*Facts family of databases.
|
||||||
|
|
||||||
OpenFoodFacts is a free, open database of food products with
|
Primary: OpenFoodFacts (food products).
|
||||||
ingredients, allergens, and nutrition facts.
|
Fallback chain: Open Beauty Facts (personal care) → Open Products Facts (household).
|
||||||
|
All three databases share the same API path and JSON format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_URL = "https://world.openfoodfacts.org/api/v2"
|
BASE_URL = "https://world.openfoodfacts.org/api/v2"
|
||||||
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
|
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
|
||||||
|
|
||||||
|
# Fallback databases tried in order when OFFs returns no match.
|
||||||
|
# Same API format as OFFs — only the host differs.
|
||||||
|
_FALLBACK_DATABASES = [
|
||||||
|
"https://world.openbeautyfacts.org/api/v2",
|
||||||
|
"https://world.openproductsfacts.org/api/v2",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _lookup_in_database(
|
||||||
|
self, barcode: str, base_url: str, client: httpx.AsyncClient
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Try one Open*Facts database using an existing client. Returns parsed product dict or None."""
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{base_url}/product/{barcode}.json",
|
||||||
|
headers={"User-Agent": self.USER_AGENT},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") != 1:
|
||||||
|
return None
|
||||||
|
return self._parse_product_data(data, barcode)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Lookup failed for %s at %s: %s", barcode, base_url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
|
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Look up a product by barcode in the OpenFoodFacts database.
|
Look up a product by barcode, trying OFFs then fallback databases.
|
||||||
|
|
||||||
|
A single httpx.AsyncClient is created for the whole lookup chain so that
|
||||||
|
connection pooling and TLS session reuse apply across all database attempts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
barcode: UPC/EAN barcode (8-13 digits)
|
barcode: UPC/EAN barcode (8-13 digits)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with product information, or None if not found
|
Dictionary with product information, or None if not found in any database.
|
||||||
|
|
||||||
Example response:
|
|
||||||
{
|
|
||||||
"name": "Organic Milk",
|
|
||||||
"brand": "Horizon",
|
|
||||||
"categories": ["Dairy", "Milk"],
|
|
||||||
"image_url": "https://...",
|
|
||||||
"nutrition_data": {...},
|
|
||||||
"raw_data": {...} # Full API response
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
try:
|
async with httpx.AsyncClient() as client:
|
||||||
async with httpx.AsyncClient() as client:
|
result = await self._lookup_in_database(barcode, self.BASE_URL, client)
|
||||||
url = f"{self.BASE_URL}/product/{barcode}.json"
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
response = await client.get(
|
for db_url in self._FALLBACK_DATABASES:
|
||||||
url,
|
result = await self._lookup_in_database(barcode, db_url, client)
|
||||||
headers={"User-Agent": self.USER_AGENT},
|
if result:
|
||||||
timeout=10.0,
|
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
|
||||||
)
|
return result
|
||||||
|
|
||||||
if response.status_code == 404:
|
logger.info("Barcode %s not found in any Open*Facts database", barcode)
|
||||||
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
return None
|
||||||
return None
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data.get("status") != 1:
|
|
||||||
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self._parse_product_data(data, barcode)
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.error(f"HTTP error looking up barcode {barcode}: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error looking up barcode {barcode}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
|
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -114,6 +123,9 @@ class OpenFoodFactsService:
|
||||||
allergens = product.get("allergens_tags", [])
|
allergens = product.get("allergens_tags", [])
|
||||||
labels = product.get("labels_tags", [])
|
labels = product.get("labels_tags", [])
|
||||||
|
|
||||||
|
# Pack size detection: prefer explicit unit_count, fall back to serving count
|
||||||
|
pack_quantity, pack_unit = self._extract_pack_size(product)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"brand": brand,
|
"brand": brand,
|
||||||
|
|
@ -124,9 +136,47 @@ class OpenFoodFactsService:
|
||||||
"nutrition_data": nutrition_data,
|
"nutrition_data": nutrition_data,
|
||||||
"allergens": allergens,
|
"allergens": allergens,
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
|
"pack_quantity": pack_quantity,
|
||||||
|
"pack_unit": pack_unit,
|
||||||
"raw_data": product, # Store full response for debugging
|
"raw_data": product, # Store full response for debugging
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]:
|
||||||
|
"""Return (quantity, unit) for multi-pack products, or (None, None).
|
||||||
|
|
||||||
|
OFFs fields tried in order:
|
||||||
|
1. `number_of_units` (explicit count, highest confidence)
|
||||||
|
2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt)
|
||||||
|
3. Parse `quantity` string like "4 x 113 g" or "6 pack"
|
||||||
|
|
||||||
|
Returns None, None when data is absent, ambiguous, or single-unit.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Field 1: explicit unit count
|
||||||
|
unit_count = product.get("number_of_units")
|
||||||
|
if unit_count:
|
||||||
|
try:
|
||||||
|
n = float(unit_count)
|
||||||
|
if n > 1:
|
||||||
|
return n, product.get("serving_size_unit") or "unit"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Field 2: parse quantity string for "N x ..." pattern
|
||||||
|
qty_str = product.get("quantity", "")
|
||||||
|
if qty_str:
|
||||||
|
m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip())
|
||||||
|
if m:
|
||||||
|
n = float(m.group(1))
|
||||||
|
if n > 1:
|
||||||
|
# Try to get a sensible sub-unit label from the rest
|
||||||
|
rest = qty_str[m.end():].strip()
|
||||||
|
unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit"
|
||||||
|
return n, unit_label
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
|
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract nutrition facts from product data.
|
Extract nutrition facts from product data.
|
||||||
|
|
|
||||||
256
app/services/recipe/browse_counts_cache.py
Normal file
256
app/services/recipe/browse_counts_cache.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""
|
||||||
|
Browse counts cache — pre-computes and persists recipe counts for all
|
||||||
|
browse domain keyword sets so category/subcategory page loads never
|
||||||
|
hit the 3.8 GB FTS index at request time.
|
||||||
|
|
||||||
|
Counts change only when the corpus changes (after a pipeline run).
|
||||||
|
The cache is a small SQLite file separate from both the read-only
|
||||||
|
corpus DB and per-user kiwi.db files, so the container can write it.
|
||||||
|
|
||||||
|
Refresh triggers:
|
||||||
|
1. Startup — if cache is missing or older than STALE_DAYS
|
||||||
|
2. Nightly — asyncio background task started in main.py lifespan
|
||||||
|
3. Pipeline — infer_recipe_tags.py calls refresh() at end of run
|
||||||
|
|
||||||
|
The in-memory _COUNT_CACHE in store.py is pre-warmed from this file
|
||||||
|
on startup, so FTS queries are never needed for known keyword sets.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STALE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _kw_key(keywords: list[str]) -> str:
|
||||||
|
"""Stable string key for a keyword list — sorted and pipe-joined."""
|
||||||
|
return "|".join(sorted(keywords))
|
||||||
|
|
||||||
|
|
||||||
|
def _fts_match_expr(keywords: list[str]) -> str:
|
||||||
|
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
||||||
|
return " OR ".join(phrases)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS browse_counts (
|
||||||
|
keywords_key TEXT PRIMARY KEY,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
computed_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS browse_counts_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_stale(cache_path: Path, max_age_days: int = STALE_DAYS) -> bool:
|
||||||
|
"""Return True if the cache is missing, empty, or older than max_age_days."""
|
||||||
|
if not cache_path.exists():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(cache_path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM browse_counts_meta WHERE key = 'refreshed_at'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(row[0])).days
|
||||||
|
return age >= max_age_days
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load_into_memory(cache_path: Path, count_cache: dict, corpus_path: str) -> int:
|
||||||
|
"""
|
||||||
|
Load all rows from the cache file into the in-memory count_cache dict.
|
||||||
|
|
||||||
|
Uses corpus_path (the current RECIPE_DB_PATH env value) as the cache key,
|
||||||
|
not what was stored in the file — the file may have been built against a
|
||||||
|
different mount path (e.g. pipeline ran on host, container sees a different
|
||||||
|
path). Counts are corpus-content-derived and path-independent.
|
||||||
|
|
||||||
|
Returns the number of entries loaded.
|
||||||
|
"""
|
||||||
|
if not cache_path.exists():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(cache_path)
|
||||||
|
rows = conn.execute("SELECT keywords_key, count FROM browse_counts").fetchall()
|
||||||
|
conn.close()
|
||||||
|
loaded = 0
|
||||||
|
for kw_key, count in rows:
|
||||||
|
keywords = kw_key.split("|") if kw_key else []
|
||||||
|
cache_key = (corpus_path, *sorted(keywords))
|
||||||
|
count_cache[cache_key] = count
|
||||||
|
loaded += 1
|
||||||
|
logger.info("browse_counts: warmed %d entries from %s", loaded, cache_path)
|
||||||
|
return loaded
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("browse_counts: load failed: %s", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(corpus_path: str, cache_path: Path) -> int:
|
||||||
|
"""
|
||||||
|
Run FTS5 queries for every keyword set in browser_domains.DOMAINS
|
||||||
|
and write results to cache_path.
|
||||||
|
|
||||||
|
Safe to call from both the host pipeline script and the in-container
|
||||||
|
nightly task. The corpus_path must be reachable and readable from
|
||||||
|
the calling process.
|
||||||
|
|
||||||
|
Returns the number of keyword sets computed.
|
||||||
|
"""
|
||||||
|
from app.services.recipe.browser_domains import DOMAINS # local import — avoid circular
|
||||||
|
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_conn = sqlite3.connect(cache_path)
|
||||||
|
_ensure_schema(cache_conn)
|
||||||
|
|
||||||
|
# Collect every unique keyword list across all domains/categories/subcategories.
|
||||||
|
# DOMAINS structure: {domain: {label: str, categories: {cat_name: {keywords, subcategories}}}}
|
||||||
|
seen: dict[str, list[str]] = {}
|
||||||
|
for domain_data in DOMAINS.values():
|
||||||
|
for cat_data in domain_data.get("categories", {}).values():
|
||||||
|
if not isinstance(cat_data, dict):
|
||||||
|
continue
|
||||||
|
top_kws = cat_data.get("keywords", [])
|
||||||
|
if top_kws:
|
||||||
|
seen[_kw_key(top_kws)] = top_kws
|
||||||
|
for subcat_kws in cat_data.get("subcategories", {}).values():
|
||||||
|
if subcat_kws:
|
||||||
|
seen[_kw_key(subcat_kws)] = subcat_kws
|
||||||
|
|
||||||
|
try:
|
||||||
|
corpus_conn = sqlite3.connect(f"file:{corpus_path}?mode=ro", uri=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("browse_counts: cannot open corpus %s: %s", corpus_path, exc)
|
||||||
|
cache_conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
computed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for kw_key, kws in seen.items():
|
||||||
|
try:
|
||||||
|
row = corpus_conn.execute(
|
||||||
|
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
||||||
|
(_fts_match_expr(kws),),
|
||||||
|
).fetchone()
|
||||||
|
count = row[0] if row else 0
|
||||||
|
cache_conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO browse_counts (keywords_key, count, computed_at)"
|
||||||
|
" VALUES (?, ?, ?)",
|
||||||
|
(kw_key, count, now),
|
||||||
|
)
|
||||||
|
computed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("browse_counts: query failed key=%r: %s", kw_key[:60], exc)
|
||||||
|
|
||||||
|
# Merge accepted community tags into counts.
|
||||||
|
# For each (domain, category, subcategory) that has accepted community
|
||||||
|
# tags, add the count of distinct tagged recipe_ids to the FTS count.
|
||||||
|
# The two overlap rarely (community tags exist precisely because FTS
|
||||||
|
# missed those recipes), so simple addition is accurate enough.
|
||||||
|
try:
|
||||||
|
_merge_community_tag_counts(cache_conn, DOMAINS, now)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("browse_counts: community merge skipped: %s", exc)
|
||||||
|
|
||||||
|
cache_conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('refreshed_at', ?)",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
cache_conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('corpus_path', ?)",
|
||||||
|
(corpus_path,),
|
||||||
|
)
|
||||||
|
cache_conn.commit()
|
||||||
|
logger.info("browse_counts: wrote %d counts → %s", computed, cache_path)
|
||||||
|
finally:
|
||||||
|
corpus_conn.close()
|
||||||
|
cache_conn.close()
|
||||||
|
|
||||||
|
return computed
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_community_tag_counts(
|
||||||
|
cache_conn: sqlite3.Connection,
|
||||||
|
domains: dict,
|
||||||
|
now: str,
|
||||||
|
threshold: int = 2,
|
||||||
|
) -> None:
|
||||||
|
"""Add accepted community tag counts on top of FTS counts in the cache.
|
||||||
|
|
||||||
|
Queries the community PostgreSQL store (if available) for accepted tags
|
||||||
|
grouped by (domain, category, subcategory), maps each back to its keyword
|
||||||
|
set key, then increments the cached count.
|
||||||
|
|
||||||
|
Silently skips if community features are unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.api.endpoints.community import _get_community_store
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
for domain_id, domain_data in domains.items():
|
||||||
|
for cat_name, cat_data in domain_data.get("categories", {}).items():
|
||||||
|
if not isinstance(cat_data, dict):
|
||||||
|
continue
|
||||||
|
# Check subcategories
|
||||||
|
for subcat_name, subcat_kws in cat_data.get("subcategories", {}).items():
|
||||||
|
if not subcat_kws:
|
||||||
|
continue
|
||||||
|
ids = store.get_accepted_recipe_ids_for_subcategory(
|
||||||
|
domain=domain_id,
|
||||||
|
category=cat_name,
|
||||||
|
subcategory=subcat_name,
|
||||||
|
threshold=threshold,
|
||||||
|
)
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
kw_key = _kw_key(subcat_kws)
|
||||||
|
cache_conn.execute(
|
||||||
|
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
||||||
|
(len(ids), kw_key),
|
||||||
|
)
|
||||||
|
# Check category-level tags (subcategory IS NULL)
|
||||||
|
top_kws = cat_data.get("keywords", [])
|
||||||
|
if top_kws:
|
||||||
|
ids = store.get_accepted_recipe_ids_for_subcategory(
|
||||||
|
domain=domain_id,
|
||||||
|
category=cat_name,
|
||||||
|
subcategory=None,
|
||||||
|
threshold=threshold,
|
||||||
|
)
|
||||||
|
if ids:
|
||||||
|
kw_key = _kw_key(top_kws)
|
||||||
|
cache_conn.execute(
|
||||||
|
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
||||||
|
(len(ids), kw_key),
|
||||||
|
)
|
||||||
|
logger.info("browse_counts: community tag counts merged")
|
||||||
|
|
@ -5,6 +5,12 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
|
||||||
Keyword matching is case-insensitive against the recipes.category column and the
|
Keyword matching is case-insensitive against the recipes.category column and the
|
||||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||||
|
|
||||||
|
Category values are either:
|
||||||
|
- list[str] — flat keyword list (no subcategories)
|
||||||
|
- dict — {"keywords": list[str], "subcategories": {name: list[str]}}
|
||||||
|
keywords covers the whole category (used for "All X" browse);
|
||||||
|
subcategories each have their own narrower keyword list.
|
||||||
|
|
||||||
These are starter mappings based on the food.com dataset structure. Run:
|
These are starter mappings based on the food.com dataset structure. Run:
|
||||||
|
|
||||||
SELECT category, count(*) FROM recipes
|
SELECT category, count(*) FROM recipes
|
||||||
|
|
@ -19,68 +25,657 @@ DOMAINS: dict[str, dict] = {
|
||||||
"cuisine": {
|
"cuisine": {
|
||||||
"label": "Cuisine",
|
"label": "Cuisine",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
"Italian": {
|
||||||
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
"keywords": ["cuisine:Italian", "italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||||
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
"subcategories": {
|
||||||
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
||||||
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
"involtini", "cannoli"],
|
||||||
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
||||||
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
"sfogliatelle", "ragù"],
|
||||||
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
|
||||||
|
"pappardelle", "crostini"],
|
||||||
|
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
|
||||||
|
"amatriciana", "gricia", "supplì"],
|
||||||
|
"Venetian": ["venetian", "venice", "risotto", "bigoli",
|
||||||
|
"baccalà", "sarde in saor"],
|
||||||
|
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
|
||||||
|
"trofie", "farinata"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Mexican": {
|
||||||
|
"keywords": ["cuisine:Mexican", "mexican", "taco", "enchilada", "burrito",
|
||||||
|
"salsa", "guacamole", "mole", "tamale"],
|
||||||
|
"subcategories": {
|
||||||
|
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
||||||
|
"chapulines", "mezcal", "tasajo", "memelas"],
|
||||||
|
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
|
||||||
|
"sopa de lima", "panuchos", "papadzules"],
|
||||||
|
"Veracruz": ["veracruz", "veracruzana", "huachinango",
|
||||||
|
"picadas", "enfrijoladas", "caldo de mariscos"],
|
||||||
|
"Street Food": ["taco", "elote", "tlacoyos", "torta", "tamale",
|
||||||
|
"quesadilla", "tostada", "sope", "gordita"],
|
||||||
|
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
|
||||||
|
"mole poblano", "mole amarillo", "pipián"],
|
||||||
|
"Baja / Cal-Mex": ["baja", "baja california", "cal-mex", "baja fish taco",
|
||||||
|
"fish taco", "carne asada fries", "california burrito",
|
||||||
|
"birria", "birria tacos", "quesabirria",
|
||||||
|
"lobster puerto nuevo", "tijuana", "ensenada",
|
||||||
|
"agua fresca", "caesar salad tijuana"],
|
||||||
|
"Mexico City": ["mexico city", "chilaquiles", "tlayuda cdmx",
|
||||||
|
"tacos de canasta", "torta ahogada", "pozole",
|
||||||
|
"chiles en nogada"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Asian": {
|
||||||
|
"keywords": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Korean",
|
||||||
|
"cuisine:Thai", "cuisine:Vietnamese",
|
||||||
|
"asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
||||||
|
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
||||||
|
"taiwanese", "singaporean", "burmese", "cambodian",
|
||||||
|
"laotian", "mongolian", "hong kong"],
|
||||||
|
"subcategories": {
|
||||||
|
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
|
||||||
|
"doenjang", "gochujang", "tteokbokki", "sundubu",
|
||||||
|
"galbi", "jjigae", "kbbq", "korean fried chicken"],
|
||||||
|
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
|
||||||
|
"teriyaki", "udon", "soba", "bento", "yakitori",
|
||||||
|
"tonkatsu", "onigiri", "okonomiyaki", "takoyaki",
|
||||||
|
"kaiseki", "izakaya"],
|
||||||
|
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
|
||||||
|
"spring roll", "szechuan", "sichuan", "cantonese",
|
||||||
|
"chow mein", "mapo tofu", "lo mein", "hot pot",
|
||||||
|
"peking duck", "char siu", "congee"],
|
||||||
|
"Thai": ["thai", "pad thai", "green curry", "red curry",
|
||||||
|
"coconut milk", "lemongrass", "satay", "tom yum",
|
||||||
|
"larb", "khao man gai", "massaman", "pad see ew"],
|
||||||
|
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
|
||||||
|
"vermicelli", "nuoc cham", "bun bo hue",
|
||||||
|
"banh xeo", "com tam", "bun cha"],
|
||||||
|
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
|
||||||
|
"kare-kare", "lechon", "sisig", "halo-halo",
|
||||||
|
"dinuguan", "tinola", "bistek"],
|
||||||
|
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
|
||||||
|
"tempeh", "sambal", "soto", "opor ayam",
|
||||||
|
"bakso", "mie goreng", "nasi uduk"],
|
||||||
|
"Malaysian": ["malaysian", "laksa", "nasi lemak", "char kway teow",
|
||||||
|
"satay malaysia", "roti canai", "bak kut teh",
|
||||||
|
"cendol", "mee goreng mamak", "curry laksa"],
|
||||||
|
"Taiwanese": ["taiwanese", "beef noodle soup", "lu rou fan",
|
||||||
|
"oyster vermicelli", "scallion pancake taiwan",
|
||||||
|
"pork chop rice", "three cup chicken",
|
||||||
|
"bubble tea", "stinky tofu", "ba wan"],
|
||||||
|
"Singaporean": ["singaporean", "chicken rice", "chili crab",
|
||||||
|
"singaporean laksa", "bak chor mee", "rojak",
|
||||||
|
"kaya toast", "nasi padang", "satay singapore"],
|
||||||
|
"Burmese": ["burmese", "myanmar", "mohinga", "laphet thoke",
|
||||||
|
"tea leaf salad", "ohn no khao swe",
|
||||||
|
"mont di", "nangyi thoke"],
|
||||||
|
"Hong Kong": ["hong kong", "hk style", "pineapple bun",
|
||||||
|
"wonton noodle soup", "hk milk tea", "egg tart",
|
||||||
|
"typhoon shelter crab", "char siu bao", "jook",
|
||||||
|
"congee hk", "silk stocking tea", "dan tat",
|
||||||
|
"siu mai hk", "cheung fun"],
|
||||||
|
"Cambodian": ["cambodian", "khmer", "amok", "lok lak",
|
||||||
|
"kuy teav", "bai sach chrouk", "nom banh chok",
|
||||||
|
"samlor korko", "beef loc lac"],
|
||||||
|
"Laotian": ["laotian", "lao", "larb", "tam mak hoong",
|
||||||
|
"or lam", "khao niaw", "ping kai",
|
||||||
|
"naem khao", "khao piak sen", "mok pa"],
|
||||||
|
"Mongolian": ["mongolian", "buuz", "khuushuur", "tsuivan",
|
||||||
|
"boodog", "airag", "khorkhog", "bansh",
|
||||||
|
"guriltai shol", "suutei tsai"],
|
||||||
|
"South Asian Fusion": ["south asian fusion", "indo-chinese",
|
||||||
|
"hakka chinese", "chilli chicken",
|
||||||
|
"manchurian", "schezwan"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Indian": {
|
||||||
|
"keywords": ["cuisine:Indian", "indian", "curry", "lentil", "dal", "tikka", "masala",
|
||||||
|
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
||||||
|
"bangladeshi", "nepali"],
|
||||||
|
"subcategories": {
|
||||||
|
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
|
||||||
|
"naan", "tandoori", "butter chicken", "palak paneer",
|
||||||
|
"chole", "rajma", "aloo gobi"],
|
||||||
|
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
|
||||||
|
"sambar", "rasam", "coconut chutney", "appam",
|
||||||
|
"fish curry kerala", "puttu", "payasam"],
|
||||||
|
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe ilish",
|
||||||
|
"mishti doi", "rasgulla", "kosha mangsho"],
|
||||||
|
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu",
|
||||||
|
"khandvi", "fafda", "gujarati dal"],
|
||||||
|
"Pakistani": ["pakistani", "nihari", "haleem", "seekh kebab",
|
||||||
|
"karahi", "biryani karachi", "chapli kebab",
|
||||||
|
"halwa puri", "paya"],
|
||||||
|
"Sri Lankan": ["sri lankan", "kottu roti", "hoppers", "pol sambol",
|
||||||
|
"sri lankan curry", "lamprais", "string hoppers",
|
||||||
|
"wambatu moju"],
|
||||||
|
"Bangladeshi": ["bangladeshi", "bangladesh", "dhaka biryani",
|
||||||
|
"shutki", "pitha", "hilsa curry", "kacchi biryani",
|
||||||
|
"bhuna khichuri", "doi maach", "rezala"],
|
||||||
|
"Nepali": ["nepali", "dal bhat", "momos", "sekuwa",
|
||||||
|
"sel roti", "gundruk", "thukpa"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Mediterranean": {
|
||||||
|
"keywords": ["cuisine:Mediterranean", "cuisine:Greek", "cuisine:Middle Eastern",
|
||||||
|
"mediterranean", "greek", "middle eastern", "turkish",
|
||||||
|
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
||||||
|
"syrian", "iraqi", "jordanian"],
|
||||||
|
"subcategories": {
|
||||||
|
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
|
||||||
|
"souvlaki", "dolmades", "spanakopita", "tiropita",
|
||||||
|
"galaktoboureko"],
|
||||||
|
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
|
||||||
|
"lahmacun", "menemen", "pide", "iskender",
|
||||||
|
"kisir", "simit"],
|
||||||
|
"Syrian": ["syrian", "fattet hummus", "kibbeh syria",
|
||||||
|
"muhammara", "maklouba syria", "sfeeha",
|
||||||
|
"halawet el jibn"],
|
||||||
|
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
|
||||||
|
"tabbouleh", "kibbeh", "fattoush", "manakish",
|
||||||
|
"kafta", "sfiha"],
|
||||||
|
"Jewish": ["jewish", "israeli", "ashkenazi", "sephardic",
|
||||||
|
"shakshuka", "sabich", "za'atar", "tahini",
|
||||||
|
"zhug", "zhoug", "s'khug", "z'houg",
|
||||||
|
"hawaiij", "hawaij", "hawayej",
|
||||||
|
"matzo", "latke", "rugelach", "babka", "challah",
|
||||||
|
"cholent", "gefilte fish", "brisket", "kugel",
|
||||||
|
"new york jewish", "new york deli", "pastrami",
|
||||||
|
"knish", "lox", "bagel and lox", "jewish deli"],
|
||||||
|
"Palestinian": ["palestinian", "musakhan", "maqluba", "knafeh",
|
||||||
|
"maftoul", "freekeh", "sumac chicken"],
|
||||||
|
"Yemeni": ["yemeni", "saltah", "lahoh", "bint al-sahn",
|
||||||
|
"zhug", "zhoug", "hulba", "fahsa",
|
||||||
|
"hawaiij", "hawaij", "hawayej"],
|
||||||
|
"Egyptian": ["egyptian", "koshari", "molokhia", "mahshi",
|
||||||
|
"ful medames", "ta'ameya", "feteer meshaltet"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"American": {
|
||||||
|
"keywords": ["cuisine:American", "cuisine:Southern", "cuisine:Cajun",
|
||||||
|
"american", "southern", "comfort food", "cajun", "creole",
|
||||||
|
"hawaiian", "tex-mex", "soul food"],
|
||||||
|
"subcategories": {
|
||||||
|
"Southern": ["southern", "soul food", "fried chicken",
|
||||||
|
"collard greens", "cornbread", "biscuits and gravy",
|
||||||
|
"mac and cheese", "sweet potato pie", "okra"],
|
||||||
|
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
|
||||||
|
"jambalaya", "etouffee", "dirty rice", "po'boy",
|
||||||
|
"muffuletta", "red beans and rice"],
|
||||||
|
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
|
||||||
|
"queso", "breakfast taco", "chile con carne"],
|
||||||
|
"New England": ["new england", "chowder", "lobster", "clam",
|
||||||
|
"maple", "yankee", "boston baked beans",
|
||||||
|
"johnnycake", "fish and chips"],
|
||||||
|
"Pacific Northwest": ["pacific northwest", "pnw", "dungeness crab",
|
||||||
|
"salmon", "cedar plank", "razor clam",
|
||||||
|
"geoduck", "chanterelle", "marionberry"],
|
||||||
|
"Hawaiian": ["hawaiian", "hawaii", "plate lunch", "loco moco",
|
||||||
|
"poke", "spam musubi", "kalua pig", "lau lau",
|
||||||
|
"haupia", "poi", "manapua", "garlic shrimp",
|
||||||
|
"saimin", "huli huli", "malasada"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"BBQ & Smoke": {
|
||||||
|
# Top-level keywords: cuisine:BBQ inferred tag + broad corpus terms.
|
||||||
|
"keywords": ["cuisine:BBQ", "bbq", "barbecue", "barbeque", "smoked", "smoky",
|
||||||
|
"smoke", "pit", "smoke ring", "low and slow",
|
||||||
|
"brisket", "pulled pork", "ribs", "spare ribs",
|
||||||
|
"baby back", "baby back ribs", "dry rub", "wet rub",
|
||||||
|
"cookout", "smoker", "smoked meat", "smoked chicken",
|
||||||
|
"smoked pork", "smoked beef", "smoked turkey",
|
||||||
|
"pit smoked", "wood smoked", "slow smoked",
|
||||||
|
"charcoal", "chargrilled", "burnt ends"],
|
||||||
|
"subcategories": {
|
||||||
|
"Texas BBQ": ["texas bbq", "central texas bbq", "brisket",
|
||||||
|
"beef brisket", "beef ribs", "smoked brisket",
|
||||||
|
"post oak", "salt and pepper rub",
|
||||||
|
"east texas bbq", "lockhart", "franklin style"],
|
||||||
|
"Carolina BBQ": ["carolina bbq", "north carolina bbq", "whole hog",
|
||||||
|
"vinegar sauce", "vinegar bbq", "lexington style",
|
||||||
|
"eastern nc", "south carolina bbq", "mustard sauce",
|
||||||
|
"carolina pulled pork"],
|
||||||
|
"Kansas City BBQ": ["kansas city bbq", "kc bbq", "burnt ends",
|
||||||
|
"sweet bbq sauce", "tomato molasses sauce",
|
||||||
|
"baby back ribs", "kansas city ribs"],
|
||||||
|
"Memphis BBQ": ["memphis bbq", "dry rub ribs", "wet ribs",
|
||||||
|
"memphis style", "dry rub pork", "memphis ribs"],
|
||||||
|
"Alabama BBQ": ["alabama bbq", "white sauce", "alabama white sauce",
|
||||||
|
"smoked chicken", "white bbq sauce"],
|
||||||
|
"Kentucky BBQ": ["kentucky bbq", "mutton bbq", "owensboro bbq",
|
||||||
|
"black dip", "western kentucky barbecue", "mutton"],
|
||||||
|
"St. Louis BBQ": ["st louis bbq", "st louis ribs", "st. louis ribs",
|
||||||
|
"st louis cut ribs", "spare ribs st louis"],
|
||||||
|
"Backyard Grill": ["backyard bbq", "cookout", "grilled burgers",
|
||||||
|
"charcoal grill", "kettle grill", "tailgate",
|
||||||
|
"grill out", "backyard grilling"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"European": {
|
||||||
|
"keywords": ["cuisine:French", "cuisine:German", "cuisine:Spanish",
|
||||||
|
"french", "german", "spanish", "british", "irish", "scottish",
|
||||||
|
"welsh", "scandinavian", "nordic", "eastern european"],
|
||||||
|
"subcategories": {
|
||||||
|
"French": ["french", "provencal", "beurre", "crepe",
|
||||||
|
"ratatouille", "cassoulet", "bouillabaisse"],
|
||||||
|
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
|
||||||
|
"tortilla espanola", "chorizo"],
|
||||||
|
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
|
||||||
|
"pretzel", "strudel"],
|
||||||
|
"British": ["british", "english", "pub food", "cornish",
|
||||||
|
"shepherd's pie", "bangers", "toad in the hole",
|
||||||
|
"coronation chicken", "london", "londoner",
|
||||||
|
"cornish pasty", "ploughman's"],
|
||||||
|
"Irish": ["irish", "ireland", "colcannon", "coddle",
|
||||||
|
"irish stew", "soda bread", "boxty", "champ"],
|
||||||
|
"Scottish": ["scottish", "scotland", "haggis", "cullen skink",
|
||||||
|
"cranachan", "scotch broth", "glaswegian",
|
||||||
|
"neeps and tatties", "tablet"],
|
||||||
|
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
|
||||||
|
"danish", "finnish", "gravlax", "swedish meatballs",
|
||||||
|
"lefse", "smörgåsbord", "fika", "crispbread",
|
||||||
|
"cardamom bun", "herring", "æbleskiver",
|
||||||
|
"lingonberry", "lutefisk", "janssons frestelse",
|
||||||
|
"knäckebröd", "kladdkaka"],
|
||||||
|
"Eastern European": ["eastern european", "polish", "russian", "ukrainian",
|
||||||
|
"czech", "hungarian", "pierogi", "borscht",
|
||||||
|
"goulash", "kielbasa", "varenyky", "pelmeni"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Latin American": {
|
||||||
|
"keywords": ["cuisine:Latin American", "cuisine:Caribbean",
|
||||||
|
"latin american", "peruvian", "argentinian", "colombian",
|
||||||
|
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
||||||
|
"subcategories": {
|
||||||
|
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
||||||
|
"aji amarillo", "causa", "leche de tigre",
|
||||||
|
"arroz con leche peru", "pollo a la brasa"],
|
||||||
|
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
|
||||||
|
"brigadeiro", "coxinha", "moqueca", "vatapa",
|
||||||
|
"caipirinha", "acai bowl"],
|
||||||
|
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
|
||||||
|
"sancocho", "ajiaco", "buñuelos", "changua"],
|
||||||
|
"Argentinian": ["argentinian", "asado", "chimichurri", "empanadas argentina",
|
||||||
|
"milanesa", "locro", "dulce de leche", "medialunas"],
|
||||||
|
"Venezuelan": ["venezuelan", "pabellón criollo", "arepas venezuela",
|
||||||
|
"hallacas", "cachapas", "tequeños", "caraotas"],
|
||||||
|
"Chilean": ["chilean", "cazuela", "pastel de choclo", "curanto",
|
||||||
|
"sopaipillas", "charquicán", "completo"],
|
||||||
|
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
|
||||||
|
"picadillo", "lechon cubano", "vaca frita",
|
||||||
|
"tostones", "platanos maduros"],
|
||||||
|
"Jamaican": ["jamaican", "jerk chicken", "jerk pork", "ackee saltfish",
|
||||||
|
"curry goat", "rice and peas", "escovitch",
|
||||||
|
"jamaican patty", "callaloo jamaica", "festival"],
|
||||||
|
"Puerto Rican": ["puerto rican", "mofongo", "pernil", "arroz con gandules",
|
||||||
|
"sofrito", "pasteles", "tostones pr", "tembleque",
|
||||||
|
"coquito", "asopao"],
|
||||||
|
"Dominican": ["dominican", "mangu", "sancocho dominicano",
|
||||||
|
"pollo guisado", "habichuelas guisadas",
|
||||||
|
"tostones dominicanos", "morir soñando"],
|
||||||
|
"Haitian": ["haitian", "griot", "pikliz", "riz et pois",
|
||||||
|
"joumou", "akra", "pain patate", "labouyi"],
|
||||||
|
"Trinidad": ["trinidadian", "doubles", "roti trinidad", "pelau",
|
||||||
|
"callaloo trinidad", "bake and shark",
|
||||||
|
"curry duck", "oil down"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Central American": {
|
||||||
|
"keywords": ["central american", "salvadoran", "guatemalan",
|
||||||
|
"honduran", "nicaraguan", "costa rican", "panamanian"],
|
||||||
|
"subcategories": {
|
||||||
|
"Salvadoran": ["salvadoran", "el salvador", "pupusas", "curtido",
|
||||||
|
"sopa de pata", "nuégados", "atol shuco"],
|
||||||
|
"Guatemalan": ["guatemalan", "pepián", "jocon", "kak'ik",
|
||||||
|
"hilachas", "rellenitos", "fiambre"],
|
||||||
|
"Costa Rican": ["costa rican", "gallo pinto", "casado",
|
||||||
|
"olla de carne", "arroz con leche cr",
|
||||||
|
"tres leches cr"],
|
||||||
|
"Honduran": ["honduran", "baleadas", "sopa de caracol",
|
||||||
|
"tapado", "machuca", "catrachitas"],
|
||||||
|
"Nicaraguan": ["nicaraguan", "nacatamal", "vigorón", "indio viejo",
|
||||||
|
"gallo pinto nicaragua", "güirilas"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"African": {
|
||||||
|
"keywords": ["african", "west african", "east african", "ethiopian",
|
||||||
|
"nigerian", "ghanaian", "kenyan", "south african",
|
||||||
|
"senegalese", "tunisian"],
|
||||||
|
"subcategories": {
|
||||||
|
"West African": ["west african", "nigerian", "ghanaian",
|
||||||
|
"jollof rice", "egusi soup", "fufu", "suya",
|
||||||
|
"groundnut stew", "kelewele", "kontomire",
|
||||||
|
"waakye", "ofam", "bitterleaf soup"],
|
||||||
|
"Senegalese": ["senegalese", "senegal", "thieboudienne",
|
||||||
|
"yassa", "mafe", "thiou", "ceebu jen",
|
||||||
|
"domoda"],
|
||||||
|
"Ethiopian & Eritrean": ["ethiopian", "eritrean", "injera", "doro wat",
|
||||||
|
"kitfo", "tibs", "shiro", "misir wat",
|
||||||
|
"gomen", "ful ethiopian", "tegamino"],
|
||||||
|
"East African": ["east african", "kenyan", "tanzanian", "ugandan",
|
||||||
|
"nyama choma", "ugali", "sukuma wiki",
|
||||||
|
"pilau kenya", "mandazi", "matoke",
|
||||||
|
"githeri", "irio"],
|
||||||
|
"North African": ["north african", "tunisian", "algerian", "libyan",
|
||||||
|
"brik", "lablabi", "merguez", "shakshuka tunisian",
|
||||||
|
"harissa tunisian", "couscous algerian"],
|
||||||
|
"South African": ["south african", "braai", "bobotie", "boerewors",
|
||||||
|
"bunny chow", "pap", "chakalaka", "biltong",
|
||||||
|
"malva pudding", "koeksister", "potjiekos"],
|
||||||
|
"Moroccan": ["moroccan", "tagine", "couscous morocco",
|
||||||
|
"harissa", "chermoula", "preserved lemon",
|
||||||
|
"pastilla", "mechoui", "bastilla"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Pacific & Oceania": {
|
||||||
|
"keywords": ["pacific", "oceania", "polynesian", "melanesian",
|
||||||
|
"micronesian", "maori", "fijian", "samoan", "tongan",
|
||||||
|
"hawaiian", "australian", "new zealand"],
|
||||||
|
"subcategories": {
|
||||||
|
"Māori / New Zealand": ["maori", "new zealand", "hangi", "rewena bread",
|
||||||
|
"boil-up", "paua", "kumara", "pavlova nz",
|
||||||
|
"whitebait fritter", "kina", "hokey pokey"],
|
||||||
|
"Australian": ["australian", "meat pie", "lamington",
|
||||||
|
"anzac biscuits", "damper", "barramundi",
|
||||||
|
"vegemite", "pavlova australia", "tim tam",
|
||||||
|
"sausage sizzle", "chiko roll", "fairy bread"],
|
||||||
|
"Fijian": ["fijian", "fiji", "kokoda", "lovo",
|
||||||
|
"rourou", "palusami fiji", "duruka",
|
||||||
|
"vakalolo"],
|
||||||
|
"Samoan": ["samoan", "samoa", "palusami", "oka",
|
||||||
|
"fa'ausi", "chop suey samoa", "sapasui",
|
||||||
|
"koko alaisa", "supo esi"],
|
||||||
|
"Tongan": ["tongan", "tonga", "lu pulu", "'ota 'ika",
|
||||||
|
"fekkai", "faikakai topai", "kapisi pulu"],
|
||||||
|
"Papua New Guinean": ["papua new guinea", "png", "mumu",
|
||||||
|
"sago", "aibika", "kaukau",
|
||||||
|
"taro png", "coconut crab"],
|
||||||
|
"Hawaiian": ["hawaiian", "hawaii", "poke", "loco moco",
|
||||||
|
"plate lunch", "kalua pig", "haupia",
|
||||||
|
"spam musubi", "poi", "malasada"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Central Asian & Caucasus": {
|
||||||
|
"keywords": ["central asian", "caucasus", "georgian", "armenian", "uzbek",
|
||||||
|
"afghan", "persian", "iranian", "azerbaijani", "kazakh"],
|
||||||
|
"subcategories": {
|
||||||
|
"Persian / Iranian": ["persian", "iranian", "ghormeh sabzi", "fesenjan",
|
||||||
|
"tahdig", "joojeh kabab", "ash reshteh",
|
||||||
|
"zereshk polo", "khoresh", "mast o khiar",
|
||||||
|
"kashk-e-bademjan", "mirza ghasemi",
|
||||||
|
"baghali polo"],
|
||||||
|
"Georgian": ["georgian", "georgia", "khachapuri", "khinkali",
|
||||||
|
"churchkhela", "ajapsandali", "satsivi",
|
||||||
|
"pkhali", "lobiani", "badrijani nigvzit"],
|
||||||
|
"Armenian": ["armenian", "dolma armenia", "lahmajoun",
|
||||||
|
"manti armenia", "ghapama", "basturma",
|
||||||
|
"harissa armenia", "nazook", "tolma"],
|
||||||
|
"Azerbaijani": ["azerbaijani", "azerbaijan", "plov azerbaijan",
|
||||||
|
"dolma azeri", "dushbara", "levengi",
|
||||||
|
"shah plov", "gutab"],
|
||||||
|
"Uzbek": ["uzbek", "uzbekistan", "plov", "samsa",
|
||||||
|
"lagman", "shashlik", "manti uzbek",
|
||||||
|
"non bread", "dimlama", "sumalak"],
|
||||||
|
"Afghan": ["afghan", "afghanistan", "kabuli pulao", "mantu",
|
||||||
|
"bolani", "qorma", "ashak", "shorwa",
|
||||||
|
"aushak", "borani banjan"],
|
||||||
|
"Kazakh": ["kazakh", "beshbarmak", "kuyrdak", "baursak",
|
||||||
|
"kurt", "shubat", "kazy"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"meal_type": {
|
"meal_type": {
|
||||||
"label": "Meal Type",
|
"label": "Meal Type",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
# Keywords use two complementary sources:
|
||||||
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
# 1. inferred_tag phrases ("meal:X", "main:X") — indexed in recipe_browser_fts.inferred_tags.
|
||||||
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
# FTS5 tokenises "meal:Breakfast" → ["meal","breakfast"], so the quoted phrase
|
||||||
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
# "meal:Breakfast" matches exactly that consecutive token pair.
|
||||||
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
# 2. Corpus keyword/category text — only covers the ~1,200 keyword-tagged recipes.
|
||||||
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
# Kept as a fallback; not the primary signal.
|
||||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
"Breakfast": {
|
||||||
|
"keywords": ["meal:Breakfast", "breakfast", "brunch", "pancakes",
|
||||||
|
"waffles", "oatmeal", "muffin"],
|
||||||
|
"subcategories": {
|
||||||
|
"Eggs": ["meal:Breakfast", "egg", "omelette", "frittata",
|
||||||
|
"quiche", "scrambled", "benedict", "shakshuka"],
|
||||||
|
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
||||||
|
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
||||||
|
"coffee cake", "danish"],
|
||||||
|
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
|
||||||
|
"overnight oats"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Lunch": {
|
||||||
|
# meal:Lunch tag covers explicitly-tagged recipes.
|
||||||
|
# Coverage is limited — most lunch-style recipes have no distinct meal-type tag.
|
||||||
|
"keywords": ["meal:Lunch", "lunch", "sandwich", "wrap", "salad",
|
||||||
|
"soup", "light meal"],
|
||||||
|
"subcategories": {
|
||||||
|
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
||||||
|
"grilled cheese", "blt"],
|
||||||
|
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
||||||
|
"cobb"],
|
||||||
|
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
||||||
|
"minestrone", "lentil soup"],
|
||||||
|
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
||||||
|
"quesadilla"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Dinner": {
|
||||||
|
# Primary: main:X inferred tags (800k+ recipes).
|
||||||
|
# "meal:Dinner" does not exist in the inferred-tag vocabulary — main-protein
|
||||||
|
# tags are the best available proxy for main-course dinner recipes.
|
||||||
|
"keywords": ["main:Chicken", "main:Beef", "main:Pork", "main:Fish",
|
||||||
|
"main:Pasta", "dinner", "main dish", "entree",
|
||||||
|
"main course", "supper"],
|
||||||
|
"subcategories": {
|
||||||
|
"Chicken": ["main:Chicken"],
|
||||||
|
"Beef": ["main:Beef"],
|
||||||
|
"Pork": ["main:Pork"],
|
||||||
|
"Fish & Seafood": ["main:Fish"],
|
||||||
|
"Pasta": ["main:Pasta"],
|
||||||
|
"Casseroles": ["casserole", "bake", "gratin", "pot pie"],
|
||||||
|
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
||||||
|
"daube"],
|
||||||
|
"Grilled": ["grilled", "grill", "barbecue", "kebab", "skewer"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Snack": {
|
||||||
|
"keywords": ["meal:Snack", "snack", "appetizer", "finger food",
|
||||||
|
"dip", "bite", "starter"],
|
||||||
|
"subcategories": {
|
||||||
|
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
||||||
|
"salsa", "pate"],
|
||||||
|
"Finger Foods": ["finger food", "bite", "skewer", "slider",
|
||||||
|
"wing", "nugget"],
|
||||||
|
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
|
||||||
|
"pretzel"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Dessert": {
|
||||||
|
# "sweet" removed — it matches flavor:Sweet inferred tags, causing false positives.
|
||||||
|
"keywords": ["meal:Dessert", "dessert", "cake", "cookie", "pie",
|
||||||
|
"pudding", "ice cream", "brownie"],
|
||||||
|
"subcategories": {
|
||||||
|
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
||||||
|
"cheesecake", "torte"],
|
||||||
|
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
|
||||||
|
"biscotti", "shortbread"],
|
||||||
|
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
|
||||||
|
"crumble"],
|
||||||
|
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
|
||||||
|
"popsicle", "granita"],
|
||||||
|
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
|
||||||
|
"flan", "creme brulee"],
|
||||||
|
"Candy": ["candy", "fudge", "truffle", "brittle",
|
||||||
|
"caramel", "toffee"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Beverage": ["meal:Beverage", "drink", "smoothie", "cocktail", "beverage",
|
||||||
|
"juice", "shake", "lemonade"],
|
||||||
|
"Side Dish": {
|
||||||
|
# meal:Side Dish not in inferred-tag vocabulary.
|
||||||
|
# main:Vegetables and main:Grains are the best proxies — will overlap
|
||||||
|
# with some vegetarian mains, which is acceptable.
|
||||||
|
"keywords": ["main:Vegetables", "main:Grains", "side dish", "side",
|
||||||
|
"pilaf", "accompaniment"],
|
||||||
|
"subcategories": {
|
||||||
|
"Vegetables": ["main:Vegetables"],
|
||||||
|
"Grains & Rice": ["main:Grains", "rice", "pilaf", "quinoa"],
|
||||||
|
"Bread": ["meal:Bread", "bread", "roll", "biscuit"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dietary": {
|
"dietary": {
|
||||||
"label": "Dietary",
|
"label": "Dietary",
|
||||||
|
# Primary: dietary:X inferred tags (indexed in recipe_browser_fts.inferred_tags).
|
||||||
|
# Secondary: text tokens kept as fallback for keyword-tagged recipes.
|
||||||
|
# IMPORTANT: Use ONLY structured dietary:X phrases here.
|
||||||
|
# Bare text keywords like "vegan", "low-carb" also match can_be:Vegan,
|
||||||
|
# can_be:Low-Carb etc. — those are "achievable with substitutions", not
|
||||||
|
# "recipe already is". The structured phrase "dietary:Vegan" (consecutive
|
||||||
|
# FTS tokens "dietary"+"vegan") does NOT match can_be:Vegan.
|
||||||
"categories": {
|
"categories": {
|
||||||
"Vegetarian": ["vegetarian"],
|
"Vegetarian": ["dietary:Vegetarian"],
|
||||||
"Vegan": ["vegan", "plant-based", "plant based"],
|
"Vegan": ["dietary:Vegan"],
|
||||||
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
|
"Gluten-Free": ["dietary:Gluten-Free"],
|
||||||
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
|
"Low-Carb": ["dietary:Low-Carb"],
|
||||||
"High-Protein": ["high protein", "high-protein"],
|
"High-Protein": ["dietary:High-Protein"],
|
||||||
"Low-Fat": ["low-fat", "low fat", "light"],
|
"Low-Fat": ["dietary:Low-Fat"],
|
||||||
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
|
"Dairy-Free": ["dietary:Dairy-Free"],
|
||||||
|
"Low-Sodium": ["dietary:Low-Sodium"],
|
||||||
|
"Paleo": ["dietary:Paleo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"main_ingredient": {
|
"main_ingredient": {
|
||||||
"label": "Main Ingredient",
|
"label": "Main Ingredient",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Chicken": ["chicken", "poultry", "turkey"],
|
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
||||||
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
|
"Chicken": {
|
||||||
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
|
"keywords": ["main:Chicken"],
|
||||||
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
|
"subcategories": {
|
||||||
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
|
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
||||||
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
|
"chicken bake"],
|
||||||
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
|
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
|
||||||
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
|
"chicken skewer"],
|
||||||
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
|
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
|
||||||
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
|
"crispy chicken"],
|
||||||
|
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
|
||||||
|
"chicken curry", "chicken braise"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Beef": {
|
||||||
|
"keywords": ["main:Beef"],
|
||||||
|
"subcategories": {
|
||||||
|
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
|
||||||
|
"bolognese", "burger"],
|
||||||
|
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
|
||||||
|
"filet mignon", "t-bone"],
|
||||||
|
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
|
||||||
|
"chuck roast"],
|
||||||
|
"Stews": ["beef stew", "beef braise", "beef bourguignon",
|
||||||
|
"short ribs"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Pork": {
|
||||||
|
"keywords": ["main:Pork"],
|
||||||
|
"subcategories": {
|
||||||
|
"Chops": ["pork chop", "pork loin", "pork cutlet"],
|
||||||
|
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
|
||||||
|
"carnitas", "slow cooker pork"],
|
||||||
|
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
|
||||||
|
"Italian sausage"],
|
||||||
|
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
|
||||||
|
"pork belly"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Fish": {
|
||||||
|
"keywords": ["main:Fish"],
|
||||||
|
"subcategories": {
|
||||||
|
"Salmon": ["salmon", "smoked salmon", "gravlax"],
|
||||||
|
"Tuna": ["tuna", "albacore", "ahi"],
|
||||||
|
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
|
||||||
|
"flounder", "bass"],
|
||||||
|
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
|
||||||
|
"mussel", "clam", "oyster"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Pasta": ["main:Pasta"],
|
||||||
|
"Vegetables": {
|
||||||
|
"keywords": ["main:Vegetables"],
|
||||||
|
"subcategories": {
|
||||||
|
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
|
||||||
|
"parsnip", "turnip"],
|
||||||
|
"Leafy": ["spinach", "kale", "chard", "arugula",
|
||||||
|
"collard greens", "lettuce"],
|
||||||
|
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
|
||||||
|
"cabbage", "bok choy"],
|
||||||
|
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
|
||||||
|
"squash"],
|
||||||
|
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
|
||||||
|
"chanterelle"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Eggs": ["main:Eggs"],
|
||||||
|
"Legumes": ["main:Legumes"],
|
||||||
|
"Grains": ["main:Grains"],
|
||||||
|
"Cheese": ["main:Cheese"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
|
||||||
|
"""Return the raw category definition, or None if not found."""
|
||||||
|
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
|
||||||
|
|
||||||
|
|
||||||
def get_domain_labels() -> list[dict]:
|
def get_domain_labels() -> list[dict]:
|
||||||
"""Return [{id, label}] for all available domains."""
|
"""Return [{id, label}] for all available domains."""
|
||||||
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
||||||
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
"""Return the keyword list for the category (top-level, covers all subcategories).
|
||||||
domain_data = DOMAINS.get(domain, {})
|
|
||||||
categories = domain_data.get("categories", {})
|
For flat categories returns the list directly.
|
||||||
return categories.get(category, [])
|
For nested categories returns the 'keywords' key.
|
||||||
|
Returns [] if category or domain not found.
|
||||||
|
"""
|
||||||
|
cat_def = _get_category_def(domain, category)
|
||||||
|
if cat_def is None:
|
||||||
|
return []
|
||||||
|
if isinstance(cat_def, list):
|
||||||
|
return cat_def
|
||||||
|
return cat_def.get("keywords", [])
|
||||||
|
|
||||||
|
|
||||||
|
def category_has_subcategories(domain: str, category: str) -> bool:
|
||||||
|
"""Return True when a category has a subcategory level."""
|
||||||
|
cat_def = _get_category_def(domain, category)
|
||||||
|
if not isinstance(cat_def, dict):
|
||||||
|
return False
|
||||||
|
return bool(cat_def.get("subcategories"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_subcategory_names(domain: str, category: str) -> list[str]:
|
||||||
|
"""Return subcategory names for a category, or [] if none exist."""
|
||||||
|
cat_def = _get_category_def(domain, category)
|
||||||
|
if not isinstance(cat_def, dict):
|
||||||
|
return []
|
||||||
|
return list(cat_def.get("subcategories", {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
|
||||||
|
"""Return keyword list for a specific subcategory, or [] if not found."""
|
||||||
|
cat_def = _get_category_def(domain, category)
|
||||||
|
if not isinstance(cat_def, dict):
|
||||||
|
return []
|
||||||
|
return cat_def.get("subcategories", {}).get(subcategory, [])
|
||||||
|
|
||||||
|
|
||||||
def get_category_names(domain: str) -> list[str]:
|
def get_category_names(domain: str) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -84,15 +84,27 @@ class ElementClassifier:
|
||||||
name = ingredient_name.lower().strip()
|
name = ingredient_name.lower().strip()
|
||||||
if not name:
|
if not name:
|
||||||
return IngredientProfile(name="", elements=[], source="heuristic")
|
return IngredientProfile(name="", elements=[], source="heuristic")
|
||||||
|
c = self._store._cp
|
||||||
row = self._store._fetch_one(
|
row = self._store._fetch_one(
|
||||||
"SELECT * FROM ingredient_profiles WHERE name = ?", (name,)
|
f"SELECT * FROM {c}ingredient_profiles WHERE name = ?", (name,)
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
return self._row_to_profile(row)
|
return self._row_to_profile(row)
|
||||||
return self._heuristic_profile(name)
|
return self._heuristic_profile(name)
|
||||||
|
|
||||||
def classify_batch(self, names: list[str]) -> list[IngredientProfile]:
|
def classify_batch(self, names: list[str]) -> list[IngredientProfile]:
|
||||||
return [self.classify(n) for n in names]
|
"""Classify multiple names in one DB round-trip, falling back to heuristics."""
|
||||||
|
if not names:
|
||||||
|
return []
|
||||||
|
normalised = [n.lower().strip() for n in names]
|
||||||
|
c = self._store._cp
|
||||||
|
placeholders = ",".join("?" * len(normalised))
|
||||||
|
rows = self._store._fetch_all(
|
||||||
|
f"SELECT * FROM {c}ingredient_profiles WHERE name IN ({placeholders})",
|
||||||
|
tuple(normalised),
|
||||||
|
)
|
||||||
|
by_name = {r["name"]: self._row_to_profile(r) for r in rows}
|
||||||
|
return [by_name.get(n) or self._heuristic_profile(n) for n in normalised]
|
||||||
|
|
||||||
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
||||||
"""Return element names that have no coverage in the given profile list."""
|
"""Return element names that have no coverage in the given profile list."""
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Walmart is kept inline until cf-core adds Impact network support:
|
||||||
|
|
||||||
Links are always generated (plain URLs are useful even without affiliate IDs).
|
Links are always generated (plain URLs are useful even without affiliate IDs).
|
||||||
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
||||||
|
Instacart and Walmart are US/CA-only; other locales get Amazon only.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -23,19 +24,27 @@ from urllib.parse import quote_plus
|
||||||
from circuitforge_core.affiliates import wrap_url
|
from circuitforge_core.affiliates import wrap_url
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink
|
from app.models.schemas.recipe import GroceryLink
|
||||||
|
from app.services.recipe.locale_config import get_locale
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _amazon_fresh_link(ingredient: str) -> GroceryLink:
|
def _amazon_link(ingredient: str, locale: str) -> GroceryLink:
|
||||||
|
cfg = get_locale(locale)
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
|
domain = cfg["amazon_domain"]
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon"))
|
dept = cfg["amazon_grocery_dept"]
|
||||||
|
base = f"https://www.{domain}/s?k={q}&{dept}"
|
||||||
|
retailer = "Amazon" if locale != "us" else "Amazon Fresh"
|
||||||
|
return GroceryLink(ingredient=ingredient, retailer=retailer, url=wrap_url(base, "amazon"))
|
||||||
|
|
||||||
|
|
||||||
def _instacart_link(ingredient: str) -> GroceryLink:
|
def _instacart_link(ingredient: str, locale: str) -> GroceryLink:
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
base = f"https://www.instacart.com/store/s?k={q}"
|
if locale == "ca":
|
||||||
|
base = f"https://www.instacart.ca/store/s?k={q}"
|
||||||
|
else:
|
||||||
|
base = f"https://www.instacart.com/store/s?k={q}"
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -50,26 +59,28 @@ def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkBuilder:
|
class GroceryLinkBuilder:
|
||||||
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
def __init__(self, tier: str = "free", has_byok: bool = False, locale: str = "us") -> None:
|
||||||
self._tier = tier
|
self._tier = tier
|
||||||
|
self._locale = locale
|
||||||
|
self._locale_cfg = get_locale(locale)
|
||||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
||||||
|
|
||||||
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
||||||
"""Build grocery deeplinks for a single ingredient.
|
"""Build grocery deeplinks for a single ingredient.
|
||||||
|
|
||||||
Amazon Fresh and Instacart links are always included; wrap_url handles
|
Amazon link is always included, routed to the user's locale domain.
|
||||||
affiliate ID injection (or returns a plain URL if none is configured).
|
Instacart and Walmart are only shown where they operate (US/CA).
|
||||||
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a
|
wrap_url handles affiliate ID injection for supported programs.
|
||||||
path-based redirect that doesn't degrade cleanly to a plain URL).
|
|
||||||
"""
|
"""
|
||||||
if not ingredient.strip():
|
if not ingredient.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
links: list[GroceryLink] = [
|
links: list[GroceryLink] = [_amazon_link(ingredient, self._locale)]
|
||||||
_amazon_fresh_link(ingredient),
|
|
||||||
_instacart_link(ingredient),
|
if self._locale_cfg["instacart"]:
|
||||||
]
|
links.append(_instacart_link(ingredient, self._locale))
|
||||||
if self._walmart_id:
|
|
||||||
|
if self._locale_cfg["walmart"] and self._walmart_id:
|
||||||
links.append(_walmart_link(ingredient, self._walmart_id))
|
links.append(_walmart_link(ingredient, self._walmart_id))
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"""LLM-driven recipe generator for Levels 3 and 4."""
|
"""LLM-driven recipe generator for Levels 3 and 4."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, AsyncGenerator
|
||||||
|
|
||||||
from openai import OpenAI
|
from openai import AsyncOpenAI, OpenAI
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
@ -68,6 +69,9 @@ class LLMRecipeGenerator:
|
||||||
if allergy_list:
|
if allergy_list:
|
||||||
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
|
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
|
||||||
|
|
||||||
|
if req.exclude_ingredients:
|
||||||
|
lines.append(f"IMPORTANT — user does not want these today: {', '.join(req.exclude_ingredients)}. Do not include them.")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
|
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
|
||||||
|
|
||||||
|
|
@ -84,7 +88,13 @@ class LLMRecipeGenerator:
|
||||||
if template.aromatics:
|
if template.aromatics:
|
||||||
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
|
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
|
||||||
|
|
||||||
|
unit_line = (
|
||||||
|
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
||||||
|
if req.unit_system == "metric"
|
||||||
|
else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures."
|
||||||
|
)
|
||||||
lines += [
|
lines += [
|
||||||
|
unit_line,
|
||||||
"",
|
"",
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
||||||
"Title: <name of the dish>",
|
"Title: <name of the dish>",
|
||||||
|
|
@ -118,8 +128,17 @@ class LLMRecipeGenerator:
|
||||||
if allergy_list:
|
if allergy_list:
|
||||||
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
||||||
|
|
||||||
|
if req.exclude_ingredients:
|
||||||
|
lines.append(f"Do not use today: {', '.join(req.exclude_ingredients)}")
|
||||||
|
|
||||||
|
unit_line = (
|
||||||
|
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
||||||
|
if req.unit_system == "metric"
|
||||||
|
else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures."
|
||||||
|
)
|
||||||
lines += [
|
lines += [
|
||||||
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
||||||
|
unit_line,
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
||||||
"Title: <name of the dish>",
|
"Title: <name of the dish>",
|
||||||
"Ingredients: <comma-separated list>",
|
"Ingredients: <comma-separated list>",
|
||||||
|
|
@ -131,12 +150,15 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
|
_SERVICE_TYPE = "cf-text"
|
||||||
|
_MODEL_CANDIDATES = ["granite-4.1-8b", "deepseek-r1-1.5b"]
|
||||||
|
_TTL_S = 300.0
|
||||||
|
_CALLER = "kiwi-recipe"
|
||||||
|
|
||||||
def _get_llm_context(self):
|
def _get_llm_context(self):
|
||||||
"""Return a sync context manager that yields an Allocation or None.
|
"""Return a sync context manager that yields an Allocation or None.
|
||||||
|
|
||||||
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
|
When CF_ORCH_URL is set, uses CFOrchClient to acquire a cf-text allocation
|
||||||
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
||||||
when the env var is absent or CFOrchClient raises on construction.
|
when the env var is absent or CFOrchClient raises on construction.
|
||||||
"""
|
"""
|
||||||
|
|
@ -146,10 +168,11 @@ class LLMRecipeGenerator:
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
client = CFOrchClient(cf_orch_url)
|
client = CFOrchClient(cf_orch_url)
|
||||||
return client.allocate(
|
return client.allocate(
|
||||||
service="vllm",
|
service=self._SERVICE_TYPE,
|
||||||
model_candidates=self._MODEL_CANDIDATES,
|
model_candidates=self._MODEL_CANDIDATES,
|
||||||
ttl_s=300.0,
|
ttl_s=self._TTL_S,
|
||||||
caller="kiwi-recipe",
|
caller=self._CALLER,
|
||||||
|
pipeline=os.environ.get("CF_APP_NAME") or None,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
||||||
|
|
@ -160,7 +183,12 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
||||||
calls the OpenAI-compatible API directly against the allocated service URL.
|
calls the OpenAI-compatible API directly against the allocated service URL.
|
||||||
Allocation failure falls through to LLMRouter rather than silently returning "".
|
Falls back to LLMRouter when:
|
||||||
|
- Allocation succeeded but the service is cold (warm=False) — avoids
|
||||||
|
making the user wait for model load; LLMRouter uses Ollama which is
|
||||||
|
already running.
|
||||||
|
- Allocation succeeded but the connection to the service URL fails — the
|
||||||
|
agent may have registered the service but failed to start it.
|
||||||
Without CF_ORCH_URL: uses LLMRouter directly.
|
Without CF_ORCH_URL: uses LLMRouter directly.
|
||||||
"""
|
"""
|
||||||
ctx = self._get_llm_context()
|
ctx = self._get_llm_context()
|
||||||
|
|
@ -168,11 +196,33 @@ class LLMRecipeGenerator:
|
||||||
try:
|
try:
|
||||||
alloc = ctx.__enter__()
|
alloc = ctx.__enter__()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
# 429 = coordinator at capacity (all nodes at max_concurrent limit).
|
||||||
|
# Don't fall back to LLMRouter — it's also overloaded and the slow
|
||||||
|
# fallback causes nginx 504s. Return "" fast so the caller degrades
|
||||||
|
# gracefully (empty recipe result) rather than timing out.
|
||||||
|
if "429" in msg or "max_concurrent" in msg.lower():
|
||||||
|
logger.info("cf-orch at capacity — returning empty result (graceful degradation)")
|
||||||
|
if ctx is not None:
|
||||||
|
try:
|
||||||
|
ctx.__exit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
ctx = None # __enter__ raised — do not call __exit__
|
ctx = None # __enter__ raised — do not call __exit__
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if alloc is not None:
|
if alloc is not None:
|
||||||
|
# Skip cold services — model not yet loaded means the user would
|
||||||
|
# wait 60–120 s for model load before any response. Use LLMRouter
|
||||||
|
# (Ollama) instead, which is already warm on the host.
|
||||||
|
if not alloc.warm:
|
||||||
|
logger.info(
|
||||||
|
"cf-orch vllm allocated but cold (warm=False) — releasing and falling back to LLMRouter"
|
||||||
|
)
|
||||||
|
raise RuntimeError("vllm cold")
|
||||||
|
|
||||||
base_url = alloc.url.rstrip("/") + "/v1"
|
base_url = alloc.url.rstrip("/") + "/v1"
|
||||||
client = OpenAI(base_url=base_url, api_key="any")
|
client = OpenAI(base_url=base_url, api_key="any")
|
||||||
model = alloc.model or "__auto__"
|
model = alloc.model or "__auto__"
|
||||||
|
|
@ -188,6 +238,20 @@ class LLMRecipeGenerator:
|
||||||
return LLMRouter().complete(prompt)
|
return LLMRouter().complete(prompt)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("LLM call failed: %s", exc)
|
logger.error("LLM call failed: %s", exc)
|
||||||
|
# When cf-orch gave us an allocation but the service is unreachable
|
||||||
|
# (cold skip, connection refused, or other error), fall back to
|
||||||
|
# LLMRouter rather than silently returning empty.
|
||||||
|
# Skip "vllm" in the fallback order — that backend also routes through
|
||||||
|
# cf-orch, which would trigger a second (wasted) cold allocation.
|
||||||
|
if alloc is not None:
|
||||||
|
logger.info("Falling back to LLMRouter after vllm failure")
|
||||||
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
router = LLMRouter()
|
||||||
|
_order = [b for b in (router.config.get("fallback_order") or []) if b != "vllm"]
|
||||||
|
return router.complete(prompt, fallback_order=_order or None)
|
||||||
|
except Exception as fallback_exc:
|
||||||
|
logger.error("LLMRouter fallback also failed: %s", fallback_exc)
|
||||||
return ""
|
return ""
|
||||||
finally:
|
finally:
|
||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
|
|
@ -324,3 +388,91 @@ class LLMRecipeGenerator:
|
||||||
suggestions=[suggestion],
|
suggestions=[suggestion],
|
||||||
element_gaps=gaps,
|
element_gaps=gaps,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def stream_generate(
|
||||||
|
self,
|
||||||
|
req: RecipeRequest,
|
||||||
|
profiles: list,
|
||||||
|
gaps: list[str],
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Stream LLM tokens for L3/L4. Yields raw text chunks as they arrive.
|
||||||
|
|
||||||
|
Tries cf-orch warm vllm first; falls back to Ollama via AsyncOpenAI.
|
||||||
|
When neither is reachable, falls back to blocking _call_llm and yields
|
||||||
|
the complete response as a single chunk so the caller always gets output.
|
||||||
|
"""
|
||||||
|
if req.level == 4:
|
||||||
|
prompt = self.build_level4_prompt(req)
|
||||||
|
else:
|
||||||
|
prompt = self.build_level3_prompt(req, profiles, gaps)
|
||||||
|
|
||||||
|
# Phase 1: try cf-orch warm vllm (sync allocation, wrapped in thread)
|
||||||
|
alloc_info = await asyncio.to_thread(self._try_alloc_for_stream)
|
||||||
|
if alloc_info is not None:
|
||||||
|
alloc, ctx = alloc_info
|
||||||
|
try:
|
||||||
|
async for token in self._stream_openai_compat(
|
||||||
|
alloc.url.rstrip("/") + "/v1", "any", alloc.model or "__auto__", prompt
|
||||||
|
):
|
||||||
|
yield token
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch stream failed, falling back to Ollama: %s", exc)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(lambda: _safe_exit(ctx))
|
||||||
|
|
||||||
|
# Phase 2: Ollama streaming via OpenAI-compat API
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
router = LLMRouter()
|
||||||
|
ollama = router.config.get("backends", {}).get("ollama")
|
||||||
|
if ollama and ollama.get("enabled", True):
|
||||||
|
base_url = ollama["base_url"]
|
||||||
|
model = ollama.get("model", "llama3")
|
||||||
|
try:
|
||||||
|
async for token in self._stream_openai_compat(base_url, "any", model, prompt):
|
||||||
|
yield token
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Ollama streaming failed, falling back to blocking: %s", exc)
|
||||||
|
|
||||||
|
# Phase 3: blocking fallback — yields full response at once
|
||||||
|
result = await asyncio.to_thread(self._call_llm, prompt)
|
||||||
|
if result:
|
||||||
|
yield result
|
||||||
|
|
||||||
|
def _try_alloc_for_stream(self):
|
||||||
|
"""Attempt cf-orch allocation synchronously; return (alloc, ctx) or None."""
|
||||||
|
ctx = self._get_llm_context()
|
||||||
|
try:
|
||||||
|
alloc = ctx.__enter__()
|
||||||
|
if alloc is not None and alloc.warm:
|
||||||
|
return alloc, ctx
|
||||||
|
# Not warm — release and signal fallback
|
||||||
|
_safe_exit(ctx)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch alloc for stream failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _stream_openai_compat(
|
||||||
|
base_url: str, api_key: str, model: str, prompt: str
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
|
||||||
|
if model == "__auto__":
|
||||||
|
models = await client.models.list()
|
||||||
|
model = models.data[0].id
|
||||||
|
stream = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
async for chunk in stream:
|
||||||
|
if chunk.choices and chunk.choices[0].delta.content:
|
||||||
|
yield chunk.choices[0].delta.content
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_exit(ctx) -> None:
|
||||||
|
try:
|
||||||
|
ctx.__exit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
|
||||||
160
app/services/recipe/locale_config.py
Normal file
160
app/services/recipe/locale_config.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
Shopping locale configuration.
|
||||||
|
|
||||||
|
Maps a locale key to Amazon domain, currency metadata, and retailer availability.
|
||||||
|
Instacart and Walmart are US/CA-only; all other locales get Amazon only.
|
||||||
|
Amazon Fresh (&i=amazonfresh) is US-only — international domains use the general
|
||||||
|
grocery department (&rh=n:16310101) where available, plain search elsewhere.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class LocaleConfig(TypedDict):
|
||||||
|
amazon_domain: str
|
||||||
|
amazon_grocery_dept: str # URL fragment for grocery department on this locale's site
|
||||||
|
currency_code: str
|
||||||
|
currency_symbol: str
|
||||||
|
instacart: bool
|
||||||
|
walmart: bool
|
||||||
|
|
||||||
|
|
||||||
|
LOCALES: dict[str, LocaleConfig] = {
|
||||||
|
"us": {
|
||||||
|
"amazon_domain": "amazon.com",
|
||||||
|
"amazon_grocery_dept": "i=amazonfresh",
|
||||||
|
"currency_code": "USD",
|
||||||
|
"currency_symbol": "$",
|
||||||
|
"instacart": True,
|
||||||
|
"walmart": True,
|
||||||
|
},
|
||||||
|
"ca": {
|
||||||
|
"amazon_domain": "amazon.ca",
|
||||||
|
"amazon_grocery_dept": "rh=n:6967215011", # Grocery dept on .ca # gitleaks:allow
|
||||||
|
"currency_code": "CAD",
|
||||||
|
"currency_symbol": "CA$",
|
||||||
|
"instacart": True,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"gb": {
|
||||||
|
"amazon_domain": "amazon.co.uk",
|
||||||
|
"amazon_grocery_dept": "rh=n:340831031", # Grocery dept on .co.uk
|
||||||
|
"currency_code": "GBP",
|
||||||
|
"currency_symbol": "£",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"au": {
|
||||||
|
"amazon_domain": "amazon.com.au",
|
||||||
|
"amazon_grocery_dept": "rh=n:5765081051", # Pantry/grocery on .com.au # gitleaks:allow
|
||||||
|
"currency_code": "AUD",
|
||||||
|
"currency_symbol": "A$",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"nz": {
|
||||||
|
# NZ has no Amazon storefront — route to .com.au as nearest option
|
||||||
|
"amazon_domain": "amazon.com.au",
|
||||||
|
"amazon_grocery_dept": "rh=n:5765081051", # gitleaks:allow
|
||||||
|
"currency_code": "NZD",
|
||||||
|
"currency_symbol": "NZ$",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"amazon_domain": "amazon.de",
|
||||||
|
"amazon_grocery_dept": "rh=n:340843031", # Lebensmittel & Getränke
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"currency_symbol": "€",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"amazon_domain": "amazon.fr",
|
||||||
|
"amazon_grocery_dept": "rh=n:197858031",
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"currency_symbol": "€",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"amazon_domain": "amazon.it",
|
||||||
|
"amazon_grocery_dept": "rh=n:525616031",
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"currency_symbol": "€",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"amazon_domain": "amazon.es",
|
||||||
|
"amazon_grocery_dept": "rh=n:599364031",
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"currency_symbol": "€",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"amazon_domain": "amazon.nl",
|
||||||
|
"amazon_grocery_dept": "rh=n:16584827031",
|
||||||
|
"currency_code": "EUR",
|
||||||
|
"currency_symbol": "€",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"se": {
|
||||||
|
"amazon_domain": "amazon.se",
|
||||||
|
"amazon_grocery_dept": "rh=n:20741393031",
|
||||||
|
"currency_code": "SEK",
|
||||||
|
"currency_symbol": "kr",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"jp": {
|
||||||
|
"amazon_domain": "amazon.co.jp",
|
||||||
|
"amazon_grocery_dept": "rh=n:2246283051", # gitleaks:allow
|
||||||
|
"currency_code": "JPY",
|
||||||
|
"currency_symbol": "¥",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"in": {
|
||||||
|
"amazon_domain": "amazon.in",
|
||||||
|
"amazon_grocery_dept": "rh=n:2454178031", # gitleaks:allow
|
||||||
|
"currency_code": "INR",
|
||||||
|
"currency_symbol": "₹",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"mx": {
|
||||||
|
"amazon_domain": "amazon.com.mx",
|
||||||
|
"amazon_grocery_dept": "rh=n:10737659011",
|
||||||
|
"currency_code": "MXN",
|
||||||
|
"currency_symbol": "MX$",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"br": {
|
||||||
|
"amazon_domain": "amazon.com.br",
|
||||||
|
"amazon_grocery_dept": "rh=n:17878420011",
|
||||||
|
"currency_code": "BRL",
|
||||||
|
"currency_symbol": "R$",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
"sg": {
|
||||||
|
"amazon_domain": "amazon.sg",
|
||||||
|
"amazon_grocery_dept": "rh=n:6981647051", # gitleaks:allow
|
||||||
|
"currency_code": "SGD",
|
||||||
|
"currency_symbol": "S$",
|
||||||
|
"instacart": False,
|
||||||
|
"walmart": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_LOCALE = "us"
|
||||||
|
|
||||||
|
|
||||||
|
def get_locale(key: str) -> LocaleConfig:
|
||||||
|
"""Return locale config for *key*, falling back to US if unknown."""
|
||||||
|
return LOCALES.get(key, LOCALES[DEFAULT_LOCALE])
|
||||||
|
|
@ -20,10 +20,13 @@ from typing import TYPE_CHECKING
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
|
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, StepAnalysis, TimeEffortProfile, SwapCandidate
|
||||||
from app.services.recipe.element_classifier import ElementClassifier
|
from app.services.recipe.element_classifier import ElementClassifier
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
||||||
from app.services.recipe.substitution_engine import SubstitutionEngine
|
from app.services.recipe.substitution_engine import SubstitutionEngine
|
||||||
|
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
|
||||||
|
from app.services.recipe.time_effort import parse_time_effort
|
||||||
|
from app.services.recipe.reranker import rerank_suggestions
|
||||||
|
|
||||||
_LEFTOVER_DAILY_MAX_FREE = 5
|
_LEFTOVER_DAILY_MAX_FREE = 5
|
||||||
|
|
||||||
|
|
@ -33,6 +36,38 @@ _SWAP_STOPWORDS = frozenset({
|
||||||
"to", "from", "at", "by", "as", "on",
|
"to", "from", "at", "by", "as", "on",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Marketing / prep / packaging words stripped when tokenising product-label names
|
||||||
|
# into individual ingredient tokens. Parallel to Store._FTS_TOKEN_STOPWORDS —
|
||||||
|
# both lists should agree. Kept here to avoid a circular import at runtime.
|
||||||
|
_PRODUCT_TOKEN_STOPWORDS = frozenset({
|
||||||
|
# Basic English stopwords
|
||||||
|
"a", "an", "the", "of", "in", "for", "with", "and", "or", "to",
|
||||||
|
"from", "at", "by", "as", "on", "into",
|
||||||
|
# Brand / marketing words that appear in product names
|
||||||
|
"lean", "cuisine", "healthy", "choice", "stouffer", "original",
|
||||||
|
"classic", "deluxe", "homestyle", "family", "style", "grade",
|
||||||
|
"premium", "select", "natural", "organic", "fresh", "lite",
|
||||||
|
"ready", "quick", "easy", "instant", "microwave", "frozen",
|
||||||
|
"brand", "size", "large", "small", "medium", "extra",
|
||||||
|
# Plant-based / alt-meat brand names
|
||||||
|
"daring", "gardein", "morningstar", "lightlife", "tofurky",
|
||||||
|
"quorn", "omni", "nuggs", "simulate",
|
||||||
|
# Preparation states
|
||||||
|
"cut", "diced", "sliced", "chopped", "minced", "shredded",
|
||||||
|
"cooked", "raw", "whole", "boneless", "skinless", "trimmed",
|
||||||
|
"pre", "prepared", "marinated", "seasoned", "breaded", "battered",
|
||||||
|
"grilled", "roasted", "smoked", "canned", "dried", "dehydrated",
|
||||||
|
"pieces", "piece", "strips", "strip", "chunks", "chunk",
|
||||||
|
"fillets", "fillet", "cutlets", "cutlet", "tenders", "nuggets",
|
||||||
|
# Units / packaging
|
||||||
|
"oz", "lb", "lbs", "pkg", "pack", "box", "can", "bag", "jar",
|
||||||
|
# Adjectives that aren't ingredients
|
||||||
|
"firm", "soft", "silken", "hard", "crispy", "crunchy", "smooth",
|
||||||
|
"mild", "spicy", "hot", "sweet", "savory", "unsalted", "salted",
|
||||||
|
"low", "high", "reduced", "free", "fat", "sodium", "sugar", "calorie",
|
||||||
|
"dairy", "gluten", "vegan", "plant", "based", "free",
|
||||||
|
})
|
||||||
|
|
||||||
# Maps product-label substrings to recipe-corpus canonical terms.
|
# Maps product-label substrings to recipe-corpus canonical terms.
|
||||||
# Kept in sync with Store._FTS_SYNONYMS — both must agree on canonical names.
|
# Kept in sync with Store._FTS_SYNONYMS — both must agree on canonical names.
|
||||||
# Used to expand pantry_set so single-word recipe ingredients can match
|
# Used to expand pantry_set so single-word recipe ingredients can match
|
||||||
|
|
@ -155,6 +190,56 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# When a pantry item is in a secondary state (e.g. bread → "stale"), expand
|
||||||
|
# the pantry set with terms that recipe ingredients commonly use to describe
|
||||||
|
# that state. This lets "stale bread" in a recipe ingredient match a pantry
|
||||||
|
# entry that is simply called "Bread" but is past its nominal use-by date.
|
||||||
|
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
|
||||||
|
# Values are additional strings added to the pantry set for FTS coverage.
|
||||||
|
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
|
||||||
|
# ── Existing entries (corrected) ─────────────────────────────────────────
|
||||||
|
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
|
||||||
|
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry",
|
||||||
|
"day-old croissant", "stale croissant", "day-old muffin",
|
||||||
|
"stale cake", "old pastry", "day-old baguette"],
|
||||||
|
("bananas", "overripe"): ["overripe bananas", "very ripe bananas", "spotty bananas",
|
||||||
|
"brown bananas", "black bananas", "mushy bananas",
|
||||||
|
"mashed banana", "ripe bananas"],
|
||||||
|
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk",
|
||||||
|
"soured milk", "off milk", "milk gone sour"],
|
||||||
|
("dairy", "sour"): ["sour milk", "slightly sour milk", "soured milk"],
|
||||||
|
("cheese", "rind-ready"): ["parmesan rind", "cheese rind", "aged cheese",
|
||||||
|
"hard cheese rind", "parmigiano rind", "grana padano rind",
|
||||||
|
"pecorino rind", "dry cheese"],
|
||||||
|
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice",
|
||||||
|
"old rice"],
|
||||||
|
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
|
||||||
|
# ── New entries ──────────────────────────────────────────────────────────
|
||||||
|
("apples", "soft"): ["soft apples", "mealy apples", "overripe apples",
|
||||||
|
"bruised apples", "mushy apple"],
|
||||||
|
("leafy_greens", "wilting"):["wilted spinach", "wilted greens", "limp lettuce",
|
||||||
|
"wilted kale", "tired greens"],
|
||||||
|
("tomatoes", "soft"): ["overripe tomatoes", "very ripe tomatoes", "ripe tomatoes",
|
||||||
|
"soft tomatoes", "bruised tomatoes"],
|
||||||
|
("cooked_pasta", "day-old"):["leftover pasta", "cooked pasta", "day-old pasta",
|
||||||
|
"cold pasta", "pre-cooked pasta"],
|
||||||
|
("cooked_potatoes", "day-old"): ["leftover potatoes", "cooked potatoes", "day-old potatoes",
|
||||||
|
"mashed potatoes", "baked potatoes"],
|
||||||
|
("yogurt", "tangy"): ["sour yogurt", "tangy yogurt", "past-date yogurt",
|
||||||
|
"older yogurt", "well-cultured yogurt"],
|
||||||
|
("cream", "sour"): ["slightly soured cream", "cultured cream",
|
||||||
|
"heavy cream gone sour", "soured cream"],
|
||||||
|
("wine", "open"): ["open wine", "leftover wine", "day-old wine",
|
||||||
|
"cooking wine", "red wine", "white wine"],
|
||||||
|
("cooked_beans", "day-old"):["leftover beans", "cooked beans", "day-old beans",
|
||||||
|
"cold beans", "pre-cooked beans",
|
||||||
|
"cooked chickpeas", "cooked lentils"],
|
||||||
|
("cooked_meat", "leftover"):["leftover chicken", "shredded chicken", "leftover beef",
|
||||||
|
"cooked chicken", "pulled chicken", "leftover pork",
|
||||||
|
"cooked meat", "rotisserie chicken"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
||||||
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
||||||
# "3 oz. butter" → "butter"
|
# "3 oz. butter" → "butter"
|
||||||
|
|
@ -284,14 +369,24 @@ def _prep_note_for(ingredient: str) -> str | None:
|
||||||
return template.format(ingredient=ingredient_name)
|
return template.format(ingredient=ingredient_name)
|
||||||
|
|
||||||
|
|
||||||
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
|
def _expand_pantry_set(
|
||||||
|
pantry_items: list[str],
|
||||||
|
secondary_pantry_items: dict[str, str] | None = None,
|
||||||
|
) -> set[str]:
|
||||||
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
||||||
|
|
||||||
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
||||||
and adds the canonical form. This lets single-word recipe ingredients
|
and adds the canonical form. This lets single-word recipe ingredients
|
||||||
("hamburger", "chicken") match product-label pantry entries
|
("hamburger", "chicken") match product-label pantry entries
|
||||||
("burger patties", "rotisserie chicken").
|
("burger patties", "rotisserie chicken").
|
||||||
|
|
||||||
|
If secondary_pantry_items is provided (product_name → state label), items
|
||||||
|
in a secondary state also receive state-specific synonym expansion so that
|
||||||
|
recipe ingredients like "stale bread" or "day-old rice" are matched.
|
||||||
"""
|
"""
|
||||||
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
_predictor = ExpirationPredictor()
|
||||||
|
|
||||||
expanded: set[str] = set()
|
expanded: set[str] = set()
|
||||||
for item in pantry_items:
|
for item in pantry_items:
|
||||||
lower = item.lower().strip()
|
lower = item.lower().strip()
|
||||||
|
|
@ -299,6 +394,22 @@ def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
|
||||||
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
||||||
if pattern in lower:
|
if pattern in lower:
|
||||||
expanded.add(canonical)
|
expanded.add(canonical)
|
||||||
|
|
||||||
|
# Extract individual ingredient tokens from multi-word product names.
|
||||||
|
# "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice".
|
||||||
|
# This catches plain ingredients that _PANTRY_LABEL_SYNONYMS doesn't translate.
|
||||||
|
for token in lower.split():
|
||||||
|
if len(token) >= 4 and token not in _PRODUCT_TOKEN_STOPWORDS:
|
||||||
|
expanded.add(token)
|
||||||
|
|
||||||
|
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
|
||||||
|
if secondary_pantry_items and item in secondary_pantry_items:
|
||||||
|
state_label = secondary_pantry_items[item]
|
||||||
|
category = _predictor.get_category_from_product(item)
|
||||||
|
if category:
|
||||||
|
synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), [])
|
||||||
|
expanded.update(synonyms)
|
||||||
|
|
||||||
return expanded
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -562,6 +673,34 @@ def _hard_day_sort_tier(
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
||||||
|
"""Rough cooking time estimate from step count and method complexity.
|
||||||
|
|
||||||
|
Not precise — intended for filtering and display hints only.
|
||||||
|
"""
|
||||||
|
steps = len(directions)
|
||||||
|
if complexity == "easy":
|
||||||
|
return max(5, 10 + steps * 3)
|
||||||
|
if complexity == "involved":
|
||||||
|
return max(20, 30 + steps * 6)
|
||||||
|
return max(10, 20 + steps * 4) # moderate
|
||||||
|
|
||||||
|
|
||||||
|
def _within_time(directions: list[str], max_total_min: int) -> bool:
|
||||||
|
"""Return True if parsed total time (active + passive) is within max_total_min.
|
||||||
|
|
||||||
|
Graceful degradation:
|
||||||
|
- Empty directions -> True (no data, don't hide)
|
||||||
|
- total_min == 0 (no time signals found) -> True (unparseable, don't hide)
|
||||||
|
"""
|
||||||
|
if not directions:
|
||||||
|
return True
|
||||||
|
profile = parse_time_effort(directions)
|
||||||
|
if profile.total_min == 0:
|
||||||
|
return True
|
||||||
|
return profile.total_min <= max_total_min
|
||||||
|
|
||||||
|
|
||||||
def _classify_method_complexity(
|
def _classify_method_complexity(
|
||||||
directions: list[str],
|
directions: list[str],
|
||||||
available_equipment: list[str] | None = None,
|
available_equipment: list[str] | None = None,
|
||||||
|
|
@ -621,7 +760,8 @@ class RecipeEngine:
|
||||||
|
|
||||||
profiles = self._classifier.classify_batch(req.pantry_items)
|
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||||
gaps = self._classifier.identify_gaps(profiles)
|
gaps = self._classifier.identify_gaps(profiles)
|
||||||
pantry_set = _expand_pantry_set(req.pantry_items)
|
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
|
||||||
|
exclude_set = _expand_pantry_set(req.exclude_ingredients) if req.exclude_ingredients else set()
|
||||||
|
|
||||||
if req.level >= 3:
|
if req.level >= 3:
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
|
@ -635,9 +775,13 @@ class RecipeEngine:
|
||||||
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
|
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
|
||||||
_l1 = req.level == 1 and not req.shopping_mode
|
_l1 = req.level == 1 and not req.shopping_mode
|
||||||
nf = req.nutrition_filters
|
nf = req.nutrition_filters
|
||||||
|
# L1 uses a larger candidate pool — the ratio gate below will prune
|
||||||
|
# aggressively anyway, so we need more raw candidates to end up with
|
||||||
|
# enough results for a packaged-food / plant-based pantry.
|
||||||
|
_fts_limit = 60 if _l1 else 20
|
||||||
rows = self._store.search_recipes_by_ingredients(
|
rows = self._store.search_recipes_by_ingredients(
|
||||||
req.pantry_items,
|
req.pantry_items,
|
||||||
limit=20,
|
limit=_fts_limit,
|
||||||
category=req.category or None,
|
category=req.category or None,
|
||||||
max_calories=nf.max_calories,
|
max_calories=nf.max_calories,
|
||||||
max_sugar_g=nf.max_sugar_g,
|
max_sugar_g=nf.max_sugar_g,
|
||||||
|
|
@ -648,14 +792,21 @@ class RecipeEngine:
|
||||||
)
|
)
|
||||||
|
|
||||||
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
|
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
|
||||||
|
# 0.35 allows ~1/3 ingredient coverage — low enough for packaged/plant-based
|
||||||
|
# pantries that rarely match raw-ingredient corpus recipes 1:1, but still
|
||||||
|
# filters out recipes where only one common staple matched.
|
||||||
_L1_MAX_MISSING_DEFAULT = 2
|
_L1_MAX_MISSING_DEFAULT = 2
|
||||||
_L1_MIN_MATCH_RATIO = 0.6
|
_L1_MIN_MATCH_RATIO = 0.35
|
||||||
effective_max_missing = req.max_missing
|
effective_max_missing = req.max_missing
|
||||||
if _l1 and effective_max_missing is None:
|
if _l1 and effective_max_missing is None:
|
||||||
effective_max_missing = _L1_MAX_MISSING_DEFAULT
|
effective_max_missing = _L1_MAX_MISSING_DEFAULT
|
||||||
|
|
||||||
|
# Load sensory preferences -- applied as silent post-score filter
|
||||||
|
_sensory_prefs_json = self._store.get_setting("sensory_preferences")
|
||||||
|
_sensory_exclude = build_sensory_exclude(_sensory_prefs_json)
|
||||||
|
|
||||||
suggestions = []
|
suggestions = []
|
||||||
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
|
hard_day_tier_map: dict[int, int] = {} # recipe_id -> tier when hard_day_mode
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
ingredient_names: list[str] = row.get("ingredient_names") or []
|
ingredient_names: list[str] = row.get("ingredient_names") or []
|
||||||
|
|
@ -665,6 +816,15 @@ class RecipeEngine:
|
||||||
except Exception:
|
except Exception:
|
||||||
ingredient_names = []
|
ingredient_names = []
|
||||||
|
|
||||||
|
# Skip recipes that require any ingredient the user has excluded.
|
||||||
|
if exclude_set and any(_ingredient_in_pantry(n, exclude_set) for n in ingredient_names):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sensory filter -- silent exclusion of recipes exceeding user tolerance
|
||||||
|
if not _sensory_exclude.is_empty():
|
||||||
|
if not passes_sensory_filter(row.get("sensory_tags"), _sensory_exclude):
|
||||||
|
continue
|
||||||
|
|
||||||
# Compute missing ingredients, detecting pantry coverage first.
|
# Compute missing ingredients, detecting pantry coverage first.
|
||||||
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
||||||
# → note "Melt the butter before starting.") to surface separately.
|
# → note "Melt the butter before starting.") to surface separately.
|
||||||
|
|
@ -699,6 +859,11 @@ class RecipeEngine:
|
||||||
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
|
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# "Can make now" toggle: drop any recipe that still has missing ingredients
|
||||||
|
# after swaps are applied. Swapped items count as covered.
|
||||||
|
if req.pantry_match_only and missing:
|
||||||
|
continue
|
||||||
|
|
||||||
# L1 match ratio gate: drop results where less than 60% of the recipe's
|
# L1 match ratio gate: drop results where less than 60% of the recipe's
|
||||||
# ingredients are in the pantry. Prevents low-signal results like a
|
# ingredients are in the pantry. Prevents low-signal results like a
|
||||||
# 10-ingredient recipe matching on only one common item.
|
# 10-ingredient recipe matching on only one common item.
|
||||||
|
|
@ -707,16 +872,26 @@ class RecipeEngine:
|
||||||
if match_ratio < _L1_MIN_MATCH_RATIO:
|
if match_ratio < _L1_MIN_MATCH_RATIO:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Parse directions — needed for complexity, hard_day_mode, and time estimate.
|
||||||
|
directions: list[str] = row.get("directions") or []
|
||||||
|
if isinstance(directions, str):
|
||||||
|
try:
|
||||||
|
directions = json.loads(directions)
|
||||||
|
except Exception:
|
||||||
|
directions = [directions]
|
||||||
|
|
||||||
|
# Compute complexity + parse time effort once — reused for filters and response.
|
||||||
|
row_complexity = _classify_method_complexity(directions, available_equipment)
|
||||||
|
row_time_min = _estimate_time_min(directions, row_complexity)
|
||||||
|
row_time_effort = parse_time_effort(
|
||||||
|
directions,
|
||||||
|
ingredients=row.get("ingredients") or [],
|
||||||
|
ingredient_names=row.get("ingredient_names") or [],
|
||||||
|
)
|
||||||
|
|
||||||
# Filter and tier-rank by hard_day_mode
|
# Filter and tier-rank by hard_day_mode
|
||||||
if req.hard_day_mode:
|
if req.hard_day_mode:
|
||||||
directions: list[str] = row.get("directions") or []
|
if row_complexity == "involved":
|
||||||
if isinstance(directions, str):
|
|
||||||
try:
|
|
||||||
directions = json.loads(directions)
|
|
||||||
except Exception:
|
|
||||||
directions = [directions]
|
|
||||||
complexity = _classify_method_complexity(directions, available_equipment)
|
|
||||||
if complexity == "involved":
|
|
||||||
continue
|
continue
|
||||||
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
||||||
title=row.get("title", ""),
|
title=row.get("title", ""),
|
||||||
|
|
@ -724,6 +899,33 @@ class RecipeEngine:
|
||||||
directions=directions,
|
directions=directions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Complexity filter (#58)
|
||||||
|
if req.complexity_filter and row_complexity != req.complexity_filter:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Max time filter (#58)
|
||||||
|
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Total time filter (kiwi#52).
|
||||||
|
# Prefer parsed time extracted from direction text (explicit "15 minutes" mentions).
|
||||||
|
# When directions contain no parseable time signals, fall back to the
|
||||||
|
# step-count estimate so the filter still has teeth on the corpus majority.
|
||||||
|
if req.max_total_min is not None:
|
||||||
|
if row_time_effort.total_min > 0:
|
||||||
|
if row_time_effort.total_min > req.max_total_min:
|
||||||
|
continue
|
||||||
|
elif row_time_min > req.max_total_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Active (hands-on) time filter — independent of total time.
|
||||||
|
# Lets users request "≤30 min hands-on, any total" to include slow braises.
|
||||||
|
# Skips recipes where active_min == 0 (no time signals parsed) to avoid
|
||||||
|
# hiding valid results when the parser couldn't extract timing.
|
||||||
|
if req.max_active_min is not None and row_time_effort.active_min > 0:
|
||||||
|
if row_time_effort.active_min > req.max_active_min:
|
||||||
|
continue
|
||||||
|
|
||||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||||
if req.level == 2 and req.constraints:
|
if req.level == 2 and req.constraints:
|
||||||
for ing in ingredient_names:
|
for ing in ingredient_names:
|
||||||
|
|
@ -761,6 +963,21 @@ class RecipeEngine:
|
||||||
v is not None
|
v is not None
|
||||||
for v in (nutrition.calories, nutrition.sugar_g, nutrition.carbs_g)
|
for v in (nutrition.calories, nutrition.sugar_g, nutrition.carbs_g)
|
||||||
)
|
)
|
||||||
|
te = TimeEffortProfile(
|
||||||
|
active_min=row_time_effort.active_min,
|
||||||
|
passive_min=row_time_effort.passive_min,
|
||||||
|
total_min=row_time_effort.total_min,
|
||||||
|
effort_label=row_time_effort.effort_label,
|
||||||
|
equipment=list(row_time_effort.equipment),
|
||||||
|
step_analyses=[
|
||||||
|
StepAnalysis(
|
||||||
|
is_passive=sa.is_passive,
|
||||||
|
detected_minutes=sa.detected_minutes,
|
||||||
|
prep_min=sa.prep_min,
|
||||||
|
)
|
||||||
|
for sa in row_time_effort.step_analyses
|
||||||
|
],
|
||||||
|
)
|
||||||
suggestions.append(RecipeSuggestion(
|
suggestions.append(RecipeSuggestion(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
title=row["title"],
|
title=row["title"],
|
||||||
|
|
@ -769,17 +986,31 @@ class RecipeEngine:
|
||||||
swap_candidates=swap_candidates,
|
swap_candidates=swap_candidates,
|
||||||
matched_ingredients=matched,
|
matched_ingredients=matched,
|
||||||
missing_ingredients=missing,
|
missing_ingredients=missing,
|
||||||
|
directions=directions,
|
||||||
prep_notes=sorted(prep_note_set),
|
prep_notes=sorted(prep_note_set),
|
||||||
level=req.level,
|
level=req.level,
|
||||||
nutrition=nutrition if has_nutrition else None,
|
nutrition=nutrition if has_nutrition else None,
|
||||||
source_url=_build_source_url(row),
|
source_url=_build_source_url(row),
|
||||||
|
complexity=row_complexity,
|
||||||
|
estimated_time_min=row_time_min,
|
||||||
|
time_effort=te,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
# Sort corpus results.
|
||||||
# Hard day mode: primary sort by tier (0=premade, 1=simple, 2=moderate),
|
# Paid+ tier: cross-encoder reranker orders by full pantry + dietary fit.
|
||||||
# then by match_count within each tier.
|
# Free tier (or reranker failure): overlap sort with hard_day_mode tier grouping.
|
||||||
# Normal mode: sort by match_count descending.
|
reranked = rerank_suggestions(req, suggestions)
|
||||||
if req.hard_day_mode and hard_day_tier_map:
|
if reranked is not None:
|
||||||
|
# Reranker provided relevance order. In hard_day_mode, still respect
|
||||||
|
# tier grouping as primary sort; reranker order applies within each tier.
|
||||||
|
if req.hard_day_mode and hard_day_tier_map:
|
||||||
|
suggestions = sorted(
|
||||||
|
reranked,
|
||||||
|
key=lambda s: hard_day_tier_map.get(s.id, 1),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
suggestions = reranked
|
||||||
|
elif req.hard_day_mode and hard_day_tier_map:
|
||||||
suggestions = sorted(
|
suggestions = sorted(
|
||||||
suggestions,
|
suggestions,
|
||||||
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
|
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
|
||||||
|
|
|
||||||
524
app/services/recipe/recipe_scanner.py
Normal file
524
app/services/recipe/recipe_scanner.py
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
"""Recipe scanner service (kiwi#9).
|
||||||
|
|
||||||
|
Extracts structured recipe data from one or more photos of recipe cards,
|
||||||
|
cookbook pages, or handwritten notes.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
photo(s) -> EXIF correction -> VLM extraction -> JSON parse -> pantry cross-ref
|
||||||
|
|
||||||
|
Vision backend priority (mirrors receipt OCR pattern):
|
||||||
|
1. cf-orch vision service (if CF_ORCH_URL set)
|
||||||
|
2. Local Qwen2.5-VL (if GPU available)
|
||||||
|
3. Anthropic API (BYOK -- if ANTHROPIC_API_KEY set)
|
||||||
|
|
||||||
|
BSL 1.1 -- requires Paid tier or BYOK.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum number of photos per scan call (to limit VLM context / VRAM)
|
||||||
|
MAX_IMAGES = 4
|
||||||
|
|
||||||
|
# VLM prompt -- adapted from tests/fixtures/recipe_scan/extract_test.py
|
||||||
|
_EXTRACTION_PROMPT = """
|
||||||
|
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.
|
||||||
|
|
||||||
|
If two or more images are provided, treat them as a single recipe across multiple pages
|
||||||
|
(e.g. ingredients on page 1, directions on page 2).
|
||||||
|
|
||||||
|
Return a single JSON object with these fields:
|
||||||
|
- title: recipe name (string)
|
||||||
|
- subtitle: any secondary title or serving suggestion e.g. "with Broccoli & Ranch Dressing" (string or null)
|
||||||
|
- servings: serving size if shown, as a string e.g. "2", "4-6" (string or null)
|
||||||
|
- cook_time: total cook time if shown, e.g. "15 min", "1 hour" (string or null)
|
||||||
|
- source_note: any attribution text like "From Betty Crocker" or "Purple Carrot" (string or null)
|
||||||
|
- ingredients: array of ingredient objects, each with:
|
||||||
|
- name: normalized generic ingredient name, lowercase, no quantities, no brand names
|
||||||
|
(e.g. "Follow Your Heart Vegan Ranch" becomes "ranch dressing")
|
||||||
|
- qty: quantity as a string, preserving fractions e.g. "1/2", a quarter symbol (string or null)
|
||||||
|
- unit: unit of measure, null for countable items (e.g. "3 eggs" has unit: null)
|
||||||
|
- raw: the original ingredient line verbatim, exactly as it appears
|
||||||
|
- steps: ordered array of instruction strings, one distinct step per element
|
||||||
|
- notes: any tips, substitutions, storage instructions, or variations (string or null)
|
||||||
|
- confidence: "high" if text is clear and complete, "medium" if some parts are uncertain,
|
||||||
|
"low" if mostly handwritten or significantly degraded
|
||||||
|
- warnings: array of strings describing anything the user should double-check
|
||||||
|
(e.g. "Directions appear to continue on another page not shown")
|
||||||
|
|
||||||
|
Return only valid JSON. No markdown fences. No explanation outside the JSON.
|
||||||
|
If the image does not appear to be a recipe at all, return: {"error": "not_a_recipe"}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScannedIngredient:
|
||||||
|
name: str
|
||||||
|
qty: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
raw: str | None = None
|
||||||
|
in_pantry: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScannedRecipeResult:
|
||||||
|
title: str | None
|
||||||
|
subtitle: str | None
|
||||||
|
servings: str | None
|
||||||
|
cook_time: str | None
|
||||||
|
source_note: str | None
|
||||||
|
ingredients: list[ScannedIngredient]
|
||||||
|
steps: list[str]
|
||||||
|
notes: str | None
|
||||||
|
tags: list[str]
|
||||||
|
pantry_match_pct: int
|
||||||
|
confidence: str
|
||||||
|
warnings: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Image helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_image_b64(path: Path) -> str:
|
||||||
|
"""Load image, apply EXIF rotation, return base64-encoded JPEG bytes."""
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
img = Image.open(io.BytesIO(raw))
|
||||||
|
img = ImageOps.exif_transpose(img).convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=90)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vision backend ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _call_via_anthropic(image_paths: list[Path], prompt: str) -> str:
|
||||||
|
"""Send image(s) + prompt to Anthropic API. Raises RuntimeError if unavailable."""
|
||||||
|
try:
|
||||||
|
import anthropic
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("anthropic package not installed") from exc
|
||||||
|
|
||||||
|
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("ANTHROPIC_API_KEY not set")
|
||||||
|
|
||||||
|
client = anthropic.Anthropic(api_key=api_key)
|
||||||
|
|
||||||
|
content: list[dict] = []
|
||||||
|
for i, path in enumerate(image_paths):
|
||||||
|
if i > 0:
|
||||||
|
content.append({"type": "text", "text": f"(Page {i + 1} of the same recipe:)"})
|
||||||
|
content.append({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/jpeg",
|
||||||
|
"data": _load_image_b64(path),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
content.append({"type": "text", "text": prompt})
|
||||||
|
|
||||||
|
msg = client.messages.create(
|
||||||
|
# Haiku is cost-efficient for well-structured extraction prompts
|
||||||
|
model="claude-haiku-4-5-20251001",
|
||||||
|
max_tokens=2048,
|
||||||
|
messages=[{"role": "user", "content": content}],
|
||||||
|
)
|
||||||
|
return msg.content[0].text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
|
||||||
|
"""Send image(s) + prompt to local Qwen2.5-VL. Raises RuntimeError if unavailable."""
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("torch not installed") from exc
|
||||||
|
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError("No CUDA device -- local VLM unavailable")
|
||||||
|
|
||||||
|
# Lazy import so the module loads fast when GPU is absent
|
||||||
|
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||||
|
logger.info("Loading local VLM for recipe scan: %s", model_name)
|
||||||
|
|
||||||
|
model = Qwen2VLForConditionalGeneration.from_pretrained(
|
||||||
|
model_name,
|
||||||
|
torch_dtype=torch.float16,
|
||||||
|
device_map="auto",
|
||||||
|
low_cpu_mem_usage=True,
|
||||||
|
)
|
||||||
|
processor = AutoProcessor.from_pretrained(model_name)
|
||||||
|
model.train(False) # inference mode
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for path in image_paths:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
img = Image.open(io.BytesIO(raw))
|
||||||
|
img = ImageOps.exif_transpose(img).convert("RGB")
|
||||||
|
images.append(img)
|
||||||
|
|
||||||
|
inputs = processor(images=images, text=prompt, return_tensors="pt")
|
||||||
|
inputs = {k: v.to("cuda", torch.float16) if isinstance(v, torch.Tensor) else v
|
||||||
|
for k, v in inputs.items()}
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
output_ids = model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_new_tokens=2048,
|
||||||
|
do_sample=False,
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = processor.decode(output_ids[0], skip_special_tokens=True)
|
||||||
|
output = output.replace(prompt, "").strip()
|
||||||
|
|
||||||
|
# Free VRAM
|
||||||
|
del model
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ocr_extraction_prompt(ocr_text: str) -> str:
|
||||||
|
"""Build a text-LLM prompt for structuring OCR output into recipe JSON.
|
||||||
|
|
||||||
|
Swaps the image-centric preamble of _EXTRACTION_PROMPT for an OCR-centric
|
||||||
|
one, then appends the combined OCR text as input. The JSON schema section
|
||||||
|
is shared verbatim to keep the two paths in sync.
|
||||||
|
"""
|
||||||
|
schema_idx = _EXTRACTION_PROMPT.find("Return a single JSON object")
|
||||||
|
schema_part = _EXTRACTION_PROMPT[schema_idx:] if schema_idx != -1 else _EXTRACTION_PROMPT
|
||||||
|
return (
|
||||||
|
"You are extracting a recipe from OCR text taken from a recipe card, "
|
||||||
|
"cookbook page, or handwritten note.\n\n"
|
||||||
|
"The text below was obtained via optical character recognition and may "
|
||||||
|
"contain minor scanning artifacts or formatting irregularities.\n\n"
|
||||||
|
f"{schema_part}\n\nOCR Text:\n{ocr_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_via_cf_text_vlm(alloc_url: str, image_paths: list[Path], prompt: str) -> str:
|
||||||
|
"""Call the cf-text OpenAI-compat API with images via the llama.cpp multimodal backend."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
content: list[dict] = []
|
||||||
|
for i, path in enumerate(image_paths):
|
||||||
|
if i > 0:
|
||||||
|
content.append({"type": "text", "text": f"(Page {i + 1} of the same recipe:)"})
|
||||||
|
b64 = _load_image_b64(path)
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||||
|
})
|
||||||
|
content.append({"type": "text", "text": prompt})
|
||||||
|
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{alloc_url.rstrip('/')}/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "local",
|
||||||
|
"messages": [{"role": "user", "content": content}],
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"temperature": 0.0,
|
||||||
|
},
|
||||||
|
timeout=180.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _call_vision_backend(
|
||||||
|
image_paths: list[Path],
|
||||||
|
prompt: str,
|
||||||
|
progress_cb: "Callable[[str, str], None] | None" = None,
|
||||||
|
) -> str:
|
||||||
|
"""Dispatch to the best available vision backend.
|
||||||
|
|
||||||
|
Priority: cf-orch (Qwen2-VL GGUF via cf-text) -> local Qwen2.5-VL -> Anthropic API.
|
||||||
|
Raises RuntimeError with a clear message when no backend is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_paths: Images to process.
|
||||||
|
prompt: Extraction prompt (used by local VLM / Anthropic paths).
|
||||||
|
progress_cb: Optional callback(status, message) for SSE progress events.
|
||||||
|
Called synchronously from the thread — caller bridges to async.
|
||||||
|
"""
|
||||||
|
def _progress(status: str, message: str) -> None:
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(status, message)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# 1. Try cf-orch task allocation → cf-docuvision (Qwen2-VL GGUF via llama.cpp).
|
||||||
|
# Two-step: docuvision OCRs the image(s), then LLMRouter structures the text into JSON.
|
||||||
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
|
if cf_orch_url:
|
||||||
|
try:
|
||||||
|
from app.services.task_inference import TaskNotRegistered, task_allocate
|
||||||
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
|
||||||
|
try:
|
||||||
|
_progress("allocating", "Starting vision service...")
|
||||||
|
with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc:
|
||||||
|
_progress("scanning", "Extracting recipe text from photo...")
|
||||||
|
doc_client = DocuvisionClient(alloc.url)
|
||||||
|
ocr_parts: list[str] = []
|
||||||
|
for i, path in enumerate(image_paths):
|
||||||
|
result = doc_client.extract_text(path, hint="text")
|
||||||
|
prefix = f"(Page {i + 1} of the same recipe)\n" if len(image_paths) > 1 else ""
|
||||||
|
ocr_parts.append(f"{prefix}{result.text}")
|
||||||
|
combined_ocr = "\n\n".join(ocr_parts)
|
||||||
|
|
||||||
|
if not combined_ocr.strip():
|
||||||
|
raise ValueError("Docuvision returned no text — image may not be a recipe")
|
||||||
|
|
||||||
|
_progress("structuring", "Parsing recipe structure...")
|
||||||
|
text = LLMRouter().complete(
|
||||||
|
_build_ocr_extraction_prompt(combined_ocr),
|
||||||
|
system="You are a recipe data extractor. Return ONLY valid JSON. No markdown, no explanation, no code fences.",
|
||||||
|
)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
except TaskNotRegistered:
|
||||||
|
logger.debug("kiwi.recipe_scan not yet registered in cf-orch assignments")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch vision failed for recipe scan: %s", exc)
|
||||||
|
errors.append(f"cf-orch: {exc}")
|
||||||
|
|
||||||
|
# 2. Try local Qwen2.5-VL
|
||||||
|
try:
|
||||||
|
return _call_via_local_vlm(image_paths, prompt)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Local VLM unavailable for recipe scan: %s", exc)
|
||||||
|
errors.append(f"local VLM: {exc}")
|
||||||
|
|
||||||
|
# 3. Try Anthropic API (BYOK)
|
||||||
|
try:
|
||||||
|
return _call_via_anthropic(image_paths, prompt)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Anthropic API failed for recipe scan: %s", exc)
|
||||||
|
errors.append(f"Anthropic: {exc}")
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"No vision backend configured for recipe scanning. "
|
||||||
|
"Options: cf-orch (CF_ORCH_URL), local GPU, or ANTHROPIC_API_KEY (BYOK). "
|
||||||
|
f"Errors: {'; '.join(errors)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Parsing helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _normalize_ingredient_name(name: str) -> str:
|
||||||
|
"""Lowercase + strip whitespace. Preserves multi-word names as-is."""
|
||||||
|
return name.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> str | None:
|
||||||
|
"""Return the first balanced JSON object from text, or None if not found.
|
||||||
|
|
||||||
|
Uses brace-counting rather than a greedy regex so trailing prose and
|
||||||
|
nested objects are handled correctly.
|
||||||
|
"""
|
||||||
|
start = text.find("{")
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape_next = False
|
||||||
|
for i, ch in enumerate(text[start:], start):
|
||||||
|
if escape_next:
|
||||||
|
escape_next = False
|
||||||
|
continue
|
||||||
|
if ch == "\\" and in_string:
|
||||||
|
escape_next = True
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
in_string = not in_string
|
||||||
|
continue
|
||||||
|
if in_string:
|
||||||
|
continue
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start : i + 1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scanner_json(raw_text: str) -> dict:
|
||||||
|
"""Extract and return the JSON dict from VLM output.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Pure JSON
|
||||||
|
- JSON in ```json ... ``` markdown fences
|
||||||
|
- Qwen3-style <think>...</think> or <thinking>...</thinking> preambles
|
||||||
|
- JSON preceded or followed by prose
|
||||||
|
|
||||||
|
Raises ValueError on not_a_recipe or unparseable output.
|
||||||
|
"""
|
||||||
|
text = raw_text.strip()
|
||||||
|
|
||||||
|
# Strip thinking-token blocks emitted by reasoning models (Qwen3, DeepSeek-R1, etc.)
|
||||||
|
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||||
|
text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||||
|
|
||||||
|
# Strip markdown fences if present
|
||||||
|
if "```" in text:
|
||||||
|
# Find the content between the first ``` pair
|
||||||
|
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
||||||
|
if fence_match:
|
||||||
|
text = fence_match.group(1).strip()
|
||||||
|
|
||||||
|
# Try direct parse
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Fall back to brace-balanced extraction from anywhere in the output
|
||||||
|
candidate = _extract_json_object(text)
|
||||||
|
if not candidate:
|
||||||
|
logger.warning("Could not parse JSON from LLM output (first 400 chars): %r", text[:400])
|
||||||
|
raise ValueError(f"Could not parse JSON from VLM output: {text[:200]!r}")
|
||||||
|
try:
|
||||||
|
data = json.loads(candidate)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.warning("Brace-extracted JSON still invalid: %r", candidate[:400])
|
||||||
|
raise ValueError(f"Could not parse JSON from VLM output: {exc}") from exc
|
||||||
|
|
||||||
|
if isinstance(data, dict) and data.get("error") == "not_a_recipe":
|
||||||
|
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pantry cross-reference ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _cross_reference_pantry(
|
||||||
|
ingredients: list[ScannedIngredient],
|
||||||
|
pantry_names: list[str],
|
||||||
|
) -> tuple[list[ScannedIngredient], int]:
|
||||||
|
"""Mark ingredients found in the pantry and return updated list + match percent.
|
||||||
|
|
||||||
|
Matching is bidirectional by token:
|
||||||
|
- "broccoli florets" matches pantry item "broccoli" (pantry token in ingredient)
|
||||||
|
- "pumpkin seeds" matches pantry "pumpkin seeds" (exact)
|
||||||
|
|
||||||
|
Returns (updated_ingredients, pantry_match_pct).
|
||||||
|
"""
|
||||||
|
if not ingredients:
|
||||||
|
return ingredients, 0
|
||||||
|
|
||||||
|
normalized_pantry = [_normalize_ingredient_name(p) for p in pantry_names]
|
||||||
|
updated: list[ScannedIngredient] = []
|
||||||
|
matched = 0
|
||||||
|
|
||||||
|
for ingr in ingredients:
|
||||||
|
norm_ingr = _normalize_ingredient_name(ingr.name)
|
||||||
|
in_pantry = any(
|
||||||
|
(p_tok in norm_ingr or norm_ingr in p_tok)
|
||||||
|
for p in normalized_pantry
|
||||||
|
for p_tok in p.split()
|
||||||
|
if len(p_tok) >= 4 # skip short stop-words like "of", "and", "the"
|
||||||
|
)
|
||||||
|
updated.append(ScannedIngredient(
|
||||||
|
name=ingr.name,
|
||||||
|
qty=ingr.qty,
|
||||||
|
unit=ingr.unit,
|
||||||
|
raw=ingr.raw,
|
||||||
|
in_pantry=in_pantry,
|
||||||
|
))
|
||||||
|
if in_pantry:
|
||||||
|
matched += 1
|
||||||
|
|
||||||
|
pct = round(matched / len(ingredients) * 100)
|
||||||
|
return updated, pct
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main scanner class ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RecipeScanner:
|
||||||
|
"""Stateless recipe scanner. One instance can be reused across requests."""
|
||||||
|
|
||||||
|
def scan(
|
||||||
|
self,
|
||||||
|
image_paths: list[Path],
|
||||||
|
pantry_names: list[str] | None = None,
|
||||||
|
progress_cb: Callable[[str, str], None] | None = None,
|
||||||
|
) -> ScannedRecipeResult:
|
||||||
|
"""Extract a structured recipe from one or more photos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_paths: 1-4 image files (phone photos, scans).
|
||||||
|
pantry_names: Flat list of product names from user's inventory.
|
||||||
|
Pass [] or None to skip pantry cross-reference.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScannedRecipeResult with all fields populated.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Image is not a recipe, or JSON could not be parsed.
|
||||||
|
RuntimeError: No vision backend is configured.
|
||||||
|
"""
|
||||||
|
if not image_paths:
|
||||||
|
raise ValueError("At least one image is required")
|
||||||
|
if len(image_paths) > MAX_IMAGES:
|
||||||
|
raise ValueError(f"Maximum {MAX_IMAGES} images per scan (got {len(image_paths)})")
|
||||||
|
|
||||||
|
# Call vision backend
|
||||||
|
raw_text = _call_vision_backend(image_paths, _EXTRACTION_PROMPT, progress_cb=progress_cb)
|
||||||
|
|
||||||
|
# Parse JSON from VLM output
|
||||||
|
data = _parse_scanner_json(raw_text)
|
||||||
|
|
||||||
|
# Build ingredient list
|
||||||
|
raw_ingredients = data.get("ingredients") or []
|
||||||
|
ingredients: list[ScannedIngredient] = [
|
||||||
|
ScannedIngredient(
|
||||||
|
name=str(item.get("name") or "").strip() or "unknown",
|
||||||
|
qty=str(item["qty"]) if item.get("qty") is not None else None,
|
||||||
|
unit=str(item["unit"]) if item.get("unit") is not None else None,
|
||||||
|
raw=str(item["raw"]) if item.get("raw") is not None else None,
|
||||||
|
)
|
||||||
|
for item in raw_ingredients
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pantry cross-reference
|
||||||
|
ingredients, pct = _cross_reference_pantry(
|
||||||
|
ingredients,
|
||||||
|
pantry_names or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScannedRecipeResult(
|
||||||
|
title=data.get("title") or None,
|
||||||
|
subtitle=data.get("subtitle") or None,
|
||||||
|
servings=str(data["servings"]) if data.get("servings") is not None else None,
|
||||||
|
cook_time=str(data["cook_time"]) if data.get("cook_time") is not None else None,
|
||||||
|
source_note=data.get("source_note") or None,
|
||||||
|
ingredients=ingredients,
|
||||||
|
steps=[str(s) for s in (data.get("steps") or []) if s],
|
||||||
|
notes=data.get("notes") or None,
|
||||||
|
tags=list(data.get("tags") or []),
|
||||||
|
pantry_match_pct=pct,
|
||||||
|
confidence=data.get("confidence") or "medium",
|
||||||
|
warnings=list(data.get("warnings") or []),
|
||||||
|
)
|
||||||
175
app/services/recipe/reranker.py
Normal file
175
app/services/recipe/reranker.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""
|
||||||
|
Reranker integration for recipe suggestions.
|
||||||
|
|
||||||
|
Wraps circuitforge_core.reranker to score recipe candidates against a
|
||||||
|
natural-language query built from the user's pantry, constraints, and
|
||||||
|
preferences. Paid+ tier only; free tier returns None (caller keeps
|
||||||
|
existing sort). All exceptions are caught and logged — the reranker
|
||||||
|
must never break recipe suggestions.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
CF_RERANKER_MOCK=1 — force mock backend (tests, no model required)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Tiers that get reranker access.
|
||||||
|
_RERANKER_TIERS: frozenset[str] = frozenset({"paid", "premium", "local"})
|
||||||
|
|
||||||
|
# Minimum candidates worth reranking — below this the cross-encoder
|
||||||
|
# overhead is not justified and the overlap sort is fine.
|
||||||
|
_MIN_CANDIDATES: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RerankerInput:
|
||||||
|
"""Intermediate representation passed to the reranker."""
|
||||||
|
query: str
|
||||||
|
candidates: list[str]
|
||||||
|
suggestion_ids: list[int] # parallel to candidates, for re-mapping
|
||||||
|
|
||||||
|
|
||||||
|
# ── Query builder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_query(req: RecipeRequest) -> str:
|
||||||
|
"""Build a natural-language query string from the recipe request.
|
||||||
|
|
||||||
|
Encodes the user's full context so the cross-encoder can score
|
||||||
|
relevance, dietary fit, and expiry urgency in a single pass.
|
||||||
|
Only non-empty segments are included.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
if req.pantry_items:
|
||||||
|
parts.append(f"Recipe using: {', '.join(req.pantry_items)}")
|
||||||
|
|
||||||
|
if req.exclude_ingredients:
|
||||||
|
parts.append(f"Avoid: {', '.join(req.exclude_ingredients)}")
|
||||||
|
|
||||||
|
if req.allergies:
|
||||||
|
parts.append(f"Allergies: {', '.join(req.allergies)}")
|
||||||
|
|
||||||
|
if req.constraints:
|
||||||
|
parts.append(f"Dietary: {', '.join(req.constraints)}")
|
||||||
|
|
||||||
|
if req.category:
|
||||||
|
parts.append(f"Category: {req.category}")
|
||||||
|
|
||||||
|
if req.style_id:
|
||||||
|
parts.append(f"Style: {req.style_id}")
|
||||||
|
|
||||||
|
if req.complexity_filter:
|
||||||
|
parts.append(f"Prefer: {req.complexity_filter}")
|
||||||
|
|
||||||
|
if req.hard_day_mode:
|
||||||
|
parts.append("Prefer: easy, minimal effort")
|
||||||
|
|
||||||
|
# Secondary pantry items carry a state label (e.g. "stale", "overripe")
|
||||||
|
# that helps the reranker favour recipes suited to those specific states.
|
||||||
|
if req.secondary_pantry_items:
|
||||||
|
expiry_parts = [f"{name} ({state})" for name, state in req.secondary_pantry_items.items()]
|
||||||
|
parts.append(f"Use soon: {', '.join(expiry_parts)}")
|
||||||
|
elif req.expiry_first:
|
||||||
|
parts.append("Prefer: recipes that use expiring items first")
|
||||||
|
|
||||||
|
return ". ".join(parts) + "." if parts else "Recipe."
|
||||||
|
|
||||||
|
|
||||||
|
# ── Candidate builder ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_candidate_string(suggestion: RecipeSuggestion) -> str:
|
||||||
|
"""Build a candidate string for a single recipe suggestion.
|
||||||
|
|
||||||
|
Format: "{title}. Ingredients: {comma-joined ingredients}"
|
||||||
|
Matched ingredients appear before missing ones.
|
||||||
|
Directions excluded to stay within BGE's 512-token window.
|
||||||
|
"""
|
||||||
|
ingredients = suggestion.matched_ingredients + suggestion.missing_ingredients
|
||||||
|
if not ingredients:
|
||||||
|
return suggestion.title
|
||||||
|
return f"{suggestion.title}. Ingredients: {', '.join(ingredients)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Input assembler ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_reranker_input(
|
||||||
|
req: RecipeRequest,
|
||||||
|
suggestions: list[RecipeSuggestion],
|
||||||
|
) -> RerankerInput:
|
||||||
|
"""Assemble query and candidate strings for the reranker."""
|
||||||
|
query = build_query(req)
|
||||||
|
candidates: list[str] = []
|
||||||
|
ids: list[int] = []
|
||||||
|
for s in suggestions:
|
||||||
|
candidates.append(build_candidate_string(s))
|
||||||
|
ids.append(s.id)
|
||||||
|
return RerankerInput(query=query, candidates=candidates, suggestion_ids=ids)
|
||||||
|
|
||||||
|
|
||||||
|
# ── cf-core seam (isolated for monkeypatching in tests) ──────────────────────
|
||||||
|
|
||||||
|
def _do_rerank(query: str, candidates: list[str], top_n: int = 0):
|
||||||
|
"""Thin wrapper around cf-core rerank(). Extracted so tests can patch it."""
|
||||||
|
from circuitforge_core.reranker import rerank
|
||||||
|
return rerank(query, candidates, top_n=top_n)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def rerank_suggestions(
|
||||||
|
req: RecipeRequest,
|
||||||
|
suggestions: list[RecipeSuggestion],
|
||||||
|
) -> list[RecipeSuggestion] | None:
|
||||||
|
"""Rerank suggestions using the cf-core cross-encoder.
|
||||||
|
|
||||||
|
Returns a reordered list with rerank_score populated, or None when:
|
||||||
|
- Tier is not paid+ (free tier keeps overlap sort)
|
||||||
|
- Fewer than _MIN_CANDIDATES suggestions (not worth the overhead)
|
||||||
|
- Any exception is raised (graceful fallback to existing sort)
|
||||||
|
|
||||||
|
The caller should treat None as "keep existing sort order".
|
||||||
|
Original suggestions are never mutated.
|
||||||
|
"""
|
||||||
|
if req.tier not in _RERANKER_TIERS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(suggestions) < _MIN_CANDIDATES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
rinput = build_reranker_input(req, suggestions)
|
||||||
|
results = _do_rerank(rinput.query, rinput.candidates, top_n=0)
|
||||||
|
|
||||||
|
# Map reranked results back to RecipeSuggestion objects using the
|
||||||
|
# candidate string as key (build_candidate_string is deterministic).
|
||||||
|
candidate_map: dict[str, RecipeSuggestion] = {
|
||||||
|
build_candidate_string(s): s for s in suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
reranked: list[RecipeSuggestion] = []
|
||||||
|
for rr in results:
|
||||||
|
suggestion = candidate_map.get(rr.candidate)
|
||||||
|
if suggestion is not None:
|
||||||
|
reranked.append(suggestion.model_copy(
|
||||||
|
update={"rerank_score": round(float(rr.score), 4)}
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(reranked) < len(suggestions):
|
||||||
|
log.warning(
|
||||||
|
"Reranker lost %d/%d suggestions during mapping, falling back",
|
||||||
|
len(suggestions) - len(reranked),
|
||||||
|
len(suggestions),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return reranked
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception("Reranker failed, falling back to overlap sort")
|
||||||
|
return None
|
||||||
133
app/services/recipe/sensory.py
Normal file
133
app/services/recipe/sensory.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""
|
||||||
|
Sensory filter dataclass and helpers.
|
||||||
|
|
||||||
|
SensoryExclude bridges user preferences (from user_settings) to the
|
||||||
|
store browse methods and recipe engine suggest flow.
|
||||||
|
|
||||||
|
Recipes with sensory_tags = '{}' (untagged) pass ALL filters --
|
||||||
|
graceful degradation when tag_sensory_profiles.py has not run.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
_SMELL_LEVELS: tuple[str, ...] = ("mild", "aromatic", "pungent", "fermented")
|
||||||
|
_NOISE_LEVELS: tuple[str, ...] = ("quiet", "moderate", "loud", "very_loud")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SensoryExclude:
|
||||||
|
"""Derived filter criteria from user sensory preferences.
|
||||||
|
|
||||||
|
textures: texture tags to exclude (empty tuple = no texture filter)
|
||||||
|
smell_above: if set, exclude recipes whose smell level is strictly above
|
||||||
|
this level in the smell spectrum
|
||||||
|
noise_above: if set, exclude recipes whose noise level is strictly above
|
||||||
|
this level in the noise spectrum
|
||||||
|
"""
|
||||||
|
textures: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
smell_above: str | None = None
|
||||||
|
noise_above: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty(cls) -> "SensoryExclude":
|
||||||
|
"""No filtering -- pass-through for users with no preferences set."""
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""True when no filtering will be applied."""
|
||||||
|
return not self.textures and self.smell_above is None and self.noise_above is None
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensory_exclude(prefs_json: str | None) -> SensoryExclude:
|
||||||
|
"""Parse user_settings value for 'sensory_preferences' into a SensoryExclude.
|
||||||
|
|
||||||
|
Expected JSON shape:
|
||||||
|
{
|
||||||
|
"avoid_textures": ["mushy", "slimy"],
|
||||||
|
"max_smell": "pungent",
|
||||||
|
"max_noise": "loud"
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns SensoryExclude.empty() on missing, null, or malformed input.
|
||||||
|
"""
|
||||||
|
if not prefs_json:
|
||||||
|
return SensoryExclude.empty()
|
||||||
|
try:
|
||||||
|
prefs = json.loads(prefs_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return SensoryExclude.empty()
|
||||||
|
if not isinstance(prefs, dict):
|
||||||
|
return SensoryExclude.empty()
|
||||||
|
|
||||||
|
avoid_textures = tuple(
|
||||||
|
t for t in (prefs.get("avoid_textures") or [])
|
||||||
|
if isinstance(t, str)
|
||||||
|
)
|
||||||
|
max_smell: str | None = prefs.get("max_smell") or None
|
||||||
|
max_noise: str | None = prefs.get("max_noise") or None
|
||||||
|
|
||||||
|
if max_smell and max_smell not in _SMELL_LEVELS:
|
||||||
|
max_smell = None
|
||||||
|
if max_noise and max_noise not in _NOISE_LEVELS:
|
||||||
|
max_noise = None
|
||||||
|
|
||||||
|
return SensoryExclude(
|
||||||
|
textures=avoid_textures,
|
||||||
|
smell_above=max_smell,
|
||||||
|
noise_above=max_noise,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def passes_sensory_filter(
|
||||||
|
sensory_tags_raw: str | dict | None,
|
||||||
|
exclude: SensoryExclude,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if the recipe passes the sensory exclude criteria.
|
||||||
|
|
||||||
|
sensory_tags_raw: the sensory_tags column value (JSON string or already-parsed dict).
|
||||||
|
exclude: derived filter criteria.
|
||||||
|
|
||||||
|
Untagged recipes (empty dict or '{}') always pass -- graceful degradation.
|
||||||
|
Empty SensoryExclude always passes -- no preferences set.
|
||||||
|
"""
|
||||||
|
if exclude.is_empty():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if sensory_tags_raw is None:
|
||||||
|
return True
|
||||||
|
if isinstance(sensory_tags_raw, str):
|
||||||
|
try:
|
||||||
|
tags: dict = json.loads(sensory_tags_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
tags = sensory_tags_raw
|
||||||
|
|
||||||
|
if not tags:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if exclude.textures:
|
||||||
|
recipe_textures: list[str] = tags.get("textures") or []
|
||||||
|
for t in recipe_textures:
|
||||||
|
if t in exclude.textures:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if exclude.smell_above is not None:
|
||||||
|
recipe_smell: str | None = tags.get("smell")
|
||||||
|
if recipe_smell and recipe_smell in _SMELL_LEVELS:
|
||||||
|
max_idx = _SMELL_LEVELS.index(exclude.smell_above)
|
||||||
|
recipe_idx = _SMELL_LEVELS.index(recipe_smell)
|
||||||
|
if recipe_idx > max_idx:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if exclude.noise_above is not None:
|
||||||
|
recipe_noise: str | None = tags.get("noise")
|
||||||
|
if recipe_noise and recipe_noise in _NOISE_LEVELS:
|
||||||
|
max_idx = _NOISE_LEVELS.index(exclude.noise_above)
|
||||||
|
recipe_idx = _NOISE_LEVELS.index(recipe_noise)
|
||||||
|
if recipe_idx > max_idx:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
139
app/services/recipe/style_classifier.py
Normal file
139
app/services/recipe/style_classifier.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# app/services/recipe/style_classifier.py
|
||||||
|
# BSL 1.1 — LLM feature
|
||||||
|
"""LLM style-tag classifier for saved recipes.
|
||||||
|
|
||||||
|
Reads recipe title, ingredients, and directions and suggests 3–5 style tags
|
||||||
|
from the curated vocabulary shared with SaveRecipeModal.vue.
|
||||||
|
|
||||||
|
Cloud (CF_ORCH_URL set): allocates a cf-text service via cf-orch (2 GB VRAM).
|
||||||
|
Local: falls back to LLMRouter (ollama / vllm / openai-compat).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SERVICE_TYPE = "cf-text"
|
||||||
|
_TTL_S = 60.0
|
||||||
|
_CALLER = "kiwi-style-classify"
|
||||||
|
|
||||||
|
# Canonical vocabulary — must stay in sync with SUGGESTED_TAGS in SaveRecipeModal.vue.
|
||||||
|
STYLE_TAG_VOCAB: frozenset[str] = frozenset({
|
||||||
|
"comforting", "light", "spicy", "umami", "sweet", "savory", "rich",
|
||||||
|
"crispy", "creamy", "hearty", "quick", "hands-off", "meal-prep-friendly",
|
||||||
|
"fancy", "one-pot",
|
||||||
|
})
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """\
|
||||||
|
You are a culinary tagger. Given a recipe, suggest 3 to 5 style tags that best \
|
||||||
|
describe its character. You MUST only use tags from this list:
|
||||||
|
|
||||||
|
comforting, light, spicy, umami, sweet, savory, rich, crispy, creamy, hearty, \
|
||||||
|
quick, hands-off, meal-prep-friendly, fancy, one-pot
|
||||||
|
|
||||||
|
Return ONLY a JSON array of strings, no explanation. Example:
|
||||||
|
["comforting", "hearty", "one-pot"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_router():
|
||||||
|
"""Return (router, context_manager) for style classify tasks.
|
||||||
|
|
||||||
|
Tries cf-orch cf-text allocation first; falls back to LLMRouter.
|
||||||
|
Returns (None, nullcontext) if no backend is available.
|
||||||
|
"""
|
||||||
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
|
if cf_orch_url:
|
||||||
|
try:
|
||||||
|
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
|
||||||
|
from circuitforge_orch.client import CFOrchClient
|
||||||
|
client = CFOrchClient(cf_orch_url)
|
||||||
|
ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER)
|
||||||
|
alloc = ctx.__enter__()
|
||||||
|
if alloc is not None:
|
||||||
|
return _OrchTextRouter(alloc.url), ctx
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch allocation failed for style classify, falling back: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
return LLMRouter(), nullcontext(None)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.debug("LLMRouter: no llm.yaml — style classifier LLM disabled")
|
||||||
|
return None, nullcontext(None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("LLMRouter init failed: %s", exc)
|
||||||
|
return None, nullcontext(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tags(raw: str) -> list[str]:
|
||||||
|
"""Extract valid vocab tags from raw LLM output.
|
||||||
|
|
||||||
|
Tries JSON parse first; falls back to extracting any vocab word present
|
||||||
|
in the response text so minor formatting deviations still work.
|
||||||
|
"""
|
||||||
|
# Strip markdown fences
|
||||||
|
raw = re.sub(r"```[a-z]*", "", raw).strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [t for t in parsed if isinstance(t, str) and t in STYLE_TAG_VOCAB][:5]
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: scan for vocab words
|
||||||
|
found = [t for t in STYLE_TAG_VOCAB if re.search(rf"\b{re.escape(t)}\b", raw, re.IGNORECASE)]
|
||||||
|
return sorted(found, key=lambda t: raw.lower().index(t.lower()))[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_style(recipe: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return 3–5 suggested style tags for *recipe*.
|
||||||
|
|
||||||
|
*recipe* is a Store row dict with at least ``title``, ``ingredient_names``
|
||||||
|
(list[str]), and ``directions`` (list[str] or str).
|
||||||
|
|
||||||
|
Returns an empty list if no LLM backend is available.
|
||||||
|
"""
|
||||||
|
router, ctx = _build_router()
|
||||||
|
if router is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
title = recipe.get("title") or "Unknown"
|
||||||
|
ingredients = recipe.get("ingredient_names") or []
|
||||||
|
if isinstance(ingredients, str):
|
||||||
|
try:
|
||||||
|
ingredients = json.loads(ingredients)
|
||||||
|
except Exception:
|
||||||
|
ingredients = [ingredients]
|
||||||
|
|
||||||
|
directions = recipe.get("directions") or []
|
||||||
|
if isinstance(directions, str):
|
||||||
|
try:
|
||||||
|
directions = json.loads(directions)
|
||||||
|
except Exception:
|
||||||
|
directions = [directions]
|
||||||
|
|
||||||
|
user_prompt = (
|
||||||
|
f"Recipe: {title}\n"
|
||||||
|
f"Ingredients: {', '.join(str(i) for i in ingredients[:20])}\n"
|
||||||
|
f"Steps: {' '.join(str(d) for d in directions[:8])[:600]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ctx:
|
||||||
|
raw = router.complete(
|
||||||
|
system=_SYSTEM_PROMPT,
|
||||||
|
user=user_prompt,
|
||||||
|
max_tokens=64,
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
return _parse_tags(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Style classifier LLM call failed: %s", exc)
|
||||||
|
return []
|
||||||
|
|
@ -55,11 +55,12 @@ class SubstitutionEngine:
|
||||||
ingredient_name: str,
|
ingredient_name: str,
|
||||||
constraint: str,
|
constraint: str,
|
||||||
) -> list[SubstitutionSwap]:
|
) -> list[SubstitutionSwap]:
|
||||||
rows = self._store._fetch_all("""
|
c = self._store._cp
|
||||||
|
rows = self._store._fetch_all(f"""
|
||||||
SELECT substitute_name, constraint_label,
|
SELECT substitute_name, constraint_label,
|
||||||
fat_delta, moisture_delta, glutamate_delta, protein_delta,
|
fat_delta, moisture_delta, glutamate_delta, protein_delta,
|
||||||
occurrence_count, compensation_hints
|
occurrence_count, compensation_hints
|
||||||
FROM substitution_pairs
|
FROM {c}substitution_pairs
|
||||||
WHERE original_name = ? AND constraint_label = ?
|
WHERE original_name = ? AND constraint_label = ?
|
||||||
ORDER BY occurrence_count DESC
|
ORDER BY occurrence_count DESC
|
||||||
""", (ingredient_name.lower(), constraint))
|
""", (ingredient_name.lower(), constraint))
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ queries find recipes the food.com corpus tags alone would miss.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Text-signal tables
|
# Text-signal tables
|
||||||
|
|
@ -68,6 +70,15 @@ _CUISINE_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
|
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
|
||||||
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
|
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
|
||||||
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
|
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
|
||||||
|
# BBQ detection: match on title terms and key ingredients; these rarely appear
|
||||||
|
# in food.com's own keyword/category taxonomy so we derive the tag from content.
|
||||||
|
("cuisine:BBQ", ["brisket", "pulled pork", "spare ribs", "baby back ribs",
|
||||||
|
"baby back", "burnt ends", "pit smoked", "smoke ring",
|
||||||
|
"low and slow", "hickory", "mesquite", "liquid smoke",
|
||||||
|
"bbq brisket", "smoked brisket", "barbecue brisket",
|
||||||
|
"carolina bbq", "texas bbq", "kansas city bbq",
|
||||||
|
"memphis bbq", "smoked ribs", "smoked pulled pork",
|
||||||
|
"dry rub ribs", "wet rub ribs", "beer can chicken smoked"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
|
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
|
|
@ -112,6 +123,65 @@ _TIME_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Meal type signals — matched against TITLE ONLY (not ingredient text).
|
||||||
|
# Ingredient names frequently contain words like "cake flour" or "sandwich
|
||||||
|
# bread" which would produce false meal-type tags if matched against the full
|
||||||
|
# title+ingredient string.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_MEAL_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
|
("meal:Breakfast", [
|
||||||
|
"breakfast", "pancake", "waffle", "french toast", "scrambled egg",
|
||||||
|
"frittata", "hash brown", "hash browns", "breakfast burrito",
|
||||||
|
"breakfast sandwich", "breakfast casserole", "overnight oat",
|
||||||
|
"granola", "oatmeal", "muffin", "morning glory", "eggs benedict",
|
||||||
|
"shakshuka", "crepe", "scone",
|
||||||
|
]),
|
||||||
|
("meal:Dessert", [
|
||||||
|
"dessert", "cake", "cookie", "brownie", "cheesecake", "pudding",
|
||||||
|
"fudge", "ice cream", "sorbet", "cupcake", "mousse", "candy",
|
||||||
|
"truffle", "gelato", "donut", "doughnut", "cobbler", "crisp",
|
||||||
|
"crumble", "tiramisu", "eclair", "sundae", "milkshake", "parfait",
|
||||||
|
"biscotti", "macaron", "panna cotta", "baklava", "churro", "tart",
|
||||||
|
"torte", "strudel", "compote", "semifreddo",
|
||||||
|
]),
|
||||||
|
("meal:Snack", [
|
||||||
|
"snack", "appetizer", "dip", "chips", "popcorn", "trail mix",
|
||||||
|
"energy ball", "deviled egg", "cheese ball", "nachos",
|
||||||
|
"pretzel bites", "protein ball", "granola bar",
|
||||||
|
]),
|
||||||
|
("meal:Beverage", [
|
||||||
|
"smoothie", "cocktail", "mocktail", "lemonade", "limeade",
|
||||||
|
"margarita", "sangria", "punch", "milkshake", "milk shake",
|
||||||
|
"juice", "spritzer", "iced tea", "hot chocolate", "chai latte",
|
||||||
|
"mulled wine", "eggnog", "slushie", "frappe", "horchata",
|
||||||
|
"agua fresca", "shrub", "switchel",
|
||||||
|
]),
|
||||||
|
("meal:Lunch", [
|
||||||
|
"lunch", "sandwich", "panini", "grilled cheese", "wrap",
|
||||||
|
"lunchbox", "lunch box",
|
||||||
|
]),
|
||||||
|
("meal:Bread", [
|
||||||
|
"bread", "sourdough", "focaccia", "flatbread", "dinner roll",
|
||||||
|
"loaf", "baguette", "ciabatta", "brioche", "challah", "pita",
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
_MAIN_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
|
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
||||||
|
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
|
||||||
|
("main:Pork", ["pork", "bacon", "ham", "sausage", "prosciutto"]),
|
||||||
|
("main:Fish", ["salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood", "fish"]),
|
||||||
|
("main:Pasta", ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"]),
|
||||||
|
("main:Vegetables", ["broccoli", "cauliflower", "zucchini", "eggplant", "carrot",
|
||||||
|
"vegetable", "veggie"]),
|
||||||
|
("main:Eggs", ["egg", "frittata", "omelette", "omelet", "quiche"]),
|
||||||
|
("main:Legumes", ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"]),
|
||||||
|
("main:Grains", ["rice", "quinoa", "barley", "farro", "oat", "grain"]),
|
||||||
|
("main:Cheese", ["cheddar", "mozzarella", "parmesan", "ricotta", "brie",
|
||||||
|
"cheese"]),
|
||||||
|
]
|
||||||
|
|
||||||
# food.com corpus tag -> normalized tags
|
# food.com corpus tag -> normalized tags
|
||||||
_CORPUS_TAG_MAP: dict[str, list[str]] = {
|
_CORPUS_TAG_MAP: dict[str, list[str]] = {
|
||||||
"european": ["cuisine:Italian", "cuisine:French", "cuisine:German",
|
"european": ["cuisine:Italian", "cuisine:French", "cuisine:German",
|
||||||
|
|
@ -172,6 +242,29 @@ def _match_signals(text: str, table: list[tuple[str, list[str]]]) -> list[str]:
|
||||||
return [tag for tag, pats in table if any(p in text for p in pats)]
|
return [tag for tag, pats in table if any(p in text for p in pats)]
|
||||||
|
|
||||||
|
|
||||||
|
def _match_title_signals(title: str, table: list[tuple[str, list[str]]]) -> list[str]:
|
||||||
|
"""Match signals against title text only, using word-boundary + optional plural.
|
||||||
|
|
||||||
|
Pattern: `\\bWORD(?:s|es)?\\b`
|
||||||
|
|
||||||
|
This handles:
|
||||||
|
- Plurals: "cookie" matches "cookies", "sandwich" matches "sandwiches"
|
||||||
|
- Substring rejection: "cake" does NOT match "pancake" (no word boundary
|
||||||
|
before 'c' in pan|cake), "tart" does NOT match "tartare" (after "tart"
|
||||||
|
the 'a' is a word char, not a boundary)
|
||||||
|
- Avoids false positives from ingredient text ("cake flour", "sandwich bread")
|
||||||
|
by only matching the recipe title, not the full title+ingredient string.
|
||||||
|
"""
|
||||||
|
t = title.lower()
|
||||||
|
return [
|
||||||
|
tag for tag, pats in table
|
||||||
|
if any(
|
||||||
|
re.search(r"\b" + re.escape(p.strip()) + r"(?:s|es)?\b", t)
|
||||||
|
for p in pats
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def infer_tags(
|
def infer_tags(
|
||||||
title: str,
|
title: str,
|
||||||
ingredient_names: list[str],
|
ingredient_names: list[str],
|
||||||
|
|
@ -232,6 +325,10 @@ def infer_tags(
|
||||||
tags.update(_match_signals(text, _CUISINE_SIGNALS))
|
tags.update(_match_signals(text, _CUISINE_SIGNALS))
|
||||||
tags.update(_match_signals(text, _DIETARY_SIGNALS))
|
tags.update(_match_signals(text, _DIETARY_SIGNALS))
|
||||||
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
||||||
|
tags.update(_match_signals(text, _MAIN_INGREDIENT_SIGNALS))
|
||||||
|
|
||||||
|
# Meal type: title-only to avoid "cake flour" → meal:Dessert false positives
|
||||||
|
tags.update(_match_title_signals(title, _MEAL_SIGNALS))
|
||||||
|
|
||||||
# 3. Time signals from corpus keywords + text
|
# 3. Time signals from corpus keywords + text
|
||||||
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
||||||
|
|
|
||||||
602
app/services/recipe/time_effort.py
Normal file
602
app/services/recipe/time_effort.py
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
"""
|
||||||
|
Runtime parser for active/passive time split, prep effort, and equipment detection.
|
||||||
|
|
||||||
|
Operates over a list of direction strings plus an optional ingredient list.
|
||||||
|
No I/O — pure Python functions. Sub-millisecond for up to 20 recipes.
|
||||||
|
|
||||||
|
Time estimation strategy (in priority order):
|
||||||
|
1. Explicit time mention in step text ("simmer for 20 minutes")
|
||||||
|
2. Passive keyword + per-technique default ("bake until golden" → 30 min)
|
||||||
|
3. Prep action + ingredient quantity scaling ("dice 2 lbs potatoes" → ~5 min)
|
||||||
|
4. Fallback active default (assembly/misc steps → 2 min each)
|
||||||
|
|
||||||
|
Quantity scaling uses n^0.75 (sub-linear, matching human batch-work curves).
|
||||||
|
Pass `ingredients` + `ingredient_names` to enable cross-referenced scaling.
|
||||||
|
Without them, prep actions use base times only (no scaling).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# ── Passive step keywords ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_PASSIVE_PATTERNS: Final[list[str]] = [
|
||||||
|
"simmer", "bake", "roast", "broil", "refrigerate", "marinate",
|
||||||
|
"chill", "cool", "freeze", "rest", "stand", "set", "soak",
|
||||||
|
"steep", "proof", "rise", "let", "wait", "overnight", "braise",
|
||||||
|
r"slow\s+cook", r"pressure\s+cook",
|
||||||
|
]
|
||||||
|
|
||||||
|
_PASSIVE_RE: re.Pattern[str] = re.compile(
|
||||||
|
r"\b(?:" + "|".join(_PASSIVE_PATTERNS) + r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-technique passive defaults (minutes) — used when no explicit time found.
|
||||||
|
# Calibrated to conservative midpoints from USDA FoodKeeper + culinary practice.
|
||||||
|
_PASSIVE_DEFAULTS: Final[list[tuple[re.Pattern[str], int]]] = [
|
||||||
|
# Multi-word first (longer match wins)
|
||||||
|
(re.compile(r"\bslow\s+cook\b", re.IGNORECASE), 300), # 5 hr crockpot default
|
||||||
|
(re.compile(r"\bpressure\s+cook\b", re.IGNORECASE), 15),
|
||||||
|
(re.compile(r"\bovernight\b", re.IGNORECASE), 480), # 8 hr
|
||||||
|
# Single-word
|
||||||
|
(re.compile(r"\bbraise\b", re.IGNORECASE), 90),
|
||||||
|
(re.compile(r"\bmarinate\b", re.IGNORECASE), 60),
|
||||||
|
(re.compile(r"\brefrigerate\b", re.IGNORECASE), 120),
|
||||||
|
(re.compile(r"\bproof\b|\brise\b", re.IGNORECASE), 60),
|
||||||
|
(re.compile(r"\bsoak\b", re.IGNORECASE), 30),
|
||||||
|
(re.compile(r"\bfreeze\b", re.IGNORECASE), 120),
|
||||||
|
(re.compile(r"\bchill\b", re.IGNORECASE), 60),
|
||||||
|
(re.compile(r"\broast\b", re.IGNORECASE), 40),
|
||||||
|
(re.compile(r"\bbake\b", re.IGNORECASE), 30),
|
||||||
|
(re.compile(r"\bbroil\b", re.IGNORECASE), 8),
|
||||||
|
(re.compile(r"\bsimmer\b", re.IGNORECASE), 20),
|
||||||
|
(re.compile(r"\bset\b", re.IGNORECASE), 30), # gelatin / custard set
|
||||||
|
(re.compile(r"\bsteep\b", re.IGNORECASE), 5),
|
||||||
|
(re.compile(r"\brest\b|\bstand\b", re.IGNORECASE), 10),
|
||||||
|
(re.compile(r"\bcool\b", re.IGNORECASE), 15),
|
||||||
|
(re.compile(r"\bwait\b|\blet\b", re.IGNORECASE), 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Explicit time extraction ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
_TIME_RE: re.Pattern[str] = re.compile(
|
||||||
|
r"(\d+)\s*(?:[-\u2013]|-to-)\s*(\d+)\s*(hour|hr|minute|min|second|sec)s?"
|
||||||
|
r"|"
|
||||||
|
r"(\d+)\s*(hour|hr|minute|min|second|sec)s?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MAX_MINUTES_PER_STEP: Final[int] = 480 # 8-hour sanity cap
|
||||||
|
|
||||||
|
# ── Prep action detection ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Base times (minutes) per prep action, calibrated to ~3 items / 0.5 lb reference.
|
||||||
|
# These are starting points — flagged for calibration against real recipe timing data.
|
||||||
|
_PREP_ACTION_BASES: Final[dict[str, float]] = {
|
||||||
|
# Peeling / stripping
|
||||||
|
"peel": 1.5,
|
||||||
|
"pare": 1.5,
|
||||||
|
"hull": 1.5,
|
||||||
|
"pit": 2.0, # cherries, avocados
|
||||||
|
"core": 1.0,
|
||||||
|
"stem": 1.0,
|
||||||
|
"trim": 1.0,
|
||||||
|
# Cutting
|
||||||
|
"chop": 2.0,
|
||||||
|
"cut": 1.5,
|
||||||
|
"dice": 2.5, # more precise than chop
|
||||||
|
"mince": 2.0,
|
||||||
|
"slice": 1.5,
|
||||||
|
"julienne": 4.0,
|
||||||
|
"cube": 2.0,
|
||||||
|
"quarter": 1.0,
|
||||||
|
"halve": 0.5,
|
||||||
|
"shred": 2.0,
|
||||||
|
# Grating / zesting
|
||||||
|
"grate": 3.0,
|
||||||
|
"zest": 2.0,
|
||||||
|
# Crushing
|
||||||
|
"crush": 0.5,
|
||||||
|
"smash": 0.5,
|
||||||
|
"crack": 0.5,
|
||||||
|
# Mixing / assembly (lower base — less physical effort)
|
||||||
|
"knead": 8.0, # bread dough: consistent regardless of quantity
|
||||||
|
"whisk": 1.5,
|
||||||
|
"beat": 2.0,
|
||||||
|
"cream": 3.0, # butter + sugar until fluffy
|
||||||
|
"fold": 1.5,
|
||||||
|
"stir": 0.5,
|
||||||
|
"combine": 0.5,
|
||||||
|
"mix": 1.0,
|
||||||
|
"season": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compiled regex — longer patterns first to avoid partial matches.
|
||||||
|
_PREP_RE: re.Pattern[str] = re.compile(
|
||||||
|
r"\b(?:" + "|".join(
|
||||||
|
re.escape(k) for k in sorted(_PREP_ACTION_BASES, key=len, reverse=True)
|
||||||
|
) + r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default active time per step when no explicit time and no prep action detected.
|
||||||
|
_ACTIVE_STEP_DEFAULT_MIN: Final[float] = 2.0
|
||||||
|
|
||||||
|
# ── Prep-needing ingredient classification ────────────────────────────────
|
||||||
|
#
|
||||||
|
# Only ingredients in this set get quantity-scaled prep time.
|
||||||
|
# Liquids, spices, canned goods, and dry staples are excluded — they require
|
||||||
|
# no physical prep beyond measuring.
|
||||||
|
|
||||||
|
_PREP_NEEDING: Final[frozenset[str]] = frozenset({
|
||||||
|
# Alliums
|
||||||
|
"onion", "shallot", "leek", "scallion", "green onion", "chive", "garlic",
|
||||||
|
# Root / stem vegetables
|
||||||
|
"ginger", "carrot", "celery", "potato", "sweet potato", "yam",
|
||||||
|
"beet", "turnip", "parsnip", "radish", "fennel", "celeriac",
|
||||||
|
# Squash / gourd family
|
||||||
|
"zucchini", "squash", "pumpkin", "cucumber",
|
||||||
|
# Peppers
|
||||||
|
"pepper", "bell pepper", "jalapeño", "jalapeno", "chili", "chile",
|
||||||
|
# Brassicas
|
||||||
|
"broccoli", "cauliflower", "cabbage", "kale", "chard", "spinach",
|
||||||
|
"brussels sprout",
|
||||||
|
# Other vegetables
|
||||||
|
"tomato", "eggplant", "aubergine", "corn", "artichoke", "asparagus",
|
||||||
|
"green bean", "snow pea", "snap pea", "mushroom", "lettuce",
|
||||||
|
# Fruits
|
||||||
|
"apple", "pear", "peach", "nectarine", "plum", "apricot",
|
||||||
|
"mango", "papaya", "pineapple", "melon", "watermelon", "cantaloupe",
|
||||||
|
"avocado", "banana",
|
||||||
|
"strawberry", "raspberry", "blackberry", "blueberry", "cherry",
|
||||||
|
"citrus", "lemon", "lime", "orange", "grapefruit",
|
||||||
|
# Protein (trimming / portioning)
|
||||||
|
"chicken", "turkey", "duck",
|
||||||
|
"beef", "pork", "lamb", "veal",
|
||||||
|
"fish", "salmon", "tuna", "cod", "tilapia", "halibut", "shrimp",
|
||||||
|
"scallop", "crab", "lobster",
|
||||||
|
# Dairy requiring active prep
|
||||||
|
"cheese",
|
||||||
|
# Nuts / seeds (chopping)
|
||||||
|
"almond", "walnut", "pecan", "cashew", "peanut", "hazelnut",
|
||||||
|
"pistachio", "macadamia", "nut",
|
||||||
|
# Fresh herbs (chopping / tearing)
|
||||||
|
"basil", "parsley", "cilantro", "thyme", "rosemary", "sage",
|
||||||
|
"dill", "mint", "tarragon",
|
||||||
|
# Other
|
||||||
|
"bread",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_prep_needing(name: str) -> bool:
|
||||||
|
"""True if the normalized ingredient name contains any prep-needing keyword."""
|
||||||
|
nl = name.lower()
|
||||||
|
return any(kw in nl for kw in _PREP_NEEDING)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quantity extraction ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_FRAC_RE: re.Pattern[str] = re.compile(r"(\d+)\s*/\s*(\d+)")
|
||||||
|
|
||||||
|
# Weight units → converted to pounds internally
|
||||||
|
_WEIGHT_RE: re.Pattern[str] = re.compile(
|
||||||
|
r"(\d+(?:\.\d+)?|\d+\s*/\s*\d+)\s*"
|
||||||
|
r"(pound|lb|ounce|oz|gram|g(?![a-z])|kilogram|kg)\s*s?\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Volume (cups only — the common recipe unit for quantity scaling)
|
||||||
|
_VOLUME_CUP_RE: re.Pattern[str] = re.compile(
|
||||||
|
r"(\d+(?:\.\d+)?|\d+\s*/\s*\d+)\s*cups?\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count — bare integer or decimal followed by optional size/unit word
|
||||||
|
_COUNT_RE: re.Pattern[str] = re.compile(
|
||||||
|
r"(?<!\d)(\d+(?:\.\d+)?)\s*"
|
||||||
|
r"(?:large|medium|small|whole|clove|cloves|head|heads|ear|ears|"
|
||||||
|
r"stalk|stalks|sprig|sprigs|bunch|bunches|fillet|fillets|"
|
||||||
|
r"breast|breasts|piece|pieces|slice|slices)?\s*\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reference quantities: the "1× base" for each unit type.
|
||||||
|
# Calibrated so that a typical single-ingredient amount = 1× prep time.
|
||||||
|
_QTY_REFS: Final[dict[str, float]] = {
|
||||||
|
"lb": 0.5, # 0.5 lb is the base → 1 lb = 1.4×, 2 lb = 2.0×
|
||||||
|
"cup": 1.0, # 1 cup = base
|
||||||
|
"count": 3.0, # 3 items = base → 1 = 0.46×, 6 = 1.6×
|
||||||
|
}
|
||||||
|
|
||||||
|
_SCALE_POWER: Final[float] = 0.75 # sub-linear; revisit with empirical data
|
||||||
|
_MAX_SCALE: Final[float] = 4.0 # cap at 4× regardless of quantity
|
||||||
|
_MIN_SCALE: Final[float] = 0.33 # floor at 1/3× for tiny amounts
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fraction(s: str) -> float:
|
||||||
|
m = _FRAC_RE.search(s)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return float(m.group(1)) / float(m.group(2))
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
return 1.0
|
||||||
|
try:
|
||||||
|
return float(s.replace(" ", ""))
|
||||||
|
except ValueError:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_qty(text: str) -> tuple[float, str] | None:
|
||||||
|
"""Return (quantity_in_canonical_units, unit_type) or None.
|
||||||
|
|
||||||
|
Unit types: "lb" (weight in pounds), "cup", "count".
|
||||||
|
All weights are normalised to pounds.
|
||||||
|
"""
|
||||||
|
# Weight (most specific — check first)
|
||||||
|
m = _WEIGHT_RE.search(text)
|
||||||
|
if m:
|
||||||
|
qty = _parse_fraction(m.group(1))
|
||||||
|
u = m.group(2).lower().rstrip("s")
|
||||||
|
if u in ("pound", "lb"):
|
||||||
|
return (qty, "lb")
|
||||||
|
if u in ("ounce", "oz"):
|
||||||
|
return (qty / 16.0, "lb")
|
||||||
|
if u in ("gram", "g"):
|
||||||
|
return (qty / 453.6, "lb")
|
||||||
|
if u in ("kilogram", "kg"):
|
||||||
|
return (qty * 2.205, "lb")
|
||||||
|
|
||||||
|
# Volume (cups)
|
||||||
|
m = _VOLUME_CUP_RE.search(text)
|
||||||
|
if m:
|
||||||
|
return (_parse_fraction(m.group(1)), "cup")
|
||||||
|
|
||||||
|
# Count — only accept values in a sane range to avoid false positives
|
||||||
|
m = _COUNT_RE.search(text)
|
||||||
|
if m:
|
||||||
|
qty = float(m.group(1))
|
||||||
|
if 0 < qty <= 24:
|
||||||
|
return (qty, "count")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_inline_qty_for(text: str, ing_name: str) -> tuple[float, str] | None:
|
||||||
|
"""Extract the quantity specifically associated with `ing_name` in a direction step.
|
||||||
|
|
||||||
|
Looks for a number immediately before the ingredient name (plus optional size/unit
|
||||||
|
words). Falls back to None if the pattern does not match.
|
||||||
|
|
||||||
|
Example: "Dice 2 large onions and 3 carrots" → for "onion" returns (2.0, "count").
|
||||||
|
"""
|
||||||
|
pattern = re.compile(
|
||||||
|
r"(\d+(?:\.\d+)?|\d+\s*/\s*\d+)\s*"
|
||||||
|
r"(?:large|medium|small|whole|"
|
||||||
|
r"(?:pound|lb|ounce|oz|gram|g|kilogram|kg|cup|clove|cloves|"
|
||||||
|
r"head|heads|fillet|fillets|breast|breasts|piece|pieces)s?)??\s*"
|
||||||
|
+ re.escape(ing_name) + r"(?:es|s)?\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
m = pattern.search(text)
|
||||||
|
if m:
|
||||||
|
# Re-extract with _extract_qty on the full matched span to get unit too
|
||||||
|
span = text[m.start(): m.end()]
|
||||||
|
result = _extract_qty(span)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
# Fallback: bare count
|
||||||
|
try:
|
||||||
|
return (_parse_fraction(m.group(1)), "count")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _quantity_scale(qty: float, unit: str) -> float:
|
||||||
|
"""Apply n^0.75 scaling relative to unit reference, clamped to [MIN, MAX]."""
|
||||||
|
ref = _QTY_REFS.get(unit, 1.0)
|
||||||
|
if ref <= 0 or qty <= 0:
|
||||||
|
return 1.0
|
||||||
|
raw = (qty / ref) ** _SCALE_POWER
|
||||||
|
return max(_MIN_SCALE, min(_MAX_SCALE, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Equipment detection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_EQUIPMENT_RULES: Final[list[tuple[re.Pattern[str], str]]] = [
|
||||||
|
(re.compile(r"\b(?:chop|dice|mince|slice|julienne)\b", re.IGNORECASE), "Knife"),
|
||||||
|
(re.compile(r"\b(?:skillet|sauté|saute|fry|sear|pan-fry|pan fry)\b", re.IGNORECASE), "Skillet"),
|
||||||
|
(re.compile(r"\b(?:wooden spoon|spatula|stir|fold)\b", re.IGNORECASE), "Spoon"),
|
||||||
|
(re.compile(r"\b(?:pot|boil|simmer|blanch|stock)\b", re.IGNORECASE), "Pot"),
|
||||||
|
(re.compile(r"\b(?:oven|bake|roast|preheat|broil)\b", re.IGNORECASE), "Oven"),
|
||||||
|
(re.compile(r"\b(?:blender|blend|purée|puree|food processor)\b", re.IGNORECASE), "Blender"),
|
||||||
|
(re.compile(r"\b(?:stand mixer|hand mixer|whip|beat)\b", re.IGNORECASE), "Mixer"),
|
||||||
|
(re.compile(r"\b(?:grill|barbecue|char|griddle)\b", re.IGNORECASE), "Grill"),
|
||||||
|
(re.compile(r"\b(?:slow cooker|crockpot|low and slow)\b", re.IGNORECASE), "Slow cooker"),
|
||||||
|
(re.compile(r"\b(?:pressure cooker|instant pot)\b", re.IGNORECASE), "Pressure cooker"),
|
||||||
|
(re.compile(r"\b(?:drain|strain|colander|rinse pasta)\b", re.IGNORECASE), "Colander"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_equipment(all_text: str, has_passive: bool) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for pattern, label in _EQUIPMENT_RULES:
|
||||||
|
if label not in seen and pattern.search(all_text):
|
||||||
|
seen.add(label)
|
||||||
|
result.append(label)
|
||||||
|
if has_passive and "Timer" not in seen:
|
||||||
|
result.append("Timer")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ingredient–step cross-reference ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _ingredient_mentioned(text: str, name: str) -> bool:
|
||||||
|
"""True if `name` appears in `text` as a whole word.
|
||||||
|
|
||||||
|
Handles both regular plurals (onion → onions) and -es plurals
|
||||||
|
(potato → potatoes, tomato → tomatoes).
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r"\b" + re.escape(name.lower()) + r"(?:es|s)?\b", re.IGNORECASE)
|
||||||
|
return bool(pattern.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_step_ingredient_qtys(
|
||||||
|
ingredients: list[str],
|
||||||
|
ingredient_names: list[str],
|
||||||
|
directions: list[str],
|
||||||
|
) -> list[dict[str, tuple[float, str]]]:
|
||||||
|
"""Return, for each direction step, {ing_name: (qty_for_this_step, unit)}.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- Filter ingredient pairs to prep-needing items only.
|
||||||
|
- Parse total quantities from the raw ingredient strings.
|
||||||
|
- For each step, try to find an inline quantity tied to that ingredient name.
|
||||||
|
- If no inline quantity, distribute the total evenly across all steps that
|
||||||
|
mention the ingredient (handles "3 onions" split across 2 steps).
|
||||||
|
"""
|
||||||
|
# Build total qty map for prep-needing ingredients
|
||||||
|
total_qtys: dict[str, tuple[float, str]] = {}
|
||||||
|
for raw, name in zip(ingredients, ingredient_names):
|
||||||
|
base = name.lower().strip()
|
||||||
|
if not _is_prep_needing(base):
|
||||||
|
continue
|
||||||
|
result = _extract_qty(raw)
|
||||||
|
if result is not None:
|
||||||
|
total_qtys[base] = result
|
||||||
|
|
||||||
|
if not total_qtys:
|
||||||
|
return [{} for _ in directions]
|
||||||
|
|
||||||
|
# Count how many steps mention each ingredient
|
||||||
|
step_counts: dict[str, int] = {n: 0 for n in total_qtys}
|
||||||
|
for step in directions:
|
||||||
|
for name in total_qtys:
|
||||||
|
if _ingredient_mentioned(step, name):
|
||||||
|
step_counts[name] += 1
|
||||||
|
|
||||||
|
# Build per-step qty maps
|
||||||
|
per_step: list[dict[str, tuple[float, str]]] = []
|
||||||
|
for step in directions:
|
||||||
|
step_map: dict[str, tuple[float, str]] = {}
|
||||||
|
for name, (total, unit) in total_qtys.items():
|
||||||
|
if not _ingredient_mentioned(step, name):
|
||||||
|
continue
|
||||||
|
# Try ingredient-specific inline quantity first
|
||||||
|
inline = _extract_inline_qty_for(step, name)
|
||||||
|
if inline is not None:
|
||||||
|
step_map[name] = inline
|
||||||
|
else:
|
||||||
|
# Distribute total across steps that reference this ingredient
|
||||||
|
n = max(step_counts.get(name, 1), 1)
|
||||||
|
step_map[name] = (total / n, unit)
|
||||||
|
per_step.append(step_map)
|
||||||
|
|
||||||
|
return per_step
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dataclasses ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StepAnalysis:
|
||||||
|
"""Analysis result for a single direction step."""
|
||||||
|
is_passive: bool
|
||||||
|
detected_minutes: int | None # explicit or estimated time (None = no signal)
|
||||||
|
prep_min: int | None = None # estimated physical prep time from action detection
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TimeEffortProfile:
|
||||||
|
"""Aggregated time and effort profile for a full recipe."""
|
||||||
|
active_min: int
|
||||||
|
passive_min: int
|
||||||
|
total_min: int
|
||||||
|
step_analyses: list[StepAnalysis] = field(default_factory=list)
|
||||||
|
equipment: list[str] = field(default_factory=list)
|
||||||
|
effort_label: str = "moderate" # "quick" | "moderate" | "involved"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Core parsing helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_minutes(text: str) -> int | None:
|
||||||
|
"""Return explicit minutes from text, or None."""
|
||||||
|
m = _TIME_RE.search(text)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
if m.group(1) is not None:
|
||||||
|
low, high = int(m.group(1)), int(m.group(2))
|
||||||
|
unit = m.group(3).lower()
|
||||||
|
raw: float = (low + high) / 2
|
||||||
|
else:
|
||||||
|
low = int(m.group(4))
|
||||||
|
unit = m.group(5).lower()
|
||||||
|
raw = float(low)
|
||||||
|
|
||||||
|
if unit in ("hour", "hr"):
|
||||||
|
minutes: float = raw * 60
|
||||||
|
elif unit in ("second", "sec"):
|
||||||
|
minutes = max(1.0, math.ceil(raw / 60))
|
||||||
|
else:
|
||||||
|
minutes = raw
|
||||||
|
|
||||||
|
return min(int(minutes), _MAX_MINUTES_PER_STEP)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_passive(text: str) -> bool:
|
||||||
|
return _PASSIVE_RE.search(text) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _passive_default(text: str) -> int | None:
|
||||||
|
"""Return estimated passive minutes from per-keyword defaults."""
|
||||||
|
for pattern, minutes in _PASSIVE_DEFAULTS:
|
||||||
|
if pattern.search(text):
|
||||||
|
return minutes
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prep_estimate(
|
||||||
|
text: str,
|
||||||
|
step_ing_qtys: dict[str, tuple[float, str]],
|
||||||
|
) -> int:
|
||||||
|
"""Estimate active prep time from the first detected prep action + ingredient qtys.
|
||||||
|
|
||||||
|
If no prep-needing ingredient is identified in the step, uses the action's
|
||||||
|
base time at 1× (no scaling).
|
||||||
|
"""
|
||||||
|
m = _PREP_RE.search(text)
|
||||||
|
if m is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
action = m.group(0).lower()
|
||||||
|
base = _PREP_ACTION_BASES.get(action, _ACTIVE_STEP_DEFAULT_MIN)
|
||||||
|
|
||||||
|
# Find which prep-needing ingredients this step mentions
|
||||||
|
matches: list[tuple[float, str]] = [
|
||||||
|
qty_unit
|
||||||
|
for name, qty_unit in step_ing_qtys.items()
|
||||||
|
if _ingredient_mentioned(text, name)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return round(base) # no ingredient context — use base unscaled
|
||||||
|
|
||||||
|
total = sum(base * _quantity_scale(qty, unit) for qty, unit in matches)
|
||||||
|
return round(total)
|
||||||
|
|
||||||
|
|
||||||
|
def _effort_label(total_min: int, step_count: int) -> str:
|
||||||
|
"""Effort label based on total estimated time; falls back to step count."""
|
||||||
|
if total_min > 0:
|
||||||
|
if total_min <= 20:
|
||||||
|
return "quick"
|
||||||
|
if total_min <= 45:
|
||||||
|
return "moderate"
|
||||||
|
return "involved"
|
||||||
|
# No time signals at all — fall back to step count heuristic
|
||||||
|
if step_count <= 3:
|
||||||
|
return "quick"
|
||||||
|
if step_count <= 7:
|
||||||
|
return "moderate"
|
||||||
|
return "involved"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_effort(
|
||||||
|
directions: list[str],
|
||||||
|
ingredients: list[str] | None = None,
|
||||||
|
ingredient_names: list[str] | None = None,
|
||||||
|
) -> TimeEffortProfile:
|
||||||
|
"""Parse direction strings into a TimeEffortProfile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directions: List of step strings from the recipe corpus.
|
||||||
|
ingredients: Raw ingredient strings ("2 large onions", "1.5 lbs potatoes").
|
||||||
|
Parallel to ingredient_names.
|
||||||
|
ingredient_names: Normalised ingredient names ("onion", "potato").
|
||||||
|
Required alongside ingredients to enable quantity scaling.
|
||||||
|
|
||||||
|
Returns a zero-value profile with empty lists when directions is empty.
|
||||||
|
Never raises — all failures produce sensible defaults.
|
||||||
|
"""
|
||||||
|
if not directions:
|
||||||
|
return TimeEffortProfile(
|
||||||
|
active_min=0, passive_min=0, total_min=0,
|
||||||
|
step_analyses=[], equipment=[], effort_label="quick",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build per-step ingredient quantity maps (empty dicts if no ingredient data)
|
||||||
|
use_ingredients = (
|
||||||
|
bool(ingredients)
|
||||||
|
and bool(ingredient_names)
|
||||||
|
and len(ingredients) == len(ingredient_names)
|
||||||
|
)
|
||||||
|
step_ing_qtys: list[dict[str, tuple[float, str]]]
|
||||||
|
if use_ingredients:
|
||||||
|
step_ing_qtys = _build_step_ingredient_qtys(
|
||||||
|
list(ingredients), # type: ignore[arg-type]
|
||||||
|
list(ingredient_names), # type: ignore[arg-type]
|
||||||
|
directions,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
step_ing_qtys = [{} for _ in directions]
|
||||||
|
|
||||||
|
step_analyses: list[StepAnalysis] = []
|
||||||
|
active_min = 0
|
||||||
|
passive_min = 0
|
||||||
|
has_any_passive = False
|
||||||
|
|
||||||
|
for i, step in enumerate(directions):
|
||||||
|
is_passive = _classify_passive(step)
|
||||||
|
detected = _extract_minutes(step)
|
||||||
|
prep_estimate: int | None = None
|
||||||
|
|
||||||
|
if is_passive:
|
||||||
|
has_any_passive = True
|
||||||
|
if detected is not None:
|
||||||
|
passive_min += detected
|
||||||
|
else:
|
||||||
|
# Fall back to per-technique default
|
||||||
|
default = _passive_default(step)
|
||||||
|
if default is not None:
|
||||||
|
passive_min += default
|
||||||
|
detected = default # surface in UI as the hint time
|
||||||
|
else:
|
||||||
|
if detected is not None:
|
||||||
|
active_min += detected
|
||||||
|
|
||||||
|
# Estimate prep time from action detection + quantity scaling
|
||||||
|
prep_est = _prep_estimate(step, step_ing_qtys[i])
|
||||||
|
if prep_est > 0:
|
||||||
|
prep_estimate = prep_est
|
||||||
|
active_min += prep_est
|
||||||
|
elif detected is None:
|
||||||
|
# General active step with no time signal — apply a small default
|
||||||
|
active_min += round(_ACTIVE_STEP_DEFAULT_MIN)
|
||||||
|
|
||||||
|
step_analyses.append(StepAnalysis(
|
||||||
|
is_passive=is_passive,
|
||||||
|
detected_minutes=detected,
|
||||||
|
prep_min=prep_estimate,
|
||||||
|
))
|
||||||
|
|
||||||
|
combined_text = " ".join(directions)
|
||||||
|
equipment = _detect_equipment(combined_text, has_any_passive)
|
||||||
|
total = active_min + passive_min
|
||||||
|
|
||||||
|
return TimeEffortProfile(
|
||||||
|
active_min=active_min,
|
||||||
|
passive_min=passive_min,
|
||||||
|
total_min=total,
|
||||||
|
step_analyses=step_analyses,
|
||||||
|
equipment=equipment,
|
||||||
|
effort_label=_effort_label(total, len(directions)),
|
||||||
|
)
|
||||||
124
app/services/task_inference.py
Normal file
124
app/services/task_inference.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# app/services/task_inference.py
|
||||||
|
# BSL 1.1 — LLM feature
|
||||||
|
"""Task-based service allocation via the cf-orch coordinator.
|
||||||
|
|
||||||
|
Calls POST /api/inference/task instead of a hardcoded service type.
|
||||||
|
The coordinator resolves model_id and service_type from assignments.yaml.
|
||||||
|
|
||||||
|
Fallback contract (for callers):
|
||||||
|
- 404 → TaskNotRegistered (fall back to direct client.allocate())
|
||||||
|
- other error → RuntimeError
|
||||||
|
- CF_ORCH_URL unset → RuntimeError (guard with os.environ.get first)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskNotRegistered(Exception):
|
||||||
|
"""Coordinator returned 404 for a product/task pair.
|
||||||
|
|
||||||
|
Means the task is not yet in assignments.yaml. Callers should fall
|
||||||
|
back to direct service allocation (client.allocate()).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Allocation:
|
||||||
|
url: str
|
||||||
|
allocation_id: str
|
||||||
|
service: str
|
||||||
|
|
||||||
|
|
||||||
|
def _orch_url() -> str:
|
||||||
|
return os.environ.get("CF_ORCH_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def task_allocate(
|
||||||
|
product: str,
|
||||||
|
task: str,
|
||||||
|
*,
|
||||||
|
service_hint: str,
|
||||||
|
ttl_s: float = 120.0,
|
||||||
|
) -> Generator[Allocation, None, None]:
|
||||||
|
"""Context manager: allocate a service via task-based routing.
|
||||||
|
|
||||||
|
Calls POST /api/inference/task, yields Allocation, releases on exit.
|
||||||
|
Supports both `with task_allocate(...) as alloc:` and manual
|
||||||
|
`ctx = task_allocate(...); alloc = ctx.__enter__()` patterns.
|
||||||
|
|
||||||
|
**Sync-only**: uses the synchronous httpx API. Do not call from an
|
||||||
|
``async def`` handler without wrapping in ``asyncio.to_thread``. Current
|
||||||
|
call sites (``llm_router.py``, ``vl_model.py``) are synchronous.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product: CF product name (e.g. "kiwi")
|
||||||
|
task: Task identifier (e.g. "meal_plan", "ocr")
|
||||||
|
service_hint: Service type for the release DELETE call. The
|
||||||
|
coordinator response does not include service_type, so the
|
||||||
|
caller provides it. When the coordinator is updated to return
|
||||||
|
service in the response (cf-orch#63), this becomes unused.
|
||||||
|
ttl_s: Allocation TTL in seconds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TaskNotRegistered: Coordinator returned 404.
|
||||||
|
RuntimeError: Coordinator unreachable, returned non-404 error, or
|
||||||
|
returned a malformed (non-JSON / missing fields) response.
|
||||||
|
RuntimeError: CF_ORCH_URL is not set.
|
||||||
|
"""
|
||||||
|
base = _orch_url()
|
||||||
|
if not base:
|
||||||
|
raise RuntimeError("CF_ORCH_URL is not set")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{base}/api/inference/task",
|
||||||
|
json={"product": product, "task": task, "payload": {}},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise RuntimeError(f"cf-orch unreachable: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise TaskNotRegistered(
|
||||||
|
f"No assignment for product={product!r} task={task!r} — "
|
||||||
|
"ensure cf-orch#61/62 are deployed and coordinator reloaded"
|
||||||
|
)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"cf-orch /api/inference/task failed: "
|
||||||
|
f"HTTP {resp.status_code} — {resp.text[:200]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
alloc = Allocation(
|
||||||
|
url=data["url"],
|
||||||
|
allocation_id=data["allocation_id"],
|
||||||
|
service=data.get("service") or service_hint,
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"cf-orch /api/inference/task returned malformed response: {exc} — "
|
||||||
|
f"body: {resp.text[:200]}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield alloc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
httpx.delete(
|
||||||
|
f"{base}/api/services/{alloc.service}/allocations/{alloc.allocation_id}",
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch task allocation release failed (non-fatal): %s", exc)
|
||||||
|
|
@ -22,7 +22,7 @@ from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback"})
|
LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback", "recipe_llm"})
|
||||||
|
|
||||||
VRAM_BUDGETS: dict[str, float] = {
|
VRAM_BUDGETS: dict[str, float] = {
|
||||||
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
|
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
|
||||||
|
|
@ -88,6 +88,8 @@ def run_task(
|
||||||
try:
|
try:
|
||||||
if task_type == "expiry_llm_fallback":
|
if task_type == "expiry_llm_fallback":
|
||||||
_run_expiry_llm_fallback(db_path, job_id, params)
|
_run_expiry_llm_fallback(db_path, job_id, params)
|
||||||
|
elif task_type == "recipe_llm":
|
||||||
|
_run_recipe_llm(db_path, job_id, params)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
|
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
|
||||||
_update_task_status(db_path, task_id, "completed")
|
_update_task_status(db_path, task_id, "completed")
|
||||||
|
|
@ -143,3 +145,41 @@ def _run_expiry_llm_fallback(
|
||||||
expiry,
|
expiry,
|
||||||
days,
|
days,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_recipe_llm(db_path: Path, _job_id_int: int, params: str | None) -> None:
|
||||||
|
"""Run LLM recipe generation for an async recipe job.
|
||||||
|
|
||||||
|
params JSON keys:
|
||||||
|
job_id (required) — recipe_jobs.job_id string (e.g. "rec_a1b2c3...")
|
||||||
|
|
||||||
|
Creates its own Store — follows same pattern as _suggest_in_thread.
|
||||||
|
MUST call store.fail_recipe_job() before re-raising so recipe_jobs.status
|
||||||
|
doesn't stay 'running' while background_tasks shows 'failed'.
|
||||||
|
"""
|
||||||
|
from app.db.store import Store
|
||||||
|
from app.models.schemas.recipe import RecipeRequest
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
|
|
||||||
|
p = json.loads(params or "{}")
|
||||||
|
recipe_job_id: str = p.get("job_id", "")
|
||||||
|
if not recipe_job_id:
|
||||||
|
raise ValueError("recipe_llm: 'job_id' is required in params")
|
||||||
|
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
store.update_recipe_job_running(recipe_job_id)
|
||||||
|
row = store._fetch_one(
|
||||||
|
"SELECT request FROM recipe_jobs WHERE job_id=?", (recipe_job_id,)
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"recipe_llm: recipe_jobs row not found: {recipe_job_id!r}")
|
||||||
|
req = RecipeRequest.model_validate_json(row["request"])
|
||||||
|
result = RecipeEngine(store).suggest(req)
|
||||||
|
store.complete_recipe_job(recipe_job_id, result.model_dump_json())
|
||||||
|
log.info("recipe_llm: job %s completed (%d suggestion(s))", recipe_job_id, len(result.suggestions))
|
||||||
|
except Exception as exc:
|
||||||
|
store.fail_recipe_job(recipe_job_id, str(exc))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# app/tasks/scheduler.py
|
# app/tasks/scheduler.py
|
||||||
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler."""
|
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler.
|
||||||
|
|
||||||
|
Local mode (CLOUD_MODE unset): LocalScheduler — simple FIFO, no coordinator.
|
||||||
|
Cloud mode (CLOUD_MODE=true): OrchestratedScheduler — coordinator-aware, fans
|
||||||
|
out concurrent jobs across all registered cf-orch GPU nodes.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -7,15 +12,68 @@ from pathlib import Path
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskScheduler,
|
TaskScheduler,
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
reset_scheduler, # re-export for tests
|
reset_scheduler as _reset_local, # re-export for tests
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.cloud_session import CLOUD_MODE
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
||||||
|
|
||||||
|
|
||||||
|
def _orch_available() -> bool:
|
||||||
|
"""Return True if circuitforge_orch is installed in this environment."""
|
||||||
|
try:
|
||||||
|
import circuitforge_orch # noqa: F401
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _use_orch() -> bool:
|
||||||
|
"""Return True if the OrchestratedScheduler should be used.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. USE_ORCH_SCHEDULER env var — explicit override always wins.
|
||||||
|
2. CLOUD_MODE=true — use orch in managed cloud deployments.
|
||||||
|
3. circuitforge_orch installed — paid+ local users who have cf-orch
|
||||||
|
set up get coordinator-aware scheduling (local GPU first) automatically.
|
||||||
|
"""
|
||||||
|
override = settings.USE_ORCH_SCHEDULER
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
|
return CLOUD_MODE or _orch_available()
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler(db_path: Path) -> TaskScheduler:
|
def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
"""Return the process-level TaskScheduler singleton for Kiwi."""
|
"""Return the process-level TaskScheduler singleton for Kiwi.
|
||||||
|
|
||||||
|
OrchestratedScheduler: coordinator-aware, fans out concurrent jobs across
|
||||||
|
all registered cf-orch GPU nodes. Active when USE_ORCH_SCHEDULER=true,
|
||||||
|
CLOUD_MODE=true, or circuitforge_orch is installed locally (paid+ users
|
||||||
|
running their own cf-orch stack get this automatically; local GPU is
|
||||||
|
preferred by the coordinator's allocation queue).
|
||||||
|
|
||||||
|
LocalScheduler: serial FIFO, no coordinator dependency. Free-tier local
|
||||||
|
installs without circuitforge_orch installed use this automatically.
|
||||||
|
"""
|
||||||
|
if _use_orch():
|
||||||
|
try:
|
||||||
|
from circuitforge_orch.scheduler import get_orch_scheduler
|
||||||
|
except ImportError:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"circuitforge_orch not installed — falling back to LocalScheduler"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return get_orch_scheduler(
|
||||||
|
db_path=db_path,
|
||||||
|
run_task_fn=run_task,
|
||||||
|
task_types=LLM_TASK_TYPES,
|
||||||
|
vram_budgets=VRAM_BUDGETS,
|
||||||
|
coordinator_url=settings.COORDINATOR_URL,
|
||||||
|
service_name="kiwi",
|
||||||
|
)
|
||||||
|
|
||||||
return _base_get_scheduler(
|
return _base_get_scheduler(
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
run_task_fn=run_task,
|
run_task_fn=run_task,
|
||||||
|
|
@ -24,3 +82,15 @@ def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
coordinator_url=settings.COORDINATOR_URL,
|
coordinator_url=settings.COORDINATOR_URL,
|
||||||
service_name="kiwi",
|
service_name="kiwi",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_scheduler() -> None:
|
||||||
|
"""Shut down and clear the active scheduler singleton. TEST TEARDOWN ONLY."""
|
||||||
|
if _use_orch():
|
||||||
|
try:
|
||||||
|
from circuitforge_orch.scheduler import reset_orch_scheduler
|
||||||
|
reset_orch_scheduler()
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
_reset_local()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"recipe_suggestions",
|
"recipe_suggestions",
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
|
"recipe_scan",
|
||||||
"style_classifier",
|
"style_classifier",
|
||||||
"meal_plan_llm",
|
"meal_plan_llm",
|
||||||
"meal_plan_llm_timing",
|
"meal_plan_llm_timing",
|
||||||
|
|
@ -44,6 +45,7 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
|
|
||||||
# Paid tier
|
# Paid tier
|
||||||
"receipt_ocr": "paid", # BYOK-unlockable
|
"receipt_ocr": "paid", # BYOK-unlockable
|
||||||
|
"visual_label_capture": "paid", # Camera capture for unenriched barcodes (kiwi#79)
|
||||||
"recipe_suggestions": "paid", # BYOK-unlockable
|
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||||
"expiry_llm_matching": "paid", # BYOK-unlockable
|
"expiry_llm_matching": "paid", # BYOK-unlockable
|
||||||
"meal_planning": "free",
|
"meal_planning": "free",
|
||||||
|
|
@ -57,6 +59,9 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||||
|
|
||||||
|
# Paid tier (continued)
|
||||||
|
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
||||||
|
|
||||||
# Premium tier
|
# Premium tier
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
"background_monitoring": "premium",
|
"background_monitoring": "premium",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
CLOUD_MODE: "true"
|
CLOUD_MODE: "true"
|
||||||
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
||||||
|
RECIPE_DB_PATH: /devl/kiwi-corpus/recipes.db
|
||||||
KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi
|
KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi
|
||||||
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
|
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
|
||||||
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
||||||
|
|
@ -20,6 +21,12 @@ services:
|
||||||
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
||||||
# cf-orch: route LLM calls through the coordinator for managed GPU inference
|
# cf-orch: route LLM calls through the coordinator for managed GPU inference
|
||||||
CF_ORCH_URL: http://host.docker.internal:7700
|
CF_ORCH_URL: http://host.docker.internal:7700
|
||||||
|
# Product identifier for coordinator analytics — per-product VRAM/request breakdown
|
||||||
|
CF_APP_NAME: kiwi
|
||||||
|
# cf-orch streaming proxy — coordinator URL + product key for /proxy/authorize
|
||||||
|
# COORDINATOR_KIWI_KEY must be set in .env (never commit the value)
|
||||||
|
COORDINATOR_URL: http://10.1.10.71:7700
|
||||||
|
COORDINATOR_KIWI_KEY: ${COORDINATOR_KIWI_KEY:-}
|
||||||
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
|
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
|
||||||
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
|
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
|
||||||
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
|
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
|
||||||
|
|
@ -27,6 +34,8 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
||||||
|
# Recipe corpus — shared read-only NFS-backed SQLite (3.1M recipes, 2.9GB)
|
||||||
|
- /Library/Assets/kiwi/kiwi.db:/devl/kiwi-corpus/recipes.db:ro
|
||||||
# LLM config — shared with other CF products; read-only in container
|
# LLM config — shared with other CF products; read-only in container
|
||||||
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
|
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -8,23 +8,6 @@ services:
|
||||||
# Docker can follow the symlink inside the container.
|
# Docker can follow the symlink inside the container.
|
||||||
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
||||||
|
|
||||||
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
|
# cf-orch agent sidecar removed 2026-04-24: Sif is now a dedicated compute node
|
||||||
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
# with its own systemd cf-orch-agent service (port 7703, advertise-host 10.1.10.158).
|
||||||
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.
|
# This sidecar was only valid when Kiwi ran on Sif directly.
|
||||||
cf-orch-agent:
|
|
||||||
image: kiwi-api # reuse local api image — cf-core already installed there
|
|
||||||
network_mode: host
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
# Override coordinator URL here or via .env
|
|
||||||
COORDINATOR_URL: ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
|
||||||
command: >
|
|
||||||
conda run -n kiwi cf-orch agent
|
|
||||||
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
|
||||||
--node-id kiwi
|
|
||||||
--host 0.0.0.0
|
|
||||||
--port 7702
|
|
||||||
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
|
|
|
||||||
74
config/llm.yaml.example
Normal file
74
config/llm.yaml.example
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Kiwi — LLM backend configuration
|
||||||
|
#
|
||||||
|
# Copy to ~/.config/circuitforge/llm.yaml (shared across all CF products)
|
||||||
|
# or to config/llm.yaml (Kiwi-local, takes precedence).
|
||||||
|
#
|
||||||
|
# Kiwi uses LLMs for:
|
||||||
|
# - Expiry prediction fallback (unknown products not in the lookup table)
|
||||||
|
# - Meal planning suggestions
|
||||||
|
#
|
||||||
|
# Local inference (Ollama / vLLM) is the default path — no API key required.
|
||||||
|
# BYOK (bring your own key): set api_key_env to point at your API key env var.
|
||||||
|
# cf-orch trunk: set CF_ORCH_URL env var to allocate cf-text on-demand via
|
||||||
|
# the coordinator instead of hitting a static URL.
|
||||||
|
|
||||||
|
backends:
|
||||||
|
ollama:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: true
|
||||||
|
base_url: http://localhost:11434/v1
|
||||||
|
model: llama3.2:3b
|
||||||
|
api_key: ollama
|
||||||
|
supports_images: false
|
||||||
|
|
||||||
|
vllm:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: http://localhost:8000/v1
|
||||||
|
model: __auto__ # resolved from /v1/models at runtime
|
||||||
|
api_key: ''
|
||||||
|
supports_images: false
|
||||||
|
|
||||||
|
# ── cf-orch trunk services ──────────────────────────────────────────────────
|
||||||
|
# These allocate via cf-orch rather than connecting to a static URL.
|
||||||
|
# cf-orch starts the service on-demand and returns its live URL.
|
||||||
|
# Set CF_ORCH_URL env var or fill in url below; leave enabled: false if
|
||||||
|
# cf-orch is not deployed in your environment.
|
||||||
|
|
||||||
|
cf_text:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: http://localhost:8008/v1 # fallback when cf-orch is not available
|
||||||
|
model: __auto__
|
||||||
|
api_key: any
|
||||||
|
supports_images: false
|
||||||
|
cf_orch:
|
||||||
|
service: cf-text
|
||||||
|
# model_candidates: leave empty to use the service's default_model,
|
||||||
|
# or specify a catalog alias (e.g. "qwen2.5-3b").
|
||||||
|
model_candidates: []
|
||||||
|
ttl_s: 3600
|
||||||
|
|
||||||
|
# ── Cloud / BYOK ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
anthropic:
|
||||||
|
type: anthropic
|
||||||
|
enabled: false
|
||||||
|
model: claude-haiku-4-5-20251001
|
||||||
|
api_key_env: ANTHROPIC_API_KEY
|
||||||
|
supports_images: false
|
||||||
|
|
||||||
|
openai:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: https://api.openai.com/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key_env: OPENAI_API_KEY
|
||||||
|
supports_images: false
|
||||||
|
|
||||||
|
fallback_order:
|
||||||
|
- cf_text
|
||||||
|
- ollama
|
||||||
|
- vllm
|
||||||
|
- anthropic
|
||||||
|
- openai
|
||||||
|
|
@ -8,14 +8,38 @@ server {
|
||||||
# Proxy API requests to the FastAPI container via Docker bridge network.
|
# Proxy API requests to the FastAPI container via Docker bridge network.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8512;
|
proxy_pass http://api:8512;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr
|
||||||
|
# when accessed directly on LAN without Caddy in the path.
|
||||||
|
proxy_set_header X-Real-IP $http_x_real_ip;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
# Forward the session header injected by Caddy from cf_session cookie.
|
# Forward the session header injected by Caddy from cf_session cookie.
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
# Allow image uploads (barcode/receipt photos from phone cameras).
|
# Allow image uploads (barcode/receipt photos from phone cameras).
|
||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
|
# LLM inference (recipe suggestions, expiry fallback) can take 60-120s.
|
||||||
|
# Default proxy_read_timeout is 60s which causes 504s on full recipe generation.
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
|
||||||
|
# builds API calls as /kiwi/api/v1/... — proxy these to the API container.
|
||||||
|
# Through Caddy the /kiwi prefix is stripped before reaching nginx, so this block
|
||||||
|
# is only active for direct-port access without Caddy in the path.
|
||||||
|
# Longer prefix (/kiwi/api/ = 10 chars) beats ^~/kiwi/ (6 chars) per nginx rules.
|
||||||
|
location /kiwi/api/ {
|
||||||
|
rewrite ^/kiwi(/api/.*)$ $1 break;
|
||||||
|
proxy_pass http://api:8512;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $http_x_real_ip;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
|
client_max_body_size 20m;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
||||||
|
|
|
||||||
69
docs/getting-started/installation.md
Normal file
69
docs/getting-started/installation.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Kiwi runs as a Docker Compose stack: a FastAPI backend and a Vue 3 frontend served by nginx. No external services are required for the core feature set.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- 500 MB disk for images + space for your pantry database
|
||||||
|
|
||||||
|
## Quick setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi
|
||||||
|
cd kiwi
|
||||||
|
cp .env.example .env
|
||||||
|
./manage.sh build
|
||||||
|
./manage.sh start
|
||||||
|
```
|
||||||
|
|
||||||
|
The web UI opens at `http://localhost:8511`. The FastAPI backend is at `http://localhost:8512`.
|
||||||
|
|
||||||
|
## manage.sh commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `./manage.sh start` | Start all services |
|
||||||
|
| `./manage.sh stop` | Stop all services |
|
||||||
|
| `./manage.sh restart` | Restart all services |
|
||||||
|
| `./manage.sh status` | Show running containers |
|
||||||
|
| `./manage.sh logs` | Tail logs (all services) |
|
||||||
|
| `./manage.sh build` | Rebuild images |
|
||||||
|
| `./manage.sh open` | Open browser to the web UI |
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required — generate a random secret
|
||||||
|
SECRET_KEY=your-random-secret-here
|
||||||
|
|
||||||
|
# Optional — LLM backend for AI features (receipt OCR, recipe suggestions)
|
||||||
|
# See LLM Setup guide for details
|
||||||
|
LLM_BACKEND=ollama # ollama | openai-compatible | vllm
|
||||||
|
LLM_BASE_URL=http://localhost:11434
|
||||||
|
LLM_MODEL=llama3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data location
|
||||||
|
|
||||||
|
By default, Kiwi stores its SQLite database in `./data/kiwi.db` inside the repo directory. The `data/` folder is bind-mounted into the container so your pantry survives image rebuilds.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
./manage.sh build
|
||||||
|
./manage.sh restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Database migrations run automatically on startup.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./manage.sh stop
|
||||||
|
docker compose down -v # removes containers and volumes
|
||||||
|
rm -rf data/ # removes local database
|
||||||
|
```
|
||||||
74
docs/getting-started/llm-setup.md
Normal file
74
docs/getting-started/llm-setup.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# LLM Backend Setup (Optional)
|
||||||
|
|
||||||
|
An LLM backend unlocks **receipt OCR**, **recipe suggestions (L3–L4)**, and **style auto-classification**. Everything else works without one.
|
||||||
|
|
||||||
|
You can use any OpenAI-compatible inference server: Ollama, vLLM, LM Studio, a local llama.cpp server, or a commercial API.
|
||||||
|
|
||||||
|
## BYOK — Bring Your Own Key
|
||||||
|
|
||||||
|
BYOK means you provide your own LLM backend. Paid AI features are unlocked at **any tier** when a valid backend is configured. You pay for your own inference; Kiwi just uses it.
|
||||||
|
|
||||||
|
## Choosing a backend
|
||||||
|
|
||||||
|
| Backend | Best for | Notes |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| **Ollama** | Local, easy setup | Recommended for getting started |
|
||||||
|
| **vLLM** | Local, high throughput | Better for faster hardware |
|
||||||
|
| **OpenAI API** | No local GPU | Requires paid API key |
|
||||||
|
| **Anthropic API** | No local GPU | Requires paid API key |
|
||||||
|
|
||||||
|
## Ollama setup (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Ollama
|
||||||
|
curl -fsSL https://ollama.ai/install.sh | sh
|
||||||
|
|
||||||
|
# Pull a model — llama3.1 8B works well for recipe tasks
|
||||||
|
ollama pull llama3.1
|
||||||
|
|
||||||
|
# Verify it's running
|
||||||
|
ollama list
|
||||||
|
```
|
||||||
|
|
||||||
|
In your Kiwi `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_BACKEND=ollama
|
||||||
|
LLM_BASE_URL=http://host.docker.internal:11434
|
||||||
|
LLM_MODEL=llama3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note "Docker networking"
|
||||||
|
Use `host.docker.internal` instead of `localhost` when Ollama is running on your host and Kiwi is in Docker.
|
||||||
|
|
||||||
|
## OpenAI-compatible API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_BACKEND=openai
|
||||||
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
|
LLM_API_KEY=sk-your-key-here
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify the connection
|
||||||
|
|
||||||
|
In the Kiwi **Settings** page, the LLM status indicator shows whether the backend is reachable. A green checkmark means OCR and L3–L4 recipe suggestions are active.
|
||||||
|
|
||||||
|
## What LLM is used for
|
||||||
|
|
||||||
|
| Feature | LLM required |
|
||||||
|
|---------|-------------|
|
||||||
|
| Receipt OCR (line-item extraction) | Yes |
|
||||||
|
| Recipe suggestions L1 (pantry match) | No |
|
||||||
|
| Recipe suggestions L2 (substitution) | No |
|
||||||
|
| Recipe suggestions L3 (style templates) | Yes |
|
||||||
|
| Recipe suggestions L4 (full generation) | Yes |
|
||||||
|
| Style auto-classifier | Yes |
|
||||||
|
|
||||||
|
L1 and L2 suggestions use deterministic matching — they work without any LLM configured. See [Recipe Engine](../reference/recipe-engine.md) for the full algorithm breakdown.
|
||||||
|
|
||||||
|
## Model recommendations
|
||||||
|
|
||||||
|
- **Receipt OCR**: any model with vision capability (LLaVA, GPT-4o, etc.)
|
||||||
|
- **Recipe suggestions**: 7B–13B instruction-tuned models work well; larger models produce more creative L4 output
|
||||||
|
- **Style classification**: small models handle this fine (3B+)
|
||||||
52
docs/getting-started/quick-start.md
Normal file
52
docs/getting-started/quick-start.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Quick Start
|
||||||
|
|
||||||
|
This guide walks you through adding your first pantry item and getting a recipe suggestion. No LLM backend needed for these steps.
|
||||||
|
|
||||||
|
## 1. Add an item by barcode
|
||||||
|
|
||||||
|
Open the **Inventory** tab. Tap the barcode icon or click **Scan barcode**, then point your camera at a product barcode. Kiwi looks up the product in the open barcode database and adds it to your pantry.
|
||||||
|
|
||||||
|
If the barcode isn't recognized, you'll be prompted to enter the product name and details manually.
|
||||||
|
|
||||||
|
## 2. Add an item manually
|
||||||
|
|
||||||
|
Click **Add item** and fill in:
|
||||||
|
|
||||||
|
- **Name** — what is it? (e.g., "Canned chickpeas")
|
||||||
|
- **Quantity** — how many or how much
|
||||||
|
- **Expiry date** — when does it expire? (optional but recommended)
|
||||||
|
- **Category** — used for dietary filtering and pantry stats
|
||||||
|
|
||||||
|
## 3. Upload a receipt
|
||||||
|
|
||||||
|
Click **Receipts** in the sidebar, then **Upload receipt**. Take a photo of a grocery receipt or upload an image from your device.
|
||||||
|
|
||||||
|
- **Free tier**: the receipt is stored for you to review; line items are entered manually
|
||||||
|
- **Paid / BYOK**: OCR runs automatically and extracts items for you to approve
|
||||||
|
|
||||||
|
## 4. Browse recipes
|
||||||
|
|
||||||
|
Click **Recipes** in the sidebar. The recipe browser shows your **pantry match percentage** for each recipe — how much of the ingredient list you already have.
|
||||||
|
|
||||||
|
Use the filters to narrow by:
|
||||||
|
|
||||||
|
- **Cuisine** — Italian, Mexican, Japanese, etc.
|
||||||
|
- **Meal type** — breakfast, lunch, dinner, snack
|
||||||
|
- **Dietary** — vegetarian, vegan, gluten-free, dairy-free, etc.
|
||||||
|
- **Main ingredient** — chicken, pasta, lentils, etc.
|
||||||
|
|
||||||
|
## 5. Get a suggestion based on what's expiring
|
||||||
|
|
||||||
|
Click **Leftover mode** (the clock icon or toggle). Kiwi re-ranks suggestions to surface recipes that use your nearly-expired items first.
|
||||||
|
|
||||||
|
Free accounts get 5 leftover-mode requests per day. Paid accounts get unlimited.
|
||||||
|
|
||||||
|
## 6. Save a recipe
|
||||||
|
|
||||||
|
Click the bookmark icon on any recipe card to save it. You can add:
|
||||||
|
|
||||||
|
- **Notes** — cooking tips, modifications, family preferences
|
||||||
|
- **Star rating** — 0 to 5 stars
|
||||||
|
- **Style tags** — quick, comforting, weeknight, etc.
|
||||||
|
|
||||||
|
Saved recipes appear in the **Saved** tab. Paid accounts can organize them into named collections.
|
||||||
35
docs/index.md
Normal file
35
docs/index.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Kiwi — Pantry Tracker
|
||||||
|
|
||||||
|
**Stop throwing food away. Cook what you already have.**
|
||||||
|
|
||||||
|
Kiwi tracks your pantry, watches for expiry dates, and suggests recipes based on what's about to go bad. Scan barcodes, photograph receipts, and let Kiwi tell you what to make for dinner — without needing an AI backend to do it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Kiwi does
|
||||||
|
|
||||||
|
- **Inventory tracking** — add items by barcode scan, receipt photo, or manual entry
|
||||||
|
- **Expiry alerts** — know what's about to go bad before it does
|
||||||
|
- **Recipe browser** — browse by cuisine, meal type, dietary preference, or main ingredient; see pantry match percentage inline
|
||||||
|
- **Leftover mode** — prioritize nearly-expired items when getting recipe suggestions
|
||||||
|
- **Receipt OCR** — extract line items from receipt photos automatically (Paid / BYOK)
|
||||||
|
- **Recipe suggestions** — four levels from pantry-match corpus to full LLM generation (Paid / BYOK)
|
||||||
|
- **Saved recipes** — bookmark any recipe with notes, 0–5 star rating, and style tags
|
||||||
|
- **CSV export** — export your full pantry inventory anytime
|
||||||
|
|
||||||
|
## Quick links
|
||||||
|
|
||||||
|
- [Installation](getting-started/installation.md) — local self-hosted setup
|
||||||
|
- [Quick Start](getting-started/quick-start.md) — add your first item and get a recipe
|
||||||
|
- [LLM Setup](getting-started/llm-setup.md) — unlock AI features with your own backend
|
||||||
|
- [Tier System](reference/tier-system.md) — what's free vs. paid
|
||||||
|
|
||||||
|
## No AI required
|
||||||
|
|
||||||
|
Inventory tracking, barcode scanning, expiry alerts, the recipe browser, saved recipes, and CSV export all work without any LLM configured. AI features (receipt OCR, recipe suggestions, style auto-classification) are optional and BYOK-unlockable at any tier.
|
||||||
|
|
||||||
|
## Free and open core
|
||||||
|
|
||||||
|
Discovery and pipeline code is MIT-licensed. AI features are BSL 1.1 — free for personal non-commercial self-hosting, commercial SaaS requires a license. See the [tier table](reference/tier-system.md) for the full breakdown.
|
||||||
1
docs/plausible.js
Normal file
1
docs/plausible.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
(function(){var s=document.createElement("script");s.defer=true;s.dataset.domain="docs.circuitforge.tech,circuitforge.tech";s.dataset.api="https://analytics.circuitforge.tech/api/event";s.src="https://analytics.circuitforge.tech/js/script.js";document.head.appendChild(s);})();
|
||||||
80
docs/reference/architecture.md
Normal file
80
docs/reference/architecture.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
Kiwi is a self-contained Docker Compose stack with a Vue 3 (SPA) frontend and a FastAPI backend backed by SQLite.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Frontend | Vue 3 + TypeScript + Vite |
|
||||||
|
| Backend | FastAPI (Python 3.11+) |
|
||||||
|
| Database | SQLite (via circuitforge-core) |
|
||||||
|
| Auth (cloud) | CF session cookie → Directus JWT |
|
||||||
|
| Licensing | Heimdall (RS256 JWT, offline-capable) |
|
||||||
|
| LLM inference | Pluggable — Ollama, vLLM, OpenAI-compatible |
|
||||||
|
| Barcode lookup | Open Food Facts / UPC Database API |
|
||||||
|
| OCR | LLM vision model (configurable) |
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
User -->|browser| Vue3[Vue 3 SPA]
|
||||||
|
Vue3 -->|/api/*| FastAPI
|
||||||
|
FastAPI -->|SQL| SQLite[(SQLite DB)]
|
||||||
|
FastAPI -->|HTTP| LLM[LLM Backend]
|
||||||
|
FastAPI -->|HTTP| Barcode[Barcode DB API]
|
||||||
|
FastAPI -->|JWT| Heimdall[Heimdall License]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose services
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
# FastAPI backend — network_mode: host in dev
|
||||||
|
# Exposed at port 8512
|
||||||
|
web:
|
||||||
|
# Vue 3 SPA served by nginx
|
||||||
|
# Exposed at port 8511
|
||||||
|
```
|
||||||
|
|
||||||
|
In development, the API uses host networking so nginx can reach it at `172.17.0.1:8512` (Docker bridge gateway).
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLite at `./data/kiwi.db`. The schema is managed by numbered migration files in `app/db/migrations/`. Migrations run automatically on startup — the startup script applies any new `*.sql` files in order.
|
||||||
|
|
||||||
|
Key tables:
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `products` | Product catalog (shared, barcode-keyed) |
|
||||||
|
| `pantry_items` | User's pantry (quantity, expiry, notes) |
|
||||||
|
| `recipes` | Recipe corpus |
|
||||||
|
| `saved_recipes` | User-bookmarked recipes |
|
||||||
|
| `collections` | Named recipe collections (Paid) |
|
||||||
|
| `receipts` | Receipt uploads and OCR results |
|
||||||
|
| `user_preferences` | User settings (dietary, LLM config) |
|
||||||
|
|
||||||
|
## Cloud mode
|
||||||
|
|
||||||
|
In cloud mode (managed instance at `menagerie.circuitforge.tech/kiwi`), each user gets their own SQLite database isolated under `/devl/kiwi-cloud-data/<user_id>/kiwi.db`. The cloud compose stack adds:
|
||||||
|
|
||||||
|
- `CLOUD_MODE=true` environment variable
|
||||||
|
- Directus JWT validation for session resolution
|
||||||
|
- Heimdall tier check on AI feature endpoints
|
||||||
|
|
||||||
|
The same codebase runs in both local and cloud modes — the cloud session middleware is a thin wrapper around the local auth logic.
|
||||||
|
|
||||||
|
## LLM integration
|
||||||
|
|
||||||
|
Kiwi uses `circuitforge-core`'s LLM router, which abstracts over Ollama, vLLM, and OpenAI-compatible APIs. The router is configured via environment variables at startup. All LLM calls are asynchronous and non-blocking — if the backend is unavailable, Kiwi falls back to the highest deterministic level (L2) and returns results without waiting.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- No PII is logged in production
|
||||||
|
- Pantry data stays on your machine in self-hosted mode
|
||||||
|
- Cloud mode: data stored per-user on Heimdall server, not shared with third parties, not used for training
|
||||||
|
- LLM calls include pantry context in the prompt — if using a cloud API, that context leaves your machine
|
||||||
|
- Using a local LLM backend (Ollama, vLLM) keeps all data on-device
|
||||||
75
docs/reference/recipe-engine.md
Normal file
75
docs/reference/recipe-engine.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Recipe Engine
|
||||||
|
|
||||||
|
Kiwi uses a four-level recipe suggestion system. Each level adds more intelligence and better results, but requires more resources. Levels 1–2 are fully deterministic and work without any LLM. Levels 3–4 require an LLM backend.
|
||||||
|
|
||||||
|
## Level overview
|
||||||
|
|
||||||
|
| Level | Name | LLM required | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| L1 | Pantry match | No | Rank existing corpus by ingredient overlap |
|
||||||
|
| L2 | Substitution | No | Suggest swaps for missing ingredients |
|
||||||
|
| L3 | Style templates | Yes | Generate recipe variations from style templates |
|
||||||
|
| L4 | Full generation | Yes | Generate new recipes from scratch |
|
||||||
|
|
||||||
|
## L1 — Pantry match
|
||||||
|
|
||||||
|
The simplest level. Kiwi scores every recipe in the corpus by how many of its ingredients you already have:
|
||||||
|
|
||||||
|
```
|
||||||
|
score = (matched ingredients) / (total ingredients)
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipes are sorted by this score descending. If leftover mode is active, the score is further weighted by expiry proximity.
|
||||||
|
|
||||||
|
This works entirely offline with no LLM — just set arithmetic on your current pantry.
|
||||||
|
|
||||||
|
## L2 — Substitution
|
||||||
|
|
||||||
|
L2 extends L1 by suggesting substitutions for missing ingredients. When a recipe scores well but you're missing one or two items, Kiwi checks a substitution table to see if something in your pantry could stand in:
|
||||||
|
|
||||||
|
- Buttermilk → plain yogurt + lemon juice
|
||||||
|
- Heavy cream → evaporated milk
|
||||||
|
- Fresh herbs → dried herbs (adjusted quantity)
|
||||||
|
|
||||||
|
Substitutions are sourced from a curated table — no LLM involved. L2 raises the effective match score for recipes where a reasonable substitute exists.
|
||||||
|
|
||||||
|
## L3 — Style templates
|
||||||
|
|
||||||
|
L3 uses the LLM to generate recipe variations from a style template. Rather than generating fully free-form text, it fills in a structured template:
|
||||||
|
|
||||||
|
```
|
||||||
|
[protein] + [vegetable] + [starch] + [sauce/flavor profile]
|
||||||
|
```
|
||||||
|
|
||||||
|
The template is populated from your pantry contents and the style tags you've set (e.g., "quick", "Italian"). The LLM fills in the techniques, proportions, and instructions.
|
||||||
|
|
||||||
|
Style templates produce consistent, practical results with less hallucination risk than fully open-ended generation.
|
||||||
|
|
||||||
|
## L4 — Full generation
|
||||||
|
|
||||||
|
L4 gives the LLM full creative freedom. Kiwi passes:
|
||||||
|
|
||||||
|
- Your full pantry inventory
|
||||||
|
- Your dietary preferences
|
||||||
|
- Any expiring items (if leftover mode is active)
|
||||||
|
- Your saved recipe history and style tags
|
||||||
|
|
||||||
|
The LLM generates a new recipe optimized for your situation. Results are more creative than L1–L3 but require a capable model (7B+ recommended) and take longer to generate.
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
When you click **Suggest**, Kiwi tries each level in order and returns results as soon as a level produces usable output:
|
||||||
|
|
||||||
|
1. L1 and L2 run immediately (no LLM)
|
||||||
|
2. If no good matches exist (all scores < 30%), Kiwi escalates to L3
|
||||||
|
3. If L3 produces no results (LLM unavailable or error), Kiwi falls back to best L1 result
|
||||||
|
4. L4 is only triggered explicitly by the user ("Generate something new")
|
||||||
|
|
||||||
|
## Tier gates
|
||||||
|
|
||||||
|
| Level | Free | Paid | BYOK (any tier) |
|
||||||
|
|-------|------|------|-----------------|
|
||||||
|
| L1 — Pantry match | ✓ | ✓ | ✓ |
|
||||||
|
| L2 — Substitution | ✓ | ✓ | ✓ |
|
||||||
|
| L3 — Style templates | — | ✓ | ✓ |
|
||||||
|
| L4 — Full generation | — | ✓ | ✓ |
|
||||||
53
docs/reference/tier-system.md
Normal file
53
docs/reference/tier-system.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Tier System
|
||||||
|
|
||||||
|
Kiwi uses CircuitForge's standard four-tier model. The free tier covers the full pantry tracking workflow. AI features are gated behind Paid or BYOK.
|
||||||
|
|
||||||
|
## Feature matrix
|
||||||
|
|
||||||
|
| Feature | Free | Paid | Premium |
|
||||||
|
|---------|------|------|---------|
|
||||||
|
| **Inventory** | | | |
|
||||||
|
| Inventory CRUD | ✓ | ✓ | ✓ |
|
||||||
|
| Barcode scan | ✓ | ✓ | ✓ |
|
||||||
|
| Receipt upload | ✓ | ✓ | ✓ |
|
||||||
|
| Expiry alerts | ✓ | ✓ | ✓ |
|
||||||
|
| CSV export | ✓ | ✓ | ✓ |
|
||||||
|
| **Recipes** | | | |
|
||||||
|
| Recipe browser | ✓ | ✓ | ✓ |
|
||||||
|
| Pantry match (L1) | ✓ | ✓ | ✓ |
|
||||||
|
| Substitution (L2) | ✓ | ✓ | ✓ |
|
||||||
|
| Style templates (L3) | BYOK | ✓ | ✓ |
|
||||||
|
| Full generation (L4) | BYOK | ✓ | ✓ |
|
||||||
|
| Leftover mode | 5/day | Unlimited | Unlimited |
|
||||||
|
| **Saved recipes** | | | |
|
||||||
|
| Save + notes + star rating | ✓ | ✓ | ✓ |
|
||||||
|
| Style tags (manual) | ✓ | ✓ | ✓ |
|
||||||
|
| LLM style auto-classifier | — | BYOK | ✓ |
|
||||||
|
| Named collections | — | ✓ | ✓ |
|
||||||
|
| Meal planning | — | ✓ | ✓ |
|
||||||
|
| **OCR** | | | |
|
||||||
|
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||||
|
| **Account** | | | |
|
||||||
|
| Multi-household | — | — | ✓ |
|
||||||
|
|
||||||
|
**BYOK** = Bring Your Own LLM backend. Configure a local or cloud inference endpoint and these features activate at any tier. See [LLM Setup](../getting-started/llm-setup.md).
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
| Tier | Monthly | Lifetime |
|
||||||
|
|------|---------|----------|
|
||||||
|
| Free | $0 | — |
|
||||||
|
| Paid | $8/mo | $129 |
|
||||||
|
| Premium | $16/mo | $249 |
|
||||||
|
|
||||||
|
Lifetime licenses are available at [circuitforge.tech](https://circuitforge.tech).
|
||||||
|
|
||||||
|
## Self-hosting
|
||||||
|
|
||||||
|
Self-hosted Kiwi is free under the MIT license (inventory/pipeline) and BSL 1.1 (AI features, free for personal non-commercial use). You run it on your own hardware with your own LLM backend. No subscription required.
|
||||||
|
|
||||||
|
The cloud-managed instance at `menagerie.circuitforge.tech/kiwi` runs the same codebase and requires a CircuitForge account.
|
||||||
|
|
||||||
|
## Free key
|
||||||
|
|
||||||
|
Claim a free Paid-tier key (30 days) at [circuitforge.tech](https://circuitforge.tech/free-key). No credit card required.
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue