Compare commits
No commits in common. "main" and "feature/orch-auto-lifecycle" have entirely different histories.
main
...
feature/or
241 changed files with 1078 additions and 36543 deletions
44
.cliff.toml
44
.cliff.toml
|
|
@ -1,44 +0,0 @@
|
||||||
# git-cliff changelog configuration for Kiwi
|
|
||||||
# See: https://git-cliff.org/docs/configuration
|
|
||||||
|
|
||||||
[changelog]
|
|
||||||
header = """
|
|
||||||
# Changelog\n
|
|
||||||
"""
|
|
||||||
body = """
|
|
||||||
{% if version %}\
|
|
||||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
||||||
{% else %}\
|
|
||||||
## [Unreleased]
|
|
||||||
{% endif %}\
|
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
|
||||||
### {{ group | upper_first }}
|
|
||||||
{% for commit in commits %}
|
|
||||||
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}\n
|
|
||||||
"""
|
|
||||||
trim = true
|
|
||||||
|
|
||||||
[git]
|
|
||||||
conventional_commits = true
|
|
||||||
filter_unconventional = true
|
|
||||||
split_commits = false
|
|
||||||
commit_preprocessors = []
|
|
||||||
commit_parsers = [
|
|
||||||
{ message = "^feat", group = "Features" },
|
|
||||||
{ message = "^fix", group = "Bug Fixes" },
|
|
||||||
{ message = "^perf", group = "Performance" },
|
|
||||||
{ message = "^refactor", group = "Refactoring" },
|
|
||||||
{ message = "^docs", group = "Documentation" },
|
|
||||||
{ message = "^test", group = "Testing" },
|
|
||||||
{ message = "^chore", group = "Chores" },
|
|
||||||
{ message = "^ci", group = "CI/CD" },
|
|
||||||
{ message = "^revert", group = "Reverts" },
|
|
||||||
]
|
|
||||||
filter_commits = false
|
|
||||||
tag_pattern = "v[0-9].*"
|
|
||||||
skip_tags = ""
|
|
||||||
ignore_tags = ""
|
|
||||||
topo_order = false
|
|
||||||
sort_commits = "oldest"
|
|
||||||
33
.env.example
33
.env.example
|
|
@ -21,12 +21,10 @@ DATA_DIR=./data
|
||||||
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
||||||
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
||||||
|
|
||||||
# GPU inference server (cf-orch coordinator for recipe scan, LLM generation, etc.)
|
# CF-core hosted coordinator (managed cloud GPU inference — Paid+ tier)
|
||||||
# GPU_SERVER_URL: set to your local cf-orch coordinator (self-hosted rack).
|
# Set CF_ORCH_URL to use a hosted cf-orch coordinator instead of self-hosting.
|
||||||
# CF_ORCH_URL is the backward-compat alias — both are honoured.
|
# CF_LICENSE_KEY is read automatically by CFOrchClient for bearer auth.
|
||||||
# Paid+ default: when CF_LICENSE_KEY is present and neither URL is set,
|
# CF_ORCH_URL=https://orch.circuitforge.tech
|
||||||
# the app automatically points to https://orch.circuitforge.tech.
|
|
||||||
# GPU_SERVER_URL=http://10.1.10.71:7700
|
|
||||||
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
||||||
|
|
||||||
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
||||||
|
|
@ -53,15 +51,6 @@ ENABLE_OCR=false
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
CLOUD_MODE=false
|
CLOUD_MODE=false
|
||||||
DEMO_MODE=false
|
DEMO_MODE=false
|
||||||
# Product identifier reported in cf-orch coordinator analytics for per-app breakdown
|
|
||||||
CF_APP_NAME=kiwi
|
|
||||||
# USE_ORCH_SCHEDULER: use coordinator-aware multi-GPU scheduler instead of local FIFO.
|
|
||||||
# Unset = auto-detect: true if CLOUD_MODE or circuitforge_orch is installed (paid+ local).
|
|
||||||
# Set false to force LocalScheduler even when cf-orch is present.
|
|
||||||
# USE_ORCH_SCHEDULER=false
|
|
||||||
# GPU_SERVER_URL: cf-orch coordinator endpoint. Required for recipe scan (cf-docuvision)
|
|
||||||
# and LLM features on a self-hosted rack. CF_ORCH_URL is the backward-compat alias.
|
|
||||||
# GPU_SERVER_URL=http://10.1.10.71:7700
|
|
||||||
|
|
||||||
# Cloud mode (set in compose.cloud.yml; also set here for reference)
|
# Cloud mode (set in compose.cloud.yml; also set here for reference)
|
||||||
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
|
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
|
||||||
|
|
@ -79,14 +68,9 @@ CF_APP_NAME=kiwi
|
||||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||||
# HEIMDALL_ADMIN_TOKEN=
|
# HEIMDALL_ADMIN_TOKEN=
|
||||||
|
|
||||||
# Directus JWT (must match cf-directus SECRET env var exactly, including base64 == padding)
|
# Directus JWT (must match cf-directus SECRET env var)
|
||||||
# DIRECTUS_JWT_SECRET=
|
# DIRECTUS_JWT_SECRET=
|
||||||
|
|
||||||
# E2E test account (Directus — free tier, used by automated tests)
|
|
||||||
# E2E_TEST_EMAIL=e2e@circuitforge.tech
|
|
||||||
# E2E_TEST_PASSWORD=
|
|
||||||
# E2E_TEST_USER_ID=
|
|
||||||
|
|
||||||
# In-app feedback → Forgejo issue creation
|
# In-app feedback → Forgejo issue creation
|
||||||
# FORGEJO_API_TOKEN=
|
# FORGEJO_API_TOKEN=
|
||||||
# FORGEJO_REPO=Circuit-Forge/kiwi
|
# FORGEJO_REPO=Circuit-Forge/kiwi
|
||||||
|
|
@ -99,10 +83,3 @@ CF_APP_NAME=kiwi
|
||||||
# INSTACART_AFFILIATE_ID=circuitforge
|
# INSTACART_AFFILIATE_ID=circuitforge
|
||||||
# Walmart Impact network affiliate ID (inline, path-based redirect)
|
# Walmart Impact network affiliate ID (inline, path-based redirect)
|
||||||
# WALMART_AFFILIATE_ID=
|
# WALMART_AFFILIATE_ID=
|
||||||
|
|
||||||
|
|
||||||
# Community PostgreSQL — shared across CF products (cloud only; leave unset for local dev)
|
|
||||||
# Points at cf-orch's cf-community-postgres container (port 5434 on the orch host).
|
|
||||||
# When unset, community write paths fail soft with a plain-language message.
|
|
||||||
# COMMUNITY_DB_URL=postgresql://cf_community:changeme@cf-orch-host:5434/cf_community
|
|
||||||
# COMMUNITY_PSEUDONYM_SALT=change-this-to-a-random-32-char-string
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Kiwi CI — lint, type-check, test on PR/push
|
|
||||||
# Full-stack: FastAPI (Python) + Vue 3 SPA (Node)
|
|
||||||
# Adapted from Circuit-Forge/cf-agents workflows/ci.yml (cf-agents#4 tracks the
|
|
||||||
# upstream ci-fullstack.yml variant; update this file when that lands).
|
|
||||||
#
|
|
||||||
# Note: frontend has no test suite yet — CI runs typecheck only.
|
|
||||||
# Add `npm run test` when vitest is wired (kiwi#XX).
|
|
||||||
#
|
|
||||||
# circuitforge-core is not on PyPI — installed from Forgejo git (public repo).
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, 'feature/**', 'fix/**']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e ".[dev]" || pip install -e . pytest pytest-asyncio httpx ruff
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Mirror push to GitHub and Codeberg on every push to main or tag.
|
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml
|
|
||||||
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
|
|
||||||
|
|
||||||
name: Mirror
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mirror:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Mirror to GitHub
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
run: |
|
|
||||||
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
|
|
||||||
git push github --mirror
|
|
||||||
|
|
||||||
- name: Mirror to Codeberg
|
|
||||||
env:
|
|
||||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
run: |
|
|
||||||
git remote add codeberg "https://CircuitForge:${CODEBERG_TOKEN}@codeberg.org/CircuitForge/${REPO}.git"
|
|
||||||
git push codeberg --mirror
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# Tag-triggered release workflow.
|
|
||||||
# Generates changelog and creates Forgejo release on v* tags.
|
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/release.yml
|
|
||||||
#
|
|
||||||
# Docker push is intentionally disabled — BSL 1.1 registry policy not yet resolved.
|
|
||||||
# Tracked in Circuit-Forge/cf-agents#3. Re-enable the Docker steps when that lands.
|
|
||||||
#
|
|
||||||
# Required secrets: FORGEJO_RELEASE_TOKEN
|
|
||||||
# (GHCR_TOKEN not needed until Docker push is enabled)
|
|
||||||
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# ── Changelog ────────────────────────────────────────────────────────────
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: orhun/git-cliff-action@v3
|
|
||||||
id: cliff
|
|
||||||
with:
|
|
||||||
config: .cliff.toml
|
|
||||||
args: --latest --strip header
|
|
||||||
env:
|
|
||||||
OUTPUT: CHANGES.md
|
|
||||||
|
|
||||||
# ── Docker (disabled — BSL registry policy pending cf-agents#3) ──────────
|
|
||||||
# - name: Set up QEMU
|
|
||||||
# uses: docker/setup-qemu-action@v3
|
|
||||||
# - name: Set up Buildx
|
|
||||||
# uses: docker/setup-buildx-action@v3
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GHCR_TOKEN }}
|
|
||||||
# - name: Build and push Docker image
|
|
||||||
# uses: docker/build-push-action@v6
|
|
||||||
# with:
|
|
||||||
# context: .
|
|
||||||
# push: true
|
|
||||||
# platforms: linux/amd64,linux/arm64
|
|
||||||
# tags: |
|
|
||||||
# ghcr.io/circuitforgellc/kiwi:${{ github.ref_name }}
|
|
||||||
# ghcr.io/circuitforgellc/kiwi:latest
|
|
||||||
# cache-from: type=gha
|
|
||||||
# cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
# ── Forgejo Release ───────────────────────────────────────────────────────
|
|
||||||
- name: Create Forgejo release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
NOTES: ${{ steps.cliff.outputs.content }}
|
|
||||||
run: |
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/${REPO}/releases" \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
|
|
||||||
'{tag_name: $tag, name: $tag, body: $body}')"
|
|
||||||
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
|
|
@ -1,59 +0,0 @@
|
||||||
# Kiwi CI — runs on GitHub mirror for public credibility badge.
|
|
||||||
# Forgejo (.forgejo/workflows/ci.yml) is the canonical CI — keep these in sync.
|
|
||||||
# No Forgejo-specific secrets used here; circuitforge-core is public on Forgejo.
|
|
||||||
#
|
|
||||||
# Note: frontend has no test suite yet — CI runs typecheck only.
|
|
||||||
# Add 'npm run test' when vitest is wired.
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e . pytest pytest-asyncio httpx ruff
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,9 +23,6 @@ dist/
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Local dev database
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# Test artifacts (MagicMock sqlite files from pytest)
|
# Test artifacts (MagicMock sqlite files from pytest)
|
||||||
<MagicMock*
|
<MagicMock*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Kiwi gitleaks config — extends base CircuitForge config with local rules
|
|
||||||
|
|
||||||
[extend]
|
|
||||||
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
|
||||||
|
|
||||||
# ── Global allowlist ──────────────────────────────────────────────────────────
|
|
||||||
# Amazon grocery department IDs (rh=n:<10-digit>) false-positive as phone
|
|
||||||
# numbers. locale_config.py is a static lookup table with no secrets.
|
|
||||||
|
|
||||||
[allowlist]
|
|
||||||
# Amazon grocery dept IDs (rh=n:<digits>) false-positive as phone numbers.
|
|
||||||
regexes = [
|
|
||||||
'''rh=n:\d{8,12}''',
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[[rules]]
|
|
||||||
id = "cf-generic-env-token"
|
|
||||||
description = "Generic KEY=<token> in env-style assignment — catches FORGEJO_API_TOKEN=hex etc."
|
|
||||||
regex = '''(?i)(token|secret|key|password|passwd|pwd|api_key)\s*[=:]\s*['"]?[A-Za-z0-9\-_]{20,}['"]?'''
|
|
||||||
[rules.allowlist]
|
|
||||||
paths = [
|
|
||||||
'.*test.*',
|
|
||||||
]
|
|
||||||
regexes = [
|
|
||||||
'api_key:\s*ollama',
|
|
||||||
'api_key:\s*any',
|
|
||||||
'your-[a-z\-]+-here',
|
|
||||||
'replace-with-',
|
|
||||||
'xxxx',
|
|
||||||
'test-fixture-',
|
|
||||||
'CFG-KIWI-TEST-',
|
|
||||||
]
|
|
||||||
12
Dockerfile
12
Dockerfile
|
|
@ -11,23 +11,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY circuitforge-core/ ./circuitforge-core/
|
COPY circuitforge-core/ ./circuitforge-core/
|
||||||
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
|
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
|
||||||
|
|
||||||
# Install circuitforge-orch — needed for the cf-orch-agent sidecar (compose.override.yml)
|
|
||||||
COPY circuitforge-orch/ ./circuitforge-orch/
|
|
||||||
|
|
||||||
# Create kiwi conda env and install app
|
# Create kiwi conda env and install app
|
||||||
COPY kiwi/environment.yml .
|
COPY kiwi/environment.yml .
|
||||||
RUN conda env create -f environment.yml
|
RUN conda env create -f environment.yml
|
||||||
|
|
||||||
COPY kiwi/ ./kiwi/
|
COPY kiwi/ ./kiwi/
|
||||||
|
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
|
||||||
# Remove gitignored config files that may exist locally — defense-in-depth.
|
|
||||||
# The parent .dockerignore should exclude these, but an explicit rm guarantees
|
|
||||||
# they never end up in the cloud image regardless of .dockerignore placement.
|
|
||||||
RUN rm -f /app/kiwi/.env
|
|
||||||
|
|
||||||
# Install cf-core and cf-orch into the kiwi env BEFORE installing kiwi
|
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
|
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-orch
|
|
||||||
WORKDIR /app/kiwi
|
WORKDIR /app/kiwi
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e .
|
RUN conda run -n kiwi pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
|
|
||||||
136
README.md
136
README.md
|
|
@ -1,58 +1,40 @@
|
||||||
<!-- Logo coming soon — replace docs/kiwi-logo.svg when final icon ships -->
|
# 🥝 Kiwi
|
||||||
<div align="center">
|
|
||||||
<img src="docs/kiwi-logo.svg" alt="Kiwi logo" width="96" height="96" />
|
|
||||||
|
|
||||||
# Kiwi
|
> *Part of the CircuitForge LLC "AI for the tasks the system made hard on purpose" suite.*
|
||||||
|
|
||||||
**Pantry tracking and recipe suggestions — with or without an LLM.**
|
**Pantry tracking and leftover recipe suggestions.**
|
||||||
|
|
||||||
[](#license)
|
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
||||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/actions)
|
|
||||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/releases)
|
|
||||||
|
|
||||||
[Documentation](https://docs.circuitforge.tech/kiwi) · [Live demo](https://menagerie.circuitforge.tech/kiwi) · [circuitforge.tech](https://circuitforge.tech)
|
**LLM support is optional.** Inventory tracking, barcode scanning, expiry alerts, CSV export, and receipt upload all work without any LLM configured. AI features (receipt OCR, recipe suggestions, meal planning) activate when a backend is available and are BYOK-unlockable at any tier.
|
||||||
|
|
||||||
*Part of the CircuitForge LLC suite — "AI for the tasks the system made hard on purpose."*
|
**Status:** Beta · CircuitForge LLC
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **The LLM is optional.** Barcode scanning, receipt upload, expiry alerts, the full 200k+ recipe browser, and CSV export all work with zero LLM configured. Recipe suggestions and receipt OCR activate when a backend is available, and are BYOK-unlockable at any tier. You are never forced to send your data anywhere.
|
## What it does
|
||||||
|
|
||||||
---
|
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
||||||
|
- **Expiry alerts** — know what's about to go bad
|
||||||
|
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
|
||||||
|
- **Saved recipes** — bookmark any recipe with notes, a 0–5 star rating, and free-text style tags (Free); organize into named collections (Paid)
|
||||||
|
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
|
||||||
|
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
|
||||||
|
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
|
||||||
|
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
|
||||||
|
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
|
||||||
|
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
|
||||||
|
|
||||||
## What Kiwi does
|
## Stack
|
||||||
|
|
||||||
| Feature | Notes |
|
- **Frontend:** Vue 3 SPA (Vite + TypeScript)
|
||||||
|---|---|
|
- **Backend:** FastAPI + SQLite (via `circuitforge-core`)
|
||||||
| **Inventory tracking** | Add items by barcode scan, receipt upload, or manually |
|
- **Auth:** CF session cookie → Directus JWT (cloud mode)
|
||||||
| **Expiry alerts** | Know what is about to go bad before it does |
|
- **Licensing:** Heimdall (free tier auto-provisioned at signup)
|
||||||
| **Recipe browser** | 200k+ recipes — filter by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline |
|
|
||||||
| **Leftover mode** | Prioritizes nearly-expired items in recipe ranking (5/day free, unlimited at Paid+) |
|
|
||||||
| **Recipe suggestions** | Four levels: direct corpus match, substitution/swap, cuisine-style adapter, full LLM generation |
|
|
||||||
| **Meal planning** | Plan meals for the week; pull from saved recipes or suggestions |
|
|
||||||
| **Saved recipes** | Bookmark any recipe with notes, 0-5 star rating, and free-text style tags; organize into named collections (Paid) |
|
|
||||||
| **Receipt OCR** | Extract line items from receipt photos automatically |
|
|
||||||
| **Dietary profiles** | Vegan, gluten-free, diabetic, and other constraints respected throughout |
|
|
||||||
| **Style auto-classifier** | LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes |
|
|
||||||
| **Community feed** | Browse and share recipes with other Kiwi users |
|
|
||||||
| **CSV export** | Full pantry export, always available, no tier gate |
|
|
||||||
|
|
||||||
---
|
## Running locally
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
**One-line install (self-hosted, Docker required):**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi/raw/branch/main/install.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Or clone and run manually:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi.git
|
|
||||||
cd kiwi
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
./manage.sh build
|
./manage.sh build
|
||||||
./manage.sh start
|
./manage.sh start
|
||||||
|
|
@ -60,59 +42,37 @@ cp .env.example .env
|
||||||
# API: http://localhost:8512
|
# API: http://localhost:8512
|
||||||
```
|
```
|
||||||
|
|
||||||
**Live cloud instance** (free account required):
|
## Cloud instance
|
||||||
[menagerie.circuitforge.tech/kiwi](https://menagerie.circuitforge.tech/kiwi)
|
|
||||||
|
|
||||||
Full setup and configuration guide: [docs.circuitforge.tech/kiwi](https://docs.circuitforge.tech/kiwi)
|
```bash
|
||||||
|
./manage.sh cloud-build
|
||||||
---
|
./manage.sh cloud-start
|
||||||
|
# Served at menagerie.circuitforge.tech/kiwi (JWT-gated)
|
||||||
|
```
|
||||||
|
|
||||||
## Tiers
|
## Tiers
|
||||||
|
|
||||||
| Feature | Free | Paid | Premium |
|
| Feature | Free | Paid | Premium |
|
||||||
|---|:---:|:---:|:---:|
|
|---------|------|------|---------|
|
||||||
| Inventory CRUD | Yes | Yes | Yes |
|
| Inventory CRUD | ✓ | ✓ | ✓ |
|
||||||
| Barcode scan | Yes | Yes | Yes |
|
| Barcode scan | ✓ | ✓ | ✓ |
|
||||||
| Receipt upload | Yes | Yes | Yes |
|
| Receipt upload | ✓ | ✓ | ✓ |
|
||||||
| Expiry alerts | Yes | Yes | Yes |
|
| Expiry alerts | ✓ | ✓ | ✓ |
|
||||||
| CSV export | Yes | Yes | Yes |
|
| CSV export | ✓ | ✓ | ✓ |
|
||||||
| Recipe browser (200k+ recipes) | Yes | Yes | Yes |
|
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
|
||||||
| Save recipes + notes + star rating | Yes | Yes | Yes |
|
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
|
||||||
| Style tags (manual, free-text) | Yes | Yes | Yes |
|
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
|
||||||
| Leftover mode (5/day) | Yes | Yes | Yes |
|
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||||
| Receipt OCR | BYOK | Yes | Yes |
|
| Recipe suggestions (L1–L4) | BYOK | ✓ | ✓ |
|
||||||
| Recipe suggestions (L1–L4) | BYOK | Yes | Yes |
|
| Named recipe collections | — | ✓ | ✓ |
|
||||||
| Named recipe collections | — | Yes | Yes |
|
| LLM style auto-classifier | — | BYOK | ✓ |
|
||||||
| LLM style auto-classifier | — | BYOK | Yes |
|
| Meal planning | — | ✓ | ✓ |
|
||||||
| Meal planning | — | Yes | Yes |
|
| Multi-household | — | — | ✓ |
|
||||||
| Multi-household | — | — | Yes |
|
| Leftover mode | — | — | ✓ |
|
||||||
|
|
||||||
**BYOK** = bring your own LLM backend. Configure `~/.config/circuitforge/llm.yaml` to unlock AI features at any tier without a paid subscription.
|
BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Frontend:** Vue 3 SPA (Vite + TypeScript), served on port 8511
|
|
||||||
- **Backend:** FastAPI + SQLite via `circuitforge-core`, API on port 8512
|
|
||||||
- **Auth:** CircuitForge session cookie (cloud mode); local mode requires no account
|
|
||||||
- **Licensing:** Heimdall — free tier auto-provisioned at signup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Forgejo-primary
|
|
||||||
|
|
||||||
Kiwi is developed and maintained on Forgejo at [git.opensourcesolarpunk.com/Circuit-Forge/kiwi](https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi). GitHub and Codeberg are read-only mirrors. File issues and submit pull requests on Forgejo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Kiwi uses a split license:
|
Discovery/pipeline layer: MIT
|
||||||
|
AI features: BSL 1.1 (free for personal non-commercial self-hosting)
|
||||||
- **Discovery and inventory pipeline** (barcode scan, expiry tracking, pantry CRUD, CSV export, recipe browser): [MIT](LICENSE-MIT)
|
|
||||||
- **AI features** (receipt OCR, LLM recipe suggestions, style auto-classifier): [BSL 1.1](LICENSE-BSL) — free for personal non-commercial self-hosting; commercial use or SaaS re-hosting requires a paid license. Converts to MIT after 4 years.
|
|
||||||
|
|
||||||
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
|
|
||||||
|
|
||||||
Privacy · Safety · Accessibility — co-equal, non-negotiable across all CircuitForge products.
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@
|
||||||
Kiwi: Pantry tracking and leftover recipe suggestions.
|
Kiwi: Pantry tracking and leftover recipe suggestions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.1.0"
|
||||||
__author__ = "Alan 'pyr0ball' Weinstock"
|
__author__ = "Alan 'pyr0ball' Weinstock"
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
# app/api/endpoints/activitypub.py
|
|
||||||
# MIT License
|
|
||||||
#
|
|
||||||
# ActivityPub endpoints for Kiwi instances:
|
|
||||||
# GET /.well-known/webfinger — WebFinger JRD
|
|
||||||
# GET /ap/actor — Instance actor document
|
|
||||||
# POST /ap/actor/inbox — Incoming activities
|
|
||||||
# GET /ap/outbox — Outgoing activities (OrderedCollection)
|
|
||||||
# GET /ap/posts/{slug} — Individual AP Note
|
|
||||||
# GET /ap/followers — Followers collection (count only)
|
|
||||||
# GET /ap/following — Following collection (empty stub)
|
|
||||||
#
|
|
||||||
# All endpoints are no-ops / 404 when AP_ENABLED=false or actor not loaded.
|
|
||||||
# The WebFinger and well-known routes are mounted at the root app level (not
|
|
||||||
# under /api/v1) — see main.py.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Two routers: one for well-known (root mount), one for /ap prefix ─────────
|
|
||||||
|
|
||||||
webfinger_router = APIRouter(tags=["activitypub"])
|
|
||||||
ap_router = APIRouter(prefix="/ap", tags=["activitypub"])
|
|
||||||
|
|
||||||
_AP_CONTENT_TYPE = "application/activity+json"
|
|
||||||
_JRD_CONTENT_TYPE = "application/jrd+json"
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_required():
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
raise HTTPException(status_code=404, detail="ActivityPub not enabled on this instance.")
|
|
||||||
return actor
|
|
||||||
|
|
||||||
|
|
||||||
# ── WebFinger ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@webfinger_router.get("/.well-known/webfinger")
|
|
||||||
async def webfinger(resource: str | None = None):
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
raise HTTPException(status_code=404, detail="ActivityPub not enabled.")
|
|
||||||
|
|
||||||
expected = f"acct:kiwi@{settings.AP_HOST}"
|
|
||||||
if resource and resource != expected:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Resource {resource!r} not found.")
|
|
||||||
|
|
||||||
jrd = {
|
|
||||||
"subject": expected,
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"rel": "self",
|
|
||||||
"type": _AP_CONTENT_TYPE,
|
|
||||||
"href": actor.actor_id,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return Response(
|
|
||||||
content=json.dumps(jrd),
|
|
||||||
media_type=_JRD_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Actor ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/actor")
|
|
||||||
async def get_actor_doc():
|
|
||||||
actor = _actor_required()
|
|
||||||
return Response(
|
|
||||||
content=json.dumps(actor.to_ap_dict()),
|
|
||||||
media_type=_AP_CONTENT_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Inbox (mounted via make_inbox_router below) ───────────────────────────────
|
|
||||||
|
|
||||||
async def _on_follow(activity: dict, headers: dict) -> None:
|
|
||||||
"""Accept Follow: add to ap_followers, send Accept(Follow) back."""
|
|
||||||
actor_url = activity.get("actor", "")
|
|
||||||
if not actor_url:
|
|
||||||
return
|
|
||||||
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.core.config import settings as _settings
|
|
||||||
db_path = _settings.DB_PATH
|
|
||||||
|
|
||||||
inbox_url, shared_inbox = await asyncio.to_thread(_resolve_inbox, actor_url)
|
|
||||||
if inbox_url is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT OR REPLACE INTO ap_followers
|
|
||||||
(actor_id, inbox_url, shared_inbox, followed_at, active)
|
|
||||||
VALUES (?, ?, ?, ?, 1)""",
|
|
||||||
(actor_url, inbox_url, shared_inbox, datetime.now(timezone.utc).isoformat()),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
return
|
|
||||||
accept = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.actor_id}/accepts/{activity.get('id', 'unknown')}",
|
|
||||||
"type": "Accept",
|
|
||||||
"actor": actor.actor_id,
|
|
||||||
"object": activity,
|
|
||||||
}
|
|
||||||
from circuitforge_core.activitypub import deliver_activity
|
|
||||||
await asyncio.to_thread(deliver_activity, accept, inbox_url, actor, 10.0)
|
|
||||||
|
|
||||||
|
|
||||||
async def _on_undo(activity: dict, headers: dict) -> None:
|
|
||||||
"""Handle Undo(Follow): deactivate the follower row."""
|
|
||||||
inner = activity.get("object", {})
|
|
||||||
if isinstance(inner, dict) and inner.get("type") == "Follow":
|
|
||||||
actor_url = activity.get("actor", "")
|
|
||||||
if actor_url:
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(settings.DB_PATH))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE ap_followers SET active = 0 WHERE actor_id = ?", (actor_url,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def _dedup_activity(activity_id: str | None) -> bool:
|
|
||||||
"""Return True (already seen) if activity_id is in ap_received; otherwise insert it."""
|
|
||||||
if not activity_id:
|
|
||||||
return False
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(settings.DB_PATH))
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ap_received (activity_id) VALUES (?)", (activity_id,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return False
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_inbox_router():
|
|
||||||
from circuitforge_core.activitypub.inbox import make_inbox_router
|
|
||||||
|
|
||||||
async def on_follow(activity: dict, headers: dict) -> None:
|
|
||||||
if await _dedup_activity(activity.get("id")):
|
|
||||||
return
|
|
||||||
await _on_follow(activity, headers)
|
|
||||||
|
|
||||||
async def on_undo(activity: dict, headers: dict) -> None:
|
|
||||||
if await _dedup_activity(activity.get("id")):
|
|
||||||
return
|
|
||||||
await _on_undo(activity, headers)
|
|
||||||
|
|
||||||
return make_inbox_router(
|
|
||||||
handlers={"Follow": on_follow, "Undo": on_undo},
|
|
||||||
verify_key_fetcher=None, # Signature verification enabled in prod when actor is loaded
|
|
||||||
path="/inbox",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Mount inbox at /ap/actor/inbox (AP spec: inbox is a sub-resource of the actor)
|
|
||||||
try:
|
|
||||||
_inbox_sub = _build_inbox_router()
|
|
||||||
ap_router.include_router(_inbox_sub, prefix="/actor")
|
|
||||||
except Exception as _e:
|
|
||||||
logger.warning("AP inbox router not available: %s", _e)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Outbox ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/outbox")
|
|
||||||
async def get_outbox(page: int | None = None, request: Request = None):
|
|
||||||
actor = _actor_required()
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
store = _get_community_store()
|
|
||||||
base = f"https://{settings.AP_HOST}"
|
|
||||||
|
|
||||||
if store is None:
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.outbox_url}",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": 0,
|
|
||||||
"orderedItems": [],
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
PAGE_SIZE = 20
|
|
||||||
offset = ((page or 1) - 1) * PAGE_SIZE
|
|
||||||
posts = await asyncio.to_thread(store.list_posts, limit=PAGE_SIZE, offset=offset)
|
|
||||||
items = [_post_to_ap_note(p, actor, base) for p in posts]
|
|
||||||
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": actor.outbox_url + (f"?page={page}" if page else ""),
|
|
||||||
"type": "OrderedCollectionPage" if page else "OrderedCollection",
|
|
||||||
"orderedItems": items,
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Individual post ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/posts/{slug}")
|
|
||||||
async def get_ap_post(slug: str):
|
|
||||||
actor = _actor_required()
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Community DB not available.")
|
|
||||||
|
|
||||||
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
|
||||||
if post is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found.")
|
|
||||||
|
|
||||||
base = f"https://{settings.AP_HOST}"
|
|
||||||
note = _post_to_ap_note(post, actor, base)
|
|
||||||
return Response(content=json.dumps(note), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Followers / Following ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@ap_router.get("/followers")
|
|
||||||
async def get_followers():
|
|
||||||
actor = _actor_required()
|
|
||||||
import sqlite3
|
|
||||||
count = 0
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(settings.DB_PATH))
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM ap_followers WHERE active = 1").fetchone()
|
|
||||||
conn.close()
|
|
||||||
count = row[0] if row else 0
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.actor_id}/followers",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": count,
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
@ap_router.get("/following")
|
|
||||||
async def get_following():
|
|
||||||
actor = _actor_required()
|
|
||||||
collection = {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"id": f"{actor.actor_id}/following",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": 0,
|
|
||||||
"orderedItems": [],
|
|
||||||
}
|
|
||||||
return Response(content=json.dumps(collection), media_type=_AP_CONTENT_TYPE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _post_to_ap_note(post, actor, base_url: str) -> dict:
|
|
||||||
from circuitforge_core.activitypub import make_note
|
|
||||||
from app.services.community.ap_compat import _build_content
|
|
||||||
|
|
||||||
diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or [])
|
|
||||||
hashtags = [{"type": "Hashtag", "name": "#Kiwi", "href": f"{base_url}/ap/tags/kiwi"}]
|
|
||||||
for tag in diet_tags[:4]:
|
|
||||||
ht = "".join(w.capitalize() for w in tag.replace("-", " ").split())
|
|
||||||
hashtags.append({"type": "Hashtag", "name": f"#{ht}"})
|
|
||||||
|
|
||||||
content = _build_content(
|
|
||||||
{
|
|
||||||
"title": post.title,
|
|
||||||
"description": getattr(post, "description", None),
|
|
||||||
"outcome_notes": getattr(post, "outcome_notes", None),
|
|
||||||
"dietary_tags": diet_tags,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
published = post.published
|
|
||||||
note = make_note(
|
|
||||||
actor_id=actor.actor_id,
|
|
||||||
content=content,
|
|
||||||
tag=hashtags,
|
|
||||||
published=published if isinstance(published, datetime) else None,
|
|
||||||
)
|
|
||||||
note["id"] = f"{base_url}/ap/posts/{post.slug}"
|
|
||||||
return note
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_inbox(actor_url: str) -> tuple[str | None, str | None]:
|
|
||||||
"""Fetch an AP actor document and extract inbox + sharedInbox URLs."""
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
resp = httpx.get(
|
|
||||||
actor_url,
|
|
||||||
headers={"Accept": "application/activity+json"},
|
|
||||||
timeout=8.0,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
doc = resp.json()
|
|
||||||
inbox = doc.get("inbox")
|
|
||||||
shared = doc.get("endpoints", {}).get("sharedInbox")
|
|
||||||
return inbox, shared
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Could not resolve actor %s: %s", actor_url, exc)
|
|
||||||
return None, None
|
|
||||||
|
|
@ -1,444 +0,0 @@
|
||||||
# app/api/endpoints/community.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.services.community.feed import posts_to_rss
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/community", tags=["community"])
|
|
||||||
|
|
||||||
_community_store = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_community_store():
|
|
||||||
return _community_store
|
|
||||||
|
|
||||||
|
|
||||||
def init_community_store(community_db_url: str | None) -> None:
|
|
||||||
global _community_store
|
|
||||||
if not community_db_url:
|
|
||||||
logger.info(
|
|
||||||
"COMMUNITY_DB_URL not set — community write features disabled. "
|
|
||||||
"Browse still works via cloud feed."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
from circuitforge_core.community import CommunityDB
|
|
||||||
from app.services.community.community_store import KiwiCommunityStore
|
|
||||||
db = CommunityDB(dsn=community_db_url)
|
|
||||||
db.run_migrations()
|
|
||||||
_community_store = KiwiCommunityStore(db)
|
|
||||||
logger.info("Community store initialized.")
|
|
||||||
|
|
||||||
|
|
||||||
def _visible(post, session=None) -> bool:
|
|
||||||
"""Return False for premium-tier posts when the session is not paid/premium."""
|
|
||||||
tier = getattr(post, "tier", None)
|
|
||||||
if tier == "premium":
|
|
||||||
if session is None or getattr(session, "tier", None) not in ("paid", "premium"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts")
|
|
||||||
async def list_posts(
|
|
||||||
post_type: str | None = None,
|
|
||||||
dietary_tags: str | None = None,
|
|
||||||
allergen_exclude: str | None = None,
|
|
||||||
page: int = 1,
|
|
||||||
page_size: int = 20,
|
|
||||||
):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return {
|
|
||||||
"posts": [],
|
|
||||||
"total": 0,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"note": "Community DB not available on this instance.",
|
|
||||||
}
|
|
||||||
|
|
||||||
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
|
|
||||||
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
|
|
||||||
offset = (page - 1) * min(page_size, 100)
|
|
||||||
|
|
||||||
posts = await asyncio.to_thread(
|
|
||||||
store.list_posts,
|
|
||||||
limit=min(page_size, 100),
|
|
||||||
offset=offset,
|
|
||||||
post_type=post_type,
|
|
||||||
dietary_tags=dietary,
|
|
||||||
allergen_exclude=allergen_ex,
|
|
||||||
)
|
|
||||||
visible = [_post_to_dict(p) for p in posts if _visible(p)]
|
|
||||||
return {"posts": visible, "total": len(visible), "page": page, "page_size": page_size}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts/{slug}")
|
|
||||||
async def get_post(slug: str, request: Request):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community DB not available on this instance.")
|
|
||||||
|
|
||||||
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
|
||||||
if post is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found.")
|
|
||||||
|
|
||||||
accept = request.headers.get("accept", "")
|
|
||||||
if "application/activity+json" in accept or "application/ld+json" in accept:
|
|
||||||
from app.services.community.ap_compat import post_to_ap_json_ld
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url)
|
|
||||||
|
|
||||||
return _post_to_dict(post)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/feed.rss")
|
|
||||||
async def get_rss_feed(request: Request):
|
|
||||||
store = _get_community_store()
|
|
||||||
posts_data: list[dict] = []
|
|
||||||
if store is not None:
|
|
||||||
posts = await asyncio.to_thread(store.list_posts, limit=50)
|
|
||||||
posts_data = [_post_to_dict(p) for p in posts]
|
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
|
||||||
rss = posts_to_rss(posts_data, base_url=base_url)
|
|
||||||
return Response(content=rss, media_type="application/rss+xml; charset=utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/local-feed")
|
|
||||||
async def local_feed():
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return []
|
|
||||||
posts = await asyncio.to_thread(store.list_posts, limit=50)
|
|
||||||
return [_post_to_dict(p) for p in posts]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hall-of-chaos")
|
|
||||||
async def hall_of_chaos():
|
|
||||||
"""Hidden easter egg endpoint -- returns the 10 most chaotic bloopers."""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return {"posts": [], "chaos_level": 0}
|
|
||||||
posts = await asyncio.to_thread(
|
|
||||||
store.list_posts, limit=10, post_type="recipe_blooper"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"posts": [_post_to_dict(p) for p in posts],
|
|
||||||
"chaos_level": len(posts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"}
|
|
||||||
_MAX_TITLE_LEN = 200
|
|
||||||
_MAX_TEXT_LEN = 2000
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_publish_body(body: dict) -> None:
|
|
||||||
"""Raise HTTPException(422) for any invalid fields in a publish request."""
|
|
||||||
post_type = body.get("post_type", "plan")
|
|
||||||
if post_type not in _VALID_POST_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}",
|
|
||||||
)
|
|
||||||
title = body.get("title") or ""
|
|
||||||
if len(title) > _MAX_TITLE_LEN:
|
|
||||||
raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.")
|
|
||||||
for field in ("description", "outcome_notes", "recipe_name"):
|
|
||||||
value = body.get(field)
|
|
||||||
if value and len(str(value)) > _MAX_TEXT_LEN:
|
|
||||||
raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.")
|
|
||||||
photo_url = body.get("photo_url")
|
|
||||||
if photo_url and not str(photo_url).startswith("https://"):
|
|
||||||
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/check-similar")
|
|
||||||
async def check_similar(body: dict, session: CloudUser = Depends(get_session)):
|
|
||||||
"""Pre-submission dedup check: return similar existing posts for the given title/recipe_id.
|
|
||||||
|
|
||||||
Safe to call with no community store configured — returns empty list rather than 503.
|
|
||||||
"""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return {"similar_posts": []}
|
|
||||||
|
|
||||||
title = (body.get("title") or "").strip()
|
|
||||||
recipe_id = body.get("recipe_id")
|
|
||||||
post_type = body.get("post_type")
|
|
||||||
|
|
||||||
if not title:
|
|
||||||
return {"similar_posts": []}
|
|
||||||
|
|
||||||
candidates = await asyncio.to_thread(
|
|
||||||
store.search_similar_posts,
|
|
||||||
title,
|
|
||||||
recipe_id,
|
|
||||||
post_type,
|
|
||||||
8,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return {"similar_posts": []}
|
|
||||||
|
|
||||||
from app.services.community.dedup import build_similar_post_result, fetch_recipe_ingredients
|
|
||||||
incoming_ingredients = await asyncio.to_thread(
|
|
||||||
fetch_recipe_ingredients, session.db, recipe_id
|
|
||||||
)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for post in candidates:
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
build_similar_post_result,
|
|
||||||
post,
|
|
||||||
recipe_id,
|
|
||||||
incoming_ingredients,
|
|
||||||
session.db,
|
|
||||||
)
|
|
||||||
if result["similarity_tier"] != "different":
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
return {"similar_posts": results[:5]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts", status_code=201)
|
|
||||||
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
|
||||||
from app.tiers import can_use
|
|
||||||
if not can_use("community_publish", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
|
|
||||||
|
|
||||||
_validate_publish_body(body)
|
|
||||||
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="This Kiwi instance is not connected to a community database. "
|
|
||||||
"Publishing is only available on cloud instances.",
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.services.community.community_store import get_or_create_pseudonym
|
|
||||||
def _get_pseudonym():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
return get_or_create_pseudonym(
|
|
||||||
store=s,
|
|
||||||
directus_user_id=session.user_id,
|
|
||||||
requested_name=body.get("pseudonym_name"),
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
try:
|
|
||||||
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
|
|
||||||
from app.services.community.element_snapshot import compute_snapshot
|
|
||||||
def _snapshot():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
return compute_snapshot(recipe_ids=recipe_ids, store=s)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
snapshot = await asyncio.to_thread(_snapshot)
|
|
||||||
|
|
||||||
post_type = body.get("post_type", "plan")
|
|
||||||
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
|
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
|
||||||
|
|
||||||
similar_to_ref = body.get("similar_to_ref") or None
|
|
||||||
|
|
||||||
from circuitforge_core.community.models import CommunityPost
|
|
||||||
post = CommunityPost(
|
|
||||||
slug=slug,
|
|
||||||
pseudonym=pseudonym,
|
|
||||||
post_type=post_type,
|
|
||||||
published=datetime.now(timezone.utc),
|
|
||||||
title=(body.get("title") or "Untitled")[:_MAX_TITLE_LEN],
|
|
||||||
description=body.get("description"),
|
|
||||||
photo_url=body.get("photo_url"),
|
|
||||||
slots=body.get("slots", []),
|
|
||||||
recipe_id=body.get("recipe_id"),
|
|
||||||
recipe_name=body.get("recipe_name"),
|
|
||||||
level=body.get("level"),
|
|
||||||
outcome_notes=body.get("outcome_notes"),
|
|
||||||
seasoning_score=snapshot.seasoning_score,
|
|
||||||
richness_score=snapshot.richness_score,
|
|
||||||
brightness_score=snapshot.brightness_score,
|
|
||||||
depth_score=snapshot.depth_score,
|
|
||||||
aroma_score=snapshot.aroma_score,
|
|
||||||
structure_score=snapshot.structure_score,
|
|
||||||
texture_profile=snapshot.texture_profile,
|
|
||||||
dietary_tags=list(snapshot.dietary_tags),
|
|
||||||
allergen_flags=list(snapshot.allergen_flags),
|
|
||||||
flavor_molecules=list(snapshot.flavor_molecules),
|
|
||||||
fat_pct=snapshot.fat_pct,
|
|
||||||
protein_pct=snapshot.protein_pct,
|
|
||||||
moisture_pct=snapshot.moisture_pct,
|
|
||||||
similar_to_ref=similar_to_ref,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
inserted = await asyncio.to_thread(store.insert_post, post)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="A post with this title already exists today. Try a different title.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
post_dict = _post_to_dict(inserted)
|
|
||||||
|
|
||||||
# AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in)
|
|
||||||
from app.core.config import settings as _settings
|
|
||||||
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
|
|
||||||
from circuitforge_core.activitypub import make_create, make_note, PUBLIC
|
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
from app.services.ap.delivery import deliver_to_followers
|
|
||||||
_ap_actor = get_actor()
|
|
||||||
if _ap_actor is not None:
|
|
||||||
base = f"https://{_settings.AP_HOST}"
|
|
||||||
from app.api.endpoints.activitypub import _post_to_ap_note
|
|
||||||
_note = _post_to_ap_note(inserted, _ap_actor, base)
|
|
||||||
_activity = make_create(_ap_actor, _note)
|
|
||||||
asyncio.create_task(
|
|
||||||
asyncio.to_thread(
|
|
||||||
deliver_to_followers, inserted.slug, _activity, session.db
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mastodon post if user has connected account and opted in
|
|
||||||
if body.get("post_to_mastodon"):
|
|
||||||
from app.services.ap.mastodon import build_post_content, get_token, post_status
|
|
||||||
_masto = await asyncio.to_thread(
|
|
||||||
get_token, session.db, session.user_id, _settings.AP_TOKEN_ENCRYPTION_KEY
|
|
||||||
)
|
|
||||||
if _masto:
|
|
||||||
_masto_url, _masto_token = _masto
|
|
||||||
_content = build_post_content(post_dict)
|
|
||||||
asyncio.create_task(
|
|
||||||
asyncio.to_thread(post_status, _masto_url, _masto_token, _content)
|
|
||||||
)
|
|
||||||
|
|
||||||
return post_dict
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/posts/{slug}", status_code=204)
|
|
||||||
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community DB not available.")
|
|
||||||
|
|
||||||
def _get_pseudonym():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
return s.get_current_pseudonym(session.user_id)
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
|
||||||
if not pseudonym:
|
|
||||||
raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.")
|
|
||||||
|
|
||||||
deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found or you are not the author.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts/{slug}/fork", status_code=201)
|
|
||||||
async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community DB not available.")
|
|
||||||
|
|
||||||
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
|
||||||
if post is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Post not found.")
|
|
||||||
if post.post_type != "plan":
|
|
||||||
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
|
|
||||||
|
|
||||||
required_slot_keys = {"day", "meal_type", "recipe_id"}
|
|
||||||
if any(not required_slot_keys.issubset(slot) for slot in post.slots):
|
|
||||||
raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.")
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
week_start = date.today().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
def _create_plan():
|
|
||||||
s = Store(session.db)
|
|
||||||
try:
|
|
||||||
meal_types = list({slot["meal_type"] for slot in post.slots})
|
|
||||||
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types or ["dinner"])
|
|
||||||
for slot in post.slots:
|
|
||||||
s.assign_recipe_to_slot(
|
|
||||||
plan_id=plan["id"],
|
|
||||||
day_of_week=slot["day"],
|
|
||||||
meal_type=slot["meal_type"],
|
|
||||||
recipe_id=slot["recipe_id"],
|
|
||||||
)
|
|
||||||
return plan
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
plan = await asyncio.to_thread(_create_plan)
|
|
||||||
return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts/{slug}/fork-adapt", status_code=201)
|
|
||||||
async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)):
|
|
||||||
from app.tiers import can_use
|
|
||||||
if not can_use("community_fork_adapt", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.")
|
|
||||||
# Stub: full LLM adaptation deferred
|
|
||||||
raise HTTPException(status_code=501, detail="Fork-adapt not yet implemented.")
|
|
||||||
|
|
||||||
|
|
||||||
def _post_to_dict(post) -> dict:
|
|
||||||
return {
|
|
||||||
"slug": post.slug,
|
|
||||||
"pseudonym": post.pseudonym,
|
|
||||||
"post_type": post.post_type,
|
|
||||||
"published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published),
|
|
||||||
"title": post.title,
|
|
||||||
"description": post.description,
|
|
||||||
"photo_url": post.photo_url,
|
|
||||||
"slots": list(post.slots),
|
|
||||||
"recipe_id": post.recipe_id,
|
|
||||||
"recipe_name": post.recipe_name,
|
|
||||||
"level": post.level,
|
|
||||||
"outcome_notes": post.outcome_notes,
|
|
||||||
"element_profiles": {
|
|
||||||
"seasoning_score": post.seasoning_score,
|
|
||||||
"richness_score": post.richness_score,
|
|
||||||
"brightness_score": post.brightness_score,
|
|
||||||
"depth_score": post.depth_score,
|
|
||||||
"aroma_score": post.aroma_score,
|
|
||||||
"structure_score": post.structure_score,
|
|
||||||
"texture_profile": post.texture_profile,
|
|
||||||
},
|
|
||||||
"dietary_tags": list(post.dietary_tags),
|
|
||||||
"allergen_flags": list(post.allergen_flags),
|
|
||||||
"flavor_molecules": list(post.flavor_molecules),
|
|
||||||
"fat_pct": post.fat_pct,
|
|
||||||
"protein_pct": post.protein_pct,
|
|
||||||
"moisture_pct": post.moisture_pct,
|
|
||||||
"similar_to_ref": getattr(post, "similar_to_ref", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _post_type_prefix(post_type: str) -> str:
|
|
||||||
return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post")
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training
|
|
||||||
from circuitforge_core.api import make_corrections_router
|
|
||||||
from app.db.session import get_db
|
|
||||||
|
|
||||||
router = make_corrections_router(get_db=get_db, product="kiwi")
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
"""Export endpoints — CSV and JSON export of user data."""
|
"""Export endpoints — CSV/Excel of receipt and inventory data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
@ -47,33 +45,3 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/json")
|
|
||||||
async def export_full_json(store: Store = Depends(get_store)):
|
|
||||||
"""Export full pantry inventory + saved recipes as a single JSON file.
|
|
||||||
|
|
||||||
Intended for data portability — users can import this into another
|
|
||||||
Kiwi instance or keep it as an offline backup.
|
|
||||||
"""
|
|
||||||
inventory, saved = await asyncio.gather(
|
|
||||||
asyncio.to_thread(store.list_inventory),
|
|
||||||
asyncio.to_thread(store.get_saved_recipes),
|
|
||||||
)
|
|
||||||
|
|
||||||
export_doc = {
|
|
||||||
"kiwi_export": {
|
|
||||||
"version": "1.0",
|
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"inventory": [dict(row) for row in inventory],
|
|
||||||
"saved_recipes": [dict(row) for row in saved],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body = json.dumps(export_doc, default=str, indent=2)
|
|
||||||
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([body]),
|
|
||||||
media_type="application/json",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""Screenshot attachment endpoint for in-app feedback.
|
|
||||||
|
|
||||||
After the cf-core feedback router creates a Forgejo issue, the frontend
|
|
||||||
can call POST /feedback/attach to upload a screenshot and pin it as a
|
|
||||||
comment on that issue.
|
|
||||||
|
|
||||||
The endpoint is separate from the cf-core router so Kiwi owns it
|
|
||||||
without modifying shared infrastructure.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_FORGEJO_BASE = os.environ.get(
|
|
||||||
"FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1"
|
|
||||||
)
|
|
||||||
_REPO = "Circuit-Forge/kiwi"
|
|
||||||
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
||||||
|
|
||||||
|
|
||||||
class AttachRequest(BaseModel):
|
|
||||||
issue_number: int
|
|
||||||
filename: str = Field(default="screenshot.png", max_length=80)
|
|
||||||
image_b64: str # data URI or raw base64
|
|
||||||
|
|
||||||
|
|
||||||
class AttachResponse(BaseModel):
|
|
||||||
comment_url: str
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_headers() -> dict[str, str]:
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
return {"Authorization": f"token {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_image(image_b64: str) -> tuple[bytes, str]:
|
|
||||||
"""Return (raw_bytes, mime_type) from a base64 string or data URI."""
|
|
||||||
if image_b64.startswith("data:"):
|
|
||||||
header, _, data = image_b64.partition(",")
|
|
||||||
mime = header.split(";")[0].split(":")[1] if ":" in header else "image/png"
|
|
||||||
else:
|
|
||||||
data = image_b64
|
|
||||||
mime = "image/png"
|
|
||||||
return base64.b64decode(data), mime
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attach", response_model=AttachResponse)
|
|
||||||
def attach_screenshot(payload: AttachRequest) -> AttachResponse:
|
|
||||||
"""Upload a screenshot to a Forgejo issue as a comment with embedded image.
|
|
||||||
|
|
||||||
The image is uploaded as an issue asset, then referenced in a comment
|
|
||||||
so it is visible inline when the issue is viewed.
|
|
||||||
"""
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=503, detail="Feedback not configured.")
|
|
||||||
|
|
||||||
raw_bytes, mime = _decode_image(payload.image_b64)
|
|
||||||
|
|
||||||
if len(raw_bytes) > _MAX_BYTES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"Screenshot exceeds 5 MB limit ({len(raw_bytes) // 1024} KB received).",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Upload image as issue asset
|
|
||||||
asset_resp = requests.post(
|
|
||||||
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/assets",
|
|
||||||
headers=_forgejo_headers(),
|
|
||||||
files={"attachment": (payload.filename, raw_bytes, mime)},
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
if not asset_resp.ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"Forgejo asset upload failed: {asset_resp.text[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
asset_url = asset_resp.json().get("browser_download_url", "")
|
|
||||||
|
|
||||||
# Pin as a comment so the image is visible inline
|
|
||||||
comment_body = f"**Screenshot attached by reporter:**\n\n"
|
|
||||||
comment_resp = requests.post(
|
|
||||||
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/comments",
|
|
||||||
headers={**_forgejo_headers(), "Content-Type": "application/json"},
|
|
||||||
json={"body": comment_body},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if not comment_resp.ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"Forgejo comment failed: {comment_resp.text[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
comment_url = comment_resp.json().get("html_url", "")
|
|
||||||
return AttachResponse(comment_url=comment_url)
|
|
||||||
|
|
@ -11,8 +11,7 @@ import sqlite3
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, get_session
|
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN, get_session
|
||||||
from app.services.heimdall_orch import HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.household import (
|
from app.models.schemas.household import (
|
||||||
HouseholdAcceptRequest,
|
HouseholdAcceptRequest,
|
||||||
|
|
@ -129,18 +128,15 @@ 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 = Store(session.db)
|
|
||||||
try:
|
|
||||||
store.conn.execute(
|
store.conn.execute(
|
||||||
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
||||||
VALUES (?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?)""",
|
||||||
(token, session.household_id, session.user_id, expires_at),
|
(token, session.household_id, session.user_id, expires_at),
|
||||||
)
|
)
|
||||||
store.conn.commit()
|
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)
|
||||||
|
|
||||||
|
|
@ -156,7 +152,6 @@ 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()
|
||||||
try:
|
|
||||||
row = hh_store.conn.execute(
|
row = hh_store.conn.execute(
|
||||||
"""SELECT token, expires_at, used_at FROM household_invites
|
"""SELECT token, expires_at, used_at FROM household_invites
|
||||||
WHERE token = ? AND household_id = ?""",
|
WHERE token = ? AND household_id = ?""",
|
||||||
|
|
@ -175,8 +170,6 @@ async def accept_invite(
|
||||||
(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,
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
"""Kiwi — /api/v1/imitate/samples endpoint for Avocet Imitate tab.
|
|
||||||
|
|
||||||
Returns the actual assembled prompt Kiwi sends to its LLM for recipe generation,
|
|
||||||
including the full pantry context (expiry-first ordering), dietary constraints
|
|
||||||
(from user_settings if present), and the Level 3 format instructions.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import get_session, CloudUser
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_LEVEL3_FORMAT = [
|
|
||||||
"",
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
|
||||||
"Title: <name of the dish>",
|
|
||||||
"Ingredients: <comma-separated list>",
|
|
||||||
"Directions:",
|
|
||||||
"1. <first step>",
|
|
||||||
"2. <second step>",
|
|
||||||
"3. <continue for each step>",
|
|
||||||
"Notes: <optional tips>",
|
|
||||||
]
|
|
||||||
|
|
||||||
_LEVEL4_FORMAT = [
|
|
||||||
"",
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
|
||||||
"Title: <name of the dish>",
|
|
||||||
"Ingredients: <comma-separated list>",
|
|
||||||
"Directions:",
|
|
||||||
"1. <first step>",
|
|
||||||
"2. <second step>",
|
|
||||||
"Notes: <optional tips>",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_user_settings(store: Store) -> dict:
|
|
||||||
"""Read all key/value pairs from user_settings table."""
|
|
||||||
try:
|
|
||||||
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
|
|
||||||
return {r["key"]: r["value"] for r in rows}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_recipe_prompt(
|
|
||||||
pantry_names: list[str],
|
|
||||||
expiring_names: list[str],
|
|
||||||
constraints: list[str],
|
|
||||||
allergies: list[str],
|
|
||||||
level: int = 3,
|
|
||||||
) -> str:
|
|
||||||
"""Assemble the recipe generation prompt matching Kiwi's Level 3/4 format."""
|
|
||||||
# Expiring items first, then remaining pantry items (deduped)
|
|
||||||
expiring_set = set(expiring_names)
|
|
||||||
ordered = list(expiring_names) + [n for n in pantry_names if n not in expiring_set]
|
|
||||||
|
|
||||||
if not ordered:
|
|
||||||
ordered = pantry_names
|
|
||||||
|
|
||||||
if level == 4:
|
|
||||||
lines = [
|
|
||||||
"Surprise me with a creative, unexpected recipe.",
|
|
||||||
"Only use ingredients that make culinary sense together. "
|
|
||||||
"Do not force flavoured/sweetened items (vanilla yoghurt, flavoured syrups, jam) into savoury dishes.",
|
|
||||||
f"Ingredients available: {', '.join(ordered)}",
|
|
||||||
]
|
|
||||||
if constraints:
|
|
||||||
lines.append(f"Constraints: {', '.join(constraints)}")
|
|
||||||
if allergies:
|
|
||||||
lines.append(f"Must NOT contain: {', '.join(allergies)}")
|
|
||||||
lines.append("Treat any mystery ingredient as a wildcard — use your imagination.")
|
|
||||||
lines += _LEVEL4_FORMAT
|
|
||||||
else:
|
|
||||||
lines = [
|
|
||||||
"You are a creative chef. Generate a recipe using the ingredients below.",
|
|
||||||
"IMPORTANT: When you use a pantry item, list it in Ingredients using its exact name "
|
|
||||||
"from the pantry list. Do not add adjectives, quantities, or cooking states "
|
|
||||||
"(e.g. use 'butter', not 'unsalted butter' or '2 tbsp butter').",
|
|
||||||
"IMPORTANT: Only use pantry items that make culinary sense for the dish. "
|
|
||||||
"Do NOT force flavoured/sweetened items (vanilla yoghurt, fruit yoghurt, jam, "
|
|
||||||
"dessert sauces, flavoured syrups) into savoury dishes.",
|
|
||||||
"IMPORTANT: Do not default to the same ingredient repeatedly across dishes. "
|
|
||||||
"If a pantry item does not genuinely improve this specific dish, leave it out.",
|
|
||||||
"",
|
|
||||||
f"Pantry items: {', '.join(ordered)}",
|
|
||||||
]
|
|
||||||
if expiring_names:
|
|
||||||
lines.append(
|
|
||||||
f"Priority — use these soon (expiring): {', '.join(expiring_names)}"
|
|
||||||
)
|
|
||||||
if constraints:
|
|
||||||
lines.append(f"Dietary constraints: {', '.join(constraints)}")
|
|
||||||
if allergies:
|
|
||||||
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergies)}")
|
|
||||||
lines += _LEVEL3_FORMAT
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/samples")
|
|
||||||
async def imitate_samples(
|
|
||||||
limit: int = 5,
|
|
||||||
level: int = 3,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Return assembled recipe generation prompts for Avocet's Imitate tab.
|
|
||||||
|
|
||||||
Each sample includes:
|
|
||||||
system_prompt empty (Kiwi uses no system context)
|
|
||||||
input_text full Level 3/4 prompt with pantry items, expiring items,
|
|
||||||
dietary constraints, and format instructions
|
|
||||||
output_text empty (no prior LLM output stored per-request)
|
|
||||||
|
|
||||||
level: 3 (structured with element biasing context) or 4 (wildcard creative)
|
|
||||||
limit: max number of distinct prompt variants to return (varies by pantry state)
|
|
||||||
"""
|
|
||||||
limit = max(1, min(limit, 10))
|
|
||||||
store = Store(session.db)
|
|
||||||
|
|
||||||
# Full pantry for context
|
|
||||||
all_items = store.list_inventory()
|
|
||||||
pantry_names = [r["product_name"] for r in all_items if r.get("product_name")]
|
|
||||||
|
|
||||||
# Expiring items as priority ingredients
|
|
||||||
expiring = store.expiring_soon(days=14)
|
|
||||||
expiring_names = [r["product_name"] for r in expiring if r.get("product_name")]
|
|
||||||
|
|
||||||
# Dietary constraints from user_settings (keys: constraints, allergies)
|
|
||||||
settings = _read_user_settings(store)
|
|
||||||
import json as _json
|
|
||||||
try:
|
|
||||||
constraints = _json.loads(settings.get("dietary_constraints", "[]")) or []
|
|
||||||
except Exception:
|
|
||||||
constraints = []
|
|
||||||
try:
|
|
||||||
allergies = _json.loads(settings.get("dietary_allergies", "[]")) or []
|
|
||||||
except Exception:
|
|
||||||
allergies = []
|
|
||||||
|
|
||||||
if not pantry_names:
|
|
||||||
return {"samples": [], "total": 0, "type": f"recipe_level{level}"}
|
|
||||||
|
|
||||||
# Build prompt variants: one per expiring item as the "anchor" ingredient,
|
|
||||||
# plus one general pantry prompt. Cap at limit.
|
|
||||||
samples = []
|
|
||||||
seen_anchors: set[str] = set()
|
|
||||||
|
|
||||||
for item in (expiring[:limit - 1] if expiring else []):
|
|
||||||
anchor = item.get("product_name", "")
|
|
||||||
if not anchor or anchor in seen_anchors:
|
|
||||||
continue
|
|
||||||
seen_anchors.add(anchor)
|
|
||||||
|
|
||||||
# Put this item first in the list for the prompt
|
|
||||||
ordered_expiring = [anchor] + [n for n in expiring_names if n != anchor]
|
|
||||||
prompt = _build_recipe_prompt(pantry_names, ordered_expiring, constraints, allergies, level)
|
|
||||||
|
|
||||||
samples.append({
|
|
||||||
"id": item.get("id", 0),
|
|
||||||
"anchor_item": anchor,
|
|
||||||
"expiring_count": len(expiring_names),
|
|
||||||
"pantry_count": len(pantry_names),
|
|
||||||
"system_prompt": "",
|
|
||||||
"input_text": prompt,
|
|
||||||
"output_text": "",
|
|
||||||
})
|
|
||||||
|
|
||||||
# One general prompt using all expiring as priority
|
|
||||||
if len(samples) < limit:
|
|
||||||
prompt = _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
|
|
||||||
samples.append({
|
|
||||||
"id": 0,
|
|
||||||
"anchor_item": "full pantry",
|
|
||||||
"expiring_count": len(expiring_names),
|
|
||||||
"pantry_count": len(pantry_names),
|
|
||||||
"system_prompt": "",
|
|
||||||
"input_text": prompt,
|
|
||||||
"output_text": "",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"samples": samples, "total": len(samples), "type": f"recipe_level{level}"}
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
@ -12,73 +11,28 @@ import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
|
|
||||||
_predictor = ExpirationPredictor()
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.inventory import (
|
from app.models.schemas.inventory import (
|
||||||
BarcodeScanResponse,
|
BarcodeScanResponse,
|
||||||
BulkAddByNameRequest,
|
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)
|
||||||
|
|
@ -163,12 +117,7 @@ async def delete_product(product_id: int, store: Store = Depends(get_store)):
|
||||||
# ── Inventory items ───────────────────────────────────────────────────────────
|
# ── Inventory items ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_inventory_item(
|
async def create_inventory_item(body: InventoryItemCreate, store: Store = Depends(get_store)):
|
||||||
body: InventoryItemCreate,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
log.info("add_item auth=%s tier=%s product_id=%s", _auth_label(session.user_id), session.tier, body.product_id)
|
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
body.product_id,
|
body.product_id,
|
||||||
|
|
@ -181,10 +130,7 @@ async def create_inventory_item(
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
source=body.source,
|
source=body.source,
|
||||||
)
|
)
|
||||||
# RETURNING * omits joined columns (product_name, barcode, category).
|
return InventoryItemResponse.model_validate(item)
|
||||||
# Re-fetch with the products JOIN so the response is fully populated (#99).
|
|
||||||
full_item = await asyncio.to_thread(store.get_inventory_item, item["id"])
|
|
||||||
return InventoryItemResponse.model_validate(full_item)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
||||||
|
|
@ -197,7 +143,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="manual"
|
store.get_or_create_product, entry.name, None, source="shopping"
|
||||||
)
|
)
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
|
|
@ -205,7 +151,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="manual",
|
source="shopping",
|
||||||
)
|
)
|
||||||
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:
|
||||||
|
|
@ -222,15 +168,13 @@ async def list_inventory_items(
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
):
|
):
|
||||||
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return [InventoryItemResponse.model_validate(i) for i in items]
|
||||||
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
||||||
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
||||||
items = await asyncio.to_thread(store.expiring_soon, days)
|
items = await asyncio.to_thread(store.expiring_soon, days)
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return [InventoryItemResponse.model_validate(i) for i in items]
|
||||||
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -238,8 +182,7 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(item)
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -251,83 +194,24 @@ async def update_inventory_item(
|
||||||
updates["purchase_date"] = str(updates["purchase_date"])
|
updates["purchase_date"] = str(updates["purchase_date"])
|
||||||
if "expiration_date" in updates and updates["expiration_date"]:
|
if "expiration_date" in updates and updates["expiration_date"]:
|
||||||
updates["expiration_date"] = str(updates["expiration_date"])
|
updates["expiration_date"] = str(updates["expiration_date"])
|
||||||
if "opened_date" in updates and updates["opened_date"]:
|
|
||||||
updates["opened_date"] = str(updates["opened_date"])
|
|
||||||
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(item)
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
|
||||||
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
|
||||||
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
|
|
||||||
from datetime import date
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.update_inventory_item,
|
|
||||||
item_id,
|
|
||||||
opened_date=str(date.today()),
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||||
async def consume_item(
|
async def consume_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item_id: int,
|
|
||||||
body: Optional[PartialConsumeRequest] = None,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Consume an inventory item fully or partially.
|
|
||||||
|
|
||||||
When body.quantity is provided, decrements by that amount and only marks
|
|
||||||
status=consumed when quantity reaches zero. Omit body to consume all.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
if body is not None:
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.partial_consume_item, item_id, body.quantity, now
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.update_inventory_item,
|
store.update_inventory_item,
|
||||||
item_id,
|
item_id,
|
||||||
status="consumed",
|
status="consumed",
|
||||||
consumed_at=now,
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
|
||||||
async def discard_item(
|
|
||||||
item_id: int,
|
|
||||||
body: DiscardRequest = DiscardRequest(),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Mark an item as discarded (not used, spoiled, etc).
|
|
||||||
|
|
||||||
Optional reason field accepts free text or a preset label
|
|
||||||
('not used', 'spoiled', 'excess', 'other').
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.update_inventory_item,
|
|
||||||
item_id,
|
|
||||||
status="discarded",
|
|
||||||
consumed_at=datetime.now(timezone.utc).isoformat(),
|
consumed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
disposal_reason=body.reason,
|
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
return InventoryItemResponse.model_validate(item)
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
@ -350,31 +234,6 @@ class BarcodeScanTextRequest(BaseModel):
|
||||||
auto_add_to_inventory: bool = True
|
auto_add_to_inventory: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _captured_to_product_info(row: dict) -> dict:
|
|
||||||
"""Convert a captured_products row to the product_info dict shape used by
|
|
||||||
the barcode scan flow (mirrors what OpenFoodFactsService returns)."""
|
|
||||||
macros: dict = {}
|
|
||||||
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
|
||||||
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
|
|
||||||
if row.get(field) is not None:
|
|
||||||
macros[field] = row[field]
|
|
||||||
return {
|
|
||||||
"name": row.get("product_name") or row.get("barcode", "Unknown Product"),
|
|
||||||
"brand": row.get("brand"),
|
|
||||||
"category": None,
|
|
||||||
"nutrition_data": macros,
|
|
||||||
"ingredient_names": row.get("ingredient_names") or [],
|
|
||||||
"allergens": row.get("allergens") or [],
|
|
||||||
"source": "visual_capture",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _gap_message(tier: str, has_visual_capture: bool) -> str:
|
|
||||||
if has_visual_capture:
|
|
||||||
return "We couldn't find this product. Photograph the nutrition label to add it."
|
|
||||||
return "Not found in any product database — add manually"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan/text", response_model=BarcodeScanResponse)
|
@router.post("/scan/text", response_model=BarcodeScanResponse)
|
||||||
async def scan_barcode_text(
|
async def scan_barcode_text(
|
||||||
body: BarcodeScanTextRequest,
|
body: BarcodeScanTextRequest,
|
||||||
|
|
@ -382,24 +241,12 @@ async def scan_barcode_text(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
||||||
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
predictor = ExpirationPredictor()
|
|
||||||
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
|
|
||||||
|
|
||||||
# 1. Check local captured-products cache before hitting FDC/OFF
|
|
||||||
cached = await asyncio.to_thread(store.get_captured_product, body.barcode)
|
|
||||||
if cached and cached.get("confirmed_by_user"):
|
|
||||||
product_info: dict | None = _captured_to_product_info(cached)
|
|
||||||
product_source = "visual_capture"
|
|
||||||
else:
|
|
||||||
off = OpenFoodFactsService()
|
off = OpenFoodFactsService()
|
||||||
|
predictor = ExpirationPredictor()
|
||||||
product_info = await off.lookup_product(body.barcode)
|
product_info = await off.lookup_product(body.barcode)
|
||||||
product_source = "openfoodfacts"
|
|
||||||
|
|
||||||
inventory_item = None
|
inventory_item = None
|
||||||
|
|
||||||
if product_info and body.auto_add_to_inventory:
|
if product_info and body.auto_add_to_inventory:
|
||||||
|
|
@ -410,7 +257,7 @@ async def scan_barcode_text(
|
||||||
brand=product_info.get("brand"),
|
brand=product_info.get("brand"),
|
||||||
category=product_info.get("category"),
|
category=product_info.get("category"),
|
||||||
nutrition_data=product_info.get("nutrition_data", {}),
|
nutrition_data=product_info.get("nutrition_data", {}),
|
||||||
source=product_source,
|
source="openfoodfacts",
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
exp = predictor.predict_expiration(
|
exp = predictor.predict_expiration(
|
||||||
|
|
@ -420,14 +267,10 @@ async def scan_barcode_text(
|
||||||
tier=session.tier,
|
tier=session.tier,
|
||||||
has_byok=session.has_byok,
|
has_byok=session.has_byok,
|
||||||
)
|
)
|
||||||
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
|
|
||||||
resolved_qty = product_info.get("pack_quantity") or body.quantity
|
|
||||||
resolved_unit = product_info.get("pack_unit") or "count"
|
|
||||||
inventory_item = await asyncio.to_thread(
|
inventory_item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
product["id"], body.location,
|
product["id"], body.location,
|
||||||
quantity=resolved_qty,
|
quantity=body.quantity,
|
||||||
unit=resolved_unit,
|
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="barcode_scan",
|
source="barcode_scan",
|
||||||
)
|
)
|
||||||
|
|
@ -435,8 +278,6 @@ async def scan_barcode_text(
|
||||||
else:
|
else:
|
||||||
result_product = None
|
result_product = None
|
||||||
|
|
||||||
product_found = product_info is not None
|
|
||||||
needs_capture = not product_found and has_visual_capture
|
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=True,
|
success=True,
|
||||||
barcodes_found=1,
|
barcodes_found=1,
|
||||||
|
|
@ -446,9 +287,7 @@ async def scan_barcode_text(
|
||||||
"product": result_product,
|
"product": result_product,
|
||||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||||
"added_to_inventory": inventory_item is not None,
|
"added_to_inventory": inventory_item is not None,
|
||||||
"needs_manual_entry": not product_found and not needs_capture,
|
"message": "Added to inventory" if inventory_item else "Product not found in database",
|
||||||
"needs_visual_capture": needs_capture,
|
|
||||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
|
||||||
}],
|
}],
|
||||||
message="Barcode processed",
|
message="Barcode processed",
|
||||||
)
|
)
|
||||||
|
|
@ -464,10 +303,6 @@ async def scan_barcode_image(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
||||||
log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
|
||||||
from app.tiers import can_use
|
|
||||||
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
|
|
||||||
|
|
||||||
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
||||||
|
|
@ -478,8 +313,7 @@ async def scan_barcode_image(
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
image_bytes = temp_file.read_bytes()
|
barcodes = await asyncio.to_thread(BarcodeScanner().scan_image, temp_file)
|
||||||
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=False, barcodes_found=0, results=[],
|
success=False, barcodes_found=0, results=[],
|
||||||
|
|
@ -491,30 +325,19 @@ async def scan_barcode_image(
|
||||||
results = []
|
results = []
|
||||||
for bc in barcodes:
|
for bc in barcodes:
|
||||||
code = bc["data"]
|
code = bc["data"]
|
||||||
|
|
||||||
# Check local visual-capture cache before hitting FDC/OFF
|
|
||||||
cached = await asyncio.to_thread(store.get_captured_product, code)
|
|
||||||
if cached and cached.get("confirmed_by_user"):
|
|
||||||
product_info: dict | None = _captured_to_product_info(cached)
|
|
||||||
product_source = "visual_capture"
|
|
||||||
else:
|
|
||||||
product_info = await off.lookup_product(code)
|
product_info = await off.lookup_product(code)
|
||||||
product_source = "openfoodfacts"
|
|
||||||
|
|
||||||
db_product = None
|
|
||||||
inventory_item = None
|
inventory_item = None
|
||||||
if product_info:
|
if product_info and auto_add_to_inventory:
|
||||||
db_product, _ = await asyncio.to_thread(
|
product, _ = await asyncio.to_thread(
|
||||||
store.get_or_create_product,
|
store.get_or_create_product,
|
||||||
product_info.get("name", code),
|
product_info.get("name", code),
|
||||||
code,
|
code,
|
||||||
brand=product_info.get("brand"),
|
brand=product_info.get("brand"),
|
||||||
category=product_info.get("category"),
|
category=product_info.get("category"),
|
||||||
nutrition_data=product_info.get("nutrition_data", {}),
|
nutrition_data=product_info.get("nutrition_data", {}),
|
||||||
source=product_source,
|
source="openfoodfacts",
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
if auto_add_to_inventory:
|
|
||||||
exp = predictor.predict_expiration(
|
exp = predictor.predict_expiration(
|
||||||
product_info.get("category", ""),
|
product_info.get("category", ""),
|
||||||
location,
|
location,
|
||||||
|
|
@ -522,27 +345,20 @@ async def scan_barcode_image(
|
||||||
tier=session.tier,
|
tier=session.tier,
|
||||||
has_byok=session.has_byok,
|
has_byok=session.has_byok,
|
||||||
)
|
)
|
||||||
resolved_qty = product_info.get("pack_quantity") or quantity
|
|
||||||
resolved_unit = product_info.get("pack_unit") or "count"
|
|
||||||
inventory_item = await asyncio.to_thread(
|
inventory_item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
db_product["id"], location,
|
product["id"], location,
|
||||||
quantity=resolved_qty,
|
quantity=quantity,
|
||||||
unit=resolved_unit,
|
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="barcode_scan",
|
source="barcode_scan",
|
||||||
)
|
)
|
||||||
product_found = db_product is not None
|
|
||||||
needs_capture = not product_found and has_visual_capture
|
|
||||||
results.append({
|
results.append({
|
||||||
"barcode": code,
|
"barcode": code,
|
||||||
"barcode_type": bc.get("type", "unknown"),
|
"barcode_type": bc.get("type", "unknown"),
|
||||||
"product": ProductResponse.model_validate(db_product) if db_product else None,
|
"product": ProductResponse.model_validate(product) if product_info else None,
|
||||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||||
"added_to_inventory": inventory_item is not None,
|
"added_to_inventory": inventory_item is not None,
|
||||||
"needs_manual_entry": not product_found and not needs_capture,
|
"message": "Added to inventory" if inventory_item else "Barcode scanned",
|
||||||
"needs_visual_capture": needs_capture,
|
|
||||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
|
||||||
})
|
})
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=True, barcodes_found=len(barcodes), results=results,
|
success=True, barcodes_found=len(barcodes), results=results,
|
||||||
|
|
@ -553,143 +369,6 @@ async def scan_barcode_image(
|
||||||
temp_file.unlink()
|
temp_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
# ── Visual label capture (kiwi#79) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/scan/label-capture")
|
|
||||||
async def capture_nutrition_label(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
barcode: str = Form(...),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Photograph a nutrition label for an unenriched product (paid tier).
|
|
||||||
|
|
||||||
Sends the image to the vision model and returns structured nutrition data
|
|
||||||
for user review. Fields extracted with confidence < 0.7 should be
|
|
||||||
highlighted in amber in the UI.
|
|
||||||
"""
|
|
||||||
from app.tiers import can_use
|
|
||||||
from app.models.schemas.label_capture import LabelCaptureResponse
|
|
||||||
from app.services.label_capture import extract_label, needs_review as _needs_review
|
|
||||||
|
|
||||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
|
||||||
log.info("label_capture tier=%s barcode=%r", session.tier, barcode)
|
|
||||||
|
|
||||||
image_bytes = await file.read()
|
|
||||||
extraction = await asyncio.to_thread(extract_label, image_bytes)
|
|
||||||
|
|
||||||
return LabelCaptureResponse(
|
|
||||||
barcode=barcode,
|
|
||||||
product_name=extraction.get("product_name"),
|
|
||||||
brand=extraction.get("brand"),
|
|
||||||
serving_size_g=extraction.get("serving_size_g"),
|
|
||||||
calories=extraction.get("calories"),
|
|
||||||
fat_g=extraction.get("fat_g"),
|
|
||||||
saturated_fat_g=extraction.get("saturated_fat_g"),
|
|
||||||
carbs_g=extraction.get("carbs_g"),
|
|
||||||
sugar_g=extraction.get("sugar_g"),
|
|
||||||
fiber_g=extraction.get("fiber_g"),
|
|
||||||
protein_g=extraction.get("protein_g"),
|
|
||||||
sodium_mg=extraction.get("sodium_mg"),
|
|
||||||
ingredient_names=extraction.get("ingredient_names") or [],
|
|
||||||
allergens=extraction.get("allergens") or [],
|
|
||||||
confidence=extraction.get("confidence", 0.0),
|
|
||||||
needs_review=_needs_review(extraction),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan/label-confirm")
|
|
||||||
async def confirm_nutrition_label(
|
|
||||||
body: LabelConfirmRequest,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Confirm and save a user-reviewed label extraction.
|
|
||||||
|
|
||||||
Saves the product to the local cache so future scans of the same barcode
|
|
||||||
resolve instantly without another capture. Optionally adds the item to
|
|
||||||
the user's inventory.
|
|
||||||
"""
|
|
||||||
from app.tiers import can_use
|
|
||||||
from app.models.schemas.label_capture import LabelConfirmResponse
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
|
|
||||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
|
||||||
log.info("label_confirm tier=%s barcode=%r", session.tier, body.barcode)
|
|
||||||
|
|
||||||
# Persist to local visual-capture cache
|
|
||||||
await asyncio.to_thread(
|
|
||||||
store.save_captured_product,
|
|
||||||
body.barcode,
|
|
||||||
product_name=body.product_name,
|
|
||||||
brand=body.brand,
|
|
||||||
serving_size_g=body.serving_size_g,
|
|
||||||
calories=body.calories,
|
|
||||||
fat_g=body.fat_g,
|
|
||||||
saturated_fat_g=body.saturated_fat_g,
|
|
||||||
carbs_g=body.carbs_g,
|
|
||||||
sugar_g=body.sugar_g,
|
|
||||||
fiber_g=body.fiber_g,
|
|
||||||
protein_g=body.protein_g,
|
|
||||||
sodium_mg=body.sodium_mg,
|
|
||||||
ingredient_names=body.ingredient_names,
|
|
||||||
allergens=body.allergens,
|
|
||||||
confidence=body.confidence,
|
|
||||||
confirmed_by_user=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
product_id: int | None = None
|
|
||||||
inventory_item_id: int | None = None
|
|
||||||
|
|
||||||
if body.auto_add:
|
|
||||||
predictor = ExpirationPredictor()
|
|
||||||
nutrition = {}
|
|
||||||
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
|
||||||
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
|
|
||||||
val = getattr(body, field, None)
|
|
||||||
if val is not None:
|
|
||||||
nutrition[field] = val
|
|
||||||
|
|
||||||
product, _ = await asyncio.to_thread(
|
|
||||||
store.get_or_create_product,
|
|
||||||
body.product_name or body.barcode,
|
|
||||||
body.barcode,
|
|
||||||
brand=body.brand,
|
|
||||||
category=None,
|
|
||||||
nutrition_data=nutrition,
|
|
||||||
source="visual_capture",
|
|
||||||
source_data={},
|
|
||||||
)
|
|
||||||
product_id = product["id"]
|
|
||||||
|
|
||||||
exp = predictor.predict_expiration(
|
|
||||||
"",
|
|
||||||
body.location,
|
|
||||||
product_name=body.product_name or body.barcode,
|
|
||||||
tier=session.tier,
|
|
||||||
has_byok=session.has_byok,
|
|
||||||
)
|
|
||||||
inv_item = await asyncio.to_thread(
|
|
||||||
store.add_inventory_item,
|
|
||||||
product_id, body.location,
|
|
||||||
quantity=body.quantity,
|
|
||||||
unit="count",
|
|
||||||
expiration_date=str(exp) if exp else None,
|
|
||||||
source="visual_capture",
|
|
||||||
)
|
|
||||||
inventory_item_id = inv_item["id"]
|
|
||||||
|
|
||||||
return LabelConfirmResponse(
|
|
||||||
ok=True,
|
|
||||||
barcode=body.barcode,
|
|
||||||
product_id=product_id,
|
|
||||||
inventory_item_id=inventory_item_id,
|
|
||||||
message="Product saved" + (" and added to inventory" if body.auto_add else ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tags ──────────────────────────────────────────────────────────────────────
|
# ── Tags ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/tags", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/tags", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# app/api/endpoints/mastodon_oauth.py
|
|
||||||
# MIT License
|
|
||||||
#
|
|
||||||
# Mastodon OAuth flow endpoints:
|
|
||||||
# POST /social/mastodon/connect — Start OAuth (dynamic app registration)
|
|
||||||
# GET /social/mastodon/callback — OAuth callback, exchange code for token
|
|
||||||
# DELETE /social/mastodon/disconnect — Revoke and remove stored token
|
|
||||||
# GET /social/mastodon/status — Check connection status
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/social/mastodon", tags=["mastodon"])
|
|
||||||
|
|
||||||
|
|
||||||
def _redirect_uri() -> str:
|
|
||||||
host = settings.AP_HOST or "localhost:8512"
|
|
||||||
return f"https://{host}/api/v1/social/mastodon/callback"
|
|
||||||
|
|
||||||
|
|
||||||
# In-memory pending state: maps state_token → {instance_url, client_id, client_secret, user_id}
|
|
||||||
# A real deployment would persist this in a short-TTL cache or DB.
|
|
||||||
_pending: dict[str, dict] = {}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/connect")
|
|
||||||
async def connect_mastodon(body: dict, session: CloudUser = Depends(get_session)):
|
|
||||||
"""Start the Mastodon OAuth flow.
|
|
||||||
|
|
||||||
Body: {"instance_url": "https://mastodon.social"}
|
|
||||||
Returns: {"authorize_url": "..."}
|
|
||||||
"""
|
|
||||||
import secrets
|
|
||||||
from app.services.ap.mastodon import build_authorize_url, register_app
|
|
||||||
|
|
||||||
instance_url = (body.get("instance_url") or "").strip().rstrip("/")
|
|
||||||
if not instance_url.startswith("https://"):
|
|
||||||
raise HTTPException(status_code=422, detail="instance_url must be an https:// URL.")
|
|
||||||
|
|
||||||
redirect_uri = _redirect_uri()
|
|
||||||
try:
|
|
||||||
app_creds = await asyncio.to_thread(register_app, instance_url, redirect_uri)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502, detail=f"Could not register with Mastodon instance: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
state = secrets.token_urlsafe(24)
|
|
||||||
_pending[state] = {
|
|
||||||
"instance_url": instance_url,
|
|
||||||
"client_id": app_creds["client_id"],
|
|
||||||
"client_secret": app_creds["client_secret"],
|
|
||||||
"user_id": session.user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
authorize_url = build_authorize_url(
|
|
||||||
instance_url=instance_url,
|
|
||||||
client_id=app_creds["client_id"],
|
|
||||||
redirect_uri=redirect_uri + f"?state={state}",
|
|
||||||
)
|
|
||||||
return {"authorize_url": authorize_url, "state": state}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callback")
|
|
||||||
async def mastodon_callback(code: str | None = None, state: str | None = None):
|
|
||||||
"""OAuth callback. Exchanges auth code for access token and stores it."""
|
|
||||||
if not code or not state:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing code or state parameter.")
|
|
||||||
|
|
||||||
pending = _pending.pop(state, None)
|
|
||||||
if pending is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Unknown or expired OAuth state.")
|
|
||||||
|
|
||||||
from app.services.ap.mastodon import exchange_code, store_token
|
|
||||||
|
|
||||||
redirect_uri = _redirect_uri() + f"?state={state}"
|
|
||||||
try:
|
|
||||||
access_token = await asyncio.to_thread(
|
|
||||||
exchange_code,
|
|
||||||
pending["instance_url"],
|
|
||||||
pending["client_id"],
|
|
||||||
pending["client_secret"],
|
|
||||||
code,
|
|
||||||
redirect_uri,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Token exchange failed: {exc}") from exc
|
|
||||||
|
|
||||||
await asyncio.to_thread(
|
|
||||||
store_token,
|
|
||||||
settings.DB_PATH,
|
|
||||||
pending["user_id"],
|
|
||||||
pending["instance_url"],
|
|
||||||
access_token,
|
|
||||||
settings.AP_TOKEN_ENCRYPTION_KEY,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Redirect to frontend settings page after successful connect
|
|
||||||
return RedirectResponse(url="/#/settings?mastodon=connected", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/disconnect", status_code=204)
|
|
||||||
async def disconnect_mastodon(session: CloudUser = Depends(get_session)):
|
|
||||||
"""Remove the stored Mastodon token."""
|
|
||||||
from app.services.ap.mastodon import delete_token
|
|
||||||
await asyncio.to_thread(delete_token, settings.DB_PATH, session.user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def mastodon_status(session: CloudUser = Depends(get_session)):
|
|
||||||
"""Return connection status and instance URL (no token value)."""
|
|
||||||
from app.services.ap.mastodon import get_token
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
get_token,
|
|
||||||
settings.DB_PATH,
|
|
||||||
session.user_id,
|
|
||||||
settings.AP_TOKEN_ENCRYPTION_KEY,
|
|
||||||
)
|
|
||||||
if result is None:
|
|
||||||
return {"connected": False, "instance_url": None}
|
|
||||||
instance_url, _ = result
|
|
||||||
return {"connected": True, "instance_url": instance_url}
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
# app/api/endpoints/meal_plans.py
|
|
||||||
"""Meal plan CRUD, shopping list, and prep session endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.meal_plan import (
|
|
||||||
CreatePlanRequest,
|
|
||||||
GapItem,
|
|
||||||
PlanSummary,
|
|
||||||
PrepSessionSummary,
|
|
||||||
PrepTaskSummary,
|
|
||||||
ShoppingListResponse,
|
|
||||||
SlotSummary,
|
|
||||||
UpdatePlanRequest,
|
|
||||||
UpdatePrepTaskRequest,
|
|
||||||
UpsertSlotRequest,
|
|
||||||
VALID_MEAL_TYPES,
|
|
||||||
)
|
|
||||||
from app.services.meal_plan.affiliates import get_retailer_links
|
|
||||||
from app.services.meal_plan.prep_scheduler import build_prep_tasks
|
|
||||||
from app.services.meal_plan.shopping_list import compute_shopping_list
|
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _slot_summary(row: dict) -> SlotSummary:
|
|
||||||
return SlotSummary(
|
|
||||||
id=row["id"],
|
|
||||||
plan_id=row["plan_id"],
|
|
||||||
day_of_week=row["day_of_week"],
|
|
||||||
meal_type=row["meal_type"],
|
|
||||||
recipe_id=row.get("recipe_id"),
|
|
||||||
recipe_title=row.get("recipe_title"),
|
|
||||||
servings=row["servings"],
|
|
||||||
custom_label=row.get("custom_label"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary:
|
|
||||||
meal_types = plan.get("meal_types") or ["dinner"]
|
|
||||||
if isinstance(meal_types, str):
|
|
||||||
meal_types = json.loads(meal_types)
|
|
||||||
return PlanSummary(
|
|
||||||
id=plan["id"],
|
|
||||||
week_start=plan["week_start"],
|
|
||||||
meal_types=meal_types,
|
|
||||||
slots=[_slot_summary(s) for s in slots],
|
|
||||||
created_at=plan["created_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prep_task_summary(row: dict) -> PrepTaskSummary:
|
|
||||||
return PrepTaskSummary(
|
|
||||||
id=row["id"],
|
|
||||||
recipe_id=row.get("recipe_id"),
|
|
||||||
task_label=row["task_label"],
|
|
||||||
duration_minutes=row.get("duration_minutes"),
|
|
||||||
sequence_order=row["sequence_order"],
|
|
||||||
equipment=row.get("equipment"),
|
|
||||||
is_parallel=bool(row.get("is_parallel", False)),
|
|
||||||
notes=row.get("notes"),
|
|
||||||
user_edited=bool(row.get("user_edited", False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── plan CRUD ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/", response_model=PlanSummary)
|
|
||||||
async def create_plan(
|
|
||||||
req: CreatePlanRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# Free tier is locked to dinner-only; paid+ may configure meal types
|
|
||||||
if can_use("meal_plan_config", session.tier):
|
|
||||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
|
||||||
else:
|
|
||||||
meal_types = ["dinner"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=f"A meal plan for the week of {req.week_start} already exists.",
|
|
||||||
)
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
|
||||||
return _plan_summary(plan, slots)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[PlanSummary])
|
|
||||||
async def list_plans(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> list[PlanSummary]:
|
|
||||||
plans = await asyncio.to_thread(store.list_meal_plans)
|
|
||||||
result = []
|
|
||||||
for p in plans:
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, p["id"])
|
|
||||||
result.append(_plan_summary(p, slots))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{plan_id}", response_model=PlanSummary)
|
|
||||||
async def update_plan(
|
|
||||||
plan_id: int,
|
|
||||||
req: UpdatePlanRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
# Free tier stays dinner-only; paid+ may add meal types
|
|
||||||
if can_use("meal_plan_config", session.tier):
|
|
||||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
|
||||||
else:
|
|
||||||
meal_types = ["dinner"]
|
|
||||||
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
|
|
||||||
if updated is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
return _plan_summary(updated, slots)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plan_id}", response_model=PlanSummary)
|
|
||||||
async def get_plan(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
return _plan_summary(plan, slots)
|
|
||||||
|
|
||||||
|
|
||||||
# ── slots ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.put("/{plan_id}/slots/{day_of_week}/{meal_type}", response_model=SlotSummary)
|
|
||||||
async def upsert_slot(
|
|
||||||
plan_id: int,
|
|
||||||
day_of_week: int,
|
|
||||||
meal_type: str,
|
|
||||||
req: UpsertSlotRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> SlotSummary:
|
|
||||||
if day_of_week < 0 or day_of_week > 6:
|
|
||||||
raise HTTPException(status_code=422, detail="day_of_week must be 0-6.")
|
|
||||||
if meal_type not in VALID_MEAL_TYPES:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
row = await asyncio.to_thread(
|
|
||||||
store.upsert_slot,
|
|
||||||
plan_id, day_of_week, meal_type,
|
|
||||||
req.recipe_id, req.servings, req.custom_label,
|
|
||||||
)
|
|
||||||
return _slot_summary(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{plan_id}/slots/{slot_id}", status_code=204)
|
|
||||||
async def delete_slot(
|
|
||||||
plan_id: int,
|
|
||||||
slot_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> None:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
await asyncio.to_thread(store.delete_slot, slot_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── shopping list ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{plan_id}/shopping-list", response_model=ShoppingListResponse)
|
|
||||||
async def get_shopping_list(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> ShoppingListResponse:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
|
|
||||||
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
|
|
||||||
inventory = await asyncio.to_thread(store.list_inventory)
|
|
||||||
|
|
||||||
gaps, covered = compute_shopping_list(recipes, inventory)
|
|
||||||
|
|
||||||
# Enrich gap items with retailer links
|
|
||||||
def _to_schema(item, enrich: bool) -> GapItem:
|
|
||||||
links = get_retailer_links(item.ingredient_name) if enrich else []
|
|
||||||
return GapItem(
|
|
||||||
ingredient_name=item.ingredient_name,
|
|
||||||
needed_raw=item.needed_raw,
|
|
||||||
have_quantity=item.have_quantity,
|
|
||||||
have_unit=item.have_unit,
|
|
||||||
covered=item.covered,
|
|
||||||
retailer_links=links,
|
|
||||||
)
|
|
||||||
|
|
||||||
gap_items = [_to_schema(g, enrich=True) for g in gaps]
|
|
||||||
covered_items = [_to_schema(c, enrich=False) for c in covered]
|
|
||||||
|
|
||||||
disclosure = (
|
|
||||||
"Some links may be affiliate links. Purchases through them support Kiwi development."
|
|
||||||
if gap_items else None
|
|
||||||
)
|
|
||||||
|
|
||||||
return ShoppingListResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
gap_items=gap_items,
|
|
||||||
covered_items=covered_items,
|
|
||||||
disclosure=disclosure,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── prep session ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
|
||||||
async def get_prep_session(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PrepSessionSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id)
|
|
||||||
if prep_session is None:
|
|
||||||
raise HTTPException(status_code=404, detail="No prep session for this plan.")
|
|
||||||
raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"])
|
|
||||||
return PrepSessionSummary(
|
|
||||||
id=prep_session["id"],
|
|
||||||
plan_id=plan_id,
|
|
||||||
scheduled_date=prep_session["scheduled_date"],
|
|
||||||
status=prep_session["status"],
|
|
||||||
tasks=[_prep_task_summary(t) for t in raw_tasks],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
|
||||||
async def create_prep_session(
|
|
||||||
plan_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PrepSessionSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
|
|
||||||
prep_tasks = build_prep_tasks(slots=slots, recipes=recipes)
|
|
||||||
|
|
||||||
scheduled_date = date.today().isoformat()
|
|
||||||
prep_session = await asyncio.to_thread(
|
|
||||||
store.create_prep_session, plan_id, scheduled_date
|
|
||||||
)
|
|
||||||
session_id = prep_session["id"]
|
|
||||||
|
|
||||||
task_dicts = [
|
|
||||||
{
|
|
||||||
"recipe_id": t.recipe_id,
|
|
||||||
"slot_id": t.slot_id,
|
|
||||||
"task_label": t.task_label,
|
|
||||||
"duration_minutes": t.duration_minutes,
|
|
||||||
"sequence_order": t.sequence_order,
|
|
||||||
"equipment": t.equipment,
|
|
||||||
"is_parallel": t.is_parallel,
|
|
||||||
"notes": t.notes,
|
|
||||||
}
|
|
||||||
for t in prep_tasks
|
|
||||||
]
|
|
||||||
inserted = await asyncio.to_thread(store.bulk_insert_prep_tasks, session_id, task_dicts)
|
|
||||||
|
|
||||||
return PrepSessionSummary(
|
|
||||||
id=prep_session["id"],
|
|
||||||
plan_id=prep_session["plan_id"],
|
|
||||||
scheduled_date=prep_session["scheduled_date"],
|
|
||||||
status=prep_session["status"],
|
|
||||||
tasks=[_prep_task_summary(r) for r in inserted],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{plan_id}/prep-session/tasks/{task_id}",
|
|
||||||
response_model=PrepTaskSummary,
|
|
||||||
)
|
|
||||||
async def update_prep_task(
|
|
||||||
plan_id: int,
|
|
||||||
task_id: int,
|
|
||||||
req: UpdatePrepTaskRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PrepTaskSummary:
|
|
||||||
updated = await asyncio.to_thread(
|
|
||||||
store.update_prep_task,
|
|
||||||
task_id,
|
|
||||||
duration_minutes=req.duration_minutes,
|
|
||||||
sequence_order=req.sequence_order,
|
|
||||||
notes=req.notes,
|
|
||||||
equipment=req.equipment,
|
|
||||||
)
|
|
||||||
if updated is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Task not found.")
|
|
||||||
return _prep_task_summary(updated)
|
|
||||||
|
|
@ -219,7 +219,7 @@ def _commit_items(
|
||||||
receipt_id=receipt_id,
|
receipt_id=receipt_id,
|
||||||
purchase_date=str(purchase_date) if purchase_date else None,
|
purchase_date=str(purchase_date) if purchase_date else None,
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="receipt",
|
source="receipt_ocr",
|
||||||
)
|
)
|
||||||
|
|
||||||
created.append(ApprovedInventoryItem(
|
created.append(ApprovedInventoryItem(
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Proxy endpoint: exposes cf-orch call budget to the Kiwi frontend.
|
|
||||||
|
|
||||||
Only lifetime/founders users have a license_key — subscription and free
|
|
||||||
users receive null (no budget UI shown).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.services.heimdall_orch import get_orch_usage
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def orch_usage_endpoint(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> dict | None:
|
|
||||||
"""Return the current period's orch usage for the authenticated user.
|
|
||||||
|
|
||||||
Returns null if the user has no lifetime/founders license key (i.e. they
|
|
||||||
are on a subscription or free plan — no budget cap applies to them).
|
|
||||||
"""
|
|
||||||
if session.license_key is None:
|
|
||||||
return None
|
|
||||||
return get_orch_usage(session.license_key, "kiwi")
|
|
||||||
|
|
@ -42,11 +42,9 @@ async def upload_receipt(
|
||||||
)
|
)
|
||||||
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
|
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
|
||||||
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
|
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
|
||||||
# Pass session.db (a Path) rather than store — the store dependency closes before
|
|
||||||
# background tasks run, so the task opens its own store from the DB path.
|
|
||||||
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
|
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
|
||||||
if ocr_allowed:
|
if ocr_allowed:
|
||||||
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
|
||||||
return ReceiptResponse.model_validate(receipt)
|
return ReceiptResponse.model_validate(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +64,7 @@ async def upload_receipts_batch(
|
||||||
store.create_receipt, file.filename, str(saved)
|
store.create_receipt, file.filename, str(saved)
|
||||||
)
|
)
|
||||||
if ocr_allowed:
|
if ocr_allowed:
|
||||||
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
|
||||||
results.append(ReceiptResponse.model_validate(receipt))
|
results.append(ReceiptResponse.model_validate(receipt))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -99,13 +97,8 @@ async def get_receipt_quality(receipt_id: int, store: Store = Depends(get_store)
|
||||||
return QualityAssessment.model_validate(qa)
|
return QualityAssessment.model_validate(qa)
|
||||||
|
|
||||||
|
|
||||||
async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path) -> None:
|
async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store) -> None:
|
||||||
"""Background task: run OCR pipeline on an uploaded receipt.
|
"""Background task: run OCR pipeline on an uploaded receipt."""
|
||||||
|
|
||||||
Accepts db_path (not a Store instance) because FastAPI closes the request-scoped
|
|
||||||
store before background tasks execute. This task owns its store lifecycle.
|
|
||||||
"""
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
|
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
|
||||||
from app.services.receipt_service import ReceiptService
|
from app.services.receipt_service import ReceiptService
|
||||||
|
|
@ -115,5 +108,3 @@ async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
store.update_receipt_status, receipt_id, "error", str(exc)
|
store.update_receipt_status, receipt_id, "error", str(exc)
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
"""Recipe scanner endpoints (kiwi#9).
|
|
||||||
|
|
||||||
POST /recipes/scan -- scan photo(s) -> structured recipe JSON (not saved)
|
|
||||||
POST /recipes/scan/save -- save a confirmed scanned recipe to user_recipes
|
|
||||||
GET /recipes/user -- list user-created recipes
|
|
||||||
GET /recipes/user/{id} -- get a single user recipe
|
|
||||||
DELETE /recipes/user/{id} -- delete a user recipe
|
|
||||||
|
|
||||||
BSL 1.1 -- recipe_scan requires Paid tier or BYOK.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json as _json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.recipe_scan import (
|
|
||||||
ScannedIngredientSchema,
|
|
||||||
ScannedRecipeResponse,
|
|
||||||
ScannedRecipeSaveRequest,
|
|
||||||
UserRecipeResponse,
|
|
||||||
)
|
|
||||||
from app.tiers import can_use
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_ALLOWED_MIME_TYPES = {
|
|
||||||
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"
|
|
||||||
}
|
|
||||||
_MAX_FILE_SIZE_MB = 20
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_upload_temp(file: UploadFile) -> Path:
|
|
||||||
"""Write upload to a temp path under UPLOAD_DIR. Caller is responsible for cleanup."""
|
|
||||||
settings.ensure_dirs()
|
|
||||||
dest = settings.UPLOAD_DIR / f"scan_{uuid.uuid4()}_{file.filename}"
|
|
||||||
async with aiofiles.open(dest, "wb") as f:
|
|
||||||
await f.write(await file.read())
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def _result_to_response(result) -> ScannedRecipeResponse:
|
|
||||||
"""Convert ScannedRecipeResult (dataclass) to Pydantic response schema."""
|
|
||||||
return ScannedRecipeResponse(
|
|
||||||
title=result.title,
|
|
||||||
subtitle=result.subtitle,
|
|
||||||
servings=result.servings,
|
|
||||||
cook_time=result.cook_time,
|
|
||||||
source_note=result.source_note,
|
|
||||||
ingredients=[
|
|
||||||
ScannedIngredientSchema(
|
|
||||||
name=i.name,
|
|
||||||
qty=i.qty,
|
|
||||||
unit=i.unit,
|
|
||||||
raw=i.raw,
|
|
||||||
in_pantry=i.in_pantry,
|
|
||||||
)
|
|
||||||
for i in result.ingredients
|
|
||||||
],
|
|
||||||
steps=result.steps,
|
|
||||||
notes=result.notes,
|
|
||||||
tags=result.tags,
|
|
||||||
pantry_match_pct=result.pantry_match_pct,
|
|
||||||
confidence=result.confidence,
|
|
||||||
warnings=result.warnings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_user_recipe(row: dict) -> UserRecipeResponse:
|
|
||||||
"""Convert a store row dict to UserRecipeResponse."""
|
|
||||||
return UserRecipeResponse(
|
|
||||||
id=row["id"],
|
|
||||||
title=row["title"],
|
|
||||||
subtitle=row.get("subtitle"),
|
|
||||||
servings=row.get("servings"),
|
|
||||||
cook_time=row.get("cook_time"),
|
|
||||||
source_note=row.get("source_note"),
|
|
||||||
ingredients=[
|
|
||||||
ScannedIngredientSchema(**i) if isinstance(i, dict) else i
|
|
||||||
for i in (row.get("ingredients") or [])
|
|
||||||
],
|
|
||||||
steps=row.get("steps") or [],
|
|
||||||
notes=row.get("notes"),
|
|
||||||
tags=row.get("tags") or [],
|
|
||||||
source=row.get("source", "manual"),
|
|
||||||
pantry_match_pct=row.get("pantry_match_pct"),
|
|
||||||
created_at=row["created_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Scan endpoint ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/scan", response_model=ScannedRecipeResponse)
|
|
||||||
async def scan_recipe(
|
|
||||||
files: Annotated[list[UploadFile], File(...)],
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Scan one or more recipe photos and return a structured recipe for review.
|
|
||||||
|
|
||||||
Accepts 1-4 images. Multi-page recipes (e.g. ingredients on page 1,
|
|
||||||
directions on page 2) work best when all pages are submitted together.
|
|
||||||
|
|
||||||
The response is NOT saved automatically -- the user reviews and edits it,
|
|
||||||
then calls POST /recipes/scan/save to persist.
|
|
||||||
|
|
||||||
Tier: Paid (or BYOK).
|
|
||||||
"""
|
|
||||||
if not can_use("recipe_scan", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=(
|
|
||||||
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
|
|
||||||
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
raise HTTPException(status_code=422, detail="At least one image file is required.")
|
|
||||||
if len(files) > 4:
|
|
||||||
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
|
|
||||||
|
|
||||||
for f in files:
|
|
||||||
ct = (f.content_type or "").lower()
|
|
||||||
if ct and ct not in _ALLOWED_MIME_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save uploads to temp files
|
|
||||||
saved_paths: list[Path] = []
|
|
||||||
try:
|
|
||||||
for f in files:
|
|
||||||
saved_paths.append(await _save_upload_temp(f))
|
|
||||||
|
|
||||||
# Get pantry item names for cross-reference
|
|
||||||
inventory = await asyncio.to_thread(store.list_inventory)
|
|
||||||
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
|
|
||||||
|
|
||||||
# Run scanner (blocks on VLM -- use to_thread)
|
|
||||||
from app.services.recipe.recipe_scanner import RecipeScanner
|
|
||||||
|
|
||||||
def _run_scan():
|
|
||||||
scanner = RecipeScanner()
|
|
||||||
return scanner.scan(saved_paths, pantry_names=pantry_names)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await asyncio.to_thread(_run_scan)
|
|
||||||
except ValueError as exc:
|
|
||||||
msg = str(exc)
|
|
||||||
if "not_a_recipe" in msg:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail="The image does not appear to contain a recipe. "
|
|
||||||
"Please photograph a recipe card, cookbook page, or handwritten note.",
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=422, detail=msg)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
msg = str(exc)
|
|
||||||
logger.warning("Recipe scanner unavailable: %s", msg)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail=(
|
|
||||||
"The recipe scanner is temporarily unavailable — "
|
|
||||||
"no vision backend could be reached. "
|
|
||||||
"Try again in a few minutes, or contact support if this persists."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _result_to_response(result)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up temp files
|
|
||||||
for p in saved_paths:
|
|
||||||
try:
|
|
||||||
p.unlink(missing_ok=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── SSE scan endpoint ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _scan_recipe_sse(saved_paths: list[Path], pantry_names: list[str]):
|
|
||||||
"""Async generator yielding SSE events for a recipe scan.
|
|
||||||
|
|
||||||
Emits progress events while the vision service allocates and runs, then a
|
|
||||||
final "done" event containing the full recipe payload (same shape as the
|
|
||||||
ScannedRecipeResponse from POST /scan).
|
|
||||||
|
|
||||||
Events:
|
|
||||||
{"status": "allocating", "message": "..."}
|
|
||||||
{"status": "scanning", "message": "..."}
|
|
||||||
{"status": "structuring","message": "..."}
|
|
||||||
{"status": "done", "recipe": {...}}
|
|
||||||
{"status": "error", "message": "..."}
|
|
||||||
"""
|
|
||||||
queue: asyncio.Queue = asyncio.Queue()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
def _run() -> None:
|
|
||||||
def cb(status: str, message: str) -> None:
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": status, "message": message})
|
|
||||||
try:
|
|
||||||
from app.services.recipe.recipe_scanner import RecipeScanner
|
|
||||||
result = RecipeScanner().scan(saved_paths, pantry_names=pantry_names, progress_cb=cb)
|
|
||||||
recipe_dict = _result_to_response(result).model_dump()
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "done", "recipe": recipe_dict})
|
|
||||||
except ValueError as exc:
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
|
||||||
except RuntimeError as exc:
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unexpected error in recipe scan thread")
|
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})
|
|
||||||
|
|
||||||
scan_task = asyncio.ensure_future(asyncio.to_thread(_run))
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
event = await asyncio.wait_for(queue.get(), timeout=180.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
yield f"data: {_json.dumps({'status': 'error', 'message': 'Scan timed out after 3 minutes.'})}\n\n"
|
|
||||||
break
|
|
||||||
yield f"data: {_json.dumps(event)}\n\n"
|
|
||||||
if event["status"] in ("done", "error"):
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
if not scan_task.done():
|
|
||||||
scan_task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan/stream")
|
|
||||||
async def scan_recipe_stream(
|
|
||||||
files: Annotated[list[UploadFile], File(...)],
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Scan recipe photos and stream SSE progress events during model load.
|
|
||||||
|
|
||||||
Use this endpoint instead of POST /scan when you need live feedback during
|
|
||||||
cold-start model loading (first request after a GPU-idle period can take
|
|
||||||
30-60 seconds for cf-docuvision to warm up).
|
|
||||||
|
|
||||||
Tier: Paid (or BYOK) — same gate as POST /scan.
|
|
||||||
"""
|
|
||||||
if not can_use("recipe_scan", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=(
|
|
||||||
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
|
|
||||||
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
raise HTTPException(status_code=422, detail="At least one image file is required.")
|
|
||||||
if len(files) > 4:
|
|
||||||
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
|
|
||||||
|
|
||||||
for f in files:
|
|
||||||
ct = (f.content_type or "").lower()
|
|
||||||
if ct and ct not in _ALLOWED_MIME_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
|
|
||||||
)
|
|
||||||
|
|
||||||
saved_paths: list[Path] = []
|
|
||||||
for f in files:
|
|
||||||
saved_paths.append(await _save_upload_temp(f))
|
|
||||||
|
|
||||||
inventory = await asyncio.to_thread(store.list_inventory)
|
|
||||||
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
|
|
||||||
|
|
||||||
async def generate():
|
|
||||||
try:
|
|
||||||
async for chunk in _scan_recipe_sse(saved_paths, pantry_names):
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
for p in saved_paths:
|
|
||||||
try:
|
|
||||||
p.unlink(missing_ok=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Save endpoint ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/scan/save", response_model=UserRecipeResponse, status_code=201)
|
|
||||||
async def save_scanned_recipe(
|
|
||||||
body: ScannedRecipeSaveRequest,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Save a user-reviewed (possibly edited) scanned recipe.
|
|
||||||
|
|
||||||
The body is the ScannedRecipeResponse (or a user-edited version of it).
|
|
||||||
Returns the persisted UserRecipe with an assigned ID.
|
|
||||||
|
|
||||||
Tier: Free (saving your own recipe doesn't require vision access).
|
|
||||||
"""
|
|
||||||
def _save():
|
|
||||||
return store.create_user_recipe(
|
|
||||||
title=body.title,
|
|
||||||
subtitle=body.subtitle,
|
|
||||||
servings=body.servings,
|
|
||||||
cook_time=body.cook_time,
|
|
||||||
source_note=body.source_note,
|
|
||||||
ingredients=[i.model_dump() for i in body.ingredients],
|
|
||||||
steps=body.steps,
|
|
||||||
notes=body.notes,
|
|
||||||
tags=body.tags,
|
|
||||||
source=body.source,
|
|
||||||
pantry_match_pct=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
row = await asyncio.to_thread(_save)
|
|
||||||
return _row_to_user_recipe(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ── User recipe list / get / delete ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/user", response_model=list[UserRecipeResponse])
|
|
||||||
async def list_user_recipes(
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""List all user-created recipes (scanned + manually entered), newest first."""
|
|
||||||
rows = await asyncio.to_thread(store.list_user_recipes)
|
|
||||||
return [_row_to_user_recipe(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/{recipe_id}", response_model=UserRecipeResponse)
|
|
||||||
async def get_user_recipe(
|
|
||||||
recipe_id: int,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Get a single user recipe by ID."""
|
|
||||||
row = await asyncio.to_thread(store.get_user_recipe, recipe_id)
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="User recipe not found.")
|
|
||||||
return _row_to_user_recipe(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/user/{recipe_id}", status_code=204)
|
|
||||||
async def delete_user_recipe(
|
|
||||||
recipe_id: int,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Delete a user recipe by ID."""
|
|
||||||
deleted = await asyncio.to_thread(store.delete_user_recipe, recipe_id)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="User recipe not found.")
|
|
||||||
return JSONResponse(status_code=204, content=None)
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
# app/api/endpoints/recipe_tags.py
|
|
||||||
"""Community subcategory tagging for corpus recipes.
|
|
||||||
|
|
||||||
Users can tag a recipe they're viewing with a domain/category/subcategory
|
|
||||||
from the browse taxonomy. Tags require a community pseudonym and reach
|
|
||||||
public visibility once two independent users have tagged the same recipe
|
|
||||||
to the same location (upvotes >= 2).
|
|
||||||
|
|
||||||
All tiers may submit and upvote tags — community contribution is free.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
from app.api.endpoints.session import get_session
|
|
||||||
from app.cloud_session import CloudUser
|
|
||||||
from app.services.recipe.browser_domains import DOMAINS
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
ACCEPT_THRESHOLD = 2
|
|
||||||
|
|
||||||
|
|
||||||
# ── Request / response models ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TagSubmitBody(BaseModel):
|
|
||||||
recipe_id: int
|
|
||||||
domain: str
|
|
||||||
category: str
|
|
||||||
subcategory: str | None = None
|
|
||||||
pseudonym: str
|
|
||||||
|
|
||||||
|
|
||||||
class TagResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
recipe_id: int
|
|
||||||
domain: str
|
|
||||||
category: str
|
|
||||||
subcategory: str | None
|
|
||||||
pseudonym: str
|
|
||||||
upvotes: int
|
|
||||||
accepted: bool
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(row: dict) -> TagResponse:
|
|
||||||
return TagResponse(
|
|
||||||
id=row["id"],
|
|
||||||
recipe_id=int(row["recipe_ref"]),
|
|
||||||
domain=row["domain"],
|
|
||||||
category=row["category"],
|
|
||||||
subcategory=row.get("subcategory"),
|
|
||||||
pseudonym=row["pseudonym"],
|
|
||||||
upvotes=row["upvotes"],
|
|
||||||
accepted=row["upvotes"] >= ACCEPT_THRESHOLD,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_location(domain: str, category: str, subcategory: str | None) -> None:
|
|
||||||
"""Raise 422 if (domain, category, subcategory) isn't in the known taxonomy."""
|
|
||||||
if domain not in DOMAINS:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown domain '{domain}'.")
|
|
||||||
cats = DOMAINS[domain].get("categories", {})
|
|
||||||
if category not in cats:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
|
||||||
)
|
|
||||||
if subcategory is not None:
|
|
||||||
subcats = cats[category].get("subcategories", {})
|
|
||||||
if subcategory not in subcats:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Unknown subcategory '{subcategory}' in '{domain}/{category}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/recipes/community-tags/{recipe_id}", response_model=list[TagResponse])
|
|
||||||
async def list_recipe_tags(
|
|
||||||
recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[TagResponse]:
|
|
||||||
"""Return all community tags for a corpus recipe, accepted ones first."""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return []
|
|
||||||
tags = store.list_tags_for_recipe(recipe_id)
|
|
||||||
return [_to_response(r) for r in tags]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recipes/community-tags", response_model=TagResponse, status_code=201)
|
|
||||||
async def submit_recipe_tag(
|
|
||||||
body: TagSubmitBody,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> TagResponse:
|
|
||||||
"""Tag a corpus recipe with a browse taxonomy location.
|
|
||||||
|
|
||||||
Requires the user to have a community pseudonym set. Returns 409 if this
|
|
||||||
user has already tagged this recipe to this exact location.
|
|
||||||
"""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Community features are not available on this instance.",
|
|
||||||
)
|
|
||||||
|
|
||||||
_validate_location(body.domain, body.category, body.subcategory)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import psycopg2.errors # type: ignore[import]
|
|
||||||
row = store.submit_recipe_tag(
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
domain=body.domain,
|
|
||||||
category=body.category,
|
|
||||||
subcategory=body.subcategory,
|
|
||||||
pseudonym=body.pseudonym,
|
|
||||||
)
|
|
||||||
return _to_response(row)
|
|
||||||
except Exception as exc:
|
|
||||||
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="You have already tagged this recipe to this location.",
|
|
||||||
)
|
|
||||||
logger.error("submit_recipe_tag failed: %s", exc)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to submit tag.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recipes/community-tags/{tag_id}/upvote", response_model=TagResponse)
|
|
||||||
async def upvote_recipe_tag(
|
|
||||||
tag_id: int,
|
|
||||||
pseudonym: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> TagResponse:
|
|
||||||
"""Upvote an existing community tag.
|
|
||||||
|
|
||||||
Returns 409 if this pseudonym has already voted on this tag.
|
|
||||||
Returns 404 if the tag doesn't exist.
|
|
||||||
"""
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Community features unavailable.")
|
|
||||||
|
|
||||||
tag_row = store.get_recipe_tag_by_id(tag_id)
|
|
||||||
if tag_row is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_upvotes = store.upvote_recipe_tag(tag_id, pseudonym)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
|
||||||
except Exception as exc:
|
|
||||||
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
|
||||||
raise HTTPException(status_code=409, detail="You have already voted on this tag.")
|
|
||||||
logger.error("upvote_recipe_tag failed: %s", exc)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to upvote tag.")
|
|
||||||
|
|
||||||
tag_row["upvotes"] = new_upvotes
|
|
||||||
return _to_response(tag_row)
|
|
||||||
|
|
@ -2,54 +2,21 @@
|
||||||
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, _auth_label, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import (
|
from app.models.schemas.recipe import RecipeRequest, RecipeResult
|
||||||
AskRequest,
|
|
||||||
AskResponse,
|
|
||||||
AskRecipeHit,
|
|
||||||
AssemblyTemplateOut,
|
|
||||||
BuildRequest,
|
|
||||||
LeftoversResponse,
|
|
||||||
RecipeJobStatus,
|
|
||||||
RecipeRequest,
|
|
||||||
RecipeResult,
|
|
||||||
RecipeSuggestion,
|
|
||||||
RoleCandidatesResponse,
|
|
||||||
StreamTokenRequest,
|
|
||||||
StreamTokenResponse,
|
|
||||||
)
|
|
||||||
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
|
|
||||||
from app.api.endpoints.imitate import _build_recipe_prompt
|
|
||||||
from app.services.recipe.assembly_recipes import (
|
|
||||||
build_from_selection,
|
|
||||||
get_role_candidates,
|
|
||||||
get_templates_for_api,
|
|
||||||
)
|
|
||||||
from app.services.recipe.browser_domains import (
|
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.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -70,127 +37,13 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
def _build_stream_prompt(db_path: Path, level: int) -> str:
|
@router.post("/suggest", response_model=RecipeResult)
|
||||||
"""Fetch pantry + user settings from DB and build the recipe prompt.
|
|
||||||
|
|
||||||
Runs in a thread (called via asyncio.to_thread) so it can use sync Store.
|
|
||||||
"""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
items = store.list_inventory(status="available")
|
|
||||||
pantry_names = [i["product_name"] for i in items if i.get("product_name")]
|
|
||||||
|
|
||||||
today = datetime.date.today()
|
|
||||||
expiring_names = [
|
|
||||||
i["product_name"]
|
|
||||||
for i in items
|
|
||||||
if i.get("product_name")
|
|
||||||
and i.get("expiry_date")
|
|
||||||
and (datetime.date.fromisoformat(i["expiry_date"]) - today).days <= 3
|
|
||||||
]
|
|
||||||
|
|
||||||
settings: dict = {}
|
|
||||||
try:
|
|
||||||
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
|
|
||||||
settings = {r["key"]: r["value"] for r in rows}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
constraints_raw = settings.get("dietary_constraints", "")
|
|
||||||
constraints = [c.strip() for c in constraints_raw.split(",") if c.strip()] if constraints_raw else []
|
|
||||||
allergies_raw = settings.get("allergies", "")
|
|
||||||
allergies = [a.strip() for a in allergies_raw.split(",") if a.strip()] if allergies_raw else []
|
|
||||||
|
|
||||||
return _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def _stream_recipe_sse(db_path: Path, req: RecipeRequest):
|
|
||||||
"""Async generator that yields SSE events for a streaming recipe request.
|
|
||||||
|
|
||||||
Phase 1 (thread): classify pantry items using a temporary Store.
|
|
||||||
Phase 2 (async): stream tokens from LLM via LLMRecipeGenerator.stream_generate().
|
|
||||||
"""
|
|
||||||
def _prep(db_path: Path) -> tuple[list, list[str]]:
|
|
||||||
from app.services.recipe.element_classifier import IngredientClassifier
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
classifier = IngredientClassifier(store)
|
|
||||||
profiles = classifier.classify_batch(req.pantry_items)
|
|
||||||
gaps = classifier.identify_gaps(profiles)
|
|
||||||
return profiles, gaps
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
profiles, gaps = await asyncio.to_thread(_prep, db_path)
|
|
||||||
except Exception as exc:
|
|
||||||
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
|
|
||||||
return
|
|
||||||
|
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
|
||||||
gen = LLMRecipeGenerator(None)
|
|
||||||
try:
|
|
||||||
async for token in gen.stream_generate(req, profiles, gaps):
|
|
||||||
yield f"data: {_json_mod.dumps({'chunk': token})}\n\n"
|
|
||||||
yield f"data: {_json_mod.dumps({'done': True})}\n\n"
|
|
||||||
except Exception as exc:
|
|
||||||
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
|
||||||
"""Queue an async recipe_llm job and return 202 with job_id.
|
|
||||||
|
|
||||||
Falls back to synchronous generation in CLOUD_MODE (scheduler polls only
|
|
||||||
the shared settings DB, not per-user DBs — see snipe#45 / kiwi backlog).
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from app.cloud_session import CLOUD_MODE
|
|
||||||
from app.tasks.runner import insert_task
|
|
||||||
|
|
||||||
if CLOUD_MODE:
|
|
||||||
log.warning("recipe_llm async jobs not supported in CLOUD_MODE — falling back to sync")
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
||||||
return result
|
|
||||||
|
|
||||||
job_id = f"rec_{uuid.uuid4().hex}"
|
|
||||||
|
|
||||||
def _create(db_path: Path) -> int:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
row = store.create_recipe_job(job_id, session.user_id, req.model_dump_json())
|
|
||||||
return row["id"]
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
int_id = await asyncio.to_thread(_create, session.db)
|
|
||||||
params_json = json.dumps({"job_id": job_id})
|
|
||||||
task_id, is_new = insert_task(session.db, "recipe_llm", int_id, params=params_json)
|
|
||||||
if is_new:
|
|
||||||
from app.tasks.scheduler import get_scheduler
|
|
||||||
get_scheduler(session.db).enqueue(task_id, "recipe_llm", int_id, params_json)
|
|
||||||
|
|
||||||
return JSONResponse(content={"job_id": job_id, "status": "queued"}, status_code=202)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest")
|
|
||||||
async def suggest_recipes(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
async_mode: bool = Query(default=False, alias="async"),
|
|
||||||
stream: bool = Query(default=False),
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
) -> RecipeResult:
|
||||||
):
|
|
||||||
log.info("recipes auth=%s tier=%s level=%s", _auth_label(session.user_id), session.tier, req.level)
|
|
||||||
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
||||||
# Also read stored unit_system preference; default to metric if not set.
|
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
||||||
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,
|
||||||
|
|
@ -203,105 +56,7 @@ async def suggest_recipes(
|
||||||
)
|
)
|
||||||
if req.style_id and not can_use("style_picker", req.tier):
|
if req.style_id and not can_use("style_picker", req.tier):
|
||||||
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
||||||
|
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
||||||
# Orch budget check for lifetime/founders keys — downgrade to L2 (local) if exhausted.
|
|
||||||
# Subscription and local/BYOK users skip this check entirely.
|
|
||||||
orch_fallback = False
|
|
||||||
if (
|
|
||||||
req.level in (3, 4)
|
|
||||||
and session.license_key is not None
|
|
||||||
and not session.has_byok
|
|
||||||
and session.tier != "local"
|
|
||||||
):
|
|
||||||
budget = check_orch_budget(session.license_key, "kiwi")
|
|
||||||
if not budget.get("allowed", True):
|
|
||||||
req = req.model_copy(update={"level": 2})
|
|
||||||
orch_fallback = True
|
|
||||||
|
|
||||||
if stream and req.level in (3, 4):
|
|
||||||
return StreamingResponse(
|
|
||||||
_stream_recipe_sse(session.db, req),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if req.level in (3, 4) and async_mode:
|
|
||||||
return await _enqueue_recipe_job(session, req)
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
||||||
if orch_fallback:
|
|
||||||
result = result.model_copy(update={"orch_fallback": True})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stream-token", response_model=StreamTokenResponse)
|
|
||||||
async def get_stream_token(
|
|
||||||
req: StreamTokenRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> StreamTokenResponse:
|
|
||||||
"""Issue a one-time stream token for LLM recipe generation.
|
|
||||||
|
|
||||||
Tier-gated (Paid or BYOK). Builds the prompt from pantry + user settings,
|
|
||||||
then calls the cf-orch coordinator to obtain a stream URL. Returns
|
|
||||||
immediately — the frontend opens EventSource to the stream URL directly.
|
|
||||||
"""
|
|
||||||
if not can_use("recipe_suggestions", session.tier, session.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Streaming recipe generation requires Paid tier or a configured LLM backend.",
|
|
||||||
)
|
|
||||||
if req.level == 4 and not req.wildcard_confirmed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Level 4 (Wildcard) streaming requires wildcard_confirmed=true.",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = await asyncio.to_thread(_build_stream_prompt, session.db, req.level)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await coordinator_authorize(prompt=prompt, caller="kiwi-recipe", ttl_s=300)
|
|
||||||
except CoordinatorError as exc:
|
|
||||||
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
|
||||||
|
|
||||||
return StreamTokenResponse(
|
|
||||||
stream_url=result.stream_url,
|
|
||||||
token=result.token,
|
|
||||||
expires_in_s=result.expires_in_s,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}", response_model=RecipeJobStatus)
|
|
||||||
async def get_recipe_job_status(
|
|
||||||
job_id: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> RecipeJobStatus:
|
|
||||||
"""Poll the status of an async recipe generation job.
|
|
||||||
|
|
||||||
Returns 404 when job_id is unknown or belongs to a different user.
|
|
||||||
On status='done' with suggestions=[], the LLM returned empty — client
|
|
||||||
should show a 'no recipe generated, try again' message.
|
|
||||||
"""
|
|
||||||
def _get(db_path: Path) -> dict | None:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_recipe_job(job_id, session.user_id)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
row = await asyncio.to_thread(_get, session.db)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Job not found.")
|
|
||||||
|
|
||||||
result = None
|
|
||||||
if row["status"] == "done" and row["result"]:
|
|
||||||
result = RecipeResult.model_validate_json(row["result"])
|
|
||||||
|
|
||||||
return RecipeJobStatus(
|
|
||||||
job_id=row["job_id"],
|
|
||||||
status=row["status"],
|
|
||||||
result=result,
|
|
||||||
error=row["error"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/domains")
|
@router.get("/browse/domains")
|
||||||
|
|
@ -321,42 +76,15 @@ async def list_browse_categories(
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
cat_names = get_category_names(domain)
|
keywords_by_category = {
|
||||||
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
cat: get_keywords_for_category(domain, cat)
|
||||||
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
|
for cat in get_category_names(domain)
|
||||||
|
|
||||||
def _get(db_path: Path) -> list[dict]:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_browser_categories(domain, keywords_by_category, has_subs)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/{domain}/{category}/subcategories")
|
|
||||||
async def list_browse_subcategories(
|
|
||||||
domain: str,
|
|
||||||
category: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
|
|
||||||
if domain not in DOMAINS:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
|
||||||
if not category_has_subcategories(domain, category):
|
|
||||||
return []
|
|
||||||
|
|
||||||
subcat_names = get_subcategory_names(domain, category)
|
|
||||||
keywords_by_subcat = {
|
|
||||||
sub: get_keywords_for_subcategory(domain, category, sub)
|
|
||||||
for sub in subcat_names
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get(db_path: Path) -> list[dict]:
|
def _get(db_path: Path) -> list[dict]:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
return store.get_browser_categories(domain, keywords_by_category)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
@ -370,33 +98,16 @@ async def browse_recipes(
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
subcategory: Annotated[str | None, Query()] = None,
|
|
||||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
|
||||||
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc|match)$")] = "default",
|
|
||||||
required_ingredient: Annotated[str | None, Query(max_length=100)] = None,
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
"""Return a paginated list of recipes for a domain/category.
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
Pass pantry_items as a comma-separated string to receive match_pct
|
||||||
Pass subcategory to narrow within a category that has subcategories.
|
badges on each result.
|
||||||
Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc/match).
|
|
||||||
sort=match orders by pantry coverage DESC; falls back to default when no pantry_items.
|
|
||||||
Pass required_ingredient to restrict results to recipes that must include that ingredient.
|
|
||||||
"""
|
"""
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
if category == "_all":
|
|
||||||
keywords = None # unfiltered browse
|
|
||||||
elif subcategory:
|
|
||||||
keywords = get_keywords_for_subcategory(domain, category, subcategory)
|
|
||||||
if not keywords:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
keywords = get_keywords_for_category(domain, category)
|
keywords = get_keywords_for_category(domain, category)
|
||||||
if not keywords:
|
if not keywords:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -413,90 +124,12 @@ async def browse_recipes(
|
||||||
def _browse(db_path: Path) -> dict:
|
def _browse(db_path: Path) -> dict:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
# Load sensory preferences
|
|
||||||
sensory_prefs_json = store.get_setting("sensory_preferences")
|
|
||||||
sensory_exclude = build_sensory_exclude(sensory_prefs_json)
|
|
||||||
|
|
||||||
result = store.browse_recipes(
|
result = store.browse_recipes(
|
||||||
keywords=keywords,
|
keywords=keywords,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
pantry_items=pantry_list,
|
pantry_items=pantry_list,
|
||||||
q=q or None,
|
|
||||||
sort=sort,
|
|
||||||
sensory_exclude=sensory_exclude,
|
|
||||||
required_ingredient=required_ingredient or None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Attach time/effort signals to each browse result ────────────────
|
|
||||||
import json as _json
|
|
||||||
for recipe_row in result.get("recipes", []):
|
|
||||||
directions_raw = recipe_row.get("directions") or []
|
|
||||||
if isinstance(directions_raw, str):
|
|
||||||
try:
|
|
||||||
directions_raw = _json.loads(directions_raw)
|
|
||||||
except Exception:
|
|
||||||
directions_raw = []
|
|
||||||
if directions_raw:
|
|
||||||
_profile = parse_time_effort(
|
|
||||||
directions_raw,
|
|
||||||
ingredients=recipe_row.get("ingredients") or [],
|
|
||||||
ingredient_names=recipe_row.get("ingredient_names") or [],
|
|
||||||
)
|
|
||||||
recipe_row["active_min"] = _profile.active_min
|
|
||||||
recipe_row["passive_min"] = _profile.passive_min
|
|
||||||
else:
|
|
||||||
recipe_row["active_min"] = None
|
|
||||||
recipe_row["passive_min"] = None
|
|
||||||
# Remove directions from browse payload — not needed by the card UI
|
|
||||||
recipe_row.pop("directions", None)
|
|
||||||
|
|
||||||
# Community tag fallback: if FTS returned nothing for a subcategory,
|
|
||||||
# check whether accepted community tags exist for this location and
|
|
||||||
# fetch those corpus recipes directly by ID.
|
|
||||||
if result["total"] == 0 and subcategory and keywords:
|
|
||||||
try:
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
cs = _get_community_store()
|
|
||||||
if cs is not None:
|
|
||||||
community_ids = cs.get_accepted_recipe_ids_for_subcategory(
|
|
||||||
domain=domain,
|
|
||||||
category=category,
|
|
||||||
subcategory=subcategory,
|
|
||||||
)
|
|
||||||
if community_ids:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
paged_ids = community_ids[offset: offset + page_size]
|
|
||||||
recipes = store.fetch_recipes_by_ids(paged_ids, pantry_list)
|
|
||||||
import json as _json_c
|
|
||||||
for recipe_row in recipes:
|
|
||||||
directions_raw = recipe_row.get("directions") or []
|
|
||||||
if isinstance(directions_raw, str):
|
|
||||||
try:
|
|
||||||
directions_raw = _json_c.loads(directions_raw)
|
|
||||||
except Exception:
|
|
||||||
directions_raw = []
|
|
||||||
if directions_raw:
|
|
||||||
_profile = parse_time_effort(
|
|
||||||
directions_raw,
|
|
||||||
ingredients=recipe_row.get("ingredients") or [],
|
|
||||||
ingredient_names=recipe_row.get("ingredient_names") or [],
|
|
||||||
)
|
|
||||||
recipe_row["active_min"] = _profile.active_min
|
|
||||||
recipe_row["passive_min"] = _profile.passive_min
|
|
||||||
else:
|
|
||||||
recipe_row["active_min"] = None
|
|
||||||
recipe_row["passive_min"] = None
|
|
||||||
recipe_row.pop("directions", None)
|
|
||||||
result = {
|
|
||||||
"recipes": recipes,
|
|
||||||
"total": len(community_ids),
|
|
||||||
"page": page,
|
|
||||||
"community_tagged": True,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("community tag fallback failed: %s", exc)
|
|
||||||
|
|
||||||
store.log_browser_telemetry(
|
store.log_browser_telemetry(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
category=category,
|
category=category,
|
||||||
|
|
@ -510,227 +143,6 @@ async def browse_recipes(
|
||||||
return await asyncio.to_thread(_browse, session.db)
|
return await asyncio.to_thread(_browse, session.db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates", response_model=list[AssemblyTemplateOut])
|
|
||||||
async def list_assembly_templates() -> list[dict]:
|
|
||||||
"""Return all 13 assembly templates with ordered role sequences.
|
|
||||||
|
|
||||||
Cache-friendly: static data, no per-user state.
|
|
||||||
"""
|
|
||||||
return get_templates_for_api()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/template-candidates", response_model=RoleCandidatesResponse)
|
|
||||||
async def get_template_role_candidates(
|
|
||||||
template_id: str = Query(..., description="Template slug, e.g. 'burrito_taco'"),
|
|
||||||
role: str = Query(..., description="Role display name, e.g. 'protein'"),
|
|
||||||
prior_picks: str = Query(default="", description="Comma-separated prior selections"),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> dict:
|
|
||||||
"""Return pantry-matched candidates for one wizard step."""
|
|
||||||
def _get(db_path: Path) -> dict:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
items = store.list_inventory(status="available")
|
|
||||||
pantry_set = {
|
|
||||||
item["product_name"]
|
|
||||||
for item in items
|
|
||||||
if item.get("product_name")
|
|
||||||
}
|
|
||||||
pantry_list = list(pantry_set)
|
|
||||||
prior = [p.strip() for p in prior_picks.split(",") if p.strip()]
|
|
||||||
profile_index = store.get_element_profiles(pantry_list + prior)
|
|
||||||
return get_role_candidates(
|
|
||||||
template_slug=template_id,
|
|
||||||
role_display=role,
|
|
||||||
pantry_set=pantry_set,
|
|
||||||
prior_picks=prior,
|
|
||||||
profile_index=profile_index,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/build", response_model=RecipeSuggestion)
|
|
||||||
async def build_recipe(
|
|
||||||
req: BuildRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> RecipeSuggestion:
|
|
||||||
"""Build a recipe from explicit role selections."""
|
|
||||||
def _build(db_path: Path) -> RecipeSuggestion | None:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
items = store.list_inventory(status="available")
|
|
||||||
pantry_set = {
|
|
||||||
item["product_name"]
|
|
||||||
for item in items
|
|
||||||
if item.get("product_name")
|
|
||||||
}
|
|
||||||
suggestion = build_from_selection(
|
|
||||||
template_slug=req.template_id,
|
|
||||||
role_overrides=req.role_overrides,
|
|
||||||
pantry_set=pantry_set,
|
|
||||||
)
|
|
||||||
if suggestion is None:
|
|
||||||
return None
|
|
||||||
# Persist to recipes table so the result can be saved/bookmarked.
|
|
||||||
# external_id encodes template + selections for stable dedup.
|
|
||||||
import hashlib as _hl, json as _js
|
|
||||||
sel_hash = _hl.md5(
|
|
||||||
_js.dumps(req.role_overrides, sort_keys=True).encode()
|
|
||||||
).hexdigest()[:8]
|
|
||||||
external_id = f"assembly:{req.template_id}:{sel_hash}"
|
|
||||||
real_id = store.upsert_built_recipe(
|
|
||||||
external_id=external_id,
|
|
||||||
title=suggestion.title,
|
|
||||||
ingredients=suggestion.matched_ingredients,
|
|
||||||
directions=suggestion.directions,
|
|
||||||
)
|
|
||||||
return suggestion.model_copy(update={"id": real_id})
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(_build, session.db)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Template not found or required ingredient missing.",
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
_ASK_STOPWORDS: frozenset[str] = frozenset({
|
|
||||||
"what", "can", "make", "with", "have", "some", "the", "and", "for",
|
|
||||||
"that", "this", "these", "those", "how", "about", "are", "there",
|
|
||||||
"give", "show", "find", "want", "need", "like", "any", "good",
|
|
||||||
"quick", "easy", "simple", "fast", "using", "use", "from", "into",
|
|
||||||
"more", "much", "just", "only", "my", "please", "could", "would",
|
|
||||||
"should", "something", "anything", "everything", "ideas", "idea",
|
|
||||||
"suggest", "meal", "food", "dish", "dishes", "today", "tonight",
|
|
||||||
"tomorrow", "now", "here", "there", "recipes", "recipe", "dinner",
|
|
||||||
"lunch", "breakfast", "snack", "under", "minutes", "hours", "time",
|
|
||||||
"left", "over", "also", "some", "make", "cook", "made", "cooked",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
import re as _re
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_ask_keywords(question: str) -> list[str]:
|
|
||||||
"""Extract food-relevant keywords from a natural language question."""
|
|
||||||
tokens = _re.findall(r"[a-zA-Z]+", question.lower())
|
|
||||||
return [t for t in tokens if len(t) > 3 and t not in _ASK_STOPWORDS]
|
|
||||||
|
|
||||||
|
|
||||||
def _ask_in_thread(db_path: Path, question: str, pantry_items: list[str]) -> AskResponse:
|
|
||||||
"""Run Ask logic in a worker thread.
|
|
||||||
|
|
||||||
Free tier: keyword extraction + FTS ingredient search.
|
|
||||||
Paid tier path: same search, then LLM synthesis over results.
|
|
||||||
The caller handles tier gating and LLM synthesis outside this thread
|
|
||||||
to avoid importing LLMRouter in a sync context.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
keywords = _extract_ask_keywords(question)
|
|
||||||
ingredient_hits: list[dict] = []
|
|
||||||
if keywords:
|
|
||||||
ingredient_hits = store.search_recipes_by_ingredients(keywords, limit=15)
|
|
||||||
|
|
||||||
# Also search by title using the full question text as a substring hint.
|
|
||||||
# browse_recipes q= does title LIKE %q%. Extract the longest keyword
|
|
||||||
# from the question as the title probe (most likely to appear in a title).
|
|
||||||
title_hits: list[dict] = []
|
|
||||||
title_probe = max(keywords, key=len) if keywords else None
|
|
||||||
if title_probe:
|
|
||||||
browse_result = store.browse_recipes(
|
|
||||||
keywords=None,
|
|
||||||
page=1,
|
|
||||||
page_size=12,
|
|
||||||
pantry_items=pantry_items or None,
|
|
||||||
q=title_probe,
|
|
||||||
sort="match" if pantry_items else "default",
|
|
||||||
)
|
|
||||||
title_hits = browse_result.get("recipes", [])
|
|
||||||
|
|
||||||
# Merge by ID; ingredient hits come first (more semantically relevant).
|
|
||||||
seen: set[int] = set()
|
|
||||||
merged: list[dict] = []
|
|
||||||
for row in ingredient_hits + title_hits:
|
|
||||||
rid = row.get("id")
|
|
||||||
if rid is not None and rid not in seen:
|
|
||||||
seen.add(rid)
|
|
||||||
merged.append(row)
|
|
||||||
|
|
||||||
# Compute pantry match_pct if caller sent pantry items.
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else set()
|
|
||||||
|
|
||||||
hits: list[AskRecipeHit] = []
|
|
||||||
for row in merged[:12]:
|
|
||||||
match_pct: float | None = None
|
|
||||||
if pantry_set:
|
|
||||||
raw_names = row.get("ingredient_names") or []
|
|
||||||
if isinstance(raw_names, str):
|
|
||||||
try:
|
|
||||||
raw_names = _json.loads(raw_names)
|
|
||||||
except Exception:
|
|
||||||
raw_names = []
|
|
||||||
if raw_names:
|
|
||||||
covered = sum(
|
|
||||||
1 for n in raw_names
|
|
||||||
if any(p in n.lower() for p in pantry_set)
|
|
||||||
)
|
|
||||||
match_pct = round(covered / len(raw_names), 2)
|
|
||||||
hits.append(AskRecipeHit(
|
|
||||||
id=row["id"],
|
|
||||||
title=row.get("title", ""),
|
|
||||||
category=row.get("category"),
|
|
||||||
match_pct=match_pct,
|
|
||||||
))
|
|
||||||
|
|
||||||
return AskResponse(answer=None, recipes=hits, tier="free")
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ask", response_model=AskResponse)
|
|
||||||
async def ask_recipes(
|
|
||||||
req: AskRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> AskResponse:
|
|
||||||
"""Natural-language recipe search with optional LLM synthesis.
|
|
||||||
|
|
||||||
Free tier: keyword extraction from question → FTS ingredient + title search.
|
|
||||||
Paid tier / BYOK: same search, then LLM synthesizes a short conversational answer.
|
|
||||||
"""
|
|
||||||
result = await asyncio.to_thread(_ask_in_thread, session.db, req.question, req.pantry_items)
|
|
||||||
|
|
||||||
# LLM synthesis: only for paid/premium/ultra tiers, not "local" dev tier.
|
|
||||||
# Wrapped in wait_for so an unresponsive model degrades gracefully to recipe list only.
|
|
||||||
paid_tier = session.tier in ("paid", "premium", "ultra")
|
|
||||||
if (paid_tier or session.has_byok) and result.recipes:
|
|
||||||
recipe_titles = ", ".join(r.title for r in result.recipes[:6])
|
|
||||||
prompt = (
|
|
||||||
f'You are a helpful kitchen assistant. The user asked: "{req.question}"\n\n'
|
|
||||||
f"Matching recipes: {recipe_titles}\n\n"
|
|
||||||
f"Write a brief, friendly 1–2 sentence response suggesting which of these "
|
|
||||||
f"recipes might best fit the question. Be specific and natural."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
|
||||||
answer = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(LLMRouter().complete, prompt),
|
|
||||||
timeout=8.0,
|
|
||||||
)
|
|
||||||
result = result.model_copy(update={"answer": answer.strip() or None, "tier": "paid"})
|
|
||||||
except (Exception, asyncio.TimeoutError) as exc:
|
|
||||||
log.warning("Ask LLM synthesis skipped: %s", exc)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
|
@ -743,111 +155,4 @@ async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session))
|
||||||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
||||||
if not recipe:
|
if not recipe:
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||||
|
return recipe
|
||||||
# Normalize corpus record into RecipeSuggestion shape so RecipeDetailPanel
|
|
||||||
# can render it without knowing it came from a direct DB lookup.
|
|
||||||
ingredient_names = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(ingredient_names, str):
|
|
||||||
import json as _json
|
|
||||||
try:
|
|
||||||
ingredient_names = _json.loads(ingredient_names)
|
|
||||||
except Exception:
|
|
||||||
ingredient_names = []
|
|
||||||
|
|
||||||
_directions_for_te = recipe.get("directions") or []
|
|
||||||
if isinstance(_directions_for_te, str):
|
|
||||||
import json as _json2
|
|
||||||
try:
|
|
||||||
_directions_for_te = _json2.loads(_directions_for_te)
|
|
||||||
except Exception:
|
|
||||||
_directions_for_te = []
|
|
||||||
|
|
||||||
_ingredients_for_te = recipe.get("ingredients") or []
|
|
||||||
if isinstance(_ingredients_for_te, str):
|
|
||||||
import json as _json3
|
|
||||||
try:
|
|
||||||
_ingredients_for_te = _json3.loads(_ingredients_for_te)
|
|
||||||
except Exception:
|
|
||||||
_ingredients_for_te = []
|
|
||||||
|
|
||||||
_ingredient_names_for_te = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(_ingredient_names_for_te, str):
|
|
||||||
import json as _json4
|
|
||||||
try:
|
|
||||||
_ingredient_names_for_te = _json4.loads(_ingredient_names_for_te)
|
|
||||||
except Exception:
|
|
||||||
_ingredient_names_for_te = []
|
|
||||||
|
|
||||||
if _directions_for_te:
|
|
||||||
_te = parse_time_effort(
|
|
||||||
_directions_for_te,
|
|
||||||
ingredients=_ingredients_for_te,
|
|
||||||
ingredient_names=_ingredient_names_for_te,
|
|
||||||
)
|
|
||||||
_time_effort_out: dict | None = {
|
|
||||||
"active_min": _te.active_min,
|
|
||||||
"passive_min": _te.passive_min,
|
|
||||||
"total_min": _te.total_min,
|
|
||||||
"effort_label": _te.effort_label,
|
|
||||||
"equipment": _te.equipment,
|
|
||||||
"step_analyses": [
|
|
||||||
{
|
|
||||||
"is_passive": sa.is_passive,
|
|
||||||
"detected_minutes": sa.detected_minutes,
|
|
||||||
"prep_min": sa.prep_min,
|
|
||||||
}
|
|
||||||
for sa in _te.step_analyses
|
|
||||||
],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
_time_effort_out = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": recipe.get("id"),
|
|
||||||
"title": recipe.get("title", ""),
|
|
||||||
"match_count": 0,
|
|
||||||
"matched_ingredients": ingredient_names,
|
|
||||||
"missing_ingredients": [],
|
|
||||||
"directions": recipe.get("directions") or [],
|
|
||||||
"prep_notes": [],
|
|
||||||
"swap_candidates": [],
|
|
||||||
"element_coverage": {},
|
|
||||||
"notes": recipe.get("notes") or "",
|
|
||||||
"level": 1,
|
|
||||||
"is_wildcard": False,
|
|
||||||
"nutrition": None,
|
|
||||||
"source_url": recipe.get("source_url") or None,
|
|
||||||
"complexity": None,
|
|
||||||
"estimated_time_min": None,
|
|
||||||
"time_effort": _time_effort_out,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{recipe_id}/leftovers", response_model=LeftoversResponse)
|
|
||||||
async def get_leftovers_shelf_life(
|
|
||||||
recipe_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> LeftoversResponse:
|
|
||||||
"""Return cooked-leftover shelf-life estimate for a recipe.
|
|
||||||
|
|
||||||
Free tier: deterministic lookup (FDA/USDA table).
|
|
||||||
Deterministic path always runs; no tier gate needed.
|
|
||||||
"""
|
|
||||||
def _get(db_path: Path, rid: int) -> LeftoversResponse:
|
|
||||||
from app.services.leftovers_predictor import predict_leftovers_from_row
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
recipe = store.get_recipe(rid)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
if recipe is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
|
||||||
result = predict_leftovers_from_row(recipe)
|
|
||||||
return LeftoversResponse(
|
|
||||||
fridge_days=result.fridge_days,
|
|
||||||
freeze_days=result.freeze_days,
|
|
||||||
freeze_by_day=result.freeze_by_day,
|
|
||||||
storage_advice=result.storage_advice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db, recipe_id)
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
@ -17,13 +16,8 @@ from app.models.schemas.saved_recipe import (
|
||||||
SaveRecipeRequest,
|
SaveRecipeRequest,
|
||||||
UpdateSavedRecipeRequest,
|
UpdateSavedRecipeRequest,
|
||||||
)
|
)
|
||||||
from app.services.magpie_hook import fire_recipe_signal
|
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
||||||
class StyleClassifyResponse(BaseModel):
|
|
||||||
suggested_tags: list[str]
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,7 +35,7 @@ def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
|
||||||
return SavedRecipeSummary(
|
return SavedRecipeSummary(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
recipe_id=row["recipe_id"],
|
recipe_id=row["recipe_id"],
|
||||||
title=row.get("title") or "",
|
title=row.get("title", ""),
|
||||||
saved_at=row["saved_at"],
|
saved_at=row["saved_at"],
|
||||||
notes=row.get("notes"),
|
notes=row.get("notes"),
|
||||||
rating=row.get("rating"),
|
rating=row.get("rating"),
|
||||||
|
|
@ -61,9 +55,7 @@ async def save_recipe(
|
||||||
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
||||||
return _to_summary(row, store)
|
return _to_summary(row, store)
|
||||||
|
|
||||||
result = await asyncio.to_thread(_in_thread, session.db, _run)
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
asyncio.create_task(fire_recipe_signal(session.db, req.recipe_id, req.rating, []))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{recipe_id}", status_code=204)
|
@router.delete("/{recipe_id}", status_code=204)
|
||||||
|
|
@ -90,11 +82,7 @@ async def update_saved_recipe(
|
||||||
)
|
)
|
||||||
return _to_summary(row, store)
|
return _to_summary(row, store)
|
||||||
|
|
||||||
result = await asyncio.to_thread(_in_thread, session.db, _run)
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
asyncio.create_task(
|
|
||||||
fire_recipe_signal(session.db, recipe_id, req.rating, req.style_tags or [])
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SavedRecipeSummary])
|
@router.get("", response_model=list[SavedRecipeSummary])
|
||||||
|
|
@ -110,37 +98,12 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""Session bootstrap endpoint — called once per app load by the frontend.
|
|
||||||
|
|
||||||
Logs auth= + tier= for log-based analytics without client-side tracking.
|
|
||||||
See Circuit-Forge/kiwi#86.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bootstrap")
|
|
||||||
def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
|
|
||||||
"""Record auth type and tier for log-based analytics.
|
|
||||||
|
|
||||||
Expected log output:
|
|
||||||
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
|
||||||
INFO:app.api.endpoints.session: session auth=anon tier=free
|
|
||||||
|
|
||||||
E2E test sessions (E2E_TEST_USER_ID) are logged at DEBUG so they don't
|
|
||||||
pollute analytics counts while still being visible when DEBUG=true.
|
|
||||||
"""
|
|
||||||
is_test = bool(settings.E2E_TEST_USER_ID and session.user_id == settings.E2E_TEST_USER_ID)
|
|
||||||
logger = log.debug if is_test else log.info
|
|
||||||
logger("session auth=%s tier=%s%s", _auth_label(session.user_id), session.tier, " e2e=true" if is_test else "")
|
|
||||||
return {
|
|
||||||
"auth": _auth_label(session.user_id),
|
|
||||||
"tier": session.tier,
|
|
||||||
"has_byok": session.has_byok,
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"})
|
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
class SettingBody(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
"""Shopping list endpoints.
|
|
||||||
|
|
||||||
Free tier for all users (anonymous guests included — shopping list is the
|
|
||||||
primary affiliate revenue surface). Confirm-purchase action is also Free:
|
|
||||||
it moves a checked item into pantry inventory without a tier gate so the
|
|
||||||
flow works for anyone who signs up or browses without an account.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /shopping — list items (with affiliate links)
|
|
||||||
POST /shopping — add item manually
|
|
||||||
PATCH /shopping/{id} — update (check/uncheck, rename, qty)
|
|
||||||
DELETE /shopping/{id} — remove single item
|
|
||||||
DELETE /shopping/checked — clear all checked items
|
|
||||||
DELETE /shopping/all — clear entire list
|
|
||||||
POST /shopping/from-recipe — bulk add gaps from a recipe
|
|
||||||
POST /shopping/{id}/confirm — confirm purchase → add to pantry inventory
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.shopping import (
|
|
||||||
BulkAddFromRecipeRequest,
|
|
||||||
ConfirmPurchaseRequest,
|
|
||||||
ShoppingItemCreate,
|
|
||||||
ShoppingItemResponse,
|
|
||||||
ShoppingItemUpdate,
|
|
||||||
)
|
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
|
|
||||||
"""Attach live affiliate links to a raw store row."""
|
|
||||||
links = builder.build_links(item["name"])
|
|
||||||
return ShoppingItemResponse(
|
|
||||||
**{**item, "checked": bool(item.get("checked", 0))},
|
|
||||||
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _in_thread(db_path, fn):
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return fn(store)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ── List ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _locale_from_store(store: Store) -> str:
|
|
||||||
return store.get_setting("shopping_locale") or "us"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ShoppingItemResponse])
|
|
||||||
async def list_shopping_items(
|
|
||||||
include_checked: bool = True,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
locale = await asyncio.to_thread(_in_thread, session.db, _locale_from_store)
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=locale)
|
|
||||||
items = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
|
|
||||||
)
|
|
||||||
return [_enrich(i, builder) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Add manually ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("", response_model=ShoppingItemResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_shopping_item(
|
|
||||||
body: ShoppingItemCreate,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
_in_thread,
|
|
||||||
session.db,
|
|
||||||
lambda s: s.add_shopping_item(
|
|
||||||
name=body.name,
|
|
||||||
quantity=body.quantity,
|
|
||||||
unit=body.unit,
|
|
||||||
category=body.category,
|
|
||||||
notes=body.notes,
|
|
||||||
source=body.source,
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
sort_order=body.sort_order,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return _enrich(item, builder)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bulk add from recipe ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/from-recipe", response_model=list[ShoppingItemResponse], status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_from_recipe(
|
|
||||||
body: BulkAddFromRecipeRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Add missing ingredients from a recipe to the shopping list.
|
|
||||||
|
|
||||||
Runs pantry gap analysis and adds only the items the user doesn't have
|
|
||||||
(unless include_covered=True). Skips duplicates already on the list.
|
|
||||||
"""
|
|
||||||
from app.services.meal_plan.shopping_list import compute_shopping_list
|
|
||||||
|
|
||||||
def _run(store: Store):
|
|
||||||
recipe = store.get_recipe(body.recipe_id)
|
|
||||||
if not recipe:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
|
||||||
inventory = store.list_inventory()
|
|
||||||
gaps, covered = compute_shopping_list([recipe], inventory)
|
|
||||||
targets = (gaps + covered) if body.include_covered else gaps
|
|
||||||
|
|
||||||
# Avoid duplicates already on the list
|
|
||||||
existing = {i["name"].lower() for i in store.list_shopping_items()}
|
|
||||||
added = []
|
|
||||||
for gap in targets:
|
|
||||||
if gap.ingredient_name.lower() in existing:
|
|
||||||
continue
|
|
||||||
item = store.add_shopping_item(
|
|
||||||
name=gap.ingredient_name,
|
|
||||||
quantity=None,
|
|
||||||
unit=gap.have_unit,
|
|
||||||
source="recipe",
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
)
|
|
||||||
added.append(item)
|
|
||||||
return added
|
|
||||||
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
|
||||||
items = await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
return [_enrich(i, builder) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Update ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.patch("/{item_id}", response_model=ShoppingItemResponse)
|
|
||||||
async def update_shopping_item(
|
|
||||||
item_id: int,
|
|
||||||
body: ShoppingItemUpdate,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
_in_thread,
|
|
||||||
session.db,
|
|
||||||
lambda s: s.update_shopping_item(item_id, **body.model_dump(exclude_none=True)),
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
return _enrich(item, builder)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Confirm purchase → pantry ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{item_id}/confirm", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def confirm_purchase(
|
|
||||||
item_id: int,
|
|
||||||
body: ConfirmPurchaseRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Confirm a checked item was purchased and add it to pantry inventory.
|
|
||||||
|
|
||||||
Human approval step: the user explicitly confirms what they actually bought
|
|
||||||
before it lands in their pantry. Returns the new inventory item.
|
|
||||||
"""
|
|
||||||
def _run(store: Store):
|
|
||||||
shopping_item = store.get_shopping_item(item_id)
|
|
||||||
if not shopping_item:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
|
|
||||||
qty = body.quantity if body.quantity is not None else (shopping_item.get("quantity") or 1.0)
|
|
||||||
unit = body.unit or shopping_item.get("unit") or "count"
|
|
||||||
category = shopping_item.get("category")
|
|
||||||
|
|
||||||
product = store.get_or_create_product(
|
|
||||||
name=shopping_item["name"],
|
|
||||||
category=category,
|
|
||||||
)
|
|
||||||
inv_item = store.add_inventory_item(
|
|
||||||
product_id=product["id"],
|
|
||||||
location=body.location,
|
|
||||||
quantity=qty,
|
|
||||||
unit=unit,
|
|
||||||
source="manual",
|
|
||||||
)
|
|
||||||
# Mark the shopping item checked and leave it for the user to clear
|
|
||||||
store.update_shopping_item(item_id, checked=True)
|
|
||||||
return inv_item
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Delete ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_shopping_item(
|
|
||||||
item_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
deleted = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.delete_shopping_item(item_id)
|
|
||||||
)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/checked", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def clear_checked(session: CloudUser = Depends(get_session)):
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.clear_checked_shopping_items()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/all", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def clear_all(session: CloudUser = Depends(get_session)):
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.clear_all_shopping_items()
|
|
||||||
)
|
|
||||||
|
|
@ -1,34 +1,16 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes
|
||||||
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"])
|
|
||||||
# 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(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
|
|
||||||
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
|
||||||
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
|
||||||
api_router.include_router(community_router)
|
|
||||||
api_router.include_router(recipe_tags_router)
|
|
||||||
api_router.include_router(corrections_router, prefix="/corrections", tags=["corrections"])
|
|
||||||
api_router.include_router(mastodon_router)
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""Cloud session resolution for Kiwi FastAPI.
|
"""Cloud session resolution for Kiwi FastAPI.
|
||||||
|
|
||||||
Delegates JWT validation, Heimdall provisioning, tier resolution, and guest
|
Local mode (CLOUD_MODE unset/false): returns a local CloudUser with no auth
|
||||||
session management to circuitforge_core.CloudSessionFactory. Kiwi-specific
|
checks, full tier access, and DB path pointing to settings.DB_PATH.
|
||||||
CloudUser (per-user DB path, household data, BYOK flag) and DB helpers are
|
|
||||||
kept here.
|
Cloud mode (CLOUD_MODE=true): validates the cf_session JWT injected by Caddy
|
||||||
|
as X-CF-Session, resolves user_id, auto-provisions a free Heimdall license on
|
||||||
|
first visit, fetches the tier, and returns a per-user DB path.
|
||||||
|
|
||||||
FastAPI usage:
|
FastAPI usage:
|
||||||
@app.get("/api/v1/inventory/items")
|
@app.get("/api/v1/inventory/items")
|
||||||
|
|
@ -15,11 +17,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory, detect_byok
|
import jwt as pyjwt
|
||||||
from fastapi import Depends, HTTPException, Request, Response
|
import requests
|
||||||
|
import yaml
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -27,22 +33,54 @@ 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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -54,10 +92,74 @@ class CloudUser:
|
||||||
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
||||||
household_id: str | None = None
|
household_id: str | None = None
|
||||||
is_household_owner: bool = False
|
is_household_owner: bool = False
|
||||||
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
|
||||||
|
|
||||||
|
|
||||||
# ── DB path helpers ───────────────────────────────────────────────────────────
|
# ── JWT validation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _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]:
|
||||||
|
"""Returns (tier, household_id | None, is_household_owner)."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not HEIMDALL_ADMIN_TOKEN:
|
||||||
|
return "free", None, False
|
||||||
|
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)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
||||||
|
tier, household_id, is_owner = "free", None, False
|
||||||
|
|
||||||
|
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now)
|
||||||
|
return tier, household_id, is_owner
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
@ -68,57 +170,77 @@ def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _anon_guest_db_path(guest_id: str) -> Path:
|
# ── BYOK detection ────────────────────────────────────────────────────────────
|
||||||
"""Per-session DB for unauthenticated guest visitors.
|
|
||||||
|
|
||||||
Each anonymous visitor gets an isolated SQLite DB keyed by their guest UUID
|
_LLM_CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
||||||
cookie, so shopping lists and affiliate interactions never bleed across sessions.
|
|
||||||
|
|
||||||
|
def _detect_byok(config_path: Path = _LLM_CONFIG_PATH) -> bool:
|
||||||
|
"""Return True if at least one enabled non-vision LLM backend is configured.
|
||||||
|
|
||||||
|
Reads the same llm.yaml that LLMRouter uses. Local (Ollama, vLLM) and
|
||||||
|
API-key backends both count — the policy is "user is supplying compute",
|
||||||
|
regardless of where that compute lives.
|
||||||
"""
|
"""
|
||||||
path = CLOUD_DATA_ROOT / f"anon-{guest_id}" / "kiwi.db"
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
with open(config_path) as f:
|
||||||
return path
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
return any(
|
||||||
|
b.get("enabled", True) and b.get("type") != "vision_service"
|
||||||
|
for b in cfg.get("backends", {}).values()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_session(request: Request, response: Response) -> CloudUser:
|
def get_session(request: Request) -> CloudUser:
|
||||||
"""FastAPI dependency — resolves the current user from the request.
|
"""FastAPI dependency — resolves the current user from the request.
|
||||||
|
|
||||||
Delegates auth/tier resolution to cf-core CloudSessionFactory, then maps
|
|
||||||
the result to Kiwi's CloudUser with per-user DB path and household data.
|
|
||||||
|
|
||||||
Local mode: fully-privileged "local" user pointing at local DB.
|
Local mode: fully-privileged "local" user pointing at local DB.
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
||||||
Dev bypass: CLOUD_AUTH_BYPASS_IPS match returns a "local-dev" session.
|
Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
|
||||||
Anonymous: per-session UUID cookie (cf_guest_id) isolates each guest's data.
|
returns a "local" session without JWT validation (dev/LAN use only).
|
||||||
"""
|
"""
|
||||||
core_user = _core.resolve(request, response)
|
has_byok = _detect_byok()
|
||||||
uid, tier, has_byok = core_user.user_id, core_user.tier, core_user.has_byok
|
|
||||||
|
|
||||||
if not CLOUD_MODE or uid in ("local", "local-dev"):
|
if not CLOUD_MODE:
|
||||||
# local-dev gets a writable path under CLOUD_DATA_ROOT; local uses KIWI_DB
|
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
||||||
db = _user_db_path(uid) if uid == "local-dev" else _LOCAL_KIWI_DB
|
|
||||||
return CloudUser(user_id=uid, tier=tier, db=db, has_byok=has_byok)
|
|
||||||
|
|
||||||
if uid.startswith("anon-"):
|
# Prefer X-Real-IP (set by nginx from the actual client address) over the
|
||||||
guest_id = uid[len("anon-"):]
|
# TCP peer address (which is nginx's container IP when behind the proxy).
|
||||||
return CloudUser(
|
client_ip = (
|
||||||
user_id=uid, tier=tier,
|
request.headers.get("x-real-ip", "")
|
||||||
db=_anon_guest_db_path(guest_id),
|
or (request.client.host if request.client else "")
|
||||||
has_byok=has_byok,
|
|
||||||
)
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
household_id = core_user.meta.get("household_id")
|
raw_header = (
|
||||||
is_owner = core_user.meta.get("is_household_owner", False)
|
request.headers.get("x-cf-session", "")
|
||||||
license_key = core_user.meta.get("license_key")
|
or request.headers.get("cookie", "")
|
||||||
log.debug("Resolved %s session uid=%s tier=%s household=%s", _auth_label(uid), uid[:8], tier, household_id)
|
)
|
||||||
|
if not raw_header:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
user_id = validate_session_jwt(token)
|
||||||
|
_ensure_provisioned(user_id)
|
||||||
|
tier, household_id, is_household_owner = _fetch_cloud_tier(user_id)
|
||||||
return CloudUser(
|
return CloudUser(
|
||||||
user_id=uid, tier=tier,
|
user_id=user_id,
|
||||||
db=_user_db_path(uid, household_id=household_id),
|
tier=tier,
|
||||||
|
db=_user_db_path(user_id, household_id=household_id),
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
household_id=household_id,
|
household_id=household_id,
|
||||||
is_household_owner=is_owner,
|
is_household_owner=is_household_owner,
|
||||||
license_key=license_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,28 +35,6 @@ class Settings:
|
||||||
# Database
|
# Database
|
||||||
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
||||||
|
|
||||||
# Pre-computed browse counts cache (small SQLite, separate from corpus).
|
|
||||||
# Written by the nightly refresh task and by infer_recipe_tags.py.
|
|
||||||
# Set BROWSE_COUNTS_PATH to a bind-mounted path if you want the host
|
|
||||||
# pipeline to share counts with the container without re-running FTS.
|
|
||||||
BROWSE_COUNTS_PATH: Path = Path(
|
|
||||||
os.environ.get("BROWSE_COUNTS_PATH", str(DATA_DIR / "browse_counts.db"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Magpie data flywheel — ingest endpoint for anonymized recipe signals
|
|
||||||
# Set MAGPIE_INGEST_URL to enable; leave unset (or None) to disable silently.
|
|
||||||
MAGPIE_INGEST_URL: str | None = os.environ.get("MAGPIE_INGEST_URL") or None
|
|
||||||
|
|
||||||
# Community feature settings
|
|
||||||
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
|
||||||
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
|
||||||
"COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod"
|
|
||||||
)
|
|
||||||
COMMUNITY_CLOUD_FEED_URL: str = os.environ.get(
|
|
||||||
"COMMUNITY_CLOUD_FEED_URL",
|
|
||||||
"https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Processing
|
# Processing
|
||||||
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
|
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
|
||||||
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")
|
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")
|
||||||
|
|
@ -65,52 +43,15 @@ class Settings:
|
||||||
# Quality
|
# Quality
|
||||||
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
|
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
|
||||||
|
|
||||||
# CF-core resource coordinator (VRAM lease management — lease broker, not inference)
|
# CF-core resource coordinator (VRAM lease management)
|
||||||
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
||||||
|
|
||||||
# GPU inference server URL
|
|
||||||
# Priority: GPU_SERVER_URL env var → CF_ORCH_URL env var (backward compat)
|
|
||||||
# → https://orch.circuitforge.tech when CF_LICENSE_KEY is present (Paid+)
|
|
||||||
# Resolved value is written back to os.environ["CF_ORCH_URL"] at startup so
|
|
||||||
# all service-layer callers that read CF_ORCH_URL directly see the right URL.
|
|
||||||
GPU_SERVER_URL: str | None = (
|
|
||||||
os.environ.get("GPU_SERVER_URL")
|
|
||||||
or os.environ.get("CF_ORCH_URL")
|
|
||||||
or (
|
|
||||||
"https://orch.circuitforge.tech"
|
|
||||||
if os.environ.get("CF_LICENSE_KEY")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||||
|
|
||||||
# E2E test account — analytics logging is suppressed for this user_id so test
|
|
||||||
# runs don't pollute session counts. Set to the Directus UUID of the test user.
|
|
||||||
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
|
|
||||||
|
|
||||||
# ActivityPub federation (optional; disabled by default)
|
|
||||||
AP_ENABLED: bool = os.environ.get("AP_ENABLED", "false").lower() in ("1", "true", "yes")
|
|
||||||
AP_HOST: str = os.environ.get("AP_HOST", "") # e.g. kiwi.circuitforge.tech
|
|
||||||
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/kiwi-cloud-data"))
|
|
||||||
AP_KEY_PATH: Path = Path(
|
|
||||||
os.environ.get("AP_KEY_PATH", str(CLOUD_DATA_ROOT / "ap_keys" / "instance.pem"))
|
|
||||||
)
|
|
||||||
# Fernet key for Mastodon access token encryption (base64-urlsafe, 32 bytes)
|
|
||||||
# Leave unset to skip encryption (dev only)
|
|
||||||
AP_TOKEN_ENCRYPTION_KEY: str | None = os.environ.get("AP_TOKEN_ENCRYPTION_KEY") or None
|
|
||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||||
# Use OrchestratedScheduler (coordinator-aware, multi-GPU fan-out) instead of
|
|
||||||
# LocalScheduler. Defaults to true in CLOUD_MODE; can be set independently
|
|
||||||
# for multi-GPU local rigs that don't need full cloud auth.
|
|
||||||
USE_ORCH_SCHEDULER: bool | None = (
|
|
||||||
None if os.environ.get("USE_ORCH_SCHEDULER") is None
|
|
||||||
else os.environ.get("USE_ORCH_SCHEDULER", "").lower() in ("1", "true", "yes")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
@ -123,9 +64,3 @@ class Settings:
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# Normalise GPU_SERVER_URL into CF_ORCH_URL so every service-layer caller that
|
|
||||||
# reads os.environ.get("CF_ORCH_URL") sees the resolved value, including the
|
|
||||||
# Paid+ cloud default injected above.
|
|
||||||
if settings.GPU_SERVER_URL:
|
|
||||||
os.environ["CF_ORCH_URL"] = settings.GPU_SERVER_URL
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
-- Migration 021: FTS5 inverted index for the recipe browser (category + keywords).
|
|
||||||
--
|
|
||||||
-- The browser domain queries were using LIKE '%keyword%' against category and
|
|
||||||
-- keywords columns — a leading wildcard prevents any B-tree index use, so every
|
|
||||||
-- query was a full sequential scan of 3.1M rows. This FTS5 index replaces those
|
|
||||||
-- scans with O(log N) token lookups.
|
|
||||||
--
|
|
||||||
-- Content-table backed: stores only the inverted index, no text duplication.
|
|
||||||
-- The keywords column is a JSON array; FTS5 tokenises it as plain text, stripping
|
|
||||||
-- the punctuation, which gives correct per-word matching.
|
|
||||||
--
|
|
||||||
-- One-time rebuild cost on 3.1M rows: ~20-40 seconds at first startup.
|
|
||||||
-- Subsequent startups skip this migration (IF NOT EXISTS guard).
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS recipe_browser_fts USING fts5(
|
|
||||||
category,
|
|
||||||
keywords,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id,
|
|
||||||
tokenize="unicode61"
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords)
|
|
||||||
VALUES (new.id, new.category, new.keywords);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords);
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords)
|
|
||||||
VALUES (new.id, new.category, new.keywords);
|
|
||||||
END;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- 022_meal_plans.sql
|
|
||||||
CREATE TABLE meal_plans (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
week_start TEXT NOT NULL,
|
|
||||||
meal_types TEXT NOT NULL DEFAULT '["dinner"]',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Migration 022: Add is_generic flag to recipes
|
|
||||||
-- Generic recipes are catch-all/dump recipes with loose ingredient lists
|
|
||||||
-- that should not appear in Level 1 (deterministic "use what I have") results.
|
|
||||||
-- Admins can mark recipes via the recipe editor or a bulk backfill script.
|
|
||||||
ALTER TABLE recipes ADD COLUMN is_generic INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- 023_meal_plan_slots.sql
|
|
||||||
CREATE TABLE meal_plan_slots (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
|
||||||
day_of_week INTEGER NOT NULL CHECK(day_of_week BETWEEN 0 AND 6),
|
|
||||||
meal_type TEXT NOT NULL,
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id),
|
|
||||||
servings REAL NOT NULL DEFAULT 2.0,
|
|
||||||
custom_label TEXT,
|
|
||||||
UNIQUE(plan_id, day_of_week, meal_type)
|
|
||||||
);
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- 024_prep_sessions.sql
|
|
||||||
CREATE TABLE prep_sessions (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
|
||||||
scheduled_date TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'draft'
|
|
||||||
CHECK(status IN ('draft','reviewed','done')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-- 025_prep_tasks.sql
|
|
||||||
CREATE TABLE prep_tasks (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
session_id INTEGER NOT NULL REFERENCES prep_sessions(id) ON DELETE CASCADE,
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id),
|
|
||||||
slot_id INTEGER REFERENCES meal_plan_slots(id),
|
|
||||||
task_label TEXT NOT NULL,
|
|
||||||
duration_minutes INTEGER,
|
|
||||||
sequence_order INTEGER NOT NULL,
|
|
||||||
equipment TEXT,
|
|
||||||
is_parallel INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notes TEXT,
|
|
||||||
user_edited INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- 028_community_pseudonyms.sql
|
|
||||||
-- Per-user pseudonym store: maps the user's chosen community display name
|
|
||||||
-- to their Directus user ID. This table lives in per-user kiwi.db only.
|
|
||||||
-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design.
|
|
||||||
--
|
|
||||||
-- A user may have one active pseudonym. Old pseudonyms are retained for reference
|
|
||||||
-- (posts published under them keep their pseudonym attribution) but only one is
|
|
||||||
-- flagged as current (is_current = 1).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS community_pseudonyms (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
pseudonym TEXT NOT NULL,
|
|
||||||
directus_user_id TEXT NOT NULL,
|
|
||||||
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Only one pseudonym can be current at a time per user
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current
|
|
||||||
ON community_pseudonyms (directus_user_id)
|
|
||||||
WHERE is_current = 1;
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
-- Migration 029: Add inferred_tags column and update FTS index to include it.
|
|
||||||
--
|
|
||||||
-- inferred_tags holds a JSON array of normalized tag strings derived by
|
|
||||||
-- scripts/pipeline/infer_recipe_tags.py (e.g. ["cuisine:Italian",
|
|
||||||
-- "dietary:Low-Carb", "flavor:Umami", "can_be:Gluten-Free"]).
|
|
||||||
--
|
|
||||||
-- The FTS5 browser table is rebuilt to index inferred_tags alongside
|
|
||||||
-- category and keywords so browse domain queries match against all signals.
|
|
||||||
|
|
||||||
-- 1. Add inferred_tags column (empty array default; populated by pipeline run)
|
|
||||||
ALTER TABLE recipes ADD COLUMN inferred_tags TEXT NOT NULL DEFAULT '[]';
|
|
||||||
|
|
||||||
-- 2. Drop old FTS table and triggers that only covered category + keywords
|
|
||||||
DROP TRIGGER IF EXISTS recipes_ai;
|
|
||||||
DROP TRIGGER IF EXISTS recipes_ad;
|
|
||||||
DROP TRIGGER IF EXISTS recipes_au;
|
|
||||||
DROP TABLE IF EXISTS recipe_browser_fts;
|
|
||||||
|
|
||||||
-- 3. Recreate FTS5 table: now indexes category, keywords, AND inferred_tags
|
|
||||||
CREATE VIRTUAL TABLE recipe_browser_fts USING fts5(
|
|
||||||
category,
|
|
||||||
keywords,
|
|
||||||
inferred_tags,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 4. Triggers to keep FTS in sync with recipes table changes
|
|
||||||
CREATE TRIGGER recipes_ai AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER recipes_ad AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER recipes_au AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
|
|
||||||
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- 5. Populate FTS from current table state
|
|
||||||
-- (inferred_tags is '[]' for all rows at this point; run infer_recipe_tags.py
|
|
||||||
-- to populate, then the FTS will be rebuilt as part of that script.)
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Migration 030: open-package tracking
|
|
||||||
-- Adds opened_date to track when a multi-use item was first opened,
|
|
||||||
-- enabling secondary shelf-life windows (e.g. salsa: 1 year sealed → 2 weeks opened).
|
|
||||||
|
|
||||||
ALTER TABLE inventory_items ADD COLUMN opened_date TEXT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- Migration 031: add disposal_reason for waste logging (#60)
|
|
||||||
-- status='discarded' already exists in the CHECK constraint from migration 002.
|
|
||||||
-- This column stores free-text reason (optional) and calm-framing presets.
|
|
||||||
ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- 032_meal_plan_unique_week.sql
|
|
||||||
-- Prevent duplicate plans for the same week.
|
|
||||||
-- Existing duplicates must be resolved before applying (keep MIN(id) per week_start).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_meal_plans_week_start ON meal_plans (week_start);
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- Migration 033: standalone shopping list
|
|
||||||
-- Items can be added manually, from recipe gap analysis, or from the recipe browser.
|
|
||||||
-- Affiliate links are computed at query time by the API layer (never stored).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_list_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
quantity REAL,
|
|
||||||
unit TEXT,
|
|
||||||
category TEXT,
|
|
||||||
checked INTEGER NOT NULL DEFAULT 0, -- 0=want, 1=in-cart/checked off
|
|
||||||
notes TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | recipe | meal_plan
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_checked
|
|
||||||
ON shopping_list_items (checked, sort_order);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- Migration 034: async recipe generation job queue
|
|
||||||
CREATE TABLE IF NOT EXISTS recipe_jobs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id TEXT NOT NULL UNIQUE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
request TEXT NOT NULL,
|
|
||||||
result TEXT,
|
|
||||||
error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_job_id ON recipe_jobs (job_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_user_id ON recipe_jobs (user_id, created_at DESC);
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- Migration 035: add sensory_tags column for sensory profile filtering
|
|
||||||
--
|
|
||||||
-- sensory_tags holds a JSON object with texture, smell, and noise signals:
|
|
||||||
-- {"textures": ["mushy", "creamy"], "smell": "pungent", "noise": "moderate"}
|
|
||||||
--
|
|
||||||
-- Empty object '{}' means untagged — these recipes pass ALL sensory filters
|
|
||||||
-- (graceful degradation when tag_sensory_profiles.py has not yet been run).
|
|
||||||
--
|
|
||||||
-- Populated offline by: python scripts/tag_sensory_profiles.py [path/to/kiwi.db]
|
|
||||||
-- No FTS rebuild needed — sensory_tags is filtered in Python after candidate fetch.
|
|
||||||
|
|
||||||
ALTER TABLE recipes ADD COLUMN sensory_tags TEXT NOT NULL DEFAULT '{}';
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
-- Migration 036: captured_products local cache
|
|
||||||
-- Products captured via visual label scanning (kiwi#79).
|
|
||||||
-- Keyed by barcode; checked before FDC/OFF on future scans so each product
|
|
||||||
-- is only captured once per device.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS captured_products (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
barcode TEXT UNIQUE NOT NULL,
|
|
||||||
product_name TEXT,
|
|
||||||
brand TEXT,
|
|
||||||
serving_size_g REAL,
|
|
||||||
calories REAL,
|
|
||||||
fat_g REAL,
|
|
||||||
saturated_fat_g REAL,
|
|
||||||
carbs_g REAL,
|
|
||||||
sugar_g REAL,
|
|
||||||
fiber_g REAL,
|
|
||||||
protein_g REAL,
|
|
||||||
sodium_mg REAL,
|
|
||||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
||||||
allergens TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
||||||
confidence REAL,
|
|
||||||
source TEXT NOT NULL DEFAULT 'visual_capture',
|
|
||||||
captured_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
confirmed_by_user INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
-- Migration 037: add 'visual_capture' to products.source CHECK constraint
|
|
||||||
-- SQLite cannot ALTER a CHECK constraint, so we rebuild the table.
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = OFF;
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE products_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
barcode TEXT UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
brand TEXT,
|
|
||||||
category TEXT,
|
|
||||||
description TEXT,
|
|
||||||
image_url TEXT,
|
|
||||||
nutrition_data TEXT NOT NULL DEFAULT '{}',
|
|
||||||
source TEXT NOT NULL DEFAULT 'openfoodfacts'
|
|
||||||
CHECK (source IN ('openfoodfacts', 'manual', 'receipt_ocr', 'visual_capture')),
|
|
||||||
source_data TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO products_new
|
|
||||||
SELECT id, barcode, name, brand, category, description, image_url,
|
|
||||||
nutrition_data, source, source_data, created_at, updated_at
|
|
||||||
FROM products;
|
|
||||||
|
|
||||||
DROP TABLE products;
|
|
||||||
ALTER TABLE products_new RENAME TO products;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
-- Migration 038: add 'visual_capture' to inventory_items.source CHECK constraint
|
|
||||||
-- SQLite cannot ALTER a CHECK constraint, so we rebuild the table.
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = OFF;
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE inventory_items_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
product_id INTEGER NOT NULL
|
|
||||||
REFERENCES products (id) ON DELETE RESTRICT,
|
|
||||||
receipt_id INTEGER
|
|
||||||
REFERENCES receipts (id) ON DELETE SET NULL,
|
|
||||||
quantity REAL NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
|
||||||
unit TEXT NOT NULL DEFAULT 'count',
|
|
||||||
location TEXT NOT NULL,
|
|
||||||
sublocation TEXT,
|
|
||||||
purchase_date TEXT,
|
|
||||||
expiration_date TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'available'
|
|
||||||
CHECK (status IN ('available', 'consumed', 'expired', 'discarded')),
|
|
||||||
consumed_at TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual'
|
|
||||||
CHECK (source IN ('barcode_scan', 'manual', 'receipt', 'visual_capture')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
opened_date TEXT,
|
|
||||||
disposal_reason TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO inventory_items_new
|
|
||||||
SELECT id, product_id, receipt_id, quantity, unit, location, sublocation,
|
|
||||||
purchase_date, expiration_date, status, consumed_at, notes, source,
|
|
||||||
created_at, updated_at, opened_date, disposal_reason
|
|
||||||
FROM inventory_items;
|
|
||||||
|
|
||||||
DROP TABLE inventory_items;
|
|
||||||
ALTER TABLE inventory_items_new RENAME TO inventory_items;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
-- Migration 039: Drop FK constraint on saved_recipes.recipe_id.
|
|
||||||
--
|
|
||||||
-- In cloud mode the recipe corpus is ATTACHed as a separate database.
|
|
||||||
-- SQLite FK constraints only resolve against the `main` schema, so
|
|
||||||
-- `REFERENCES recipes(id)` was always failing for cloud saves (the
|
|
||||||
-- main.recipes table is empty; all data lives in corpus.recipes).
|
|
||||||
-- The corpus is read-only and never modified by the app, so cascade-on-delete
|
|
||||||
-- is meaningless anyway. Remove the constraint without changing any data.
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = OFF;
|
|
||||||
|
|
||||||
CREATE TABLE saved_recipes_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
recipe_id INTEGER NOT NULL,
|
|
||||||
saved_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
notes TEXT,
|
|
||||||
rating INTEGER CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
|
|
||||||
style_tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
UNIQUE (recipe_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO saved_recipes_new SELECT * FROM saved_recipes;
|
|
||||||
|
|
||||||
DROP TABLE saved_recipes;
|
|
||||||
|
|
||||||
ALTER TABLE saved_recipes_new RENAME TO saved_recipes;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_recipes_saved_at ON saved_recipes (saved_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_recipes_rating ON saved_recipes (rating);
|
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- 040_corrections.sql — corrections table for SFT training data
|
|
||||||
-- Schema from circuitforge_core.api.corrections.CORRECTIONS_MIGRATION_SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS corrections (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL DEFAULT '',
|
|
||||||
product TEXT NOT NULL,
|
|
||||||
correction_type TEXT NOT NULL,
|
|
||||||
input_text TEXT NOT NULL,
|
|
||||||
original_output TEXT NOT NULL,
|
|
||||||
corrected_output TEXT NOT NULL DEFAULT '',
|
|
||||||
rating TEXT NOT NULL DEFAULT 'down',
|
|
||||||
context TEXT NOT NULL DEFAULT '{}',
|
|
||||||
opted_in INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corrections_product
|
|
||||||
ON corrections (product);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_corrections_opted_in
|
|
||||||
ON corrections (opted_in);
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Migration 041: user_recipes table for user-scanned and manually-entered recipes.
|
|
||||||
--
|
|
||||||
-- Separate from the food.com corpus (recipes table) -- user recipes are personal,
|
|
||||||
-- not curated, and need different fields (servings as string, cook_time as string).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_recipes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
subtitle TEXT,
|
|
||||||
servings TEXT, -- kept as string: "2", "4-6", "serves 8"
|
|
||||||
cook_time TEXT, -- kept as string: "25 min", "1 hour"
|
|
||||||
source_note TEXT, -- e.g. "Purple Carrot", "Betty Crocker"
|
|
||||||
ingredients TEXT NOT NULL DEFAULT '[]', -- JSON: [{name, qty, unit, raw}]
|
|
||||||
steps TEXT NOT NULL DEFAULT '[]', -- JSON: ["step 1", "step 2", ...]
|
|
||||||
notes TEXT,
|
|
||||||
tags TEXT DEFAULT '[]', -- JSON: ["vegan", "quick"]
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual', -- 'scan' | 'manual'
|
|
||||||
pantry_match_pct INTEGER, -- 0-100, computed at scan time; null for manual
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_recipes_created ON user_recipes (created_at DESC);
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
-- 042_activitypub.sql
|
|
||||||
-- ActivityPub federation tables: follower registry, delivery log, dedup, Mastodon tokens.
|
|
||||||
|
|
||||||
-- Follower registry: AP actors that Follow this Kiwi instance
|
|
||||||
CREATE TABLE IF NOT EXISTS ap_followers (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
actor_id TEXT NOT NULL UNIQUE, -- AP actor URL
|
|
||||||
inbox_url TEXT NOT NULL,
|
|
||||||
shared_inbox TEXT,
|
|
||||||
followed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
active INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_followers_active
|
|
||||||
ON ap_followers (active) WHERE active = 1;
|
|
||||||
|
|
||||||
-- Outgoing delivery log: one row per (post_slug, target_inbox) attempt
|
|
||||||
CREATE TABLE IF NOT EXISTS ap_deliveries (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
post_slug TEXT NOT NULL,
|
|
||||||
target_inbox TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | failed
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
delivered_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ap_deliveries_status
|
|
||||||
ON ap_deliveries (status) WHERE status != 'delivered';
|
|
||||||
|
|
||||||
-- Incoming activity dedup: prevents replay attacks and double-processing
|
|
||||||
CREATE TABLE IF NOT EXISTS ap_received (
|
|
||||||
activity_id TEXT PRIMARY KEY,
|
|
||||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Mastodon OAuth tokens: per-user, encrypted at rest
|
|
||||||
-- Stored in the user's local kiwi.db (CLOUD_MODE: per-user DB tree)
|
|
||||||
CREATE TABLE IF NOT EXISTS mastodon_tokens (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
directus_user_id TEXT NOT NULL UNIQUE,
|
|
||||||
instance_url TEXT NOT NULL,
|
|
||||||
access_token TEXT NOT NULL, -- Fernet-encrypted when AP_TOKEN_ENCRYPTION_KEY set
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
@ -6,8 +6,6 @@ Cloud mode: opens a Store at the per-user DB path from the CloudUser session.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
@ -23,16 +21,3 @@ def get_store(session: CloudUser = Depends(get_session)) -> Generator[Store, Non
|
||||||
yield store
|
yield store
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
def get_db(session: CloudUser = Depends(get_session)) -> Iterator[sqlite3.Connection]:
|
|
||||||
"""FastAPI dependency — yields the raw sqlite3.Connection for the current user.
|
|
||||||
|
|
||||||
Used by make_corrections_router() from circuitforge-core, which expects a
|
|
||||||
dependency that yields a sqlite3.Connection directly.
|
|
||||||
"""
|
|
||||||
store = Store(session.db)
|
|
||||||
try:
|
|
||||||
yield store.conn
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
962
app/db/store.py
962
app/db/store.py
File diff suppressed because it is too large
Load diff
72
app/main.py
72
app/main.py
|
|
@ -1,9 +1,7 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# app/main.py
|
# app/main.py
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -11,83 +9,20 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.meal_plan.affiliates import register_kiwi_programs
|
|
||||||
|
|
||||||
# Structured key=value log lines — grep/awk-friendly for log-based analytics.
|
|
||||||
# Without basicConfig, app-level INFO logs are silently dropped.
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BROWSE_REFRESH_INTERVAL_H = 24
|
|
||||||
|
|
||||||
|
|
||||||
async def _browse_counts_refresh_loop(corpus_path: str) -> None:
|
|
||||||
"""Refresh browse counts every 24 h while the container is running."""
|
|
||||||
from app.db.store import _COUNT_CACHE
|
|
||||||
from app.services.recipe.browse_counts_cache import load_into_memory, refresh
|
|
||||||
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(_BROWSE_REFRESH_INTERVAL_H * 3600)
|
|
||||||
try:
|
|
||||||
logger.info("browse_counts: starting scheduled refresh...")
|
|
||||||
computed = await asyncio.to_thread(
|
|
||||||
refresh, corpus_path, settings.BROWSE_COUNTS_PATH
|
|
||||||
)
|
|
||||||
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
|
||||||
logger.info("browse_counts: scheduled refresh complete (%d sets)", computed)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: scheduled refresh failed: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting Kiwi API...")
|
logger.info("Starting Kiwi API...")
|
||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
|
|
||||||
# Run DB migrations at startup (ensures all tables exist before any request)
|
|
||||||
from app.db.store import Store
|
|
||||||
_s = Store(settings.DB_PATH)
|
|
||||||
_s.close()
|
|
||||||
register_kiwi_programs()
|
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
from app.tasks.scheduler import get_scheduler
|
from app.tasks.scheduler import get_scheduler
|
||||||
get_scheduler(settings.DB_PATH)
|
get_scheduler(settings.DB_PATH)
|
||||||
logger.info("Task scheduler started.")
|
logger.info("Task scheduler started.")
|
||||||
|
|
||||||
# Initialize community store (no-op if COMMUNITY_DB_URL is not set)
|
|
||||||
from app.api.endpoints.community import init_community_store
|
|
||||||
init_community_store(settings.COMMUNITY_DB_URL)
|
|
||||||
|
|
||||||
# Initialize ActivityPub instance actor (no-op when AP_ENABLED=false)
|
|
||||||
if settings.AP_ENABLED and settings.AP_HOST:
|
|
||||||
try:
|
|
||||||
from app.services.ap.keys import init_actor
|
|
||||||
init_actor(host=settings.AP_HOST, key_path=settings.AP_KEY_PATH)
|
|
||||||
except Exception as _ap_exc:
|
|
||||||
logger.warning("AP init failed (AP features disabled): %s", _ap_exc)
|
|
||||||
|
|
||||||
# Browse counts cache — warm in-memory cache from disk, refresh if stale.
|
|
||||||
# Uses the corpus path the store will attach to at request time.
|
|
||||||
corpus_path = os.environ.get("RECIPE_DB_PATH", str(settings.DB_PATH))
|
|
||||||
try:
|
|
||||||
from app.db.store import _COUNT_CACHE
|
|
||||||
from app.services.recipe.browse_counts_cache import (
|
|
||||||
is_stale, load_into_memory, refresh,
|
|
||||||
)
|
|
||||||
if is_stale(settings.BROWSE_COUNTS_PATH):
|
|
||||||
logger.info("browse_counts: cache stale — refreshing in background...")
|
|
||||||
asyncio.create_task(
|
|
||||||
asyncio.to_thread(refresh, corpus_path, settings.BROWSE_COUNTS_PATH)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: startup init failed (live FTS fallback active): %s", exc)
|
|
||||||
|
|
||||||
# Nightly background refresh loop
|
|
||||||
asyncio.create_task(_browse_counts_refresh_loop(corpus_path))
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Graceful scheduler shutdown
|
# Graceful scheduler shutdown
|
||||||
|
|
@ -100,7 +35,7 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
description="Pantry tracking + leftover recipe suggestions",
|
description="Pantry tracking + leftover recipe suggestions",
|
||||||
version="0.2.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -114,11 +49,6 @@ app.add_middleware(
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.API_PREFIX)
|
app.include_router(api_router, prefix=settings.API_PREFIX)
|
||||||
|
|
||||||
# AP endpoints: WebFinger at root (not under /api/v1), AP objects under /ap
|
|
||||||
from app.api.endpoints.activitypub import ap_router, webfinger_router
|
|
||||||
app.include_router(webfinger_router)
|
|
||||||
app.include_router(ap_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
"""Kiwi MCP Server — read-only corpus DB access for tag/keyword audits.
|
|
||||||
|
|
||||||
Exposes four tools to Claude:
|
|
||||||
kiwi_query_corpus — run a read-only SQL query against the corpus DB
|
|
||||||
kiwi_count_fts — run an FTS5 MATCH expression and return row count
|
|
||||||
kiwi_sample_tags — return tag frequency distribution by prefix
|
|
||||||
kiwi_browse_preview — call the browse endpoint and return first-page results
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
python -m app.mcp.server
|
|
||||||
(from /Library/Development/CircuitForge/kiwi with cf conda env active)
|
|
||||||
|
|
||||||
Configure in Claude Code ~/.claude/settings.json mcpServers:
|
|
||||||
"kiwi": {
|
|
||||||
"command": "/devl/miniconda3/envs/cf/bin/python",
|
|
||||||
"args": ["-m", "app.mcp.server"],
|
|
||||||
"cwd": "/Library/Development/CircuitForge/kiwi",
|
|
||||||
"env": {
|
|
||||||
"KIWI_DB_PATH": "/Library/Development/CircuitForge/kiwi/data/kiwi.db",
|
|
||||||
"KIWI_API_URL": "http://localhost:8512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from mcp.server import Server
|
|
||||||
from mcp.server.stdio import stdio_server
|
|
||||||
from mcp.types import TextContent, Tool
|
|
||||||
|
|
||||||
_DB_PATH = os.environ.get(
|
|
||||||
"KIWI_DB_PATH",
|
|
||||||
str(Path(__file__).parents[3] / "data" / "kiwi.db"),
|
|
||||||
)
|
|
||||||
_API_URL = os.environ.get("KIWI_API_URL", "http://localhost:8512")
|
|
||||||
_TIMEOUT = 30.0
|
|
||||||
_QUERY_ROW_LIMIT = 200
|
|
||||||
|
|
||||||
server = Server("kiwi")
|
|
||||||
|
|
||||||
|
|
||||||
def _open_ro() -> sqlite3.Connection:
|
|
||||||
"""Open the corpus DB in read-only mode."""
|
|
||||||
uri = f"file:///{Path(_DB_PATH).as_posix()}?mode=ro"
|
|
||||||
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
@server.list_tools()
|
|
||||||
async def list_tools() -> list[Tool]:
|
|
||||||
return [
|
|
||||||
Tool(
|
|
||||||
name="kiwi_query_corpus",
|
|
||||||
description=(
|
|
||||||
"Run a read-only SQL SELECT query against the Kiwi corpus DB (kiwi.db). "
|
|
||||||
"Returns up to 200 rows as a JSON array. "
|
|
||||||
"Key tables: recipes (id, title, ingredient_names, inferred_tags, source_url), "
|
|
||||||
"recipes_fts (FTS5 virtual table for full-text search), "
|
|
||||||
"ingredient_profiles (name, elements, texture_profile). "
|
|
||||||
"Use for schema exploration, spot-checking tag coverage, and counting results. "
|
|
||||||
"Read-only — any write statement will be rejected by SQLite."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["sql"],
|
|
||||||
"properties": {
|
|
||||||
"sql": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"A SELECT statement. E.g.: "
|
|
||||||
"SELECT title, inferred_tags FROM recipes WHERE inferred_tags LIKE '%vegan%' LIMIT 10"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="kiwi_count_fts",
|
|
||||||
description=(
|
|
||||||
"Run an FTS5 MATCH expression against the recipes_fts table and return the hit count. "
|
|
||||||
"Useful for quickly auditing keyword coverage without a full query. "
|
|
||||||
"Always double-quote all terms in MATCH expressions. "
|
|
||||||
"E.g. match_expr='\"tofu\" OR \"tempeh\"' returns how many recipes include either."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["match_expr"],
|
|
||||||
"properties": {
|
|
||||||
"match_expr": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"FTS5 MATCH expression string (without the MATCH keyword). "
|
|
||||||
'E.g. \'"lentil" OR "chickpea"\' or \'"pasta" AND "vegetarian"\''
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="kiwi_sample_tags",
|
|
||||||
description=(
|
|
||||||
"Return tag frequency distribution from the corpus. "
|
|
||||||
"Queries inferred_tags column for tags matching the given prefix pattern. "
|
|
||||||
"Useful for auditing how well a category keyword set covers the corpus, "
|
|
||||||
"or discovering what tags exist under a domain (cuisine:, meal:, dietary:, texture:)."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"prefix": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": (
|
|
||||||
"Tag prefix to filter by. E.g. 'cuisine:' returns all cuisine tags, "
|
|
||||||
"'meal:' returns all meal type tags, '' returns all tags. "
|
|
||||||
"Returns top 50 by frequency."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"limit": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 50,
|
|
||||||
"description": "Max number of tag entries to return (default 50, max 200).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="kiwi_browse_preview",
|
|
||||||
description=(
|
|
||||||
"Call the Kiwi browse endpoint and return first-page results. "
|
|
||||||
"Use to verify that a domain/category returns the expected recipes "
|
|
||||||
"after a keyword or tag change, without opening the browser. "
|
|
||||||
"Returns recipe titles, match counts, and total result count."
|
|
||||||
),
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["domain", "category"],
|
|
||||||
"properties": {
|
|
||||||
"domain": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"Browse domain slug. "
|
|
||||||
"Known domains: cuisine, meal_type, dietary, ingredient, occasion, texture."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Category slug within the domain, e.g. 'italian', 'breakfast', 'vegan'.",
|
|
||||||
},
|
|
||||||
"subcategory": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Optional subcategory slug to narrow further.",
|
|
||||||
},
|
|
||||||
"page_size": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 10,
|
|
||||||
"description": "Results per page (default 10, max 50).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@server.call_tool()
|
|
||||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
||||||
if name == "kiwi_query_corpus":
|
|
||||||
return await _query_corpus(arguments)
|
|
||||||
if name == "kiwi_count_fts":
|
|
||||||
return await _count_fts(arguments)
|
|
||||||
if name == "kiwi_sample_tags":
|
|
||||||
return await _sample_tags(arguments)
|
|
||||||
if name == "kiwi_browse_preview":
|
|
||||||
return await _browse_preview(arguments)
|
|
||||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_corpus(args: dict) -> list[TextContent]:
|
|
||||||
sql = args.get("sql", "").strip()
|
|
||||||
if not sql.upper().startswith("SELECT"):
|
|
||||||
return [TextContent(type="text", text="Error: only SELECT statements are allowed.")]
|
|
||||||
|
|
||||||
def _run() -> list[dict]:
|
|
||||||
conn = _open_ro()
|
|
||||||
try:
|
|
||||||
cur = conn.execute(sql)
|
|
||||||
rows = cur.fetchmany(_QUERY_ROW_LIMIT)
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = await asyncio.get_event_loop().run_in_executor(None, _run)
|
|
||||||
return [TextContent(type="text", text=json.dumps(rows, indent=2, default=str))]
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"Query error: {exc}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _count_fts(args: dict) -> list[TextContent]:
|
|
||||||
match_expr = args.get("match_expr", "").strip()
|
|
||||||
if not match_expr:
|
|
||||||
return [TextContent(type="text", text="Error: match_expr is required.")]
|
|
||||||
|
|
||||||
def _run() -> int:
|
|
||||||
conn = _open_ro()
|
|
||||||
try:
|
|
||||||
cur = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM recipes_fts WHERE recipes_fts MATCH ?",
|
|
||||||
(match_expr,),
|
|
||||||
)
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
count = await asyncio.get_event_loop().run_in_executor(None, _run)
|
|
||||||
return [TextContent(type="text", text=json.dumps({"match_expr": match_expr, "count": count}))]
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"FTS error: {exc}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _sample_tags(args: dict) -> list[TextContent]:
|
|
||||||
prefix = args.get("prefix", "")
|
|
||||||
limit = min(int(args.get("limit", 50)), _QUERY_ROW_LIMIT)
|
|
||||||
|
|
||||||
def _run() -> list[dict]:
|
|
||||||
conn = _open_ro()
|
|
||||||
try:
|
|
||||||
# Split inferred_tags (comma or space separated) and count each tag
|
|
||||||
sql = """
|
|
||||||
WITH tag_rows AS (
|
|
||||||
SELECT trim(value) AS tag
|
|
||||||
FROM recipes, json_each('["' || replace(replace(inferred_tags, ', ', '","'), ',', '","') || '"]')
|
|
||||||
WHERE inferred_tags IS NOT NULL AND inferred_tags != ''
|
|
||||||
)
|
|
||||||
SELECT tag, COUNT(*) AS frequency
|
|
||||||
FROM tag_rows
|
|
||||||
WHERE tag LIKE ? AND tag != ''
|
|
||||||
GROUP BY tag
|
|
||||||
ORDER BY frequency DESC
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
|
||||||
pattern = f"{prefix}%" if prefix else "%"
|
|
||||||
cur = conn.execute(sql, (pattern, limit))
|
|
||||||
return [{"tag": r["tag"], "frequency": r["frequency"]} for r in cur.fetchall()]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
tags = await asyncio.get_event_loop().run_in_executor(None, _run)
|
|
||||||
return [TextContent(type="text", text=json.dumps({"prefix": prefix, "tags": tags}, indent=2))]
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"Tag query error: {exc}")]
|
|
||||||
|
|
||||||
|
|
||||||
async def _browse_preview(args: dict) -> list[TextContent]:
|
|
||||||
domain = args.get("domain", "")
|
|
||||||
category = args.get("category", "")
|
|
||||||
subcategory = args.get("subcategory", "")
|
|
||||||
page_size = min(int(args.get("page_size", 10)), 50)
|
|
||||||
|
|
||||||
params: dict = {"page": 1, "page_size": page_size}
|
|
||||||
if subcategory:
|
|
||||||
params["subcategory"] = subcategory
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
||||||
try:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{_API_URL}/api/v1/recipes/browse/{domain}/{category}",
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception as exc:
|
|
||||||
return [TextContent(type="text", text=f"Browse error: {exc}")]
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
summary = {
|
|
||||||
"domain": domain,
|
|
||||||
"category": category,
|
|
||||||
"subcategory": subcategory or None,
|
|
||||||
"total": data.get("total", 0),
|
|
||||||
"page_size": page_size,
|
|
||||||
"titles": [r.get("title", "") for r in data.get("recipes", [])],
|
|
||||||
}
|
|
||||||
return [TextContent(type="text", text=json.dumps(summary, indent=2))]
|
|
||||||
|
|
||||||
|
|
||||||
async def _main() -> None:
|
|
||||||
async with stdio_server() as (read_stream, write_stream):
|
|
||||||
await server.run(
|
|
||||||
read_stream,
|
|
||||||
write_stream,
|
|
||||||
server.create_initialization_options(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(_main())
|
|
||||||
|
|
@ -89,20 +89,9 @@ class InventoryItemUpdate(BaseModel):
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
sublocation: Optional[str] = None
|
sublocation: Optional[str] = None
|
||||||
purchase_date: Optional[date] = None
|
|
||||||
expiration_date: Optional[date] = None
|
expiration_date: Optional[date] = None
|
||||||
opened_date: Optional[date] = None
|
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
disposal_reason: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PartialConsumeRequest(BaseModel):
|
|
||||||
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
|
|
||||||
|
|
||||||
|
|
||||||
class DiscardRequest(BaseModel):
|
|
||||||
reason: Optional[str] = Field(None, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemResponse(BaseModel):
|
class InventoryItemResponse(BaseModel):
|
||||||
|
|
@ -117,15 +106,8 @@ class InventoryItemResponse(BaseModel):
|
||||||
sublocation: Optional[str]
|
sublocation: Optional[str]
|
||||||
purchase_date: Optional[str]
|
purchase_date: Optional[str]
|
||||||
expiration_date: Optional[str]
|
expiration_date: Optional[str]
|
||||||
opened_date: Optional[str] = None
|
|
||||||
opened_expiry_date: Optional[str] = None
|
|
||||||
secondary_state: Optional[str] = None
|
|
||||||
secondary_uses: Optional[List[str]] = None
|
|
||||||
secondary_warning: Optional[str] = None
|
|
||||||
secondary_discard_signs: Optional[str] = None
|
|
||||||
status: str
|
status: str
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
disposal_reason: Optional[str] = None
|
|
||||||
source: str
|
source: str
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
@ -141,8 +123,6 @@ class BarcodeScanResult(BaseModel):
|
||||||
product: Optional[ProductResponse]
|
product: Optional[ProductResponse]
|
||||||
inventory_item: Optional[InventoryItemResponse]
|
inventory_item: Optional[InventoryItemResponse]
|
||||||
added_to_inventory: bool
|
added_to_inventory: bool
|
||||||
needs_manual_entry: bool = False
|
|
||||||
needs_visual_capture: bool = False # Paid tier offer when no product data found
|
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"""Pydantic schemas for visual label capture (kiwi#79)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class LabelCaptureResponse(BaseModel):
|
|
||||||
"""Extraction result returned after the user photographs a nutrition label."""
|
|
||||||
barcode: str
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
brand: Optional[str] = None
|
|
||||||
serving_size_g: Optional[float] = None
|
|
||||||
calories: Optional[float] = None
|
|
||||||
fat_g: Optional[float] = None
|
|
||||||
saturated_fat_g: Optional[float] = None
|
|
||||||
carbs_g: Optional[float] = None
|
|
||||||
sugar_g: Optional[float] = None
|
|
||||||
fiber_g: Optional[float] = None
|
|
||||||
protein_g: Optional[float] = None
|
|
||||||
sodium_mg: Optional[float] = None
|
|
||||||
ingredient_names: List[str] = Field(default_factory=list)
|
|
||||||
allergens: List[str] = Field(default_factory=list)
|
|
||||||
confidence: float = 0.0
|
|
||||||
needs_review: bool = True # True when confidence < REVIEW_THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
class LabelConfirmRequest(BaseModel):
|
|
||||||
"""User-confirmed extraction to save to the local product cache."""
|
|
||||||
barcode: str
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
brand: Optional[str] = None
|
|
||||||
serving_size_g: Optional[float] = None
|
|
||||||
calories: Optional[float] = None
|
|
||||||
fat_g: Optional[float] = None
|
|
||||||
saturated_fat_g: Optional[float] = None
|
|
||||||
carbs_g: Optional[float] = None
|
|
||||||
sugar_g: Optional[float] = None
|
|
||||||
fiber_g: Optional[float] = None
|
|
||||||
protein_g: Optional[float] = None
|
|
||||||
sodium_mg: Optional[float] = None
|
|
||||||
ingredient_names: List[str] = Field(default_factory=list)
|
|
||||||
allergens: List[str] = Field(default_factory=list)
|
|
||||||
confidence: float = 0.0
|
|
||||||
# When True the confirmed product is also added to inventory
|
|
||||||
location: str = "pantry"
|
|
||||||
quantity: float = 1.0
|
|
||||||
auto_add: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class LabelConfirmResponse(BaseModel):
|
|
||||||
"""Result of confirming a captured product."""
|
|
||||||
ok: bool
|
|
||||||
barcode: str
|
|
||||||
product_id: Optional[int] = None
|
|
||||||
inventory_item_id: Optional[int] = None
|
|
||||||
message: str
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# app/models/schemas/meal_plan.py
|
|
||||||
"""Pydantic schemas for meal planning endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date as _date
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
|
|
||||||
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePlanRequest(BaseModel):
|
|
||||||
week_start: _date
|
|
||||||
meal_types: list[str] = Field(default_factory=lambda: ["dinner"])
|
|
||||||
|
|
||||||
@field_validator("week_start")
|
|
||||||
@classmethod
|
|
||||||
def must_be_monday(cls, v: _date) -> _date:
|
|
||||||
if v.weekday() != 0:
|
|
||||||
raise ValueError("week_start must be a Monday (weekday 0)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePlanRequest(BaseModel):
|
|
||||||
meal_types: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class UpsertSlotRequest(BaseModel):
|
|
||||||
recipe_id: int | None = None
|
|
||||||
servings: float = Field(2.0, gt=0)
|
|
||||||
custom_label: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SlotSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
plan_id: int
|
|
||||||
day_of_week: int
|
|
||||||
meal_type: str
|
|
||||||
recipe_id: int | None
|
|
||||||
recipe_title: str | None
|
|
||||||
servings: float
|
|
||||||
custom_label: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class PlanSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
week_start: str
|
|
||||||
meal_types: list[str]
|
|
||||||
slots: list[SlotSummary]
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class RetailerLink(BaseModel):
|
|
||||||
retailer: str
|
|
||||||
label: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class GapItem(BaseModel):
|
|
||||||
ingredient_name: str
|
|
||||||
needed_raw: str | None # e.g. "2 cups" from recipe text
|
|
||||||
have_quantity: float | None # from pantry
|
|
||||||
have_unit: str | None
|
|
||||||
covered: bool # True = pantry has it
|
|
||||||
retailer_links: list[RetailerLink] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListResponse(BaseModel):
|
|
||||||
plan_id: int
|
|
||||||
gap_items: list[GapItem]
|
|
||||||
covered_items: list[GapItem]
|
|
||||||
disclosure: str | None = None # affiliate disclosure text when links present
|
|
||||||
|
|
||||||
|
|
||||||
class PrepTaskSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
recipe_id: int | None
|
|
||||||
task_label: str
|
|
||||||
duration_minutes: int | None
|
|
||||||
sequence_order: int
|
|
||||||
equipment: str | None
|
|
||||||
is_parallel: bool
|
|
||||||
notes: str | None
|
|
||||||
user_edited: bool
|
|
||||||
|
|
||||||
|
|
||||||
class PrepSessionSummary(BaseModel):
|
|
||||||
id: int
|
|
||||||
plan_id: int
|
|
||||||
scheduled_date: str
|
|
||||||
status: str
|
|
||||||
tasks: list[PrepTaskSummary]
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePrepTaskRequest(BaseModel):
|
|
||||||
duration_minutes: int | None = None
|
|
||||||
sequence_order: int | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
equipment: str | None = None
|
|
||||||
|
|
@ -4,36 +4,6 @@ from __future__ import annotations
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class LeftoversResponse(BaseModel):
|
|
||||||
"""Cooked-leftover shelf-life estimate returned by POST /recipes/{id}/leftovers."""
|
|
||||||
fridge_days: int
|
|
||||||
freeze_days: int | None = None # None = not recommended
|
|
||||||
freeze_by_day: int | None = None # day number from cook date to freeze by
|
|
||||||
storage_advice: str
|
|
||||||
|
|
||||||
|
|
||||||
class StepAnalysis(BaseModel):
|
|
||||||
"""Active/passive classification for one direction step."""
|
|
||||||
is_passive: bool
|
|
||||||
detected_minutes: int | None = None
|
|
||||||
prep_min: int | None = None # estimated physical prep time (action detection)
|
|
||||||
|
|
||||||
|
|
||||||
class TimeEffortProfile(BaseModel):
|
|
||||||
"""Parsed time and effort profile for a recipe.
|
|
||||||
|
|
||||||
Mirrors app.services.recipe.time_effort.TimeEffortProfile (dataclass).
|
|
||||||
Serialised into RecipeSuggestion so the frontend can render the effort
|
|
||||||
summary without a second round-trip.
|
|
||||||
"""
|
|
||||||
active_min: int = 0
|
|
||||||
passive_min: int = 0
|
|
||||||
total_min: int = 0
|
|
||||||
effort_label: str = "moderate" # "quick" | "moderate" | "involved"
|
|
||||||
equipment: list[str] = Field(default_factory=list)
|
|
||||||
step_analyses: list[StepAnalysis] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class SwapCandidate(BaseModel):
|
class SwapCandidate(BaseModel):
|
||||||
original_name: str
|
original_name: str
|
||||||
substitute_name: str
|
substitute_name: str
|
||||||
|
|
@ -71,10 +41,6 @@ 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):
|
||||||
|
|
@ -90,19 +56,6 @@ class RecipeResult(BaseModel):
|
||||||
grocery_links: list[GroceryLink] = Field(default_factory=list)
|
grocery_links: list[GroceryLink] = Field(default_factory=list)
|
||||||
rate_limited: bool = False
|
rate_limited: bool = False
|
||||||
rate_limit_count: int = 0
|
rate_limit_count: int = 0
|
||||||
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeJobQueued(BaseModel):
|
|
||||||
job_id: str
|
|
||||||
status: str = "queued"
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeJobStatus(BaseModel):
|
|
||||||
job_id: str
|
|
||||||
status: str
|
|
||||||
result: RecipeResult | None = None
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionFilters(BaseModel):
|
class NutritionFilters(BaseModel):
|
||||||
|
|
@ -115,10 +68,6 @@ class NutritionFilters(BaseModel):
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
class RecipeRequest(BaseModel):
|
||||||
pantry_items: list[str]
|
pantry_items: list[str]
|
||||||
# Maps product name → secondary state label for items past nominal expiry
|
|
||||||
# but still within their secondary use window (e.g. {"Bread": "stale"}).
|
|
||||||
# Used by the recipe engine to boost recipes suited to those specific states.
|
|
||||||
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
|
|
||||||
level: int = Field(default=1, ge=1, le=4)
|
level: int = Field(default=1, ge=1, le=4)
|
||||||
constraints: list[str] = Field(default_factory=list)
|
constraints: list[str] = Field(default_factory=list)
|
||||||
expiry_first: bool = False
|
expiry_first: bool = False
|
||||||
|
|
@ -132,98 +81,4 @@ 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 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AssemblyRoleOut(BaseModel):
|
|
||||||
"""One role slot in a template, as returned by GET /api/recipes/templates."""
|
|
||||||
|
|
||||||
display: str
|
|
||||||
required: bool
|
|
||||||
keywords: list[str]
|
|
||||||
hint: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class AssemblyTemplateOut(BaseModel):
|
|
||||||
"""One assembly template, as returned by GET /api/recipes/templates."""
|
|
||||||
|
|
||||||
id: str # slug, e.g. "burrito_taco"
|
|
||||||
title: str
|
|
||||||
icon: str
|
|
||||||
descriptor: str
|
|
||||||
role_sequence: list[AssemblyRoleOut]
|
|
||||||
|
|
||||||
|
|
||||||
class RoleCandidateItem(BaseModel):
|
|
||||||
"""One candidate ingredient for a wizard picker step."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
in_pantry: bool
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class RoleCandidatesResponse(BaseModel):
|
|
||||||
"""Response from GET /api/recipes/template-candidates."""
|
|
||||||
|
|
||||||
compatible: list[RoleCandidateItem] = Field(default_factory=list)
|
|
||||||
other: list[RoleCandidateItem] = Field(default_factory=list)
|
|
||||||
available_tags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class BuildRequest(BaseModel):
|
|
||||||
"""Request body for POST /api/recipes/build."""
|
|
||||||
|
|
||||||
template_id: str
|
|
||||||
role_overrides: dict[str, str] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class StreamTokenRequest(BaseModel):
|
|
||||||
"""Request body for POST /recipes/stream-token.
|
|
||||||
|
|
||||||
Pantry items and dietary constraints are fetched from the DB at request
|
|
||||||
time — the client does not supply them here.
|
|
||||||
"""
|
|
||||||
level: int = Field(4, ge=3, le=4, description="Recipe level: 3=styled, 4=wildcard")
|
|
||||||
wildcard_confirmed: bool = Field(False, description="Required true for level 4")
|
|
||||||
|
|
||||||
|
|
||||||
class StreamTokenResponse(BaseModel):
|
|
||||||
"""Response from POST /recipes/stream-token.
|
|
||||||
|
|
||||||
The frontend opens EventSource at stream_url?token=<token> to receive
|
|
||||||
SSE chunks directly from the coordinator.
|
|
||||||
"""
|
|
||||||
stream_url: str
|
|
||||||
token: str
|
|
||||||
expires_in_s: int
|
|
||||||
|
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
|
||||||
"""Request body for POST /recipes/ask."""
|
|
||||||
question: str = Field(min_length=1, max_length=500)
|
|
||||||
pantry_items: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AskRecipeHit(BaseModel):
|
|
||||||
"""A single recipe result from the Ask endpoint."""
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
match_pct: float | None = None
|
|
||||||
category: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AskResponse(BaseModel):
|
|
||||||
"""Response from POST /recipes/ask."""
|
|
||||||
answer: str | None = None # LLM-synthesized response (Paid tier only)
|
|
||||||
recipes: list[AskRecipeHit]
|
|
||||||
tier: str
|
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
"""Pydantic schemas for the recipe scanner (kiwi#9).
|
|
||||||
|
|
||||||
Scan input → photo(s).
|
|
||||||
Scan output → ScannedRecipeResponse (for review + editing before save).
|
|
||||||
Save input → ScannedRecipeSaveRequest.
|
|
||||||
User recipe output → UserRecipeResponse (after save).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ScannedIngredientSchema(BaseModel):
|
|
||||||
"""One ingredient line extracted from a recipe photo."""
|
|
||||||
name: str # normalized generic name ("ranch dressing")
|
|
||||||
qty: str | None = None # quantity as string, preserving fractions ("1/2", "¼")
|
|
||||||
unit: str | None = None # unit of measure; null for countable items
|
|
||||||
raw: str | None = None # verbatim original line from the image
|
|
||||||
in_pantry: bool = False # True if this ingredient matches something in the pantry
|
|
||||||
|
|
||||||
|
|
||||||
# ── Scan response (returned immediately, not persisted) ───────────────────────
|
|
||||||
|
|
||||||
class ScannedRecipeResponse(BaseModel):
|
|
||||||
"""Structured recipe extracted from photo(s). Returned for user review before save."""
|
|
||||||
title: str | None = None
|
|
||||||
subtitle: str | None = None # e.g. "with Broccoli & Ranch Dressing"
|
|
||||||
servings: str | None = None # kept as string: "2", "4-6", "serves 8"
|
|
||||||
cook_time: str | None = None # kept as string: "25 min", "1 hour"
|
|
||||||
source_note: str | None = None # e.g. "Purple Carrot", "Betty Crocker"
|
|
||||||
ingredients: list[ScannedIngredientSchema] = Field(default_factory=list)
|
|
||||||
steps: list[str] = Field(default_factory=list)
|
|
||||||
notes: str | None = None
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
pantry_match_pct: int = 0 # 0-100: percentage of ingredients found in pantry
|
|
||||||
confidence: str = "medium" # "high" | "medium" | "low"
|
|
||||||
warnings: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Save request ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ScannedRecipeSaveRequest(BaseModel):
|
|
||||||
"""User-reviewed (possibly edited) recipe data to persist as a user recipe."""
|
|
||||||
title: str
|
|
||||||
subtitle: str | None = None
|
|
||||||
servings: str | None = None
|
|
||||||
cook_time: str | None = None
|
|
||||||
source_note: str | None = None
|
|
||||||
ingredients: list[ScannedIngredientSchema]
|
|
||||||
steps: list[str]
|
|
||||||
notes: str | None = None
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
source: str = "scan" # "scan" | "manual"
|
|
||||||
|
|
||||||
|
|
||||||
# ── User recipe (persisted) ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class UserRecipeResponse(BaseModel):
|
|
||||||
"""A user-created or user-scanned recipe stored in user_recipes table."""
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
subtitle: str | None = None
|
|
||||||
servings: str | None = None
|
|
||||||
cook_time: str | None = None
|
|
||||||
source_note: str | None = None
|
|
||||||
ingredients: list[ScannedIngredientSchema]
|
|
||||||
steps: list[str]
|
|
||||||
notes: str | None = None
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
source: str
|
|
||||||
pantry_match_pct: int | None = None
|
|
||||||
created_at: str
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""Pydantic schemas for the shopping list endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemCreate(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
|
||||||
quantity: Optional[float] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
source: str = "manual"
|
|
||||||
recipe_id: Optional[int] = None
|
|
||||||
sort_order: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemUpdate(BaseModel):
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
||||||
quantity: Optional[float] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
checked: Optional[bool] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkOut(BaseModel):
|
|
||||||
ingredient: str
|
|
||||||
retailer: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
quantity: Optional[float]
|
|
||||||
unit: Optional[str]
|
|
||||||
category: Optional[str]
|
|
||||||
checked: bool
|
|
||||||
notes: Optional[str]
|
|
||||||
source: str
|
|
||||||
recipe_id: Optional[int]
|
|
||||||
sort_order: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
grocery_links: list[GroceryLinkOut] = []
|
|
||||||
|
|
||||||
|
|
||||||
class BulkAddFromRecipeRequest(BaseModel):
|
|
||||||
recipe_id: int
|
|
||||||
include_covered: bool = False # if True, add pantry-covered items too
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmPurchaseRequest(BaseModel):
|
|
||||||
"""Move a checked item into pantry inventory."""
|
|
||||||
location: str = "pantry"
|
|
||||||
quantity: Optional[float] = None # override the list quantity
|
|
||||||
unit: Optional[str] = None
|
|
||||||
|
|
@ -3,11 +3,6 @@
|
||||||
Business logic services for Kiwi.
|
Business logic services for Kiwi.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.services.receipt_service import ReceiptService
|
||||||
|
|
||||||
__all__ = ["ReceiptService"]
|
__all__ = ["ReceiptService"]
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
|
||||||
if name == "ReceiptService":
|
|
||||||
from app.services.receipt_service import ReceiptService
|
|
||||||
return ReceiptService
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# app/services/ap/delivery.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from circuitforge_core.activitypub import deliver_activity
|
|
||||||
|
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_RETRIES = 3
|
|
||||||
_BACKOFF = [1.0, 4.0, 16.0]
|
|
||||||
|
|
||||||
|
|
||||||
def deliver_to_followers(post_slug: str, activity: dict, db_path: Path) -> None:
|
|
||||||
"""Deliver an AP activity to all active followers. Called as a background task.
|
|
||||||
|
|
||||||
Retries each inbox up to 3 times with exponential backoff.
|
|
||||||
Logs each attempt to ap_deliveries in the local kiwi.db.
|
|
||||||
"""
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
followers = conn.execute(
|
|
||||||
"SELECT inbox_url, shared_inbox FROM ap_followers WHERE active = 1"
|
|
||||||
).fetchall()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Deduplicate by shared_inbox where available
|
|
||||||
inboxes: set[str] = set()
|
|
||||||
for row in followers:
|
|
||||||
inbox = row["shared_inbox"] or row["inbox_url"]
|
|
||||||
inboxes.add(inbox)
|
|
||||||
|
|
||||||
for inbox_url in inboxes:
|
|
||||||
_deliver_with_retry(post_slug=post_slug, activity=activity, inbox_url=inbox_url, db_path=db_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _deliver_with_retry(
|
|
||||||
post_slug: str,
|
|
||||||
activity: dict,
|
|
||||||
inbox_url: str,
|
|
||||||
db_path: Path,
|
|
||||||
) -> None:
|
|
||||||
actor = get_actor()
|
|
||||||
if actor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO ap_deliveries (post_slug, target_inbox, status) VALUES (?,?,?)",
|
|
||||||
(post_slug, inbox_url, "pending"),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
last_error: str | None = None
|
|
||||||
for attempt, delay in enumerate(_BACKOFF[:_RETRIES]):
|
|
||||||
try:
|
|
||||||
resp = deliver_activity(activity=activity, inbox_url=inbox_url, actor=actor, timeout=10.0)
|
|
||||||
if resp.status_code < 300:
|
|
||||||
_update_delivery(db_path, post_slug, inbox_url, "delivered", None)
|
|
||||||
return
|
|
||||||
last_error = f"HTTP {resp.status_code}"
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = str(exc)[:200]
|
|
||||||
|
|
||||||
if attempt < _RETRIES - 1:
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
_update_delivery(db_path, post_slug, inbox_url, "failed", last_error)
|
|
||||||
logger.warning("AP delivery failed after %d attempts to %s: %s", _RETRIES, inbox_url, last_error)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_delivery(
|
|
||||||
db_path: Path,
|
|
||||||
post_slug: str,
|
|
||||||
inbox_url: str,
|
|
||||||
status: str,
|
|
||||||
error: str | None,
|
|
||||||
) -> None:
|
|
||||||
import sqlite3
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
if status == "delivered":
|
|
||||||
conn.execute(
|
|
||||||
"""UPDATE ap_deliveries SET status=?, attempts=attempts+1, delivered_at=?
|
|
||||||
WHERE post_slug=? AND target_inbox=?""",
|
|
||||||
(status, now, post_slug, inbox_url),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
"""UPDATE ap_deliveries SET status=?, attempts=attempts+1, last_error=?
|
|
||||||
WHERE post_slug=? AND target_inbox=?""",
|
|
||||||
(status, error, post_slug, inbox_url),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# app/services/ap/keys.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from circuitforge_core.activitypub import CFActor, generate_rsa_keypair, load_actor_from_key_file
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_actor: CFActor | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_actor() -> CFActor | None:
|
|
||||||
"""Return the loaded instance actor, or None if AP is not enabled."""
|
|
||||||
return _actor
|
|
||||||
|
|
||||||
|
|
||||||
def init_actor(host: str, key_path: Path) -> CFActor:
|
|
||||||
"""Load or generate the instance RSA keypair and build the CFActor singleton.
|
|
||||||
|
|
||||||
Called once at startup when AP_ENABLED=true. Generates a new 2048-bit keypair
|
|
||||||
if the key file does not yet exist (first boot).
|
|
||||||
"""
|
|
||||||
global _actor
|
|
||||||
|
|
||||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if not key_path.exists():
|
|
||||||
logger.info("AP: no key file found at %s — generating new RSA-2048 keypair", key_path)
|
|
||||||
private_pem, _pub = generate_rsa_keypair(bits=2048)
|
|
||||||
key_path.write_text(private_pem, encoding="utf-8")
|
|
||||||
key_path.chmod(0o600)
|
|
||||||
|
|
||||||
base = f"https://{host}"
|
|
||||||
actor_id = f"{base}/ap/actor"
|
|
||||||
|
|
||||||
_actor = load_actor_from_key_file(
|
|
||||||
actor_id=actor_id,
|
|
||||||
username="kiwi",
|
|
||||||
display_name="Kiwi Pantry",
|
|
||||||
private_key_path=str(key_path),
|
|
||||||
summary="Community pantry and recipe feed from a Kiwi instance.",
|
|
||||||
)
|
|
||||||
logger.info("AP: instance actor loaded — %s", actor_id)
|
|
||||||
return _actor
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
# app/services/ap/mastodon.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_APP_SCOPES = "write:statuses"
|
|
||||||
_APP_NAME = "Kiwi Pantry"
|
|
||||||
_APP_WEBSITE = "https://circuitforge.tech/kiwi"
|
|
||||||
|
|
||||||
|
|
||||||
def register_app(instance_url: str, redirect_uri: str) -> dict:
|
|
||||||
"""Dynamically register Kiwi as an OAuth app on the user's Mastodon instance.
|
|
||||||
|
|
||||||
Returns the app credentials dict (client_id, client_secret, etc.).
|
|
||||||
Raises httpx.HTTPError on failure.
|
|
||||||
"""
|
|
||||||
url = instance_url.rstrip("/") + "/api/v1/apps"
|
|
||||||
resp = httpx.post(
|
|
||||||
url,
|
|
||||||
data={
|
|
||||||
"client_name": _APP_NAME,
|
|
||||||
"redirect_uris": redirect_uri,
|
|
||||||
"scopes": _APP_SCOPES,
|
|
||||||
"website": _APP_WEBSITE,
|
|
||||||
},
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def build_authorize_url(instance_url: str, client_id: str, redirect_uri: str) -> str:
|
|
||||||
"""Return the OAuth authorize URL to redirect the user to."""
|
|
||||||
return (
|
|
||||||
f"{instance_url.rstrip('/')}/oauth/authorize"
|
|
||||||
f"?response_type=code"
|
|
||||||
f"&client_id={client_id}"
|
|
||||||
f"&redirect_uri={redirect_uri}"
|
|
||||||
f"&scope={_APP_SCOPES}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def exchange_code(
|
|
||||||
instance_url: str,
|
|
||||||
client_id: str,
|
|
||||||
client_secret: str,
|
|
||||||
code: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
) -> str:
|
|
||||||
"""Exchange an authorization code for an access token. Returns the token string."""
|
|
||||||
url = instance_url.rstrip("/") + "/oauth/token"
|
|
||||||
resp = httpx.post(
|
|
||||||
url,
|
|
||||||
data={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"code": code,
|
|
||||||
"scope": _APP_SCOPES,
|
|
||||||
},
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
def post_status(instance_url: str, access_token: str, content: str) -> dict:
|
|
||||||
"""Post a status to the user's Mastodon account. Returns the status response dict."""
|
|
||||||
url = instance_url.rstrip("/") + "/api/v1/statuses"
|
|
||||||
resp = httpx.post(
|
|
||||||
url,
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
json={"status": content, "visibility": "public"},
|
|
||||||
timeout=15.0,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def build_post_content(post: dict) -> str:
|
|
||||||
"""Format a community post dict as Mastodon-ready plain text."""
|
|
||||||
title = post.get("title") or "Untitled"
|
|
||||||
recipe = post.get("recipe_name")
|
|
||||||
notes = post.get("outcome_notes") or post.get("description")
|
|
||||||
tags_raw: list[str] = post.get("dietary_tags") or []
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
if recipe and recipe != title:
|
|
||||||
lines.append(f"🍽 {title} — {recipe}")
|
|
||||||
else:
|
|
||||||
lines.append(f"🍽 {title}")
|
|
||||||
|
|
||||||
if notes:
|
|
||||||
snippet = notes[:200].strip()
|
|
||||||
if len(notes) > 200:
|
|
||||||
snippet += "…"
|
|
||||||
lines.append(f"\n{snippet}")
|
|
||||||
|
|
||||||
hashtags = ["#Kiwi", "#Cooking"]
|
|
||||||
for tag in tags_raw[:3]:
|
|
||||||
ht = "#" + "".join(w.capitalize() for w in tag.replace("-", " ").split())
|
|
||||||
hashtags.append(ht)
|
|
||||||
lines.append("\n" + " ".join(hashtags))
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def store_token(
|
|
||||||
db_path: Path,
|
|
||||||
directus_user_id: str,
|
|
||||||
instance_url: str,
|
|
||||||
access_token: str,
|
|
||||||
encryption_key: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Persist a Mastodon access token in the user's local kiwi.db."""
|
|
||||||
token_to_store = _encrypt(access_token, encryption_key)
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO mastodon_tokens (directus_user_id, instance_url, access_token)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(directus_user_id) DO UPDATE SET
|
|
||||||
instance_url=excluded.instance_url,
|
|
||||||
access_token=excluded.access_token,
|
|
||||||
updated_at=datetime('now')""",
|
|
||||||
(directus_user_id, instance_url.rstrip("/"), token_to_store),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_token(
|
|
||||||
db_path: Path,
|
|
||||||
directus_user_id: str,
|
|
||||||
encryption_key: str | None,
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
"""Return (instance_url, plaintext_access_token) or None if not connected."""
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT instance_url, access_token FROM mastodon_tokens WHERE directus_user_id = ?",
|
|
||||||
(directus_user_id,),
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row[0], _decrypt(row[1], encryption_key)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_token(db_path: Path, directus_user_id: str) -> None:
|
|
||||||
"""Remove the user's stored Mastodon token."""
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM mastodon_tokens WHERE directus_user_id = ?", (directus_user_id,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _encrypt(plaintext: str, key: str | None) -> str:
|
|
||||||
if key is None:
|
|
||||||
return plaintext
|
|
||||||
try:
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
return Fernet(key.encode()).encrypt(plaintext.encode()).decode()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Mastodon token encryption failed — storing plaintext")
|
|
||||||
return plaintext
|
|
||||||
|
|
||||||
|
|
||||||
def _decrypt(ciphertext: str, key: str | None) -> str:
|
|
||||||
if key is None:
|
|
||||||
return ciphertext
|
|
||||||
try:
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
return Fernet(key.encode()).decrypt(ciphertext.encode()).decode()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Mastodon token decryption failed — returning as-is")
|
|
||||||
return ciphertext
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# app/services/community/ap_compat.py
|
|
||||||
# MIT License — AP scaffold only (no actor, inbox, outbox)
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def post_to_ap_json_ld(post: dict, base_url: str) -> dict:
|
|
||||||
"""Serialize a community post dict to an ActivityPub-compatible JSON-LD Note.
|
|
||||||
|
|
||||||
This is a read-only scaffold. No AP actor, inbox, or outbox.
|
|
||||||
The slug URI is stable so a future full AP implementation can reuse posts
|
|
||||||
without a DB migration.
|
|
||||||
"""
|
|
||||||
slug = post["slug"]
|
|
||||||
published = post.get("published")
|
|
||||||
if isinstance(published, datetime):
|
|
||||||
published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
else:
|
|
||||||
published_str = str(published)
|
|
||||||
|
|
||||||
dietary_tags: list[str] = post.get("dietary_tags") or []
|
|
||||||
tags = [{"type": "Hashtag", "name": "#kiwi"}]
|
|
||||||
for tag in dietary_tags:
|
|
||||||
tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
"type": "Note",
|
|
||||||
"id": f"{base_url}/api/v1/community/posts/{slug}",
|
|
||||||
"attributedTo": post.get("pseudonym", "anonymous"),
|
|
||||||
"content": _build_content(post),
|
|
||||||
"published": published_str,
|
|
||||||
"tag": tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_content(post: dict) -> str:
|
|
||||||
title = post.get("title") or "Untitled"
|
|
||||||
desc = post.get("description")
|
|
||||||
if desc:
|
|
||||||
return f"{title} — {desc}"
|
|
||||||
return title
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
# app/services/community/community_store.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from circuitforge_core.community import CommunityPost, SharedStore
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class KiwiCommunityStore(SharedStore):
|
|
||||||
"""Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore."""
|
|
||||||
|
|
||||||
def list_meal_plans(
|
|
||||||
self,
|
|
||||||
limit: int = 20,
|
|
||||||
offset: int = 0,
|
|
||||||
dietary_tags: list[str] | None = None,
|
|
||||||
allergen_exclude: list[str] | None = None,
|
|
||||||
) -> list[CommunityPost]:
|
|
||||||
return self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
post_type="plan",
|
|
||||||
dietary_tags=dietary_tags,
|
|
||||||
allergen_exclude=allergen_exclude,
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_outcomes(
|
|
||||||
self,
|
|
||||||
limit: int = 20,
|
|
||||||
offset: int = 0,
|
|
||||||
post_type: str | None = None,
|
|
||||||
) -> list[CommunityPost]:
|
|
||||||
if post_type in ("recipe_success", "recipe_blooper"):
|
|
||||||
return self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
post_type=post_type,
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
success = self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=0,
|
|
||||||
post_type="recipe_success",
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
bloopers = self.list_posts(
|
|
||||||
limit=limit,
|
|
||||||
offset=0,
|
|
||||||
post_type="recipe_blooper",
|
|
||||||
source_product="kiwi",
|
|
||||||
)
|
|
||||||
merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True)
|
|
||||||
return merged[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_pseudonym(
|
|
||||||
store,
|
|
||||||
directus_user_id: str,
|
|
||||||
requested_name: str | None,
|
|
||||||
) -> str:
|
|
||||||
"""Return the user's current pseudonym, creating it if it doesn't exist.
|
|
||||||
|
|
||||||
If the user has an existing pseudonym, return it (ignore requested_name).
|
|
||||||
If not, create using requested_name (must be provided for first-time setup).
|
|
||||||
|
|
||||||
Raises ValueError if no existing pseudonym and requested_name is None or blank.
|
|
||||||
"""
|
|
||||||
existing = store.get_current_pseudonym(directus_user_id)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
if not requested_name or not requested_name.strip():
|
|
||||||
raise ValueError(
|
|
||||||
"A pseudonym is required for first publish. "
|
|
||||||
"Pass requested_name with the user's chosen display name."
|
|
||||||
)
|
|
||||||
|
|
||||||
name = requested_name.strip()
|
|
||||||
if "@" in name:
|
|
||||||
raise ValueError(
|
|
||||||
"Pseudonym must not contain '@' — use a display name, not an email address."
|
|
||||||
)
|
|
||||||
|
|
||||||
store.set_pseudonym(directus_user_id, name)
|
|
||||||
return name
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
# app/services/community/dedup.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SIMILARITY_TIERS = {
|
|
||||||
"exact_recipe": "This exact recipe is already in the community feed.",
|
|
||||||
"very_similar": "Very similar recipes already exist (70%+ ingredient overlap).",
|
|
||||||
"somewhat_similar": "Somewhat similar recipes exist (35-70% ingredient overlap).",
|
|
||||||
"different": "No close matches found.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ingredient_names(raw) -> set[str]:
|
|
||||||
"""Return a normalised set of ingredient name tokens from various stored formats."""
|
|
||||||
if raw is None:
|
|
||||||
return set()
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
raw = json.loads(raw)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return set()
|
|
||||||
names: set[str] = set()
|
|
||||||
for item in raw:
|
|
||||||
if isinstance(item, str):
|
|
||||||
names.add(item.lower().strip())
|
|
||||||
elif isinstance(item, dict):
|
|
||||||
name = item.get("name") or item.get("ingredient") or ""
|
|
||||||
if name:
|
|
||||||
names.add(name.lower().strip())
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def jaccard(a: set[str], b: set[str]) -> float:
|
|
||||||
if not a and not b:
|
|
||||||
return 1.0
|
|
||||||
if not a or not b:
|
|
||||||
return 0.0
|
|
||||||
return len(a & b) / len(a | b)
|
|
||||||
|
|
||||||
|
|
||||||
def similarity_tier(jaccard_score: float, exact_recipe: bool) -> str:
|
|
||||||
if exact_recipe:
|
|
||||||
return "exact_recipe"
|
|
||||||
if jaccard_score >= 0.70:
|
|
||||||
return "very_similar"
|
|
||||||
if jaccard_score >= 0.35:
|
|
||||||
return "somewhat_similar"
|
|
||||||
return "different"
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_recipe_ingredients(db_path: Path, recipe_id: int | None) -> set[str]:
|
|
||||||
"""Look up ingredient names for a recipe from the local corpus. Returns empty set on miss."""
|
|
||||||
if recipe_id is None:
|
|
||||||
return set()
|
|
||||||
try:
|
|
||||||
from app.db.store import Store
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
row = store.get_recipe(recipe_id)
|
|
||||||
if row is None:
|
|
||||||
return set()
|
|
||||||
return _parse_ingredient_names(row.get("ingredient_names"))
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("ingredient lookup failed for recipe_id=%s", recipe_id)
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def build_similar_post_result(
|
|
||||||
post,
|
|
||||||
incoming_recipe_id: int | None,
|
|
||||||
incoming_ingredients: set[str],
|
|
||||||
db_path: Path,
|
|
||||||
) -> dict:
|
|
||||||
"""Build a similarity result dict for one existing community post."""
|
|
||||||
exact = (
|
|
||||||
incoming_recipe_id is not None
|
|
||||||
and post.recipe_id is not None
|
|
||||||
and post.recipe_id == incoming_recipe_id
|
|
||||||
)
|
|
||||||
|
|
||||||
j_score = 0.0
|
|
||||||
if not exact and incoming_ingredients:
|
|
||||||
existing_ingredients = fetch_recipe_ingredients(db_path, post.recipe_id)
|
|
||||||
if existing_ingredients:
|
|
||||||
j_score = jaccard(incoming_ingredients, existing_ingredients)
|
|
||||||
|
|
||||||
tier = similarity_tier(j_score, exact)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"slug": post.slug,
|
|
||||||
"title": post.title,
|
|
||||||
"recipe_name": post.recipe_name,
|
|
||||||
"pseudonym": post.pseudonym,
|
|
||||||
"published": (
|
|
||||||
post.published.isoformat()
|
|
||||||
if hasattr(post.published, "isoformat")
|
|
||||||
else str(post.published)
|
|
||||||
),
|
|
||||||
"similarity_tier": tier,
|
|
||||||
"jaccard_score": round(j_score, 3) if not exact else None,
|
|
||||||
"tier_description": _SIMILARITY_TIERS.get(tier, ""),
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
# app/services/community/element_snapshot.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
# Ingredient name substrings → allergen flag
|
|
||||||
_ALLERGEN_MAP: dict[str, str] = {
|
|
||||||
"milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy",
|
|
||||||
"yogurt": "dairy", "whey": "dairy",
|
|
||||||
"egg": "eggs",
|
|
||||||
"wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten",
|
|
||||||
"barley": "gluten", "rye": "gluten",
|
|
||||||
"peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts",
|
|
||||||
"pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts",
|
|
||||||
"soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy",
|
|
||||||
"shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish",
|
|
||||||
"clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish",
|
|
||||||
"fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish",
|
|
||||||
"tilapia": "fish", "halibut": "fish",
|
|
||||||
"sesame": "sesame",
|
|
||||||
}
|
|
||||||
|
|
||||||
_MEAT_KEYWORDS = frozenset([
|
|
||||||
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage",
|
|
||||||
"salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat",
|
|
||||||
"mince", "veal", "duck", "venison", "bison", "lard",
|
|
||||||
])
|
|
||||||
_SEAFOOD_KEYWORDS = frozenset([
|
|
||||||
"fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel",
|
|
||||||
"scallop", "anchovy", "sardine", "cod", "tilapia",
|
|
||||||
])
|
|
||||||
_ANIMAL_PRODUCT_KEYWORDS = frozenset([
|
|
||||||
"milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_allergens(ingredient_names: list[str]) -> list[str]:
|
|
||||||
found: set[str] = set()
|
|
||||||
lowered = [n.lower() for n in ingredient_names]
|
|
||||||
for ingredient in lowered:
|
|
||||||
for keyword, flag in _ALLERGEN_MAP.items():
|
|
||||||
if keyword in ingredient:
|
|
||||||
found.add(flag)
|
|
||||||
return sorted(found)
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]:
|
|
||||||
lowered = [n.lower() for n in ingredient_names]
|
|
||||||
all_text = " ".join(lowered)
|
|
||||||
|
|
||||||
has_meat = any(k in all_text for k in _MEAT_KEYWORDS)
|
|
||||||
has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS)
|
|
||||||
has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS)
|
|
||||||
|
|
||||||
tags: list[str] = []
|
|
||||||
if not has_meat and not has_seafood:
|
|
||||||
tags.append("vegetarian")
|
|
||||||
if not has_meat and not has_seafood and not has_animal_products:
|
|
||||||
tags.append("vegan")
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ElementSnapshot:
|
|
||||||
seasoning_score: float
|
|
||||||
richness_score: float
|
|
||||||
brightness_score: float
|
|
||||||
depth_score: float
|
|
||||||
aroma_score: float
|
|
||||||
structure_score: float
|
|
||||||
texture_profile: str
|
|
||||||
dietary_tags: tuple
|
|
||||||
allergen_flags: tuple
|
|
||||||
flavor_molecules: tuple
|
|
||||||
fat_pct: float | None
|
|
||||||
protein_pct: float | None
|
|
||||||
moisture_pct: float | None
|
|
||||||
|
|
||||||
|
|
||||||
def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot:
|
|
||||||
"""Compute an element snapshot from a list of recipe IDs.
|
|
||||||
|
|
||||||
Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus.
|
|
||||||
Averages numeric scores across all recipes. Unions allergen flags and dietary tags.
|
|
||||||
Call at publish time only — snapshot is stored denormalized in community_posts.
|
|
||||||
"""
|
|
||||||
if not recipe_ids:
|
|
||||||
return ElementSnapshot(
|
|
||||||
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
|
|
||||||
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
|
|
||||||
texture_profile="", dietary_tags=(), allergen_flags=(),
|
|
||||||
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = store.get_recipes_by_ids(recipe_ids)
|
|
||||||
if not rows:
|
|
||||||
return ElementSnapshot(
|
|
||||||
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
|
|
||||||
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
|
|
||||||
texture_profile="", dietary_tags=(), allergen_flags=(),
|
|
||||||
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _avg(field: str) -> float:
|
|
||||||
vals = [r.get(field) or 0.0 for r in rows]
|
|
||||||
return sum(vals) / len(vals)
|
|
||||||
|
|
||||||
all_ingredients: list[str] = []
|
|
||||||
for r in rows:
|
|
||||||
names = r.get("ingredient_names") or []
|
|
||||||
all_ingredients.extend(names if isinstance(names, list) else [])
|
|
||||||
|
|
||||||
allergens = _detect_allergens(all_ingredients)
|
|
||||||
dietary = _detect_dietary_tags(all_ingredients)
|
|
||||||
|
|
||||||
texture = rows[0].get("texture_profile") or ""
|
|
||||||
|
|
||||||
fat_vals = [r.get("fat") for r in rows if r.get("fat") is not None]
|
|
||||||
prot_vals = [r.get("protein") for r in rows if r.get("protein") is not None]
|
|
||||||
moist_vals = [r.get("moisture") for r in rows if r.get("moisture") is not None]
|
|
||||||
|
|
||||||
return ElementSnapshot(
|
|
||||||
seasoning_score=_avg("seasoning_score"),
|
|
||||||
richness_score=_avg("richness_score"),
|
|
||||||
brightness_score=_avg("brightness_score"),
|
|
||||||
depth_score=_avg("depth_score"),
|
|
||||||
aroma_score=_avg("aroma_score"),
|
|
||||||
structure_score=_avg("structure_score"),
|
|
||||||
texture_profile=texture,
|
|
||||||
dietary_tags=tuple(dietary),
|
|
||||||
allergen_flags=tuple(allergens),
|
|
||||||
flavor_molecules=(),
|
|
||||||
fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None,
|
|
||||||
protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None,
|
|
||||||
moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None,
|
|
||||||
)
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# app/services/community/feed.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from email.utils import format_datetime
|
|
||||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
|
||||||
|
|
||||||
|
|
||||||
def posts_to_rss(posts: list[dict], base_url: str) -> str:
|
|
||||||
"""Generate an RSS 2.0 feed from a list of community post dicts.
|
|
||||||
|
|
||||||
base_url: the root URL of this Kiwi instance (no trailing slash).
|
|
||||||
Returns UTF-8 XML string.
|
|
||||||
"""
|
|
||||||
rss = Element("rss", version="2.0")
|
|
||||||
channel = SubElement(rss, "channel")
|
|
||||||
|
|
||||||
_sub(channel, "title", "Kiwi Community Feed")
|
|
||||||
_sub(channel, "link", f"{base_url}/community")
|
|
||||||
_sub(channel, "description", "Meal plans and recipe outcomes from the Kiwi community")
|
|
||||||
_sub(channel, "language", "en")
|
|
||||||
_sub(channel, "lastBuildDate", format_datetime(datetime.now(timezone.utc)))
|
|
||||||
|
|
||||||
for post in posts:
|
|
||||||
item = SubElement(channel, "item")
|
|
||||||
_sub(item, "title", post.get("title") or "Untitled")
|
|
||||||
_sub(item, "link", f"{base_url}/api/v1/community/posts/{post['slug']}")
|
|
||||||
_sub(item, "guid", f"{base_url}/api/v1/community/posts/{post['slug']}")
|
|
||||||
if post.get("description"):
|
|
||||||
_sub(item, "description", post["description"])
|
|
||||||
published = post.get("published")
|
|
||||||
if isinstance(published, datetime):
|
|
||||||
_sub(item, "pubDate", format_datetime(published))
|
|
||||||
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(rss, encoding="unicode")
|
|
||||||
|
|
||||||
|
|
||||||
def _sub(parent: Element, tag: str, text: str) -> Element:
|
|
||||||
el = SubElement(parent, tag)
|
|
||||||
el.text = text
|
|
||||||
return el
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
# app/services/community/mdns.py
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Import deferred to avoid hard failure when zeroconf is not installed
|
|
||||||
try:
|
|
||||||
from zeroconf import ServiceInfo, Zeroconf
|
|
||||||
_ZEROCONF_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
_ZEROCONF_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
class KiwiMDNS:
|
|
||||||
"""Advertise this Kiwi instance on the LAN via mDNS (_kiwi._tcp.local).
|
|
||||||
|
|
||||||
Defaults to disabled (enabled=False). User must explicitly opt in via the
|
|
||||||
Settings page. This matches the CF a11y requirement: no surprise broadcasting.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
mdns = KiwiMDNS(enabled=settings.MDNS_ENABLED, port=settings.PORT,
|
|
||||||
feed_url=f"http://{hostname}:{settings.PORT}/api/v1/community/local-feed")
|
|
||||||
mdns.start() # in lifespan startup
|
|
||||||
mdns.stop() # in lifespan shutdown
|
|
||||||
"""
|
|
||||||
|
|
||||||
SERVICE_TYPE = "_kiwi._tcp.local."
|
|
||||||
|
|
||||||
def __init__(self, enabled: bool, port: int, feed_url: str) -> None:
|
|
||||||
self._enabled = enabled
|
|
||||||
self._port = port
|
|
||||||
self._feed_url = feed_url
|
|
||||||
self._zc: "Zeroconf | None" = None
|
|
||||||
self._info: "ServiceInfo | None" = None
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
if not self._enabled:
|
|
||||||
logger.debug("mDNS advertisement disabled (user has not opted in)")
|
|
||||||
return
|
|
||||||
if not _ZEROCONF_AVAILABLE:
|
|
||||||
logger.warning("zeroconf package not installed — mDNS advertisement unavailable")
|
|
||||||
return
|
|
||||||
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
service_name = f"kiwi-{hostname}.{self.SERVICE_TYPE}"
|
|
||||||
self._info = ServiceInfo(
|
|
||||||
type_=self.SERVICE_TYPE,
|
|
||||||
name=service_name,
|
|
||||||
port=self._port,
|
|
||||||
properties={
|
|
||||||
b"feed_url": self._feed_url.encode(),
|
|
||||||
b"version": b"1",
|
|
||||||
},
|
|
||||||
addresses=[socket.inet_aton("127.0.0.1")],
|
|
||||||
)
|
|
||||||
self._zc = Zeroconf()
|
|
||||||
self._zc.register_service(self._info)
|
|
||||||
logger.info("mDNS: advertising %s on port %d", service_name, self._port)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
if self._zc is None or self._info is None:
|
|
||||||
return
|
|
||||||
self._zc.unregister_service(self._info)
|
|
||||||
self._zc.close()
|
|
||||||
self._zc = None
|
|
||||||
self._info = None
|
|
||||||
logger.info("mDNS: advertisement stopped")
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"""cf-orch coordinator proxy client.
|
|
||||||
|
|
||||||
Calls the coordinator's /proxy/authorize endpoint to obtain a one-time
|
|
||||||
stream URL + token for LLM streaming. Always raises CoordinatorError on
|
|
||||||
failure — callers decide how to handle it (stream-token endpoint returns
|
|
||||||
503 or 403 as appropriate).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CoordinatorError(Exception):
|
|
||||||
"""Raised when the coordinator returns an error or is unreachable."""
|
|
||||||
def __init__(self, message: str, status_code: int = 503):
|
|
||||||
super().__init__(message)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StreamTokenResult:
|
|
||||||
stream_url: str
|
|
||||||
token: str
|
|
||||||
expires_in_s: int
|
|
||||||
|
|
||||||
|
|
||||||
def _coordinator_url() -> str:
|
|
||||||
return os.environ.get("COORDINATOR_URL", "http://10.1.10.71:7700")
|
|
||||||
|
|
||||||
|
|
||||||
def _product_key() -> str:
|
|
||||||
return os.environ.get("COORDINATOR_KIWI_KEY", "")
|
|
||||||
|
|
||||||
|
|
||||||
async def coordinator_authorize(
|
|
||||||
prompt: str,
|
|
||||||
caller: str = "kiwi-recipe",
|
|
||||||
ttl_s: int = 300,
|
|
||||||
) -> StreamTokenResult:
|
|
||||||
"""Call POST /proxy/authorize on the coordinator.
|
|
||||||
|
|
||||||
Returns a StreamTokenResult with the stream URL and one-time token.
|
|
||||||
Raises CoordinatorError on any failure (network, auth, capacity).
|
|
||||||
"""
|
|
||||||
url = f"{_coordinator_url()}/proxy/authorize"
|
|
||||||
key = _product_key()
|
|
||||||
if not key:
|
|
||||||
raise CoordinatorError(
|
|
||||||
"COORDINATOR_KIWI_KEY env var is not set — streaming unavailable",
|
|
||||||
status_code=503,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"product": "kiwi",
|
|
||||||
"product_key": key,
|
|
||||||
"caller": caller,
|
|
||||||
"prompt": prompt,
|
|
||||||
"params": {},
|
|
||||||
"ttl_s": ttl_s,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.post(url, json=payload)
|
|
||||||
except httpx.RequestError as exc:
|
|
||||||
log.warning("coordinator_authorize network error: %s", exc)
|
|
||||||
raise CoordinatorError(f"Coordinator unreachable: {exc}", status_code=503)
|
|
||||||
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise CoordinatorError("Invalid product key", status_code=401)
|
|
||||||
if resp.status_code == 429:
|
|
||||||
raise CoordinatorError("Too many concurrent streams", status_code=429)
|
|
||||||
if resp.status_code == 503:
|
|
||||||
raise CoordinatorError("No GPU available for streaming", status_code=503)
|
|
||||||
if not resp.is_success:
|
|
||||||
raise CoordinatorError(
|
|
||||||
f"Coordinator error {resp.status_code}: {resp.text[:200]}",
|
|
||||||
status_code=503,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
# Use public_stream_url if coordinator provides it (cloud mode), else stream_url
|
|
||||||
stream_url = data.get("public_stream_url") or data["stream_url"]
|
|
||||||
return StreamTokenResult(
|
|
||||||
stream_url=stream_url,
|
|
||||||
token=data["token"],
|
|
||||||
expires_in_s=data["expires_in_s"],
|
|
||||||
)
|
|
||||||
|
|
@ -116,270 +116,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""Heimdall cf-orch budget client.
|
|
||||||
|
|
||||||
Calls Heimdall's /orch/* endpoints to gate and record cf-orch usage for
|
|
||||||
lifetime/founders license holders. Always fails open on network errors —
|
|
||||||
a Heimdall outage should never block the user.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
|
||||||
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict[str, str]:
|
|
||||||
if HEIMDALL_ADMIN_TOKEN:
|
|
||||||
return {"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def check_orch_budget(key_display: str, product: str) -> dict:
|
|
||||||
"""Call POST /orch/check and return the response dict.
|
|
||||||
|
|
||||||
On any error (network, auth, etc.) returns a permissive dict so the
|
|
||||||
caller can proceed without blocking the user.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"{HEIMDALL_URL}/orch/check",
|
|
||||||
json={"key_display": key_display, "product": product},
|
|
||||||
headers=_headers(),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
return resp.json()
|
|
||||||
log.warning("Heimdall orch/check returned %s for key %s", resp.status_code, key_display[:12])
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Heimdall orch/check failed (fail-open): %s", exc)
|
|
||||||
|
|
||||||
# Fail open — Heimdall outage must never block the user
|
|
||||||
return {
|
|
||||||
"allowed": True,
|
|
||||||
"calls_used": 0,
|
|
||||||
"calls_total": 0,
|
|
||||||
"topup_calls": 0,
|
|
||||||
"period_start": "",
|
|
||||||
"resets_on": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_orch_usage(key_display: str, product: str) -> dict:
|
|
||||||
"""Call GET /orch/usage and return the response dict.
|
|
||||||
|
|
||||||
Returns zeros on error (non-blocking).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
resp = requests.get(
|
|
||||||
f"{HEIMDALL_URL}/orch/usage",
|
|
||||||
params={"key_display": key_display, "product": product},
|
|
||||||
headers=_headers(),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
return resp.json()
|
|
||||||
log.warning("Heimdall orch/usage returned %s", resp.status_code)
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("Heimdall orch/usage failed: %s", exc)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"calls_used": 0,
|
|
||||||
"topup_calls": 0,
|
|
||||||
"calls_total": 0,
|
|
||||||
"period_start": "",
|
|
||||||
"resets_on": "",
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"""Visual label capture service for unenriched products (kiwi#79).
|
|
||||||
|
|
||||||
Wraps the cf-core VisionRouter to extract structured nutrition data from a
|
|
||||||
photographed nutrition facts panel. When the VisionRouter is not yet wired
|
|
||||||
(NotImplementedError) the service falls back to a mock extraction so the
|
|
||||||
barcode scan flow can be exercised end-to-end in development.
|
|
||||||
|
|
||||||
JSON contract returned by the vision model (and mock):
|
|
||||||
{
|
|
||||||
"product_name": str | null,
|
|
||||||
"brand": str | null,
|
|
||||||
"serving_size_g": number | null,
|
|
||||||
"calories": number | null,
|
|
||||||
"fat_g": number | null,
|
|
||||||
"saturated_fat_g": number | null,
|
|
||||||
"carbs_g": number | null,
|
|
||||||
"sugar_g": number | null,
|
|
||||||
"fiber_g": number | null,
|
|
||||||
"protein_g": number | null,
|
|
||||||
"sodium_mg": number | null,
|
|
||||||
"ingredient_names": [str],
|
|
||||||
"allergens": [str],
|
|
||||||
"confidence": number (0.0–1.0)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Confidence below this threshold surfaces amber highlights in the UI.
|
|
||||||
REVIEW_THRESHOLD = 0.7
|
|
||||||
|
|
||||||
_MOCK_EXTRACTION: dict[str, Any] = {
|
|
||||||
"product_name": "Unknown Product",
|
|
||||||
"brand": None,
|
|
||||||
"serving_size_g": None,
|
|
||||||
"calories": None,
|
|
||||||
"fat_g": None,
|
|
||||||
"saturated_fat_g": None,
|
|
||||||
"carbs_g": None,
|
|
||||||
"sugar_g": None,
|
|
||||||
"fiber_g": None,
|
|
||||||
"protein_g": None,
|
|
||||||
"sodium_mg": None,
|
|
||||||
"ingredient_names": [],
|
|
||||||
"allergens": [],
|
|
||||||
"confidence": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
_EXTRACTION_PROMPT = """You are reading a nutrition facts label photograph.
|
|
||||||
Extract the following fields as a JSON object with no extra text:
|
|
||||||
|
|
||||||
{
|
|
||||||
"product_name": <product name or null>,
|
|
||||||
"brand": <brand name or null>,
|
|
||||||
"serving_size_g": <serving size in grams as a number or null>,
|
|
||||||
"calories": <calories per serving as a number or null>,
|
|
||||||
"fat_g": <total fat grams or null>,
|
|
||||||
"saturated_fat_g": <saturated fat grams or null>,
|
|
||||||
"carbs_g": <total carbohydrates grams or null>,
|
|
||||||
"sugar_g": <sugars grams or null>,
|
|
||||||
"fiber_g": <dietary fiber grams or null>,
|
|
||||||
"protein_g": <protein grams or null>,
|
|
||||||
"sodium_mg": <sodium milligrams or null>,
|
|
||||||
"ingredient_names": [list of individual ingredients as strings],
|
|
||||||
"allergens": [list of allergens explicitly stated on label],
|
|
||||||
"confidence": <your confidence this extraction is correct, 0.0 to 1.0>
|
|
||||||
}
|
|
||||||
|
|
||||||
Use null for any field you cannot read clearly. Do not guess values.
|
|
||||||
Respond with JSON only."""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_label(image_bytes: bytes) -> dict[str, Any]:
|
|
||||||
"""Run vision model extraction on raw label image bytes.
|
|
||||||
|
|
||||||
Returns a dict matching the nutrition JSON contract above.
|
|
||||||
Falls back to a zero-confidence mock if the VisionRouter is not yet
|
|
||||||
implemented (stub) or if the model returns unparseable output.
|
|
||||||
"""
|
|
||||||
# Allow unit tests to bypass the vision model entirely.
|
|
||||||
if os.environ.get("KIWI_LABEL_CAPTURE_MOCK") == "1":
|
|
||||||
log.debug("label_capture: mock mode active")
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from circuitforge_core.vision import caption as vision_caption
|
|
||||||
result = vision_caption(image_bytes, prompt=_EXTRACTION_PROMPT)
|
|
||||||
raw = result.caption or ""
|
|
||||||
return _parse_extraction(raw)
|
|
||||||
except Exception as exc:
|
|
||||||
log.warning("label_capture: extraction failed (%s) — returning mock extraction", exc)
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_extraction(raw: str) -> dict[str, Any]:
|
|
||||||
"""Parse the JSON string returned by the vision model.
|
|
||||||
|
|
||||||
Strips markdown code fences if present. Validates required shape.
|
|
||||||
Returns the mock on any parse error.
|
|
||||||
"""
|
|
||||||
text = raw.strip()
|
|
||||||
if text.startswith("```"):
|
|
||||||
# Strip ```json ... ``` fences
|
|
||||||
lines = text.splitlines()
|
|
||||||
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
log.warning("label_capture: could not parse vision response: %s", exc)
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
log.warning("label_capture: vision response is not a dict")
|
|
||||||
return dict(_MOCK_EXTRACTION)
|
|
||||||
|
|
||||||
# Normalise list fields — model may return None instead of []
|
|
||||||
for list_key in ("ingredient_names", "allergens"):
|
|
||||||
if not isinstance(data.get(list_key), list):
|
|
||||||
data[list_key] = []
|
|
||||||
|
|
||||||
# Clamp confidence to [0, 1]
|
|
||||||
confidence = data.get("confidence")
|
|
||||||
if not isinstance(confidence, (int, float)):
|
|
||||||
confidence = 0.0
|
|
||||||
data["confidence"] = max(0.0, min(1.0, float(confidence)))
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def needs_review(extraction: dict[str, Any]) -> bool:
|
|
||||||
"""Return True when the extraction confidence is below REVIEW_THRESHOLD."""
|
|
||||||
return float(extraction.get("confidence", 0.0)) < REVIEW_THRESHOLD
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
# app/services/leftovers_predictor.py
|
|
||||||
"""Cooked-leftovers shelf-life predictor.
|
|
||||||
|
|
||||||
Fast path: deterministic lookup anchored to FDA/USDA safe food handling.
|
|
||||||
Fallback: LLM for unclassifiable edge cases (same gate as expiry_llm_matching).
|
|
||||||
|
|
||||||
Design notes:
|
|
||||||
- shortest-component-wins for proteins: a fish taco is bounded by the fish.
|
|
||||||
- category/keyword signals override ingredient signals for assembled dishes
|
|
||||||
(soup, stew, casserole) where the cooking method matters more than the
|
|
||||||
dominant protein.
|
|
||||||
- no urgency/panic framing — see feedback_kiwi_no_panic.md.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LeftoversResult:
|
|
||||||
fridge_days: int
|
|
||||||
freeze_days: int | None # None = "not recommended"
|
|
||||||
freeze_by_day: int | None # day number from cook date to freeze by; None = no need
|
|
||||||
storage_advice: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Protein priority table — shorter shelf life wins when multiple match.
|
|
||||||
# Values: (fridge_days, freeze_days). All fridge values are conservative.
|
|
||||||
# Sources: USDA FoodKeeper, FDA Safe Food Handling.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_PROTEIN_SIGNALS: list[tuple[list[str], int, int | None]] = [
|
|
||||||
# (keyword_list, fridge_days, freeze_days)
|
|
||||||
(["fish", "salmon", "tuna", "cod", "tilapia", "halibut", "trout", "bass",
|
|
||||||
"mahi", "snapper", "flounder", "catfish", "swordfish", "sardine", "anchovy"],
|
|
||||||
2, 90),
|
|
||||||
(["shrimp", "prawn", "scallop", "crab", "lobster", "clam", "mussel",
|
|
||||||
"oyster", "squid", "octopus", "seafood"],
|
|
||||||
2, 90),
|
|
||||||
(["ground beef", "ground turkey", "ground pork", "ground chicken",
|
|
||||||
"ground meat", "hamburger", "mince"],
|
|
||||||
3, 90),
|
|
||||||
(["chicken", "turkey", "poultry", "duck", "hen"],
|
|
||||||
3, 90),
|
|
||||||
(["pork", "ham", "bacon", "sausage", "chorizo", "bratwurst", "kielbasa",
|
|
||||||
"salami", "pepperoni"],
|
|
||||||
4, 120),
|
|
||||||
(["beef", "steak", "brisket", "roast", "lamb", "veal", "venison"],
|
|
||||||
4, 180),
|
|
||||||
(["egg", "eggs", "frittata", "quiche", "omelette"],
|
|
||||||
3, None),
|
|
||||||
(["tofu", "tempeh", "seitan"],
|
|
||||||
4, 90),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dish-type signals — override protein signal when a structural match fires.
|
|
||||||
# Ordered from most-perishable to least.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_DISH_SIGNALS: list[tuple[list[str], int, int | None, str]] = [
|
|
||||||
# (keywords, fridge_days, freeze_days, storage_advice_fragment)
|
|
||||||
|
|
||||||
# Ceviche: acid denatures proteins but does not kill pathogens.
|
|
||||||
# FDA/USDA classify it as raw seafood — 2-day fridge max, do not freeze.
|
|
||||||
(["ceviche", "tiradito", "leche de tigre"],
|
|
||||||
2, None,
|
|
||||||
"Acid marination is not the same as heat cooking — treat as raw seafood. "
|
|
||||||
"Best eaten the day it's made; 2 days maximum in the fridge."),
|
|
||||||
|
|
||||||
# Fermented / salt-cured dishes — preservation extends shelf life significantly.
|
|
||||||
# This matches dish names, not just presence of the ingredient (lardo in a pasta
|
|
||||||
# follows normal pasta rules, not this entry).
|
|
||||||
(["kimchi", "sauerkraut", "preserved lemon"],
|
|
||||||
14, None,
|
|
||||||
"Fermented and salt-preserved dishes keep well. Store submerged in their brine."),
|
|
||||||
|
|
||||||
(["confit", "gravlax", "gravad lax", "lardo"],
|
|
||||||
7, 60,
|
|
||||||
"Store covered in its fat or cure. Keep cold and away from strong-smelling foods."),
|
|
||||||
|
|
||||||
(["soup", "stew", "broth", "chowder", "bisque", "gumbo", "chili"],
|
|
||||||
4, 120,
|
|
||||||
"Soups and stews keep well in the fridge. Cool to room temperature before covering."),
|
|
||||||
(["curry"],
|
|
||||||
4, 90,
|
|
||||||
"Store curry in an airtight container. The flavours deepen overnight."),
|
|
||||||
(["casserole", "bake", "gratin", "lasagna", "lasagne", "moussaka",
|
|
||||||
"shepherd's pie", "pot pie"],
|
|
||||||
5, 90,
|
|
||||||
"Cover tightly. Reheat individual portions rather than the whole dish."),
|
|
||||||
(["pasta", "noodle", "spaghetti", "penne", "linguine", "fettuccine",
|
|
||||||
"macaroni", "risotto"],
|
|
||||||
4, 60,
|
|
||||||
"Store pasta and sauce separately if possible to prevent sogginess."),
|
|
||||||
(["rice", "fried rice", "pilaf", "biryani"],
|
|
||||||
3, 90,
|
|
||||||
"Cool rice quickly — spread on a tray if needed. Don't leave at room temperature for more than 1 hour."),
|
|
||||||
(["salad"],
|
|
||||||
2, None,
|
|
||||||
"Keep dressing separate. Once dressed, best eaten the same day."),
|
|
||||||
(["stir fry", "stir-fry"],
|
|
||||||
3, 60,
|
|
||||||
"Reheat in a hot pan or wok rather than a microwave to keep texture."),
|
|
||||||
(["sandwich", "wrap", "taco", "burrito"],
|
|
||||||
2, None,
|
|
||||||
"Assemble fresh when possible. Fillings keep better stored separately."),
|
|
||||||
(["pizza"],
|
|
||||||
4, 60,
|
|
||||||
"Reheat in a dry skillet for a crisp base rather than a microwave."),
|
|
||||||
(["muffin", "bread", "biscuit", "scone", "roll"],
|
|
||||||
3, 90,
|
|
||||||
"Wrap tightly or seal in a bag to prevent drying out."),
|
|
||||||
(["cake", "pie", "cookie", "brownie", "dessert", "pudding"],
|
|
||||||
5, 90,
|
|
||||||
"Store covered at room temperature or in the fridge depending on fillings."),
|
|
||||||
(["smoothie", "juice", "shake"],
|
|
||||||
1, 7,
|
|
||||||
"Best consumed fresh. Stir or shake well before drinking."),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Default when no signals match.
|
|
||||||
_DEFAULT_FRIDGE = 4
|
|
||||||
_DEFAULT_FREEZE = 90
|
|
||||||
_DEFAULT_ADVICE = "Store in an airtight container in the fridge. Reheat until piping hot before eating."
|
|
||||||
|
|
||||||
|
|
||||||
def _contains_any(text: str, keywords: list[str]) -> bool:
|
|
||||||
for kw in keywords:
|
|
||||||
if re.search(rf"\b{re.escape(kw)}\b", text, re.IGNORECASE):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_ingredients(ingredients: list[str]) -> tuple[int, int | None] | None:
|
|
||||||
"""Return (fridge_days, freeze_days) for the most-perishable protein found."""
|
|
||||||
joined = " ".join(str(i) for i in ingredients).lower()
|
|
||||||
best: tuple[int, int | None] | None = None
|
|
||||||
for keywords, fridge, freeze in _PROTEIN_SIGNALS:
|
|
||||||
if _contains_any(joined, keywords):
|
|
||||||
if best is None or fridge < best[0]:
|
|
||||||
best = (fridge, freeze)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_dish_type(text: str) -> tuple[int, int | None, str] | None:
|
|
||||||
"""Return (fridge_days, freeze_days, advice) for the first matching dish type."""
|
|
||||||
for keywords, fridge, freeze, advice in _DISH_SIGNALS:
|
|
||||||
if _contains_any(text, keywords):
|
|
||||||
return fridge, freeze, advice
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def predict_leftovers(
|
|
||||||
title: str,
|
|
||||||
ingredients: list[str],
|
|
||||||
category: str | None = None,
|
|
||||||
keywords: list[str] | None = None,
|
|
||||||
) -> LeftoversResult:
|
|
||||||
"""Predict cooked-leftover shelf life deterministically.
|
|
||||||
|
|
||||||
Falls back gracefully — always returns a result even for unknown recipes.
|
|
||||||
"""
|
|
||||||
# Build a combined text blob for dish-type scanning.
|
|
||||||
search_text = " ".join(filter(None, [
|
|
||||||
title,
|
|
||||||
category or "",
|
|
||||||
" ".join(keywords or []),
|
|
||||||
]))
|
|
||||||
|
|
||||||
# Dish-type match takes structural priority over raw ingredient protein signal.
|
|
||||||
dish = _scan_dish_type(search_text)
|
|
||||||
protein = _scan_ingredients(ingredients)
|
|
||||||
|
|
||||||
if dish:
|
|
||||||
fridge_days, freeze_days, base_advice = dish
|
|
||||||
# Still apply shortest-protein-wins if protein is more perishable than dish default.
|
|
||||||
if protein and protein[0] < fridge_days:
|
|
||||||
fridge_days = protein[0]
|
|
||||||
if protein[1] is not None and (freeze_days is None or protein[1] < freeze_days):
|
|
||||||
freeze_days = protein[1]
|
|
||||||
advice = base_advice
|
|
||||||
elif protein:
|
|
||||||
fridge_days, freeze_days = protein
|
|
||||||
advice = _DEFAULT_ADVICE
|
|
||||||
else:
|
|
||||||
fridge_days = _DEFAULT_FRIDGE
|
|
||||||
freeze_days = _DEFAULT_FREEZE
|
|
||||||
advice = _DEFAULT_ADVICE
|
|
||||||
|
|
||||||
# freeze_by_day: recommend freezing on day 2 if fridge window is tight (≤3 days).
|
|
||||||
freeze_by_day: int | None = None
|
|
||||||
if freeze_days is not None and fridge_days <= 3:
|
|
||||||
freeze_by_day = 2
|
|
||||||
|
|
||||||
return LeftoversResult(
|
|
||||||
fridge_days=fridge_days,
|
|
||||||
freeze_days=freeze_days,
|
|
||||||
freeze_by_day=freeze_by_day,
|
|
||||||
storage_advice=advice,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def predict_leftovers_from_row(recipe: dict[str, Any]) -> LeftoversResult:
|
|
||||||
"""Convenience wrapper that accepts a Store row dict directly."""
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
title = recipe.get("title") or ""
|
|
||||||
|
|
||||||
raw_ingredients = recipe.get("ingredient_names") or []
|
|
||||||
if isinstance(raw_ingredients, str):
|
|
||||||
try:
|
|
||||||
raw_ingredients = _json.loads(raw_ingredients)
|
|
||||||
except Exception:
|
|
||||||
raw_ingredients = [raw_ingredients]
|
|
||||||
|
|
||||||
raw_keywords = recipe.get("keywords") or []
|
|
||||||
if isinstance(raw_keywords, str):
|
|
||||||
try:
|
|
||||||
raw_keywords = _json.loads(raw_keywords)
|
|
||||||
except Exception:
|
|
||||||
raw_keywords = [raw_keywords]
|
|
||||||
|
|
||||||
return predict_leftovers(
|
|
||||||
title=title,
|
|
||||||
ingredients=[str(i) for i in raw_ingredients],
|
|
||||||
category=recipe.get("category"),
|
|
||||||
keywords=[str(k) for k in raw_keywords],
|
|
||||||
)
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""Magpie data-flywheel hook.
|
|
||||||
|
|
||||||
Fires anonymized recipe-signal events to the Magpie ingest endpoint when a
|
|
||||||
user saves or rates a recipe. This is the Kiwi side of the flywheel — Magpie
|
|
||||||
does not have a receiver endpoint yet, so the hook stubs out gracefully: if
|
|
||||||
``MAGPIE_INGEST_URL`` is unset, or the request fails for any reason, it logs
|
|
||||||
at DEBUG level and returns without raising.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_INGEST_PATH = "/api/v1/ingest/recipe-signal"
|
|
||||||
|
|
||||||
|
|
||||||
async def fire_recipe_signal(
|
|
||||||
db_path: Path,
|
|
||||||
recipe_id: int,
|
|
||||||
rating: int | None,
|
|
||||||
style_tags: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""Post an anonymized recipe signal to Magpie if the user has opted in.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the user's SQLite database.
|
|
||||||
recipe_id: Internal Kiwi recipe ID being rated/saved.
|
|
||||||
rating: Star rating (0–5) or None if not yet rated.
|
|
||||||
style_tags: Style tags applied to the saved recipe.
|
|
||||||
"""
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
if not settings.MAGPIE_INGEST_URL:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check per-user opt-in via a short-lived Store (own connection, own thread
|
|
||||||
# context is fine — this runs in the async event loop as a background task
|
|
||||||
# so we open and close the connection immediately).
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
try:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
opt_in = store.get_setting("magpie_opt_in")
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.debug("magpie_hook: could not read magpie_opt_in setting: %s", exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
if opt_in != "true":
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fetch the recipe to get its external_id (source URL slug / corpus key).
|
|
||||||
try:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
recipe = store.get_recipe(recipe_id)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.debug("magpie_hook: could not fetch recipe %d: %s", recipe_id, exc)
|
|
||||||
return
|
|
||||||
|
|
||||||
if recipe is None:
|
|
||||||
logger.debug("magpie_hook: recipe %d not found, skipping", recipe_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
external_id: str | None = recipe.get("external_id") if isinstance(recipe, dict) else getattr(recipe, "external_id", None)
|
|
||||||
if not external_id:
|
|
||||||
# Corpus recipe not yet enriched with a source identifier — skip quietly.
|
|
||||||
logger.debug("magpie_hook: recipe %d has no external_id, skipping", recipe_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"product": "kiwi",
|
|
||||||
"signal": "recipe_rating",
|
|
||||||
"external_id": external_id,
|
|
||||||
"rating": rating,
|
|
||||||
"style_tags": style_tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
url = settings.MAGPIE_INGEST_URL.rstrip("/") + _INGEST_PATH
|
|
||||||
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
|
||||||
response = await client.post(url, json=payload)
|
|
||||||
logger.debug(
|
|
||||||
"magpie_hook: POST %s → %d", url, response.status_code
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
# Magpie may not have a receiver yet — log and swallow.
|
|
||||||
logger.debug("magpie_hook: ingest request failed (stub): %s", exc)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# app/services/meal_plan/affiliates.py
|
|
||||||
"""Register Kiwi-specific affiliate programs and provide search URL builders.
|
|
||||||
|
|
||||||
Called once at API startup. Programs not yet in core.affiliates are registered
|
|
||||||
here. The actual affiliate IDs are read from environment variables at call
|
|
||||||
time, so the process can start before accounts are approved (plain URLs
|
|
||||||
returned when env vars are absent).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from urllib.parse import quote_plus
|
|
||||||
|
|
||||||
from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url
|
|
||||||
|
|
||||||
|
|
||||||
# ── URL builders ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _walmart_search(url: str, affiliate_id: str) -> str:
|
|
||||||
sep = "&" if "?" in url else "?"
|
|
||||||
return f"{url}{sep}affil=apa&affiliateId={affiliate_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _target_search(url: str, affiliate_id: str) -> str:
|
|
||||||
sep = "&" if "?" in url else "?"
|
|
||||||
return f"{url}{sep}afid={affiliate_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _thrive_search(url: str, affiliate_id: str) -> str:
|
|
||||||
sep = "&" if "?" in url else "?"
|
|
||||||
return f"{url}{sep}raf={affiliate_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _misfits_search(url: str, affiliate_id: str) -> str:
|
|
||||||
sep = "&" if "?" in url else "?"
|
|
||||||
return f"{url}{sep}ref={affiliate_id}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Registration ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def register_kiwi_programs() -> None:
|
|
||||||
"""Register Kiwi retailer programs. Safe to call multiple times (idempotent)."""
|
|
||||||
register_program(AffiliateProgram(
|
|
||||||
name="Walmart",
|
|
||||||
retailer_key="walmart",
|
|
||||||
env_var="WALMART_AFFILIATE_ID",
|
|
||||||
build_url=_walmart_search,
|
|
||||||
))
|
|
||||||
register_program(AffiliateProgram(
|
|
||||||
name="Target",
|
|
||||||
retailer_key="target",
|
|
||||||
env_var="TARGET_AFFILIATE_ID",
|
|
||||||
build_url=_target_search,
|
|
||||||
))
|
|
||||||
register_program(AffiliateProgram(
|
|
||||||
name="Thrive Market",
|
|
||||||
retailer_key="thrive",
|
|
||||||
env_var="THRIVE_AFFILIATE_ID",
|
|
||||||
build_url=_thrive_search,
|
|
||||||
))
|
|
||||||
register_program(AffiliateProgram(
|
|
||||||
name="Misfits Market",
|
|
||||||
retailer_key="misfits",
|
|
||||||
env_var="MISFITS_AFFILIATE_ID",
|
|
||||||
build_url=_misfits_search,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Search URL helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_SEARCH_TEMPLATES: dict[str, str] = {
|
|
||||||
"amazon": "https://www.amazon.com/s?k={q}",
|
|
||||||
"instacart": "https://www.instacart.com/store/search_v3/term?term={q}",
|
|
||||||
"walmart": "https://www.walmart.com/search?q={q}",
|
|
||||||
"target": "https://www.target.com/s?searchTerm={q}",
|
|
||||||
"thrive": "https://thrivemarket.com/search?q={q}",
|
|
||||||
"misfits": "https://www.misfitsmarket.com/shop?search={q}",
|
|
||||||
}
|
|
||||||
|
|
||||||
KIWI_RETAILERS = list(_SEARCH_TEMPLATES.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def get_retailer_links(ingredient_name: str) -> list[dict]:
|
|
||||||
"""Return affiliate-wrapped search links for *ingredient_name*.
|
|
||||||
|
|
||||||
Returns a list of dicts: {"retailer": str, "label": str, "url": str}.
|
|
||||||
Falls back to plain search URL when no affiliate ID is configured.
|
|
||||||
"""
|
|
||||||
q = quote_plus(ingredient_name)
|
|
||||||
links = []
|
|
||||||
for key, template in _SEARCH_TEMPLATES.items():
|
|
||||||
plain_url = template.format(q=q)
|
|
||||||
try:
|
|
||||||
affiliate_url = wrap_url(plain_url, retailer=key)
|
|
||||||
except Exception:
|
|
||||||
affiliate_url = plain_url
|
|
||||||
links.append({"retailer": key, "label": _label(key), "url": affiliate_url})
|
|
||||||
return links
|
|
||||||
|
|
||||||
|
|
||||||
def _label(key: str) -> str:
|
|
||||||
return {
|
|
||||||
"amazon": "Amazon",
|
|
||||||
"instacart": "Instacart",
|
|
||||||
"walmart": "Walmart",
|
|
||||||
"target": "Target",
|
|
||||||
"thrive": "Thrive Market",
|
|
||||||
"misfits": "Misfits Market",
|
|
||||||
}.get(key, key.title())
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
# app/services/meal_plan/llm_planner.py
|
|
||||||
# BSL 1.1 — LLM feature
|
|
||||||
"""LLM-assisted full-week meal plan generation.
|
|
||||||
|
|
||||||
Returns suggestions for human review — never writes to the DB directly.
|
|
||||||
The API endpoint presents the suggestions and waits for user approval
|
|
||||||
before calling store.upsert_slot().
|
|
||||||
|
|
||||||
Routing: pass a router from get_meal_plan_router() in llm_router.py.
|
|
||||||
Cloud: cf-text via cf-orch (3B-7B GGUF, ~2GB VRAM).
|
|
||||||
Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_PLAN_SYSTEM = """\
|
|
||||||
You are a practical meal planning assistant. Given a pantry inventory and
|
|
||||||
dietary preferences, suggest a week of dinners (or other configured meals).
|
|
||||||
|
|
||||||
Prioritise ingredients that are expiring soon. Prefer variety across the week.
|
|
||||||
Respect all dietary restrictions.
|
|
||||||
|
|
||||||
Respond with a JSON array only — no prose, no markdown fences.
|
|
||||||
Each item: {"day": 0-6, "meal_type": "dinner", "recipe_id": <int or null>, "suggestion": "<recipe name>"}
|
|
||||||
|
|
||||||
day 0 = Monday, day 6 = Sunday.
|
|
||||||
If you cannot match a known recipe_id, set recipe_id to null and provide a suggestion name.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PlanSuggestion:
|
|
||||||
day: int # 0 = Monday
|
|
||||||
meal_type: str
|
|
||||||
recipe_id: int | None
|
|
||||||
suggestion: str # human-readable name
|
|
||||||
|
|
||||||
|
|
||||||
def generate_plan(
|
|
||||||
pantry_items: list[str],
|
|
||||||
meal_types: list[str],
|
|
||||||
dietary_notes: str,
|
|
||||||
router,
|
|
||||||
) -> list[PlanSuggestion]:
|
|
||||||
"""Return a list of PlanSuggestion for user review.
|
|
||||||
|
|
||||||
Never writes to DB — caller must upsert slots after user approves.
|
|
||||||
Returns an empty list if router is None or response is unparseable.
|
|
||||||
"""
|
|
||||||
if router is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
pantry_text = "\n".join(f"- {item}" for item in pantry_items[:50])
|
|
||||||
meal_text = ", ".join(meal_types)
|
|
||||||
user_msg = (
|
|
||||||
f"Meal types: {meal_text}\n"
|
|
||||||
f"Dietary notes: {dietary_notes or 'none'}\n\n"
|
|
||||||
f"Pantry (partial):\n{pantry_text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = router.complete(
|
|
||||||
system=_PLAN_SYSTEM,
|
|
||||||
user=user_msg,
|
|
||||||
max_tokens=512,
|
|
||||||
temperature=0.7,
|
|
||||||
)
|
|
||||||
items = json.loads(response.strip())
|
|
||||||
suggestions = []
|
|
||||||
for item in items:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
day = item.get("day")
|
|
||||||
meal_type = item.get("meal_type", "dinner")
|
|
||||||
if not isinstance(day, int) or day < 0 or day > 6:
|
|
||||||
continue
|
|
||||||
suggestions.append(PlanSuggestion(
|
|
||||||
day=day,
|
|
||||||
meal_type=meal_type,
|
|
||||||
recipe_id=item.get("recipe_id"),
|
|
||||||
suggestion=str(item.get("suggestion", "")),
|
|
||||||
))
|
|
||||||
return suggestions
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("LLM plan generation failed: %s", exc)
|
|
||||||
return []
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
# app/services/meal_plan/llm_router.py
|
|
||||||
# BSL 1.1 — LLM feature
|
|
||||||
"""Provide a router-compatible LLM client for meal plan generation tasks.
|
|
||||||
|
|
||||||
Cloud (CF_ORCH_URL set), tier 1 — task-based routing (preferred):
|
|
||||||
Calls /api/inference/task with product=kiwi, task=meal_plan.
|
|
||||||
The coordinator resolves the model from assignments.yaml.
|
|
||||||
|
|
||||||
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):
|
|
||||||
Returns an LLMRouter instance which tries ollama, vllm, or any
|
|
||||||
backend configured in ~/.config/circuitforge/llm.yaml.
|
|
||||||
|
|
||||||
All paths expose the same (router, ctx) interface so llm_planner.py
|
|
||||||
needs no knowledge of the backend.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from contextlib import nullcontext
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# cf-orch service name and TTL for direct-allocate fallback path.
|
|
||||||
_SERVICE_TYPE = "cf-text"
|
|
||||||
_TTL_S = 120.0
|
|
||||||
_CALLER = "kiwi-meal-plan"
|
|
||||||
|
|
||||||
|
|
||||||
class _OrchTextRouter:
|
|
||||||
"""Thin adapter that makes a cf-text HTTP endpoint look like LLMRouter."""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str) -> None:
|
|
||||||
self._base_url = base_url.rstrip("/")
|
|
||||||
|
|
||||||
def complete(
|
|
||||||
self,
|
|
||||||
system: str = "",
|
|
||||||
user: str = "",
|
|
||||||
max_tokens: int = 512,
|
|
||||||
temperature: float = 0.7,
|
|
||||||
**_kwargs,
|
|
||||||
) -> str:
|
|
||||||
from openai import OpenAI
|
|
||||||
client = OpenAI(base_url=self._base_url + "/v1", api_key="any")
|
|
||||||
messages = []
|
|
||||||
if system:
|
|
||||||
messages.append({"role": "system", "content": system})
|
|
||||||
messages.append({"role": "user", "content": user})
|
|
||||||
try:
|
|
||||||
model = client.models.list().data[0].id
|
|
||||||
except Exception:
|
|
||||||
model = "local"
|
|
||||||
resp = client.chat.completions.create(
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
temperature=temperature,
|
|
||||||
)
|
|
||||||
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():
|
|
||||||
"""Return an LLM client for meal plan tasks.
|
|
||||||
|
|
||||||
Returns (router, ctx) where ctx is a context manager the caller holds
|
|
||||||
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")
|
|
||||||
|
|
||||||
if cf_orch_url:
|
|
||||||
# Tier 1: task-based routing — coordinator owns model selection.
|
|
||||||
if _HAS_TASK_INFERENCE:
|
|
||||||
try:
|
|
||||||
ctx = task_allocate(
|
|
||||||
"kiwi", "meal_plan",
|
|
||||||
service_hint=_SERVICE_TYPE,
|
|
||||||
ttl_s=_TTL_S,
|
|
||||||
)
|
|
||||||
alloc = ctx.__enter__()
|
|
||||||
return _OrchTextRouter(alloc.url), ctx
|
|
||||||
except TaskNotRegistered:
|
|
||||||
logger.debug(
|
|
||||||
"kiwi.meal_plan not in coordinator assignments — "
|
|
||||||
"falling back to direct cf-text allocation"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("task allocation failed, trying direct allocate: %s", exc)
|
|
||||||
|
|
||||||
# Tier 2: direct allocation — hardcoded service type.
|
|
||||||
if CFOrchClient is not None:
|
|
||||||
try:
|
|
||||||
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)
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
# app/services/meal_plan/llm_timing.py
|
|
||||||
# BSL 1.1 — LLM feature
|
|
||||||
"""Estimate cook times for recipes missing corpus prep/cook time fields.
|
|
||||||
|
|
||||||
Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully
|
|
||||||
when no LLM backend is available.
|
|
||||||
|
|
||||||
Routing: pass a router from get_meal_plan_router() in llm_router.py.
|
|
||||||
Cloud: cf-text via cf-orch (3B GGUF, ~2GB VRAM).
|
|
||||||
Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_TIMING_PROMPT = """\
|
|
||||||
You are a practical cook. Given a recipe name and its ingredients, estimate:
|
|
||||||
1. prep_time: minutes of active prep work (chopping, mixing, etc.)
|
|
||||||
2. cook_time: minutes of cooking (oven, stovetop, etc.)
|
|
||||||
|
|
||||||
Respond with ONLY two integers on separate lines:
|
|
||||||
prep_time
|
|
||||||
cook_time
|
|
||||||
|
|
||||||
If you cannot estimate, respond with:
|
|
||||||
0
|
|
||||||
0
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_timing(recipe_name: str, ingredients: list[str], router) -> tuple[int | None, int | None]:
|
|
||||||
"""Return (prep_minutes, cook_minutes) for a recipe using LLMRouter.
|
|
||||||
|
|
||||||
Returns (None, None) if the router is unavailable or the response is
|
|
||||||
unparseable. Never raises.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recipe_name: Name of the recipe.
|
|
||||||
ingredients: List of raw ingredient strings from the corpus.
|
|
||||||
router: An LLMRouter instance (from circuitforge_core.llm).
|
|
||||||
"""
|
|
||||||
if router is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
ingredient_list = "\n".join(f"- {i}" for i in (ingredients or [])[:15])
|
|
||||||
prompt = f"Recipe: {recipe_name}\n\nIngredients:\n{ingredient_list}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = router.complete(
|
|
||||||
system=_TIMING_PROMPT,
|
|
||||||
user=prompt,
|
|
||||||
max_tokens=16,
|
|
||||||
temperature=0.0,
|
|
||||||
)
|
|
||||||
lines = response.strip().splitlines()
|
|
||||||
prep = int(lines[0].strip()) if lines else 0
|
|
||||||
cook = int(lines[1].strip()) if len(lines) > 1 else 0
|
|
||||||
if prep == 0 and cook == 0:
|
|
||||||
return None, None
|
|
||||||
return prep or None, cook or None
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("LLM timing estimation failed for %r: %s", recipe_name, exc)
|
|
||||||
return None, None
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# app/services/meal_plan/planner.py
|
|
||||||
"""Plan and slot orchestration — thin layer over Store.
|
|
||||||
|
|
||||||
No FastAPI imports. Provides helpers used by the API endpoint.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.meal_plan import VALID_MEAL_TYPES
|
|
||||||
|
|
||||||
|
|
||||||
def create_plan(store: Store, week_start: str, meal_types: list[str]) -> dict:
|
|
||||||
"""Create a plan, filtering meal_types to valid values only."""
|
|
||||||
valid = [t for t in meal_types if t in VALID_MEAL_TYPES]
|
|
||||||
if not valid:
|
|
||||||
valid = ["dinner"]
|
|
||||||
return store.create_meal_plan(week_start, valid)
|
|
||||||
|
|
||||||
|
|
||||||
def get_plan_with_slots(store: Store, plan_id: int) -> dict | None:
|
|
||||||
"""Return a plan row with its slots list attached, or None."""
|
|
||||||
plan = store.get_meal_plan(plan_id)
|
|
||||||
if plan is None:
|
|
||||||
return None
|
|
||||||
slots = store.get_plan_slots(plan_id)
|
|
||||||
return {**plan, "slots": slots}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
# app/services/meal_plan/prep_scheduler.py
|
|
||||||
"""Sequence prep tasks for a batch cooking session.
|
|
||||||
|
|
||||||
Pure function — no DB or network calls. Sorts tasks by equipment priority
|
|
||||||
(oven first to maximise oven utilisation) then assigns sequence_order.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
_EQUIPMENT_PRIORITY = {"oven": 0, "stovetop": 1, "cold": 2, "no-heat": 3}
|
|
||||||
_DEFAULT_PRIORITY = 4
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PrepTask:
|
|
||||||
recipe_id: int | None
|
|
||||||
slot_id: int | None
|
|
||||||
task_label: str
|
|
||||||
duration_minutes: int | None
|
|
||||||
sequence_order: int
|
|
||||||
equipment: str | None
|
|
||||||
is_parallel: bool = False
|
|
||||||
notes: str | None = None
|
|
||||||
user_edited: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _total_minutes(recipe: dict) -> int | None:
|
|
||||||
prep = recipe.get("prep_time")
|
|
||||||
cook = recipe.get("cook_time")
|
|
||||||
if prep is None and cook is None:
|
|
||||||
return None
|
|
||||||
return (prep or 0) + (cook or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _equipment(recipe: dict) -> str | None:
|
|
||||||
# Corpus recipes don't have an explicit equipment field; use test helper
|
|
||||||
# field if present, otherwise infer from cook_time (long = oven heuristic).
|
|
||||||
if "_equipment" in recipe:
|
|
||||||
return recipe["_equipment"]
|
|
||||||
minutes = _total_minutes(recipe)
|
|
||||||
if minutes and minutes >= 45:
|
|
||||||
return "oven"
|
|
||||||
return "stovetop"
|
|
||||||
|
|
||||||
|
|
||||||
def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]:
|
|
||||||
"""Return a sequenced list of PrepTask objects from plan slots + recipe rows.
|
|
||||||
|
|
||||||
Algorithm:
|
|
||||||
1. Build a recipe_id → recipe dict lookup.
|
|
||||||
2. Create one task per slot that has a recipe assigned.
|
|
||||||
3. Sort by equipment priority (oven first).
|
|
||||||
4. Assign contiguous sequence_order starting at 1.
|
|
||||||
"""
|
|
||||||
if not slots or not recipes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
recipe_map: dict[int, dict] = {r["id"]: r for r in recipes}
|
|
||||||
raw_tasks: list[tuple[int, dict]] = [] # (priority, kwargs)
|
|
||||||
|
|
||||||
for slot in slots:
|
|
||||||
recipe_id = slot.get("recipe_id")
|
|
||||||
if not recipe_id:
|
|
||||||
continue
|
|
||||||
recipe = recipe_map.get(recipe_id)
|
|
||||||
if not recipe:
|
|
||||||
continue
|
|
||||||
|
|
||||||
eq = _equipment(recipe)
|
|
||||||
priority = _EQUIPMENT_PRIORITY.get(eq or "", _DEFAULT_PRIORITY)
|
|
||||||
raw_tasks.append((priority, {
|
|
||||||
"recipe_id": recipe_id,
|
|
||||||
"slot_id": slot.get("id"),
|
|
||||||
"task_label": recipe.get("name", f"Recipe {recipe_id}"),
|
|
||||||
"duration_minutes": _total_minutes(recipe),
|
|
||||||
"equipment": eq,
|
|
||||||
}))
|
|
||||||
|
|
||||||
raw_tasks.sort(key=lambda t: t[0])
|
|
||||||
return [
|
|
||||||
PrepTask(
|
|
||||||
recipe_id=kw["recipe_id"],
|
|
||||||
slot_id=kw["slot_id"],
|
|
||||||
task_label=kw["task_label"],
|
|
||||||
duration_minutes=kw["duration_minutes"],
|
|
||||||
sequence_order=i,
|
|
||||||
equipment=kw["equipment"],
|
|
||||||
)
|
|
||||||
for i, (_, kw) in enumerate(raw_tasks, 1)
|
|
||||||
]
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
# app/services/meal_plan/shopping_list.py
|
|
||||||
"""Compute a shopping list from a meal plan and current pantry inventory.
|
|
||||||
|
|
||||||
Pure function — no DB or network calls. Takes plain dicts from the Store
|
|
||||||
and returns GapItem dataclasses.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GapItem:
|
|
||||||
ingredient_name: str
|
|
||||||
needed_raw: str | None # first quantity token from recipe text, e.g. "300g"
|
|
||||||
have_quantity: float | None # pantry quantity when partial match
|
|
||||||
have_unit: str | None
|
|
||||||
covered: bool
|
|
||||||
retailer_links: list = field(default_factory=list) # filled by API layer
|
|
||||||
|
|
||||||
|
|
||||||
_QUANTITY_RE = re.compile(r"^(\d+[\d./]*\s*(?:g|kg|ml|l|oz|lb|cup|cups|tsp|tbsp|tbsps|tsps)?)\b", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_quantity(ingredient_text: str) -> str | None:
|
|
||||||
"""Pull the leading quantity string from a raw ingredient line."""
|
|
||||||
m = _QUANTITY_RE.match(ingredient_text.strip())
|
|
||||||
return m.group(1).strip() if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalise(name: str) -> str:
|
|
||||||
"""Lowercase, strip possessives and plural -s for fuzzy matching."""
|
|
||||||
return name.lower().strip().rstrip("s")
|
|
||||||
|
|
||||||
|
|
||||||
def compute_shopping_list(
|
|
||||||
recipes: list[dict],
|
|
||||||
inventory: list[dict],
|
|
||||||
) -> tuple[list[GapItem], list[GapItem]]:
|
|
||||||
"""Return (gap_items, covered_items) for a list of recipe dicts + inventory dicts.
|
|
||||||
|
|
||||||
Deduplicates by normalised ingredient name — the first recipe's quantity
|
|
||||||
string wins when the same ingredient appears in multiple recipes.
|
|
||||||
"""
|
|
||||||
if not recipes:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
# Build pantry lookup: normalised_name → inventory row
|
|
||||||
pantry: dict[str, dict] = {}
|
|
||||||
for item in inventory:
|
|
||||||
pantry[_normalise(item["name"])] = item
|
|
||||||
|
|
||||||
# Collect unique ingredients with their first quantity token
|
|
||||||
seen: dict[str, str | None] = {} # normalised_name → needed_raw
|
|
||||||
for recipe in recipes:
|
|
||||||
names: list[str] = recipe.get("ingredient_names") or []
|
|
||||||
raw_lines: list[str] = recipe.get("ingredients") or []
|
|
||||||
for i, name in enumerate(names):
|
|
||||||
key = _normalise(name)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
raw = raw_lines[i] if i < len(raw_lines) else ""
|
|
||||||
seen[key] = _extract_quantity(raw)
|
|
||||||
|
|
||||||
gaps: list[GapItem] = []
|
|
||||||
covered: list[GapItem] = []
|
|
||||||
|
|
||||||
for norm_name, needed_raw in seen.items():
|
|
||||||
pantry_row = pantry.get(norm_name)
|
|
||||||
if pantry_row:
|
|
||||||
covered.append(GapItem(
|
|
||||||
ingredient_name=norm_name,
|
|
||||||
needed_raw=needed_raw,
|
|
||||||
have_quantity=pantry_row.get("quantity"),
|
|
||||||
have_unit=pantry_row.get("unit"),
|
|
||||||
covered=True,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
gaps.append(GapItem(
|
|
||||||
ingredient_name=norm_name,
|
|
||||||
needed_raw=needed_raw,
|
|
||||||
have_quantity=None,
|
|
||||||
have_unit=None,
|
|
||||||
covered=False,
|
|
||||||
))
|
|
||||||
|
|
||||||
return gaps, covered
|
|
||||||
|
|
@ -18,51 +18,43 @@ class DocuvisionResult:
|
||||||
class DocuvisionClient:
|
class DocuvisionClient:
|
||||||
"""Thin client for the cf-docuvision service."""
|
"""Thin client for the cf-docuvision service."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, timeout: float = 120.0) -> None:
|
def __init__(self, base_url: str) -> None:
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
self._timeout = timeout
|
|
||||||
|
|
||||||
def extract_text(self, image_path: str | Path, hint: str = "text") -> DocuvisionResult:
|
def extract_text(self, image_path: str | Path) -> DocuvisionResult:
|
||||||
"""Send an image to docuvision and return extracted text.
|
"""Send an image to docuvision and return extracted text."""
|
||||||
|
|
||||||
Args:
|
|
||||||
image_path: Path to the image file.
|
|
||||||
hint: Docuvision extraction hint — "text" for dense prose (recipes),
|
|
||||||
"table" for tabular data, "form" for form fields, "auto" for
|
|
||||||
automatic detection.
|
|
||||||
"""
|
|
||||||
image_bytes = Path(image_path).read_bytes()
|
image_bytes = Path(image_path).read_bytes()
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
with httpx.Client(timeout=self._timeout) as client:
|
with httpx.Client(timeout=30.0) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{self._base_url}/extract",
|
f"{self._base_url}/extract",
|
||||||
json={"image_b64": b64, "hint": hint},
|
json={"image": b64},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
return DocuvisionResult(
|
return DocuvisionResult(
|
||||||
text=data.get("raw_text", ""),
|
text=data.get("text", ""),
|
||||||
confidence=data.get("metadata", {}).get("confidence"),
|
confidence=data.get("confidence"),
|
||||||
raw=data,
|
raw=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def extract_text_async(self, image_path: str | Path, hint: str = "text") -> DocuvisionResult:
|
async def extract_text_async(self, image_path: str | Path) -> DocuvisionResult:
|
||||||
"""Async version."""
|
"""Async version."""
|
||||||
image_bytes = Path(image_path).read_bytes()
|
image_bytes = Path(image_path).read_bytes()
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{self._base_url}/extract",
|
f"{self._base_url}/extract",
|
||||||
json={"image_b64": b64, "hint": hint},
|
json={"image": b64},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
return DocuvisionResult(
|
return DocuvisionResult(
|
||||||
text=data.get("raw_text", ""),
|
text=data.get("text", ""),
|
||||||
confidence=data.get("metadata", {}).get("confidence"),
|
confidence=data.get("confidence"),
|
||||||
raw=data,
|
raw=data,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,29 +32,6 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
if not cf_orch_url:
|
if not cf_orch_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Tier 1: task-based routing — coordinator owns model selection.
|
|
||||||
try:
|
|
||||||
from app.services.task_inference import task_allocate, TaskNotRegistered
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
|
||||||
try:
|
|
||||||
with task_allocate(
|
|
||||||
"kiwi", "ocr",
|
|
||||||
service_hint="cf-docuvision",
|
|
||||||
ttl_s=60.0,
|
|
||||||
) as alloc:
|
|
||||||
doc_client = DocuvisionClient(alloc.url)
|
|
||||||
result = doc_client.extract_text(image_path)
|
|
||||||
return result.text if result.text else None
|
|
||||||
except TaskNotRegistered:
|
|
||||||
logger.debug(
|
|
||||||
"kiwi.ocr not in coordinator assignments — "
|
|
||||||
"falling back to direct cf-docuvision allocation"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("task allocation path failed, trying direct allocate: %s", exc)
|
|
||||||
|
|
||||||
# Tier 2: direct allocation — hardcoded service type.
|
|
||||||
try:
|
try:
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
|
@ -72,7 +49,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
result = doc_client.extract_text(image_path)
|
result = doc_client.extract_text(image_path)
|
||||||
return result.text if result.text else None
|
return result.text if result.text else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("cf-docuvision fast-path failed, falling back to local VLM: %s", exc)
|
logger.debug("cf-docuvision fast-path failed, falling back: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,72 +15,63 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class OpenFoodFactsService:
|
class OpenFoodFactsService:
|
||||||
"""
|
"""
|
||||||
Service for interacting with the Open*Facts family of databases.
|
Service for interacting with the OpenFoodFacts API.
|
||||||
|
|
||||||
Primary: OpenFoodFacts (food products).
|
OpenFoodFacts is a free, open database of food products with
|
||||||
Fallback chain: Open Beauty Facts (personal care) → Open Products Facts (household).
|
ingredients, allergens, and nutrition facts.
|
||||||
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, trying OFFs then fallback databases.
|
Look up a product by barcode in the OpenFoodFacts database.
|
||||||
|
|
||||||
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 in any database.
|
Dictionary with product information, or None if not found
|
||||||
|
|
||||||
|
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
|
|
||||||
|
|
||||||
for db_url in self._FALLBACK_DATABASES:
|
response = await client.get(
|
||||||
result = await self._lookup_in_database(barcode, db_url, client)
|
url,
|
||||||
if result:
|
headers={"User-Agent": self.USER_AGENT},
|
||||||
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
|
timeout=10.0,
|
||||||
return result
|
)
|
||||||
|
|
||||||
logger.info("Barcode %s not found in any Open*Facts database", barcode)
|
if response.status_code == 404:
|
||||||
|
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
||||||
|
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
|
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]:
|
||||||
|
|
@ -123,9 +114,6 @@ 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,
|
||||||
|
|
@ -136,47 +124,9 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -42,21 +42,11 @@ class AssemblyRole:
|
||||||
class AssemblyTemplate:
|
class AssemblyTemplate:
|
||||||
"""A template assembly dish."""
|
"""A template assembly dish."""
|
||||||
id: int
|
id: int
|
||||||
slug: str # URL-safe identifier, e.g. "burrito_taco"
|
|
||||||
icon: str # emoji
|
|
||||||
descriptor: str # one-line description shown in template grid
|
|
||||||
title: str
|
title: str
|
||||||
required: list[AssemblyRole]
|
required: list[AssemblyRole]
|
||||||
optional: list[AssemblyRole]
|
optional: list[AssemblyRole]
|
||||||
directions: list[str]
|
directions: list[str]
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
# Per-role hints shown in the wizard picker header
|
|
||||||
# keys match role.display values; missing keys fall back to ""
|
|
||||||
role_hints: dict[str, str] = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.role_hints is None:
|
|
||||||
self.role_hints = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
|
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
|
||||||
|
|
@ -148,9 +138,6 @@ def _personalized_title(tmpl: AssemblyTemplate, pantry_set: set[str], seed: int)
|
||||||
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-1,
|
id=-1,
|
||||||
slug="burrito_taco",
|
|
||||||
icon="🌯",
|
|
||||||
descriptor="Protein, veg, and sauce in a tortilla or over rice",
|
|
||||||
title="Burrito / Taco",
|
title="Burrito / Taco",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("tortilla or wrap", [
|
AssemblyRole("tortilla or wrap", [
|
||||||
|
|
@ -183,21 +170,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Fold in the sides and roll tightly. Optionally toast seam-side down 1-2 minutes.",
|
"Fold in the sides and roll tightly. Optionally toast seam-side down 1-2 minutes.",
|
||||||
],
|
],
|
||||||
notes="Works as a burrito (rolled), taco (folded), or quesadilla (cheese only, pressed flat).",
|
notes="Works as a burrito (rolled), taco (folded), or quesadilla (cheese only, pressed flat).",
|
||||||
role_hints={
|
|
||||||
"tortilla or wrap": "The foundation -- what holds everything",
|
|
||||||
"protein": "The main filling",
|
|
||||||
"rice or starch": "Optional base layer",
|
|
||||||
"cheese": "Optional -- melts into the filling",
|
|
||||||
"salsa or sauce": "Optional -- adds moisture and heat",
|
|
||||||
"sour cream or yogurt": "Optional -- cool contrast to heat",
|
|
||||||
"vegetables": "Optional -- adds texture and colour",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-2,
|
id=-2,
|
||||||
slug="fried_rice",
|
|
||||||
icon="🍳",
|
|
||||||
descriptor="Rice + egg + whatever's in the fridge",
|
|
||||||
title="Fried Rice",
|
title="Fried Rice",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("cooked rice", [
|
AssemblyRole("cooked rice", [
|
||||||
|
|
@ -230,21 +205,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Season with soy sauce and any other sauces. Toss to combine.",
|
"Season with soy sauce and any other sauces. Toss to combine.",
|
||||||
],
|
],
|
||||||
notes="Add a fried egg on top. A drizzle of sesame oil at the end adds a lot.",
|
notes="Add a fried egg on top. A drizzle of sesame oil at the end adds a lot.",
|
||||||
role_hints={
|
|
||||||
"cooked rice": "Day-old cold rice works best",
|
|
||||||
"protein": "Pre-cooked or raw -- cook before adding rice",
|
|
||||||
"soy sauce or seasoning": "The primary flavour driver",
|
|
||||||
"oil": "High smoke-point oil for high heat",
|
|
||||||
"egg": "Scrambled in the same pan",
|
|
||||||
"vegetables": "Add crunch and colour",
|
|
||||||
"garlic or ginger": "Aromatic base -- add first",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-3,
|
id=-3,
|
||||||
slug="omelette_scramble",
|
|
||||||
icon="🥚",
|
|
||||||
descriptor="Eggs with fillings, pan-cooked",
|
|
||||||
title="Omelette / Scramble",
|
title="Omelette / Scramble",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("eggs", ["egg"]),
|
AssemblyRole("eggs", ["egg"]),
|
||||||
|
|
@ -275,19 +238,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Season and serve immediately.",
|
"Season and serve immediately.",
|
||||||
],
|
],
|
||||||
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
|
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
|
||||||
role_hints={
|
|
||||||
"eggs": "The base -- beat with a splash of water",
|
|
||||||
"cheese": "Fold in just before serving",
|
|
||||||
"vegetables": "Saute first, then add eggs",
|
|
||||||
"protein": "Cook through before adding eggs",
|
|
||||||
"herbs or seasoning": "Season at the end",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-4,
|
id=-4,
|
||||||
slug="stir_fry",
|
|
||||||
icon="🥢",
|
|
||||||
descriptor="High-heat protein + veg in sauce",
|
|
||||||
title="Stir Fry",
|
title="Stir Fry",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("vegetables", [
|
AssemblyRole("vegetables", [
|
||||||
|
|
@ -318,20 +271,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Serve over rice or noodles.",
|
"Serve over rice or noodles.",
|
||||||
],
|
],
|
||||||
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
|
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
|
||||||
role_hints={
|
|
||||||
"vegetables": "Cut to similar size for even cooking",
|
|
||||||
"starch base": "Serve under or toss with the stir fry",
|
|
||||||
"protein": "Cook first, remove, add back at end",
|
|
||||||
"sauce": "Add last -- toss for 1-2 minutes only",
|
|
||||||
"garlic or ginger": "Add early for aromatic base",
|
|
||||||
"oil": "High smoke-point oil only",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-5,
|
id=-5,
|
||||||
slug="pasta",
|
|
||||||
icon="🍝",
|
|
||||||
descriptor="Pantry pasta with flexible sauce",
|
|
||||||
title="Pasta with Whatever You Have",
|
title="Pasta with Whatever You Have",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("pasta", [
|
AssemblyRole("pasta", [
|
||||||
|
|
@ -365,20 +307,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Toss cooked pasta with sauce. Finish with cheese if using.",
|
"Toss cooked pasta with sauce. Finish with cheese if using.",
|
||||||
],
|
],
|
||||||
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
|
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
|
||||||
role_hints={
|
|
||||||
"pasta": "The base -- cook al dente, reserve pasta water",
|
|
||||||
"sauce base": "Simmer 5 min; pasta water loosens it",
|
|
||||||
"protein": "Cook through before adding sauce",
|
|
||||||
"cheese": "Finish off heat to avoid graininess",
|
|
||||||
"vegetables": "Saute until tender before adding sauce",
|
|
||||||
"garlic": "Saute in oil first -- the flavour foundation",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-6,
|
id=-6,
|
||||||
slug="sandwich_wrap",
|
|
||||||
icon="🥪",
|
|
||||||
descriptor="Protein + veg between bread or in a wrap",
|
|
||||||
title="Sandwich / Wrap",
|
title="Sandwich / Wrap",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("bread or wrap", [
|
AssemblyRole("bread or wrap", [
|
||||||
|
|
@ -410,19 +341,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Press together and cut diagonally.",
|
"Press together and cut diagonally.",
|
||||||
],
|
],
|
||||||
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
|
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
|
||||||
role_hints={
|
|
||||||
"bread or wrap": "Toast for better texture",
|
|
||||||
"protein": "Layer on first after condiments",
|
|
||||||
"cheese": "Goes on top of protein",
|
|
||||||
"condiment": "Spread on both inner surfaces",
|
|
||||||
"vegetables": "Top layer -- keeps bread from getting soggy",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-7,
|
id=-7,
|
||||||
slug="grain_bowl",
|
|
||||||
icon="🥗",
|
|
||||||
descriptor="Grain base + protein + toppings + dressing",
|
|
||||||
title="Grain Bowl",
|
title="Grain Bowl",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("grain base", [
|
AssemblyRole("grain base", [
|
||||||
|
|
@ -456,19 +377,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Drizzle with dressing and add toppings.",
|
"Drizzle with dressing and add toppings.",
|
||||||
],
|
],
|
||||||
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
|
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
|
||||||
role_hints={
|
|
||||||
"grain base": "Season while cooking -- bland grains sink the bowl",
|
|
||||||
"protein": "Slice or shred; arrange on top",
|
|
||||||
"vegetables": "Roast or saute for best flavour",
|
|
||||||
"dressing or sauce": "Drizzle last -- ties everything together",
|
|
||||||
"toppings": "Add crunch and contrast",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-8,
|
id=-8,
|
||||||
slug="soup_stew",
|
|
||||||
icon="🥣",
|
|
||||||
descriptor="Liquid-based, flexible ingredients",
|
|
||||||
title="Soup / Stew",
|
title="Soup / Stew",
|
||||||
required=[
|
required=[
|
||||||
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
|
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
|
||||||
|
|
@ -504,19 +415,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Season to taste and simmer at least 20 minutes for flavors to develop.",
|
"Season to taste and simmer at least 20 minutes for flavors to develop.",
|
||||||
],
|
],
|
||||||
notes="Soups and stews improve overnight in the fridge. Almost any combination works.",
|
notes="Soups and stews improve overnight in the fridge. Almost any combination works.",
|
||||||
role_hints={
|
|
||||||
"broth or stock": "The liquid base -- determines overall flavour",
|
|
||||||
"protein": "Brown first for deeper flavour",
|
|
||||||
"vegetables": "Dense veg first; quick-cooking veg last",
|
|
||||||
"starch thickener": "Adds body and turns soup into stew",
|
|
||||||
"seasoning": "Taste and adjust after 20 min simmer",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-9,
|
id=-9,
|
||||||
slug="casserole_bake",
|
|
||||||
icon="🫙",
|
|
||||||
descriptor="Oven bake with protein, veg, starch",
|
|
||||||
title="Casserole / Bake",
|
title="Casserole / Bake",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("starch or base", [
|
AssemblyRole("starch or base", [
|
||||||
|
|
@ -556,20 +457,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Bake covered 25 minutes, then uncovered 15 minutes until golden and bubbly.",
|
"Bake covered 25 minutes, then uncovered 15 minutes until golden and bubbly.",
|
||||||
],
|
],
|
||||||
notes="Classic pantry dump dinner. Cream of anything soup is the universal binder.",
|
notes="Classic pantry dump dinner. Cream of anything soup is the universal binder.",
|
||||||
role_hints={
|
|
||||||
"starch or base": "Cook slightly underdone -- finishes in oven",
|
|
||||||
"binder or sauce": "Coats everything and holds the bake together",
|
|
||||||
"protein": "Pre-cook before mixing in",
|
|
||||||
"vegetables": "Chop small for even distribution",
|
|
||||||
"cheese topping": "Goes on last -- browns in the final 15 min",
|
|
||||||
"seasoning": "Casseroles need more salt than you think",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-10,
|
id=-10,
|
||||||
slug="pancakes_quickbread",
|
|
||||||
icon="🥞",
|
|
||||||
descriptor="Batter-based; sweet or savory",
|
|
||||||
title="Pancakes / Waffles / Quick Bread",
|
title="Pancakes / Waffles / Quick Bread",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("flour or baking mix", [
|
AssemblyRole("flour or baking mix", [
|
||||||
|
|
@ -605,20 +495,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"For muffins or quick bread: pour into greased pan, bake at 375 F until a toothpick comes out clean.",
|
"For muffins or quick bread: pour into greased pan, bake at 375 F until a toothpick comes out clean.",
|
||||||
],
|
],
|
||||||
notes="Overmixing develops gluten and makes pancakes tough. Stop when just combined.",
|
notes="Overmixing develops gluten and makes pancakes tough. Stop when just combined.",
|
||||||
role_hints={
|
|
||||||
"flour or baking mix": "Whisk dry ingredients together first",
|
|
||||||
"leavening or egg": "Activates rise -- don't skip",
|
|
||||||
"liquid": "Add to dry ingredients; lumps are fine",
|
|
||||||
"fat": "Adds richness and prevents sticking",
|
|
||||||
"sweetener": "Mix into wet ingredients",
|
|
||||||
"mix-ins": "Fold in last -- gently",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-11,
|
id=-11,
|
||||||
slug="porridge_oatmeal",
|
|
||||||
icon="🌾",
|
|
||||||
descriptor="Oat or grain base with toppings",
|
|
||||||
title="Porridge / Oatmeal",
|
title="Porridge / Oatmeal",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("oats or grain porridge", [
|
AssemblyRole("oats or grain porridge", [
|
||||||
|
|
@ -641,20 +520,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Top with fruit, nuts, or seeds and serve immediately.",
|
"Top with fruit, nuts, or seeds and serve immediately.",
|
||||||
],
|
],
|
||||||
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
|
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
|
||||||
role_hints={
|
|
||||||
"oats or grain porridge": "1 part oats to 2 parts liquid",
|
|
||||||
"liquid": "Use milk for creamier result",
|
|
||||||
"sweetener": "Stir in after cooking",
|
|
||||||
"fruit": "Add fresh on top or simmer dried fruit in",
|
|
||||||
"toppings": "Add last for crunch",
|
|
||||||
"spice": "Stir in with sweetener",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-12,
|
id=-12,
|
||||||
slug="pie_pot_pie",
|
|
||||||
icon="🥧",
|
|
||||||
descriptor="Pastry or biscuit crust with filling",
|
|
||||||
title="Pie / Pot Pie",
|
title="Pie / Pot Pie",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("pastry or crust", [
|
AssemblyRole("pastry or crust", [
|
||||||
|
|
@ -693,20 +561,9 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"For sweet pie: fill unbaked crust with fruit filling, top with second crust or crumble, bake similarly.",
|
"For sweet pie: fill unbaked crust with fruit filling, top with second crust or crumble, bake similarly.",
|
||||||
],
|
],
|
||||||
notes="Puff pastry from the freezer is the shortcut to impressive pot pies. Thaw in the fridge overnight.",
|
notes="Puff pastry from the freezer is the shortcut to impressive pot pies. Thaw in the fridge overnight.",
|
||||||
role_hints={
|
|
||||||
"pastry or crust": "Thaw puff pastry overnight in fridge",
|
|
||||||
"protein filling": "Cook through before adding to filling",
|
|
||||||
"vegetables": "Chop small; cook until just tender",
|
|
||||||
"sauce or binder": "Holds the filling together in the crust",
|
|
||||||
"seasoning": "Fillings need generous seasoning",
|
|
||||||
"sweet filling": "For dessert pies -- fruit + sugar",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
AssemblyTemplate(
|
AssemblyTemplate(
|
||||||
id=-13,
|
id=-13,
|
||||||
slug="pudding_custard",
|
|
||||||
icon="🍮",
|
|
||||||
descriptor="Dairy-based set dessert",
|
|
||||||
title="Pudding / Custard",
|
title="Pudding / Custard",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("dairy or dairy-free milk", [
|
AssemblyRole("dairy or dairy-free milk", [
|
||||||
|
|
@ -744,58 +601,10 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"Pour into dishes and refrigerate at least 2 hours to set.",
|
"Pour into dishes and refrigerate at least 2 hours to set.",
|
||||||
],
|
],
|
||||||
notes="UK-style pudding is broad — bread pudding, rice pudding, spotted dick, treacle sponge all count.",
|
notes="UK-style pudding is broad — bread pudding, rice pudding, spotted dick, treacle sponge all count.",
|
||||||
role_hints={
|
|
||||||
"dairy or dairy-free milk": "Heat until steaming before adding to eggs",
|
|
||||||
"thickener or set": "Cornstarch for stovetop; eggs for baked custard",
|
|
||||||
"sweetener or flavouring": "Signals dessert intent -- required",
|
|
||||||
"sweetener": "Adjust to taste",
|
|
||||||
"flavouring": "Add off-heat to preserve aroma",
|
|
||||||
"starchy base": "For bread pudding or rice pudding",
|
|
||||||
"fruit": "Layer in or fold through before setting",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Slug to template lookup (built once at import time)
|
|
||||||
_TEMPLATE_BY_SLUG: dict[str, AssemblyTemplate] = {
|
|
||||||
t.slug: t for t in ASSEMBLY_TEMPLATES
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_templates_for_api() -> list[dict]:
|
|
||||||
"""Serialise all 13 templates for GET /api/recipes/templates.
|
|
||||||
|
|
||||||
Combines required and optional roles into a single ordered role_sequence
|
|
||||||
with required roles first.
|
|
||||||
"""
|
|
||||||
out = []
|
|
||||||
for tmpl in ASSEMBLY_TEMPLATES:
|
|
||||||
roles = []
|
|
||||||
for role in tmpl.required:
|
|
||||||
roles.append({
|
|
||||||
"display": role.display,
|
|
||||||
"required": True,
|
|
||||||
"keywords": role.keywords,
|
|
||||||
"hint": tmpl.role_hints.get(role.display, ""),
|
|
||||||
})
|
|
||||||
for role in tmpl.optional:
|
|
||||||
roles.append({
|
|
||||||
"display": role.display,
|
|
||||||
"required": False,
|
|
||||||
"keywords": role.keywords,
|
|
||||||
"hint": tmpl.role_hints.get(role.display, ""),
|
|
||||||
})
|
|
||||||
out.append({
|
|
||||||
"id": tmpl.slug,
|
|
||||||
"title": tmpl.title,
|
|
||||||
"icon": tmpl.icon,
|
|
||||||
"descriptor": tmpl.descriptor,
|
|
||||||
"role_sequence": roles,
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -870,148 +679,3 @@ def match_assembly_templates(
|
||||||
# Sort by optional coverage descending — best-matched templates first
|
# Sort by optional coverage descending — best-matched templates first
|
||||||
results.sort(key=lambda s: s.match_count, reverse=True)
|
results.sort(key=lambda s: s.match_count, reverse=True)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_role_candidates(
|
|
||||||
template_slug: str,
|
|
||||||
role_display: str,
|
|
||||||
pantry_set: set[str],
|
|
||||||
prior_picks: list[str],
|
|
||||||
profile_index: dict[str, list[str]],
|
|
||||||
) -> dict:
|
|
||||||
"""Return ingredient candidates for one wizard step.
|
|
||||||
|
|
||||||
Splits candidates into 'compatible' (element overlap with prior picks)
|
|
||||||
and 'other' (valid for role but no overlap).
|
|
||||||
|
|
||||||
profile_index: {ingredient_name: [element_tag, ...]} -- pre-loaded from
|
|
||||||
Store.get_element_profiles() by the caller so this function stays DB-free.
|
|
||||||
|
|
||||||
Returns {"compatible": [...], "other": [...], "available_tags": [...]}
|
|
||||||
where each item is {"name": str, "in_pantry": bool, "tags": [str]}.
|
|
||||||
"""
|
|
||||||
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
|
|
||||||
if tmpl is None:
|
|
||||||
return {"compatible": [], "other": [], "available_tags": []}
|
|
||||||
|
|
||||||
# Find the AssemblyRole for this display name
|
|
||||||
target_role: AssemblyRole | None = None
|
|
||||||
for role in tmpl.required + tmpl.optional:
|
|
||||||
if role.display == role_display:
|
|
||||||
target_role = role
|
|
||||||
break
|
|
||||||
if target_role is None:
|
|
||||||
return {"compatible": [], "other": [], "available_tags": []}
|
|
||||||
|
|
||||||
# Build prior-pick element set for compatibility scoring
|
|
||||||
prior_elements: set[str] = set()
|
|
||||||
for pick in prior_picks:
|
|
||||||
prior_elements.update(profile_index.get(pick, []))
|
|
||||||
|
|
||||||
# Find pantry items that match this role
|
|
||||||
pantry_matches = _matches_role(target_role, pantry_set)
|
|
||||||
|
|
||||||
# Build keyword-based "other" candidates from role keywords not in pantry
|
|
||||||
pantry_lower = {p.lower() for p in pantry_set}
|
|
||||||
other_names: list[str] = []
|
|
||||||
for kw in target_role.keywords:
|
|
||||||
if not any(kw in item.lower() for item in pantry_lower):
|
|
||||||
if len(kw) >= 4:
|
|
||||||
other_names.append(kw.title())
|
|
||||||
|
|
||||||
def _make_item(name: str, in_pantry: bool) -> dict:
|
|
||||||
tags = profile_index.get(name, profile_index.get(name.lower(), []))
|
|
||||||
return {"name": name, "in_pantry": in_pantry, "tags": tags}
|
|
||||||
|
|
||||||
# Score: compatible if shares any element with prior picks (or no prior picks yet)
|
|
||||||
compatible: list[dict] = []
|
|
||||||
other: list[dict] = []
|
|
||||||
for name in pantry_matches:
|
|
||||||
item_elements = set(profile_index.get(name, []))
|
|
||||||
item = _make_item(name, in_pantry=True)
|
|
||||||
if not prior_elements or item_elements & prior_elements:
|
|
||||||
compatible.append(item)
|
|
||||||
else:
|
|
||||||
other.append(item)
|
|
||||||
|
|
||||||
for name in other_names:
|
|
||||||
other.append(_make_item(name, in_pantry=False))
|
|
||||||
|
|
||||||
# available_tags: union of all tags in the full candidate set
|
|
||||||
all_tags: set[str] = set()
|
|
||||||
for item in compatible + other:
|
|
||||||
all_tags.update(item["tags"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"compatible": compatible,
|
|
||||||
"other": other,
|
|
||||||
"available_tags": sorted(all_tags),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_from_selection(
|
|
||||||
template_slug: str,
|
|
||||||
role_overrides: dict[str, str],
|
|
||||||
pantry_set: set[str],
|
|
||||||
) -> "RecipeSuggestion | None":
|
|
||||||
"""Build a RecipeSuggestion from explicit role selections.
|
|
||||||
|
|
||||||
role_overrides: {role.display -> chosen pantry item name}
|
|
||||||
|
|
||||||
Returns None if template not found or any required role is uncovered.
|
|
||||||
"""
|
|
||||||
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
|
|
||||||
if tmpl is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
seed = _pantry_hash(pantry_set)
|
|
||||||
|
|
||||||
# Validate required roles: covered by override OR pantry match
|
|
||||||
matched_required: list[str] = []
|
|
||||||
for role in tmpl.required:
|
|
||||||
chosen = role_overrides.get(role.display)
|
|
||||||
if chosen:
|
|
||||||
matched_required.append(chosen)
|
|
||||||
else:
|
|
||||||
hits = _matches_role(role, pantry_set)
|
|
||||||
if not hits:
|
|
||||||
return None
|
|
||||||
matched_required.append(_pick_one(hits, seed + tmpl.id))
|
|
||||||
|
|
||||||
# Collect optional matches (override preferred, then pantry match)
|
|
||||||
matched_optional: list[str] = []
|
|
||||||
for role in tmpl.optional:
|
|
||||||
chosen = role_overrides.get(role.display)
|
|
||||||
if chosen:
|
|
||||||
matched_optional.append(chosen)
|
|
||||||
else:
|
|
||||||
hits = _matches_role(role, pantry_set)
|
|
||||||
if hits:
|
|
||||||
matched_optional.append(_pick_one(hits, seed + tmpl.id))
|
|
||||||
|
|
||||||
all_matched = matched_required + matched_optional
|
|
||||||
|
|
||||||
# Build title: prefer override items for personalisation
|
|
||||||
effective_pantry = pantry_set | set(role_overrides.values())
|
|
||||||
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
|
|
||||||
|
|
||||||
# Items in role_overrides that aren't in the user's pantry = shopping list
|
|
||||||
missing = [
|
|
||||||
item for item in role_overrides.values()
|
|
||||||
if item and item not in pantry_set
|
|
||||||
]
|
|
||||||
|
|
||||||
return RecipeSuggestion(
|
|
||||||
id=tmpl.id,
|
|
||||||
title=title,
|
|
||||||
match_count=len(all_matched),
|
|
||||||
element_coverage={},
|
|
||||||
swap_candidates=[],
|
|
||||||
matched_ingredients=all_matched,
|
|
||||||
missing_ingredients=missing,
|
|
||||||
directions=tmpl.directions,
|
|
||||||
notes=tmpl.notes,
|
|
||||||
level=1,
|
|
||||||
is_wildcard=False,
|
|
||||||
nutrition=None,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"""
|
|
||||||
Browse counts cache — pre-computes and persists recipe counts for all
|
|
||||||
browse domain keyword sets so category/subcategory page loads never
|
|
||||||
hit the 3.8 GB FTS index at request time.
|
|
||||||
|
|
||||||
Counts change only when the corpus changes (after a pipeline run).
|
|
||||||
The cache is a small SQLite file separate from both the read-only
|
|
||||||
corpus DB and per-user kiwi.db files, so the container can write it.
|
|
||||||
|
|
||||||
Refresh triggers:
|
|
||||||
1. Startup — if cache is missing or older than STALE_DAYS
|
|
||||||
2. Nightly — asyncio background task started in main.py lifespan
|
|
||||||
3. Pipeline — infer_recipe_tags.py calls refresh() at end of run
|
|
||||||
|
|
||||||
The in-memory _COUNT_CACHE in store.py is pre-warmed from this file
|
|
||||||
on startup, so FTS queries are never needed for known keyword sets.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STALE_DAYS = 7
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _kw_key(keywords: list[str]) -> str:
|
|
||||||
"""Stable string key for a keyword list — sorted and pipe-joined."""
|
|
||||||
return "|".join(sorted(keywords))
|
|
||||||
|
|
||||||
|
|
||||||
def _fts_match_expr(keywords: list[str]) -> str:
|
|
||||||
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
|
||||||
return " OR ".join(phrases)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS browse_counts (
|
|
||||||
keywords_key TEXT PRIMARY KEY,
|
|
||||||
count INTEGER NOT NULL,
|
|
||||||
computed_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS browse_counts_meta (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_stale(cache_path: Path, max_age_days: int = STALE_DAYS) -> bool:
|
|
||||||
"""Return True if the cache is missing, empty, or older than max_age_days."""
|
|
||||||
if not cache_path.exists():
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(cache_path)
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT value FROM browse_counts_meta WHERE key = 'refreshed_at'"
|
|
||||||
).fetchone()
|
|
||||||
conn.close()
|
|
||||||
if row is None:
|
|
||||||
return True
|
|
||||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(row[0])).days
|
|
||||||
return age >= max_age_days
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def load_into_memory(cache_path: Path, count_cache: dict, corpus_path: str) -> int:
|
|
||||||
"""
|
|
||||||
Load all rows from the cache file into the in-memory count_cache dict.
|
|
||||||
|
|
||||||
Uses corpus_path (the current RECIPE_DB_PATH env value) as the cache key,
|
|
||||||
not what was stored in the file — the file may have been built against a
|
|
||||||
different mount path (e.g. pipeline ran on host, container sees a different
|
|
||||||
path). Counts are corpus-content-derived and path-independent.
|
|
||||||
|
|
||||||
Returns the number of entries loaded.
|
|
||||||
"""
|
|
||||||
if not cache_path.exists():
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(cache_path)
|
|
||||||
rows = conn.execute("SELECT keywords_key, count FROM browse_counts").fetchall()
|
|
||||||
conn.close()
|
|
||||||
loaded = 0
|
|
||||||
for kw_key, count in rows:
|
|
||||||
keywords = kw_key.split("|") if kw_key else []
|
|
||||||
cache_key = (corpus_path, *sorted(keywords))
|
|
||||||
count_cache[cache_key] = count
|
|
||||||
loaded += 1
|
|
||||||
logger.info("browse_counts: warmed %d entries from %s", loaded, cache_path)
|
|
||||||
return loaded
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: load failed: %s", exc)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def refresh(corpus_path: str, cache_path: Path) -> int:
|
|
||||||
"""
|
|
||||||
Run FTS5 queries for every keyword set in browser_domains.DOMAINS
|
|
||||||
and write results to cache_path.
|
|
||||||
|
|
||||||
Safe to call from both the host pipeline script and the in-container
|
|
||||||
nightly task. The corpus_path must be reachable and readable from
|
|
||||||
the calling process.
|
|
||||||
|
|
||||||
Returns the number of keyword sets computed.
|
|
||||||
"""
|
|
||||||
from app.services.recipe.browser_domains import DOMAINS # local import — avoid circular
|
|
||||||
|
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
cache_conn = sqlite3.connect(cache_path)
|
|
||||||
_ensure_schema(cache_conn)
|
|
||||||
|
|
||||||
# Collect every unique keyword list across all domains/categories/subcategories.
|
|
||||||
# DOMAINS structure: {domain: {label: str, categories: {cat_name: {keywords, subcategories}}}}
|
|
||||||
seen: dict[str, list[str]] = {}
|
|
||||||
for domain_data in DOMAINS.values():
|
|
||||||
for cat_data in domain_data.get("categories", {}).values():
|
|
||||||
if not isinstance(cat_data, dict):
|
|
||||||
continue
|
|
||||||
top_kws = cat_data.get("keywords", [])
|
|
||||||
if top_kws:
|
|
||||||
seen[_kw_key(top_kws)] = top_kws
|
|
||||||
for subcat_kws in cat_data.get("subcategories", {}).values():
|
|
||||||
if subcat_kws:
|
|
||||||
seen[_kw_key(subcat_kws)] = subcat_kws
|
|
||||||
|
|
||||||
try:
|
|
||||||
corpus_conn = sqlite3.connect(f"file:{corpus_path}?mode=ro", uri=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("browse_counts: cannot open corpus %s: %s", corpus_path, exc)
|
|
||||||
cache_conn.close()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
computed = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
for kw_key, kws in seen.items():
|
|
||||||
try:
|
|
||||||
row = corpus_conn.execute(
|
|
||||||
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
|
||||||
(_fts_match_expr(kws),),
|
|
||||||
).fetchone()
|
|
||||||
count = row[0] if row else 0
|
|
||||||
cache_conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO browse_counts (keywords_key, count, computed_at)"
|
|
||||||
" VALUES (?, ?, ?)",
|
|
||||||
(kw_key, count, now),
|
|
||||||
)
|
|
||||||
computed += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: query failed key=%r: %s", kw_key[:60], exc)
|
|
||||||
|
|
||||||
# Merge accepted community tags into counts.
|
|
||||||
# For each (domain, category, subcategory) that has accepted community
|
|
||||||
# tags, add the count of distinct tagged recipe_ids to the FTS count.
|
|
||||||
# The two overlap rarely (community tags exist precisely because FTS
|
|
||||||
# missed those recipes), so simple addition is accurate enough.
|
|
||||||
try:
|
|
||||||
_merge_community_tag_counts(cache_conn, DOMAINS, now)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("browse_counts: community merge skipped: %s", exc)
|
|
||||||
|
|
||||||
cache_conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('refreshed_at', ?)",
|
|
||||||
(now,),
|
|
||||||
)
|
|
||||||
cache_conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('corpus_path', ?)",
|
|
||||||
(corpus_path,),
|
|
||||||
)
|
|
||||||
cache_conn.commit()
|
|
||||||
logger.info("browse_counts: wrote %d counts → %s", computed, cache_path)
|
|
||||||
finally:
|
|
||||||
corpus_conn.close()
|
|
||||||
cache_conn.close()
|
|
||||||
|
|
||||||
return computed
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_community_tag_counts(
|
|
||||||
cache_conn: sqlite3.Connection,
|
|
||||||
domains: dict,
|
|
||||||
now: str,
|
|
||||||
threshold: int = 2,
|
|
||||||
) -> None:
|
|
||||||
"""Add accepted community tag counts on top of FTS counts in the cache.
|
|
||||||
|
|
||||||
Queries the community PostgreSQL store (if available) for accepted tags
|
|
||||||
grouped by (domain, category, subcategory), maps each back to its keyword
|
|
||||||
set key, then increments the cached count.
|
|
||||||
|
|
||||||
Silently skips if community features are unavailable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from app.api.endpoints.community import _get_community_store
|
|
||||||
store = _get_community_store()
|
|
||||||
if store is None:
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
for domain_id, domain_data in domains.items():
|
|
||||||
for cat_name, cat_data in domain_data.get("categories", {}).items():
|
|
||||||
if not isinstance(cat_data, dict):
|
|
||||||
continue
|
|
||||||
# Check subcategories
|
|
||||||
for subcat_name, subcat_kws in cat_data.get("subcategories", {}).items():
|
|
||||||
if not subcat_kws:
|
|
||||||
continue
|
|
||||||
ids = store.get_accepted_recipe_ids_for_subcategory(
|
|
||||||
domain=domain_id,
|
|
||||||
category=cat_name,
|
|
||||||
subcategory=subcat_name,
|
|
||||||
threshold=threshold,
|
|
||||||
)
|
|
||||||
if not ids:
|
|
||||||
continue
|
|
||||||
kw_key = _kw_key(subcat_kws)
|
|
||||||
cache_conn.execute(
|
|
||||||
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
|
||||||
(len(ids), kw_key),
|
|
||||||
)
|
|
||||||
# Check category-level tags (subcategory IS NULL)
|
|
||||||
top_kws = cat_data.get("keywords", [])
|
|
||||||
if top_kws:
|
|
||||||
ids = store.get_accepted_recipe_ids_for_subcategory(
|
|
||||||
domain=domain_id,
|
|
||||||
category=cat_name,
|
|
||||||
subcategory=None,
|
|
||||||
threshold=threshold,
|
|
||||||
)
|
|
||||||
if ids:
|
|
||||||
kw_key = _kw_key(top_kws)
|
|
||||||
cache_conn.execute(
|
|
||||||
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
|
||||||
(len(ids), kw_key),
|
|
||||||
)
|
|
||||||
logger.info("browse_counts: community tag counts merged")
|
|
||||||
|
|
@ -5,12 +5,6 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
|
||||||
Keyword matching is case-insensitive against the recipes.category column and the
|
Keyword matching is case-insensitive against the recipes.category column and the
|
||||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||||
|
|
||||||
Category values are either:
|
|
||||||
- list[str] — flat keyword list (no subcategories)
|
|
||||||
- dict — {"keywords": list[str], "subcategories": {name: list[str]}}
|
|
||||||
keywords covers the whole category (used for "All X" browse);
|
|
||||||
subcategories each have their own narrower keyword list.
|
|
||||||
|
|
||||||
These are starter mappings based on the food.com dataset structure. Run:
|
These are starter mappings based on the food.com dataset structure. Run:
|
||||||
|
|
||||||
SELECT category, count(*) FROM recipes
|
SELECT category, count(*) FROM recipes
|
||||||
|
|
@ -25,657 +19,68 @@ DOMAINS: dict[str, dict] = {
|
||||||
"cuisine": {
|
"cuisine": {
|
||||||
"label": "Cuisine",
|
"label": "Cuisine",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Italian": {
|
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||||
"keywords": ["cuisine:Italian", "italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
||||||
"subcategories": {
|
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
||||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
||||||
"involtini", "cannoli"],
|
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
||||||
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
||||||
"sfogliatelle", "ragù"],
|
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
||||||
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
|
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
||||||
"pappardelle", "crostini"],
|
|
||||||
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
|
|
||||||
"amatriciana", "gricia", "supplì"],
|
|
||||||
"Venetian": ["venetian", "venice", "risotto", "bigoli",
|
|
||||||
"baccalà", "sarde in saor"],
|
|
||||||
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
|
|
||||||
"trofie", "farinata"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Mexican": {
|
|
||||||
"keywords": ["cuisine:Mexican", "mexican", "taco", "enchilada", "burrito",
|
|
||||||
"salsa", "guacamole", "mole", "tamale"],
|
|
||||||
"subcategories": {
|
|
||||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
|
||||||
"chapulines", "mezcal", "tasajo", "memelas"],
|
|
||||||
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
|
|
||||||
"sopa de lima", "panuchos", "papadzules"],
|
|
||||||
"Veracruz": ["veracruz", "veracruzana", "huachinango",
|
|
||||||
"picadas", "enfrijoladas", "caldo de mariscos"],
|
|
||||||
"Street Food": ["taco", "elote", "tlacoyos", "torta", "tamale",
|
|
||||||
"quesadilla", "tostada", "sope", "gordita"],
|
|
||||||
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
|
|
||||||
"mole poblano", "mole amarillo", "pipián"],
|
|
||||||
"Baja / Cal-Mex": ["baja", "baja california", "cal-mex", "baja fish taco",
|
|
||||||
"fish taco", "carne asada fries", "california burrito",
|
|
||||||
"birria", "birria tacos", "quesabirria",
|
|
||||||
"lobster puerto nuevo", "tijuana", "ensenada",
|
|
||||||
"agua fresca", "caesar salad tijuana"],
|
|
||||||
"Mexico City": ["mexico city", "chilaquiles", "tlayuda cdmx",
|
|
||||||
"tacos de canasta", "torta ahogada", "pozole",
|
|
||||||
"chiles en nogada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Asian": {
|
|
||||||
"keywords": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Korean",
|
|
||||||
"cuisine:Thai", "cuisine:Vietnamese",
|
|
||||||
"asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
|
||||||
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
|
||||||
"taiwanese", "singaporean", "burmese", "cambodian",
|
|
||||||
"laotian", "mongolian", "hong kong"],
|
|
||||||
"subcategories": {
|
|
||||||
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
|
|
||||||
"doenjang", "gochujang", "tteokbokki", "sundubu",
|
|
||||||
"galbi", "jjigae", "kbbq", "korean fried chicken"],
|
|
||||||
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
|
|
||||||
"teriyaki", "udon", "soba", "bento", "yakitori",
|
|
||||||
"tonkatsu", "onigiri", "okonomiyaki", "takoyaki",
|
|
||||||
"kaiseki", "izakaya"],
|
|
||||||
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
|
|
||||||
"spring roll", "szechuan", "sichuan", "cantonese",
|
|
||||||
"chow mein", "mapo tofu", "lo mein", "hot pot",
|
|
||||||
"peking duck", "char siu", "congee"],
|
|
||||||
"Thai": ["thai", "pad thai", "green curry", "red curry",
|
|
||||||
"coconut milk", "lemongrass", "satay", "tom yum",
|
|
||||||
"larb", "khao man gai", "massaman", "pad see ew"],
|
|
||||||
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
|
|
||||||
"vermicelli", "nuoc cham", "bun bo hue",
|
|
||||||
"banh xeo", "com tam", "bun cha"],
|
|
||||||
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
|
|
||||||
"kare-kare", "lechon", "sisig", "halo-halo",
|
|
||||||
"dinuguan", "tinola", "bistek"],
|
|
||||||
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
|
|
||||||
"tempeh", "sambal", "soto", "opor ayam",
|
|
||||||
"bakso", "mie goreng", "nasi uduk"],
|
|
||||||
"Malaysian": ["malaysian", "laksa", "nasi lemak", "char kway teow",
|
|
||||||
"satay malaysia", "roti canai", "bak kut teh",
|
|
||||||
"cendol", "mee goreng mamak", "curry laksa"],
|
|
||||||
"Taiwanese": ["taiwanese", "beef noodle soup", "lu rou fan",
|
|
||||||
"oyster vermicelli", "scallion pancake taiwan",
|
|
||||||
"pork chop rice", "three cup chicken",
|
|
||||||
"bubble tea", "stinky tofu", "ba wan"],
|
|
||||||
"Singaporean": ["singaporean", "chicken rice", "chili crab",
|
|
||||||
"singaporean laksa", "bak chor mee", "rojak",
|
|
||||||
"kaya toast", "nasi padang", "satay singapore"],
|
|
||||||
"Burmese": ["burmese", "myanmar", "mohinga", "laphet thoke",
|
|
||||||
"tea leaf salad", "ohn no khao swe",
|
|
||||||
"mont di", "nangyi thoke"],
|
|
||||||
"Hong Kong": ["hong kong", "hk style", "pineapple bun",
|
|
||||||
"wonton noodle soup", "hk milk tea", "egg tart",
|
|
||||||
"typhoon shelter crab", "char siu bao", "jook",
|
|
||||||
"congee hk", "silk stocking tea", "dan tat",
|
|
||||||
"siu mai hk", "cheung fun"],
|
|
||||||
"Cambodian": ["cambodian", "khmer", "amok", "lok lak",
|
|
||||||
"kuy teav", "bai sach chrouk", "nom banh chok",
|
|
||||||
"samlor korko", "beef loc lac"],
|
|
||||||
"Laotian": ["laotian", "lao", "larb", "tam mak hoong",
|
|
||||||
"or lam", "khao niaw", "ping kai",
|
|
||||||
"naem khao", "khao piak sen", "mok pa"],
|
|
||||||
"Mongolian": ["mongolian", "buuz", "khuushuur", "tsuivan",
|
|
||||||
"boodog", "airag", "khorkhog", "bansh",
|
|
||||||
"guriltai shol", "suutei tsai"],
|
|
||||||
"South Asian Fusion": ["south asian fusion", "indo-chinese",
|
|
||||||
"hakka chinese", "chilli chicken",
|
|
||||||
"manchurian", "schezwan"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Indian": {
|
|
||||||
"keywords": ["cuisine:Indian", "indian", "curry", "lentil", "dal", "tikka", "masala",
|
|
||||||
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
|
||||||
"bangladeshi", "nepali"],
|
|
||||||
"subcategories": {
|
|
||||||
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
|
|
||||||
"naan", "tandoori", "butter chicken", "palak paneer",
|
|
||||||
"chole", "rajma", "aloo gobi"],
|
|
||||||
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
|
|
||||||
"sambar", "rasam", "coconut chutney", "appam",
|
|
||||||
"fish curry kerala", "puttu", "payasam"],
|
|
||||||
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe ilish",
|
|
||||||
"mishti doi", "rasgulla", "kosha mangsho"],
|
|
||||||
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu",
|
|
||||||
"khandvi", "fafda", "gujarati dal"],
|
|
||||||
"Pakistani": ["pakistani", "nihari", "haleem", "seekh kebab",
|
|
||||||
"karahi", "biryani karachi", "chapli kebab",
|
|
||||||
"halwa puri", "paya"],
|
|
||||||
"Sri Lankan": ["sri lankan", "kottu roti", "hoppers", "pol sambol",
|
|
||||||
"sri lankan curry", "lamprais", "string hoppers",
|
|
||||||
"wambatu moju"],
|
|
||||||
"Bangladeshi": ["bangladeshi", "bangladesh", "dhaka biryani",
|
|
||||||
"shutki", "pitha", "hilsa curry", "kacchi biryani",
|
|
||||||
"bhuna khichuri", "doi maach", "rezala"],
|
|
||||||
"Nepali": ["nepali", "dal bhat", "momos", "sekuwa",
|
|
||||||
"sel roti", "gundruk", "thukpa"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Mediterranean": {
|
|
||||||
"keywords": ["cuisine:Mediterranean", "cuisine:Greek", "cuisine:Middle Eastern",
|
|
||||||
"mediterranean", "greek", "middle eastern", "turkish",
|
|
||||||
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
|
||||||
"syrian", "iraqi", "jordanian"],
|
|
||||||
"subcategories": {
|
|
||||||
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
|
|
||||||
"souvlaki", "dolmades", "spanakopita", "tiropita",
|
|
||||||
"galaktoboureko"],
|
|
||||||
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
|
|
||||||
"lahmacun", "menemen", "pide", "iskender",
|
|
||||||
"kisir", "simit"],
|
|
||||||
"Syrian": ["syrian", "fattet hummus", "kibbeh syria",
|
|
||||||
"muhammara", "maklouba syria", "sfeeha",
|
|
||||||
"halawet el jibn"],
|
|
||||||
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
|
|
||||||
"tabbouleh", "kibbeh", "fattoush", "manakish",
|
|
||||||
"kafta", "sfiha"],
|
|
||||||
"Jewish": ["jewish", "israeli", "ashkenazi", "sephardic",
|
|
||||||
"shakshuka", "sabich", "za'atar", "tahini",
|
|
||||||
"zhug", "zhoug", "s'khug", "z'houg",
|
|
||||||
"hawaiij", "hawaij", "hawayej",
|
|
||||||
"matzo", "latke", "rugelach", "babka", "challah",
|
|
||||||
"cholent", "gefilte fish", "brisket", "kugel",
|
|
||||||
"new york jewish", "new york deli", "pastrami",
|
|
||||||
"knish", "lox", "bagel and lox", "jewish deli"],
|
|
||||||
"Palestinian": ["palestinian", "musakhan", "maqluba", "knafeh",
|
|
||||||
"maftoul", "freekeh", "sumac chicken"],
|
|
||||||
"Yemeni": ["yemeni", "saltah", "lahoh", "bint al-sahn",
|
|
||||||
"zhug", "zhoug", "hulba", "fahsa",
|
|
||||||
"hawaiij", "hawaij", "hawayej"],
|
|
||||||
"Egyptian": ["egyptian", "koshari", "molokhia", "mahshi",
|
|
||||||
"ful medames", "ta'ameya", "feteer meshaltet"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"American": {
|
|
||||||
"keywords": ["cuisine:American", "cuisine:Southern", "cuisine:Cajun",
|
|
||||||
"american", "southern", "comfort food", "cajun", "creole",
|
|
||||||
"hawaiian", "tex-mex", "soul food"],
|
|
||||||
"subcategories": {
|
|
||||||
"Southern": ["southern", "soul food", "fried chicken",
|
|
||||||
"collard greens", "cornbread", "biscuits and gravy",
|
|
||||||
"mac and cheese", "sweet potato pie", "okra"],
|
|
||||||
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
|
|
||||||
"jambalaya", "etouffee", "dirty rice", "po'boy",
|
|
||||||
"muffuletta", "red beans and rice"],
|
|
||||||
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
|
|
||||||
"queso", "breakfast taco", "chile con carne"],
|
|
||||||
"New England": ["new england", "chowder", "lobster", "clam",
|
|
||||||
"maple", "yankee", "boston baked beans",
|
|
||||||
"johnnycake", "fish and chips"],
|
|
||||||
"Pacific Northwest": ["pacific northwest", "pnw", "dungeness crab",
|
|
||||||
"salmon", "cedar plank", "razor clam",
|
|
||||||
"geoduck", "chanterelle", "marionberry"],
|
|
||||||
"Hawaiian": ["hawaiian", "hawaii", "plate lunch", "loco moco",
|
|
||||||
"poke", "spam musubi", "kalua pig", "lau lau",
|
|
||||||
"haupia", "poi", "manapua", "garlic shrimp",
|
|
||||||
"saimin", "huli huli", "malasada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"BBQ & Smoke": {
|
|
||||||
# Top-level keywords: cuisine:BBQ inferred tag + broad corpus terms.
|
|
||||||
"keywords": ["cuisine:BBQ", "bbq", "barbecue", "barbeque", "smoked", "smoky",
|
|
||||||
"smoke", "pit", "smoke ring", "low and slow",
|
|
||||||
"brisket", "pulled pork", "ribs", "spare ribs",
|
|
||||||
"baby back", "baby back ribs", "dry rub", "wet rub",
|
|
||||||
"cookout", "smoker", "smoked meat", "smoked chicken",
|
|
||||||
"smoked pork", "smoked beef", "smoked turkey",
|
|
||||||
"pit smoked", "wood smoked", "slow smoked",
|
|
||||||
"charcoal", "chargrilled", "burnt ends"],
|
|
||||||
"subcategories": {
|
|
||||||
"Texas BBQ": ["texas bbq", "central texas bbq", "brisket",
|
|
||||||
"beef brisket", "beef ribs", "smoked brisket",
|
|
||||||
"post oak", "salt and pepper rub",
|
|
||||||
"east texas bbq", "lockhart", "franklin style"],
|
|
||||||
"Carolina BBQ": ["carolina bbq", "north carolina bbq", "whole hog",
|
|
||||||
"vinegar sauce", "vinegar bbq", "lexington style",
|
|
||||||
"eastern nc", "south carolina bbq", "mustard sauce",
|
|
||||||
"carolina pulled pork"],
|
|
||||||
"Kansas City BBQ": ["kansas city bbq", "kc bbq", "burnt ends",
|
|
||||||
"sweet bbq sauce", "tomato molasses sauce",
|
|
||||||
"baby back ribs", "kansas city ribs"],
|
|
||||||
"Memphis BBQ": ["memphis bbq", "dry rub ribs", "wet ribs",
|
|
||||||
"memphis style", "dry rub pork", "memphis ribs"],
|
|
||||||
"Alabama BBQ": ["alabama bbq", "white sauce", "alabama white sauce",
|
|
||||||
"smoked chicken", "white bbq sauce"],
|
|
||||||
"Kentucky BBQ": ["kentucky bbq", "mutton bbq", "owensboro bbq",
|
|
||||||
"black dip", "western kentucky barbecue", "mutton"],
|
|
||||||
"St. Louis BBQ": ["st louis bbq", "st louis ribs", "st. louis ribs",
|
|
||||||
"st louis cut ribs", "spare ribs st louis"],
|
|
||||||
"Backyard Grill": ["backyard bbq", "cookout", "grilled burgers",
|
|
||||||
"charcoal grill", "kettle grill", "tailgate",
|
|
||||||
"grill out", "backyard grilling"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"European": {
|
|
||||||
"keywords": ["cuisine:French", "cuisine:German", "cuisine:Spanish",
|
|
||||||
"french", "german", "spanish", "british", "irish", "scottish",
|
|
||||||
"welsh", "scandinavian", "nordic", "eastern european"],
|
|
||||||
"subcategories": {
|
|
||||||
"French": ["french", "provencal", "beurre", "crepe",
|
|
||||||
"ratatouille", "cassoulet", "bouillabaisse"],
|
|
||||||
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
|
|
||||||
"tortilla espanola", "chorizo"],
|
|
||||||
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
|
|
||||||
"pretzel", "strudel"],
|
|
||||||
"British": ["british", "english", "pub food", "cornish",
|
|
||||||
"shepherd's pie", "bangers", "toad in the hole",
|
|
||||||
"coronation chicken", "london", "londoner",
|
|
||||||
"cornish pasty", "ploughman's"],
|
|
||||||
"Irish": ["irish", "ireland", "colcannon", "coddle",
|
|
||||||
"irish stew", "soda bread", "boxty", "champ"],
|
|
||||||
"Scottish": ["scottish", "scotland", "haggis", "cullen skink",
|
|
||||||
"cranachan", "scotch broth", "glaswegian",
|
|
||||||
"neeps and tatties", "tablet"],
|
|
||||||
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
|
|
||||||
"danish", "finnish", "gravlax", "swedish meatballs",
|
|
||||||
"lefse", "smörgåsbord", "fika", "crispbread",
|
|
||||||
"cardamom bun", "herring", "æbleskiver",
|
|
||||||
"lingonberry", "lutefisk", "janssons frestelse",
|
|
||||||
"knäckebröd", "kladdkaka"],
|
|
||||||
"Eastern European": ["eastern european", "polish", "russian", "ukrainian",
|
|
||||||
"czech", "hungarian", "pierogi", "borscht",
|
|
||||||
"goulash", "kielbasa", "varenyky", "pelmeni"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Latin American": {
|
|
||||||
"keywords": ["cuisine:Latin American", "cuisine:Caribbean",
|
|
||||||
"latin american", "peruvian", "argentinian", "colombian",
|
|
||||||
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
|
||||||
"subcategories": {
|
|
||||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
|
||||||
"aji amarillo", "causa", "leche de tigre",
|
|
||||||
"arroz con leche peru", "pollo a la brasa"],
|
|
||||||
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
|
|
||||||
"brigadeiro", "coxinha", "moqueca", "vatapa",
|
|
||||||
"caipirinha", "acai bowl"],
|
|
||||||
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
|
|
||||||
"sancocho", "ajiaco", "buñuelos", "changua"],
|
|
||||||
"Argentinian": ["argentinian", "asado", "chimichurri", "empanadas argentina",
|
|
||||||
"milanesa", "locro", "dulce de leche", "medialunas"],
|
|
||||||
"Venezuelan": ["venezuelan", "pabellón criollo", "arepas venezuela",
|
|
||||||
"hallacas", "cachapas", "tequeños", "caraotas"],
|
|
||||||
"Chilean": ["chilean", "cazuela", "pastel de choclo", "curanto",
|
|
||||||
"sopaipillas", "charquicán", "completo"],
|
|
||||||
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
|
|
||||||
"picadillo", "lechon cubano", "vaca frita",
|
|
||||||
"tostones", "platanos maduros"],
|
|
||||||
"Jamaican": ["jamaican", "jerk chicken", "jerk pork", "ackee saltfish",
|
|
||||||
"curry goat", "rice and peas", "escovitch",
|
|
||||||
"jamaican patty", "callaloo jamaica", "festival"],
|
|
||||||
"Puerto Rican": ["puerto rican", "mofongo", "pernil", "arroz con gandules",
|
|
||||||
"sofrito", "pasteles", "tostones pr", "tembleque",
|
|
||||||
"coquito", "asopao"],
|
|
||||||
"Dominican": ["dominican", "mangu", "sancocho dominicano",
|
|
||||||
"pollo guisado", "habichuelas guisadas",
|
|
||||||
"tostones dominicanos", "morir soñando"],
|
|
||||||
"Haitian": ["haitian", "griot", "pikliz", "riz et pois",
|
|
||||||
"joumou", "akra", "pain patate", "labouyi"],
|
|
||||||
"Trinidad": ["trinidadian", "doubles", "roti trinidad", "pelau",
|
|
||||||
"callaloo trinidad", "bake and shark",
|
|
||||||
"curry duck", "oil down"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Central American": {
|
|
||||||
"keywords": ["central american", "salvadoran", "guatemalan",
|
|
||||||
"honduran", "nicaraguan", "costa rican", "panamanian"],
|
|
||||||
"subcategories": {
|
|
||||||
"Salvadoran": ["salvadoran", "el salvador", "pupusas", "curtido",
|
|
||||||
"sopa de pata", "nuégados", "atol shuco"],
|
|
||||||
"Guatemalan": ["guatemalan", "pepián", "jocon", "kak'ik",
|
|
||||||
"hilachas", "rellenitos", "fiambre"],
|
|
||||||
"Costa Rican": ["costa rican", "gallo pinto", "casado",
|
|
||||||
"olla de carne", "arroz con leche cr",
|
|
||||||
"tres leches cr"],
|
|
||||||
"Honduran": ["honduran", "baleadas", "sopa de caracol",
|
|
||||||
"tapado", "machuca", "catrachitas"],
|
|
||||||
"Nicaraguan": ["nicaraguan", "nacatamal", "vigorón", "indio viejo",
|
|
||||||
"gallo pinto nicaragua", "güirilas"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"African": {
|
|
||||||
"keywords": ["african", "west african", "east african", "ethiopian",
|
|
||||||
"nigerian", "ghanaian", "kenyan", "south african",
|
|
||||||
"senegalese", "tunisian"],
|
|
||||||
"subcategories": {
|
|
||||||
"West African": ["west african", "nigerian", "ghanaian",
|
|
||||||
"jollof rice", "egusi soup", "fufu", "suya",
|
|
||||||
"groundnut stew", "kelewele", "kontomire",
|
|
||||||
"waakye", "ofam", "bitterleaf soup"],
|
|
||||||
"Senegalese": ["senegalese", "senegal", "thieboudienne",
|
|
||||||
"yassa", "mafe", "thiou", "ceebu jen",
|
|
||||||
"domoda"],
|
|
||||||
"Ethiopian & Eritrean": ["ethiopian", "eritrean", "injera", "doro wat",
|
|
||||||
"kitfo", "tibs", "shiro", "misir wat",
|
|
||||||
"gomen", "ful ethiopian", "tegamino"],
|
|
||||||
"East African": ["east african", "kenyan", "tanzanian", "ugandan",
|
|
||||||
"nyama choma", "ugali", "sukuma wiki",
|
|
||||||
"pilau kenya", "mandazi", "matoke",
|
|
||||||
"githeri", "irio"],
|
|
||||||
"North African": ["north african", "tunisian", "algerian", "libyan",
|
|
||||||
"brik", "lablabi", "merguez", "shakshuka tunisian",
|
|
||||||
"harissa tunisian", "couscous algerian"],
|
|
||||||
"South African": ["south african", "braai", "bobotie", "boerewors",
|
|
||||||
"bunny chow", "pap", "chakalaka", "biltong",
|
|
||||||
"malva pudding", "koeksister", "potjiekos"],
|
|
||||||
"Moroccan": ["moroccan", "tagine", "couscous morocco",
|
|
||||||
"harissa", "chermoula", "preserved lemon",
|
|
||||||
"pastilla", "mechoui", "bastilla"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pacific & Oceania": {
|
|
||||||
"keywords": ["pacific", "oceania", "polynesian", "melanesian",
|
|
||||||
"micronesian", "maori", "fijian", "samoan", "tongan",
|
|
||||||
"hawaiian", "australian", "new zealand"],
|
|
||||||
"subcategories": {
|
|
||||||
"Māori / New Zealand": ["maori", "new zealand", "hangi", "rewena bread",
|
|
||||||
"boil-up", "paua", "kumara", "pavlova nz",
|
|
||||||
"whitebait fritter", "kina", "hokey pokey"],
|
|
||||||
"Australian": ["australian", "meat pie", "lamington",
|
|
||||||
"anzac biscuits", "damper", "barramundi",
|
|
||||||
"vegemite", "pavlova australia", "tim tam",
|
|
||||||
"sausage sizzle", "chiko roll", "fairy bread"],
|
|
||||||
"Fijian": ["fijian", "fiji", "kokoda", "lovo",
|
|
||||||
"rourou", "palusami fiji", "duruka",
|
|
||||||
"vakalolo"],
|
|
||||||
"Samoan": ["samoan", "samoa", "palusami", "oka",
|
|
||||||
"fa'ausi", "chop suey samoa", "sapasui",
|
|
||||||
"koko alaisa", "supo esi"],
|
|
||||||
"Tongan": ["tongan", "tonga", "lu pulu", "'ota 'ika",
|
|
||||||
"fekkai", "faikakai topai", "kapisi pulu"],
|
|
||||||
"Papua New Guinean": ["papua new guinea", "png", "mumu",
|
|
||||||
"sago", "aibika", "kaukau",
|
|
||||||
"taro png", "coconut crab"],
|
|
||||||
"Hawaiian": ["hawaiian", "hawaii", "poke", "loco moco",
|
|
||||||
"plate lunch", "kalua pig", "haupia",
|
|
||||||
"spam musubi", "poi", "malasada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Central Asian & Caucasus": {
|
|
||||||
"keywords": ["central asian", "caucasus", "georgian", "armenian", "uzbek",
|
|
||||||
"afghan", "persian", "iranian", "azerbaijani", "kazakh"],
|
|
||||||
"subcategories": {
|
|
||||||
"Persian / Iranian": ["persian", "iranian", "ghormeh sabzi", "fesenjan",
|
|
||||||
"tahdig", "joojeh kabab", "ash reshteh",
|
|
||||||
"zereshk polo", "khoresh", "mast o khiar",
|
|
||||||
"kashk-e-bademjan", "mirza ghasemi",
|
|
||||||
"baghali polo"],
|
|
||||||
"Georgian": ["georgian", "georgia", "khachapuri", "khinkali",
|
|
||||||
"churchkhela", "ajapsandali", "satsivi",
|
|
||||||
"pkhali", "lobiani", "badrijani nigvzit"],
|
|
||||||
"Armenian": ["armenian", "dolma armenia", "lahmajoun",
|
|
||||||
"manti armenia", "ghapama", "basturma",
|
|
||||||
"harissa armenia", "nazook", "tolma"],
|
|
||||||
"Azerbaijani": ["azerbaijani", "azerbaijan", "plov azerbaijan",
|
|
||||||
"dolma azeri", "dushbara", "levengi",
|
|
||||||
"shah plov", "gutab"],
|
|
||||||
"Uzbek": ["uzbek", "uzbekistan", "plov", "samsa",
|
|
||||||
"lagman", "shashlik", "manti uzbek",
|
|
||||||
"non bread", "dimlama", "sumalak"],
|
|
||||||
"Afghan": ["afghan", "afghanistan", "kabuli pulao", "mantu",
|
|
||||||
"bolani", "qorma", "ashak", "shorwa",
|
|
||||||
"aushak", "borani banjan"],
|
|
||||||
"Kazakh": ["kazakh", "beshbarmak", "kuyrdak", "baursak",
|
|
||||||
"kurt", "shubat", "kazy"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"meal_type": {
|
"meal_type": {
|
||||||
"label": "Meal Type",
|
"label": "Meal Type",
|
||||||
"categories": {
|
"categories": {
|
||||||
# Keywords use two complementary sources:
|
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
||||||
# 1. inferred_tag phrases ("meal:X", "main:X") — indexed in recipe_browser_fts.inferred_tags.
|
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||||
# FTS5 tokenises "meal:Breakfast" → ["meal","breakfast"], so the quoted phrase
|
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||||
# "meal:Breakfast" matches exactly that consecutive token pair.
|
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
||||||
# 2. Corpus keyword/category text — only covers the ~1,200 keyword-tagged recipes.
|
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
||||||
# Kept as a fallback; not the primary signal.
|
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||||
"Breakfast": {
|
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||||
"keywords": ["meal:Breakfast", "breakfast", "brunch", "pancakes",
|
|
||||||
"waffles", "oatmeal", "muffin"],
|
|
||||||
"subcategories": {
|
|
||||||
"Eggs": ["meal:Breakfast", "egg", "omelette", "frittata",
|
|
||||||
"quiche", "scrambled", "benedict", "shakshuka"],
|
|
||||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
|
||||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
|
||||||
"coffee cake", "danish"],
|
|
||||||
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
|
|
||||||
"overnight oats"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Lunch": {
|
|
||||||
# meal:Lunch tag covers explicitly-tagged recipes.
|
|
||||||
# Coverage is limited — most lunch-style recipes have no distinct meal-type tag.
|
|
||||||
"keywords": ["meal:Lunch", "lunch", "sandwich", "wrap", "salad",
|
|
||||||
"soup", "light meal"],
|
|
||||||
"subcategories": {
|
|
||||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
|
||||||
"grilled cheese", "blt"],
|
|
||||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
|
||||||
"cobb"],
|
|
||||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
|
||||||
"minestrone", "lentil soup"],
|
|
||||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
|
||||||
"quesadilla"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Dinner": {
|
|
||||||
# Primary: main:X inferred tags (800k+ recipes).
|
|
||||||
# "meal:Dinner" does not exist in the inferred-tag vocabulary — main-protein
|
|
||||||
# tags are the best available proxy for main-course dinner recipes.
|
|
||||||
"keywords": ["main:Chicken", "main:Beef", "main:Pork", "main:Fish",
|
|
||||||
"main:Pasta", "dinner", "main dish", "entree",
|
|
||||||
"main course", "supper"],
|
|
||||||
"subcategories": {
|
|
||||||
"Chicken": ["main:Chicken"],
|
|
||||||
"Beef": ["main:Beef"],
|
|
||||||
"Pork": ["main:Pork"],
|
|
||||||
"Fish & Seafood": ["main:Fish"],
|
|
||||||
"Pasta": ["main:Pasta"],
|
|
||||||
"Casseroles": ["casserole", "bake", "gratin", "pot pie"],
|
|
||||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
|
||||||
"daube"],
|
|
||||||
"Grilled": ["grilled", "grill", "barbecue", "kebab", "skewer"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Snack": {
|
|
||||||
"keywords": ["meal:Snack", "snack", "appetizer", "finger food",
|
|
||||||
"dip", "bite", "starter"],
|
|
||||||
"subcategories": {
|
|
||||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
|
||||||
"salsa", "pate"],
|
|
||||||
"Finger Foods": ["finger food", "bite", "skewer", "slider",
|
|
||||||
"wing", "nugget"],
|
|
||||||
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
|
|
||||||
"pretzel"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Dessert": {
|
|
||||||
# "sweet" removed — it matches flavor:Sweet inferred tags, causing false positives.
|
|
||||||
"keywords": ["meal:Dessert", "dessert", "cake", "cookie", "pie",
|
|
||||||
"pudding", "ice cream", "brownie"],
|
|
||||||
"subcategories": {
|
|
||||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
|
||||||
"cheesecake", "torte"],
|
|
||||||
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
|
|
||||||
"biscotti", "shortbread"],
|
|
||||||
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
|
|
||||||
"crumble"],
|
|
||||||
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
|
|
||||||
"popsicle", "granita"],
|
|
||||||
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
|
|
||||||
"flan", "creme brulee"],
|
|
||||||
"Candy": ["candy", "fudge", "truffle", "brittle",
|
|
||||||
"caramel", "toffee"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Beverage": ["meal:Beverage", "drink", "smoothie", "cocktail", "beverage",
|
|
||||||
"juice", "shake", "lemonade"],
|
|
||||||
"Side Dish": {
|
|
||||||
# meal:Side Dish not in inferred-tag vocabulary.
|
|
||||||
# main:Vegetables and main:Grains are the best proxies — will overlap
|
|
||||||
# with some vegetarian mains, which is acceptable.
|
|
||||||
"keywords": ["main:Vegetables", "main:Grains", "side dish", "side",
|
|
||||||
"pilaf", "accompaniment"],
|
|
||||||
"subcategories": {
|
|
||||||
"Vegetables": ["main:Vegetables"],
|
|
||||||
"Grains & Rice": ["main:Grains", "rice", "pilaf", "quinoa"],
|
|
||||||
"Bread": ["meal:Bread", "bread", "roll", "biscuit"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dietary": {
|
"dietary": {
|
||||||
"label": "Dietary",
|
"label": "Dietary",
|
||||||
# Primary: dietary:X inferred tags (indexed in recipe_browser_fts.inferred_tags).
|
|
||||||
# Secondary: text tokens kept as fallback for keyword-tagged recipes.
|
|
||||||
# IMPORTANT: Use ONLY structured dietary:X phrases here.
|
|
||||||
# Bare text keywords like "vegan", "low-carb" also match can_be:Vegan,
|
|
||||||
# can_be:Low-Carb etc. — those are "achievable with substitutions", not
|
|
||||||
# "recipe already is". The structured phrase "dietary:Vegan" (consecutive
|
|
||||||
# FTS tokens "dietary"+"vegan") does NOT match can_be:Vegan.
|
|
||||||
"categories": {
|
"categories": {
|
||||||
"Vegetarian": ["dietary:Vegetarian"],
|
"Vegetarian": ["vegetarian"],
|
||||||
"Vegan": ["dietary:Vegan"],
|
"Vegan": ["vegan", "plant-based", "plant based"],
|
||||||
"Gluten-Free": ["dietary:Gluten-Free"],
|
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
|
||||||
"Low-Carb": ["dietary:Low-Carb"],
|
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
|
||||||
"High-Protein": ["dietary:High-Protein"],
|
"High-Protein": ["high protein", "high-protein"],
|
||||||
"Low-Fat": ["dietary:Low-Fat"],
|
"Low-Fat": ["low-fat", "low fat", "light"],
|
||||||
"Dairy-Free": ["dietary:Dairy-Free"],
|
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
|
||||||
"Low-Sodium": ["dietary:Low-Sodium"],
|
|
||||||
"Paleo": ["dietary:Paleo"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"main_ingredient": {
|
"main_ingredient": {
|
||||||
"label": "Main Ingredient",
|
"label": "Main Ingredient",
|
||||||
"categories": {
|
"categories": {
|
||||||
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
"Chicken": ["chicken", "poultry", "turkey"],
|
||||||
"Chicken": {
|
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
|
||||||
"keywords": ["main:Chicken"],
|
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
|
||||||
"subcategories": {
|
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
|
||||||
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
|
||||||
"chicken bake"],
|
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
|
||||||
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
|
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
|
||||||
"chicken skewer"],
|
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
|
||||||
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
|
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
|
||||||
"crispy chicken"],
|
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
|
||||||
"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 the category (top-level, covers all subcategories).
|
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
||||||
|
domain_data = DOMAINS.get(domain, {})
|
||||||
For flat categories returns the list directly.
|
categories = domain_data.get("categories", {})
|
||||||
For nested categories returns the 'keywords' key.
|
return categories.get(category, [])
|
||||||
Returns [] if category or domain not found.
|
|
||||||
"""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if cat_def is None:
|
|
||||||
return []
|
|
||||||
if isinstance(cat_def, list):
|
|
||||||
return cat_def
|
|
||||||
return cat_def.get("keywords", [])
|
|
||||||
|
|
||||||
|
|
||||||
def category_has_subcategories(domain: str, category: str) -> bool:
|
|
||||||
"""Return True when a category has a subcategory level."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return False
|
|
||||||
return bool(cat_def.get("subcategories"))
|
|
||||||
|
|
||||||
|
|
||||||
def get_subcategory_names(domain: str, category: str) -> list[str]:
|
|
||||||
"""Return subcategory names for a category, or [] if none exist."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return []
|
|
||||||
return list(cat_def.get("subcategories", {}).keys())
|
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
|
|
||||||
"""Return keyword list for a specific subcategory, or [] if not found."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return []
|
|
||||||
return cat_def.get("subcategories", {}).get(subcategory, [])
|
|
||||||
|
|
||||||
|
|
||||||
def get_category_names(domain: str) -> list[str]:
|
def get_category_names(domain: str) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -84,27 +84,15 @@ 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(
|
||||||
f"SELECT * FROM {c}ingredient_profiles WHERE name = ?", (name,)
|
"SELECT * FROM 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]:
|
||||||
"""Classify multiple names in one DB round-trip, falling back to heuristics."""
|
return [self.classify(n) for n in names]
|
||||||
if not names:
|
|
||||||
return []
|
|
||||||
normalised = [n.lower().strip() for n in names]
|
|
||||||
c = self._store._cp
|
|
||||||
placeholders = ",".join("?" * len(normalised))
|
|
||||||
rows = self._store._fetch_all(
|
|
||||||
f"SELECT * FROM {c}ingredient_profiles WHERE name IN ({placeholders})",
|
|
||||||
tuple(normalised),
|
|
||||||
)
|
|
||||||
by_name = {r["name"]: self._row_to_profile(r) for r in rows}
|
|
||||||
return [by_name.get(n) or self._heuristic_profile(n) for n in normalised]
|
|
||||||
|
|
||||||
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
||||||
"""Return element names that have no coverage in the given profile list."""
|
"""Return element names that have no coverage in the given profile list."""
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ Walmart is kept inline until cf-core adds Impact network support:
|
||||||
|
|
||||||
Links are always generated (plain URLs are useful even without affiliate IDs).
|
Links are always generated (plain URLs are useful even without affiliate IDs).
|
||||||
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
||||||
Instacart and Walmart are US/CA-only; other locales get Amazon only.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -24,26 +23,18 @@ from urllib.parse import quote_plus
|
||||||
from circuitforge_core.affiliates import wrap_url
|
from circuitforge_core.affiliates import wrap_url
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink
|
from app.models.schemas.recipe import GroceryLink
|
||||||
from app.services.recipe.locale_config import get_locale
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _amazon_link(ingredient: str, locale: str) -> GroceryLink:
|
def _amazon_fresh_link(ingredient: str) -> GroceryLink:
|
||||||
cfg = get_locale(locale)
|
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
domain = cfg["amazon_domain"]
|
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
|
||||||
dept = cfg["amazon_grocery_dept"]
|
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon"))
|
||||||
base = f"https://www.{domain}/s?k={q}&{dept}"
|
|
||||||
retailer = "Amazon" if locale != "us" else "Amazon Fresh"
|
|
||||||
return GroceryLink(ingredient=ingredient, retailer=retailer, url=wrap_url(base, "amazon"))
|
|
||||||
|
|
||||||
|
|
||||||
def _instacart_link(ingredient: str, locale: str) -> GroceryLink:
|
def _instacart_link(ingredient: str) -> GroceryLink:
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
if locale == "ca":
|
|
||||||
base = f"https://www.instacart.ca/store/s?k={q}"
|
|
||||||
else:
|
|
||||||
base = f"https://www.instacart.com/store/s?k={q}"
|
base = f"https://www.instacart.com/store/s?k={q}"
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
||||||
|
|
||||||
|
|
@ -59,28 +50,26 @@ def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkBuilder:
|
class GroceryLinkBuilder:
|
||||||
def __init__(self, tier: str = "free", has_byok: bool = False, locale: str = "us") -> None:
|
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
||||||
self._tier = tier
|
self._tier = tier
|
||||||
self._locale = locale
|
|
||||||
self._locale_cfg = get_locale(locale)
|
|
||||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
||||||
|
|
||||||
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
||||||
"""Build grocery deeplinks for a single ingredient.
|
"""Build grocery deeplinks for a single ingredient.
|
||||||
|
|
||||||
Amazon link is always included, routed to the user's locale domain.
|
Amazon Fresh and Instacart links are always included; wrap_url handles
|
||||||
Instacart and Walmart are only shown where they operate (US/CA).
|
affiliate ID injection (or returns a plain URL if none is configured).
|
||||||
wrap_url handles affiliate ID injection for supported programs.
|
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a
|
||||||
|
path-based redirect that doesn't degrade cleanly to a plain URL).
|
||||||
"""
|
"""
|
||||||
if not ingredient.strip():
|
if not ingredient.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
links: list[GroceryLink] = [_amazon_link(ingredient, self._locale)]
|
links: list[GroceryLink] = [
|
||||||
|
_amazon_fresh_link(ingredient),
|
||||||
if self._locale_cfg["instacart"]:
|
_instacart_link(ingredient),
|
||||||
links.append(_instacart_link(ingredient, self._locale))
|
]
|
||||||
|
if self._walmart_id:
|
||||||
if self._locale_cfg["walmart"] and self._walmart_id:
|
|
||||||
links.append(_walmart_link(ingredient, self._walmart_id))
|
links.append(_walmart_link(ingredient, self._walmart_id))
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue