From 86fcc03c6c1342fd6546bb2a6f2b012eaa361ca9 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sat, 26 Sep 2026 21:09:22 +0200 Subject: [PATCH 1/7] feat(ai-service): Vercel entry module exposing the configured app Vercel's Python runtime imports a module-level app; uvicorn uses the create_app factory. The entry module builds the same app from the environment and fails at import when a setting is missing (fail-closed). Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- services/ai/src/requestflow_ai/vercel_app.py | 10 +++++ services/ai/tests/test_vercel_entry.py | 41 ++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 services/ai/src/requestflow_ai/vercel_app.py create mode 100644 services/ai/tests/test_vercel_entry.py diff --git a/services/ai/src/requestflow_ai/vercel_app.py b/services/ai/src/requestflow_ai/vercel_app.py new file mode 100644 index 0000000..f42fc8e --- /dev/null +++ b/services/ai/src/requestflow_ai/vercel_app.py @@ -0,0 +1,10 @@ +"""Entry point for Vercel's Python runtime (ADR-0001 D11 amendment 2026-09-26). + +Vercel imports a module-level ``app`` (``[tool.vercel] entrypoint`` in pyproject.toml). The +configuration comes from the environment exactly as for uvicorn's ``create_app`` factory, so a +missing or invalid setting fails at start (fail-closed) instead of serving a half-configured API. +""" + +from requestflow_ai.api.app import create_app + +app = create_app() diff --git a/services/ai/tests/test_vercel_entry.py b/services/ai/tests/test_vercel_entry.py new file mode 100644 index 0000000..d11a1a5 --- /dev/null +++ b/services/ai/tests/test_vercel_entry.py @@ -0,0 +1,41 @@ +"""The Vercel entry module exposes the configured app (ADR-0001 D11 amendment 2026-09-26).""" + +from __future__ import annotations + +import importlib +import sys + +import pytest +from fastapi import FastAPI +from pydantic import ValidationError + +from conftest import TEST_TOKEN + +ENTRY = "requestflow_ai.vercel_app" + + +def _import_entry() -> object: + sys.modules.pop(ENTRY, None) + return importlib.import_module(ENTRY) + + +def test_exposes_the_extract_api_as_app(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AI_SERVICE_TOKEN", TEST_TOKEN) + monkeypatch.setenv("AI_ALLOW_GEMINI_API_DEV", "true") + monkeypatch.setenv("GEMINI_API_KEY", "synthetic-test-key-not-real") + monkeypatch.delenv("VERTEX_PROJECT", raising=False) + + module = _import_entry() + + app = getattr(module, "app") + assert isinstance(app, FastAPI) + assert "/v1/extract" in {getattr(route, "path", "") for route in app.routes} + + +def test_refuses_to_start_without_a_service_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AI_SERVICE_TOKEN", raising=False) + monkeypatch.setenv("AI_ALLOW_GEMINI_API_DEV", "true") + monkeypatch.setenv("GEMINI_API_KEY", "synthetic-test-key-not-real") + + with pytest.raises(ValidationError, match="ai_service_token"): + _import_entry() From 5ef8f123383e0b09fd8a070a118276c15d229b24 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sat, 26 Sep 2026 21:12:41 +0200 Subject: [PATCH 2/7] docs(deploy): AI service on Vercel and Gemini free-tier key as a dated exception The orchestrator decided on 2026-09-26 to run the showcase without GCP: the AI service becomes a second Vercel project (spike) and model calls use a Gemini API free-tier key. Google's terms require paid services for API clients offered to EEA users, so the exception is bound to an invite-only showcase for the orchestrator with synthetic data and expires before anyone else gets access (at the latest 2026-10-31). Recorded as ADR-0001 D11 amendment, in both exception tables, AGENTS.md and the runbook. Vercel config: entrypoint via [tool.vercel], region fra1, root-anchored .vercelignore (src/requestflow_ai/evals is runtime). Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- docs/decisions/ADR-0001-pilot-architecture.md | 28 +++++++++++++++++++ docs/technical/architecture.md | 1 + docs/technical/deployment-vercel.md | 28 +++++++++++++++---- services/ai/.vercelignore | 10 +++++++ services/ai/pyproject.toml | 4 +++ services/ai/tests/test_vercel_entry.py | 8 +++--- services/ai/vercel.json | 4 +++ 8 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 services/ai/.vercelignore create mode 100644 services/ai/vercel.json diff --git a/AGENTS.md b/AGENTS.md index d668d96..0d8bf6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,6 @@ Expected early triggers here: `security-review`, `database-migration`, `ai-eval` - **Tenant context is mandatory.** Every data access runs through a module repository inside `withTenant(companyId, …)`; `companyId` comes from the session, never from client input. No raw DB client outside `src/db` and `src/features/tenancy` (ADR-0001 D7). - **The AI service is stateless.** It never gets DB or storage credentials or tenant logic; the TS worker sends bytes and persists results (D8). pg-boss is the only queue (D4). - **"Found" needs proof.** A field is `found` only if the grounding verifier confirmed its quote in the cited segment; never relax this to make evals pass (D8). -- **Gemini free tier:** local development with synthetic data only – never in the showcase or with customer data (D8). +- **Gemini free tier:** local development with synthetic data only; on the showcase only as the temporary, invite-only exception of ADR-0001 D11 amendment 2026-09-26 (#67, exceptions register) – never with customer data (D8). - **Exactly-once export** relies on the idempotency key + unique export row + row lock – keep all three (D9). - Line endings: `.gitattributes` forces LF. Git Bash on Windows tolerates CRLF (tested 2026-09-22); LF keeps scripts portable to Linux shells (CI, WSL2, containers). diff --git a/docs/decisions/ADR-0001-pilot-architecture.md b/docs/decisions/ADR-0001-pilot-architecture.md index df683f2..d016c79 100644 --- a/docs/decisions/ADR-0001-pilot-architecture.md +++ b/docs/decisions/ADR-0001-pilot-architecture.md @@ -5,6 +5,8 @@ the drafted recommendation (full TypeScript); the draft recommendation is kept as alternative 1 in D8 - **Amendment 2026-09-24 (D11, also touches D3/D5):** the showcase uses Supabase Postgres + Supabase Storage instead of Neon + R2 – see "Amendment 2026-09-24" at the end of D11 +- **Amendment 2026-09-26 (D11, touches D8):** AI service on Vercel (spike) and a Gemini API free-tier + key for the invite-only showcase as a temporary exception – see "Amendment 2026-09-26" at the end of D11 - **Deciders:** Fluory (orchestrator) · drafted by a Claude session - **Inputs:** `docs/input/2026-09-22-kundenanfrage.md` (customer request), `PROJECT-START.md` (discovery) - **Facts verified:** 2026-09-22 against official docs, registries and provider terms (sources at the end). @@ -652,6 +654,31 @@ enters the code: the app still talks plain PostgreSQL (Drizzle, pg-boss) and the project as Vertex `eu`, container image already exists, no 5 GB package or 300 s limit for docling/OCR). - **Runbook:** `docs/technical/deployment-vercel.md`. +### Amendment 2026-09-26 – AI service on Vercel and a Gemini API free-tier key (decided by Fluory, orchestrator; #67) + +**Decision.** +1. On the showcase the AI service runs as a **second Vercel project** (Python runtime, root directory + `services/ai`, entry `requestflow_ai.vercel_app:app` via `[tool.vercel]` in `pyproject.toml`, + region `fra1`), PDF pipeline `textlines`, OCR off. It is a spike: bundle size, cold start and one + extraction are measured and recorded in the PR of #67. +2. Model calls use a **Gemini API key on the free tier** (`AI_ALLOW_GEMINI_API_DEV=true`, + `GEMINI_API_KEY` set only in the Vercel project) – a temporary exception to D8. + +**Why.** No GCP account for now; one platform for both runtimes; the code path already exists and is +fail-closed (`AI_ALLOW_GEMINI_API_DEV` together with `VERTEX_PROJECT` refuses to start; no fallback). + +**Conditions of the exception.** The Gemini API terms (verified 2026-09-22) allow human review and +product-improvement use of free-tier content and state: *"You may use only Paid Services when making +API Clients available to users in the European Economic Area, Switzerland, or the UK."* There is no EU +data-residency guarantee. Therefore: invite-only demo accounts held by the orchestrator, synthetic data +only, demo banner on; the exception expires before anyone else gets an account, at the latest +2026-10-31 → paid tier (same key with billing) or Vertex `eu`. Recorded in the exceptions register. + +**Alternatives.** Google Cloud Run + Vertex `eu` (runbook recommendation until now; needs GCP billing) · +Hugging Face Space (existing Dockerfile unchanged; new account, hosting region not verified). + +**Revisit when** the spike fails on size, cold start or duration (→ Cloud Run), or the exception expires. + --- ## Summary of the challenged decisions @@ -713,6 +740,7 @@ explicit requirements. |---|---|---| | Showcase: no unattended retries (Hobby cron once/day) | Showcase only; production runs a worker | when a production-like demo is needed | | No RLS on the `auth`/`pgboss` schemas | Not tenant business data; only server code has access | on review at M3 | +| Gemini API free-tier key on the showcase (#67) | Invite-only for the orchestrator, synthetic data only, demo banner; Google's terms require paid services for API clients offered to EEA users (D11 amendment 2026-09-26) | before anyone else gets a demo account, at the latest 2026-10-31 | | Gemini free tier for local development | Synthetic data only; never in showcase or production | when the Vertex budget is set up for development | ## Open points diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 90c9bb7..fcb3513 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -53,6 +53,7 @@ Deliberately accepted risks – without an entry here a deviation counts as a de | Better Auth admin plugin mounted without any holder of its admin role | ADR-0001 D6 names the plugin; decided in #30: kept – its `banned` field implements deactivation (sign-in blocked by the plugin). Nobody holds `platform-admin`, so `/api/auth/admin/*` rejects every caller (tested); user management runs through `identity` | Fluory | 2026-12-31 (review at M3) | | Upload cap per person only where configured | Local and CI run without `UPLOAD_MAX_PER_HOUR`; the showcase refuses to start without it (#59); concurrent uploads may pass the check together – a cost cap, not an exact quota | Fluory | 2026-12-31 (review at M3) | | `.msg` uploads checked by OLE signature only | Structure check of Outlook messages needs a CFB parser; files are served only as attachments with `nosniff` and parsed later by the stateless AI service | Fluory | with #23 (MSG parsing) | +| Gemini API free-tier key on the showcase (#67) | Orchestrator decision 2026-09-26 (ADR-0001 D11 amendment): no GCP for now. Google's terms require paid services for API clients offered to users in the EEA and allow human review of free-tier content, so the showcase stays invite-only for the orchestrator, synthetic data only, demo banner on | Fluory | before anyone else gets a demo account, at the latest 2026-10-31 – then paid tier (same key with billing) or Vertex `eu` | | Gemini API free tier for local development | Synthetic data only; never showcase or customer data (D8) | Fluory | when a Vertex development budget exists | ## Data flow diff --git a/docs/technical/deployment-vercel.md b/docs/technical/deployment-vercel.md index 821bbf6..9532a2a 100644 --- a/docs/technical/deployment-vercel.md +++ b/docs/technical/deployment-vercel.md @@ -8,7 +8,7 @@ | Blocker | Why | |---|---| -| AI service host chosen and running | The drain calls it; recommendation: **Google Cloud Run in the EU** (same GCP project as Vertex `eu`, existing image `services/ai`, no 300 s / package limits) | +| AI service running (§3) | The drain calls it. Showcase decision 2026-09-26 (ADR-0001 D11 amendment, #67): a second **Vercel** project with a Gemini API key (temporary exception). Fallback if the Vercel spike fails: Google Cloud Run in the EU with Vertex `eu` | What runs where: Vercel (Hobby) runs the Next.js app, the drain route and the ERP mock (`/api/erp-mock`). Supabase (`eu-central-1`) holds Postgres (incl. the pg-boss queue) and the files. @@ -42,11 +42,27 @@ process list. The last output lists `app_owner` and `app_rw` with `f | f | f` (no superuser, no RLS bypass, no role creation). The script is all-or-nothing; running it twice fails on "role already exists" – that is fine. -## 3. AI service - -Deploy `services/ai` (Cloud Run EU recommended) with Vertex `eu` credentials (a service account of the -GCP project, never an API key in the repo) and a random `AI_SERVICE_TOKEN` (≥ 24 chars). The Gemini -free tier is **not** allowed for the showcase (D8). Note its HTTPS URL. +## 3. AI service (second Vercel project) + +1. Import the same GitHub repository as a **second Vercel project** with **Root Directory `services/ai`**. + Vercel detects Python from `pyproject.toml`; the entry is `requestflow_ai.vercel_app:app` + (`[tool.vercel]`), the region `fra1` comes from `services/ai/vercel.json`. Keep Fluid compute on. +2. Variables (**Production** only): + + | Variable | Value | + |---|---| + | `AI_SERVICE_TOKEN` | random, ≥ 24 chars – the **same** value as `AI_SERVICE_TOKEN` in the web app | + | `AI_ALLOW_GEMINI_API_DEV` | `true` | + | `GEMINI_API_KEY` | entered by the orchestrator in the Vercel dashboard – never in a chat, the repo or an issue | + | `AI_PDF_PIPELINE` / `AI_PDF_OCR` | `textlines` / `off` – no model downloads on the showcase | + + `VERTEX_PROJECT` stays **unset**: together with `AI_ALLOW_GEMINI_API_DEV=true` the service refuses to start. +3. **Exception (ADR-0001 D11 amendment 2026-09-26, exceptions register):** the Gemini API free tier is only + allowed while the showcase is invite-only for the orchestrator with synthetic data. Before anyone else + gets a demo account (at the latest 2026-10-31): enable billing for the key (paid tier) or switch to + Vertex `eu` (`VERTEX_PROJECT`, service account, `AI_ALLOW_GEMINI_API_DEV` removed). +4. `GET /healthz` → 200. Note the production URL → `AI_SERVICE_URL` of the web app (§4). + The API is protected by the bearer token; the web app calls it server-side only. ## 4. Vercel project diff --git a/services/ai/.vercelignore b/services/ai/.vercelignore new file mode 100644 index 0000000..7ae0a18 --- /dev/null +++ b/services/ai/.vercelignore @@ -0,0 +1,10 @@ +# Not needed at runtime on Vercel – keeps the upload small (dependencies come from pyproject/uv.lock). +# Anchored with a leading slash: `src/requestflow_ai/evals/` is runtime code and must stay. +/tests/ +/evals/ +/scripts/ +/Dockerfile +/.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ diff --git a/services/ai/pyproject.toml b/services/ai/pyproject.toml index ae9cc51..50e0f56 100644 --- a/services/ai/pyproject.toml +++ b/services/ai/pyproject.toml @@ -42,6 +42,10 @@ build-backend = "hatchling.build" packages = ["src/requestflow_ai"] # CPU-only torch: the service has no GPU; the default PyPI Linux wheels drag in ~3 GB of CUDA. +# Vercel Python runtime (showcase, ADR-0001 D11 amendment 2026-09-26) +[tool.vercel] +entrypoint = "requestflow_ai.vercel_app:app" + [tool.uv.sources] torch = { index = "pytorch-cpu" } torchvision = { index = "pytorch-cpu" } diff --git a/services/ai/tests/test_vercel_entry.py b/services/ai/tests/test_vercel_entry.py index d11a1a5..5d94b0d 100644 --- a/services/ai/tests/test_vercel_entry.py +++ b/services/ai/tests/test_vercel_entry.py @@ -4,17 +4,17 @@ import importlib import sys +from types import ModuleType import pytest +from conftest import TEST_TOKEN from fastapi import FastAPI from pydantic import ValidationError -from conftest import TEST_TOKEN - ENTRY = "requestflow_ai.vercel_app" -def _import_entry() -> object: +def _import_entry() -> ModuleType: sys.modules.pop(ENTRY, None) return importlib.import_module(ENTRY) @@ -27,7 +27,7 @@ def test_exposes_the_extract_api_as_app(monkeypatch: pytest.MonkeyPatch) -> None module = _import_entry() - app = getattr(module, "app") + app = module.app assert isinstance(app, FastAPI) assert "/v1/extract" in {getattr(route, "path", "") for route in app.routes} diff --git a/services/ai/vercel.json b/services/ai/vercel.json new file mode 100644 index 0000000..2c12ed8 --- /dev/null +++ b/services/ai/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "regions": ["fra1"] +} From 8cda21066e24115c9b7fd15ecd4be9ca8b8a6ffd Mon Sep 17 00:00:00 2001 From: Fluory Date: Sat, 26 Sep 2026 21:15:56 +0200 Subject: [PATCH 3/7] fix(ai-service): root entry shim for Vercel's src-layout blind spot Vercel resolved [tool.vercel] entrypoint as a file next to pyproject.toml and failed with PYTHON_ENTRYPOINT_NOT_FOUND (deployment dpl_28ZxHcH93wmKwuyYWBW1Px2DS7kg): it does not know the src/ layout. The shim puts src/ on sys.path (no-op when installed) and re-exports the tested app. Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- services/ai/app.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 services/ai/app.py diff --git a/services/ai/app.py b/services/ai/app.py new file mode 100644 index 0000000..250a918 --- /dev/null +++ b/services/ai/app.py @@ -0,0 +1,17 @@ +"""Vercel entry shim (ADR-0001 D11 amendment 2026-09-26). + +Vercel resolves ``[tool.vercel] entrypoint`` as a file next to pyproject.toml and does not know the +``src/`` layout (build error PYTHON_ENTRYPOINT_NOT_FOUND). This shim makes ``src/`` importable – a +no-op when the package is installed – and re-exports the tested app from ``requestflow_ai.vercel_app``. +""" + +import sys +from pathlib import Path + +_SRC = Path(__file__).resolve().parent / "src" +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from requestflow_ai.vercel_app import app # noqa: E402 + +__all__ = ["app"] From e608f862f94336f089c441d8e421004c47368684 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sat, 26 Sep 2026 21:16:27 +0200 Subject: [PATCH 4/7] fix(ai-service): point the Vercel entrypoint at the root shim Also restores the CPU-torch comment above the section it explains. Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- services/ai/pyproject.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/ai/pyproject.toml b/services/ai/pyproject.toml index 50e0f56..05d091a 100644 --- a/services/ai/pyproject.toml +++ b/services/ai/pyproject.toml @@ -41,11 +41,12 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/requestflow_ai"] -# CPU-only torch: the service has no GPU; the default PyPI Linux wheels drag in ~3 GB of CUDA. -# Vercel Python runtime (showcase, ADR-0001 D11 amendment 2026-09-26) +# Vercel Python runtime (showcase, ADR-0001 D11 amendment 2026-09-26). The shim `app.py` bridges +# the src/ layout, which Vercel's entrypoint lookup does not know. [tool.vercel] -entrypoint = "requestflow_ai.vercel_app:app" +entrypoint = "app:app" +# CPU-only torch: the service has no GPU; the default PyPI Linux wheels drag in ~3 GB of CUDA. [tool.uv.sources] torch = { index = "pytorch-cpu" } torchvision = { index = "pytorch-cpu" } From 3f190f358de39b919198d83f3f546259e8c89be8 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sat, 26 Sep 2026 21:17:07 +0200 Subject: [PATCH 5/7] style(ai-service): ruff-clean docstring in the entry shim Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- services/ai/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/ai/app.py b/services/ai/app.py index 250a918..9cc2d18 100644 --- a/services/ai/app.py +++ b/services/ai/app.py @@ -1,8 +1,9 @@ """Vercel entry shim (ADR-0001 D11 amendment 2026-09-26). Vercel resolves ``[tool.vercel] entrypoint`` as a file next to pyproject.toml and does not know the -``src/`` layout (build error PYTHON_ENTRYPOINT_NOT_FOUND). This shim makes ``src/`` importable – a -no-op when the package is installed – and re-exports the tested app from ``requestflow_ai.vercel_app``. +``src/`` layout (build error PYTHON_ENTRYPOINT_NOT_FOUND). This shim makes ``src/`` importable (a +no-op when the package is installed) and re-exports the tested app from +``requestflow_ai.vercel_app``. """ import sys From 9862db1be55be271f3fda830030bd3db661cdc92 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sat, 26 Sep 2026 21:20:28 +0200 Subject: [PATCH 6/7] feat(ai-service): container image for Vercel Functions (beta) The Python-function route failed on size: bundle 1386 MB against a 500 MB function limit (deployment dpl_EhZt9SnyAgsH84ayhjXWicgqanpA). Vercel runs OCI images on Functions (beta, up to 15 GB); this file mirrors ./Dockerfile and reads the port from $PORT because the non-root user cannot bind Vercel's default port 80. Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- services/ai/Dockerfile.vercel | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 services/ai/Dockerfile.vercel diff --git a/services/ai/Dockerfile.vercel b/services/ai/Dockerfile.vercel new file mode 100644 index 0000000..878837e --- /dev/null +++ b/services/ai/Dockerfile.vercel @@ -0,0 +1,31 @@ +# RequestFlow AI service on Vercel Functions as a container image (beta; ADR-0001 D11 amendment +# 2026-09-26). Same build as ./Dockerfile - keep both in sync. Differences: no optional model +# prefetch (the showcase runs AI_PDF_PIPELINE=textlines, AI_PDF_OCR=off) and the port comes from +# $PORT: Vercel's default is 80, which the non-root user cannot bind, so the project sets PORT=8080. +# The Python-function route failed: bundle 1386 MB > 500 MB limit (deployment dpl_EhZt9SnyAgsH84ayhjXWicgqanpA). +FROM python:3.13-slim + +COPY --from=ghcr.io/astral-sh/uv:0.8.17 /uv /usr/local/bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev --no-install-project +COPY src ./src +RUN uv sync --frozen --no-dev + +RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app +USER 10001 + +ENV PATH=/app/.venv/bin:$PATH \ + HF_HUB_OFFLINE=1 \ + PORT=8080 + +EXPOSE 8080 +CMD ["sh", "-c", "exec uvicorn requestflow_ai.api.app:create_app --factory --host 0.0.0.0 --port \"${PORT:-8080}\" --no-access-log --no-server-header"] From 7d8944b4d64df02b60dee941ca3737156ec0e24f Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 01:45:21 +0200 Subject: [PATCH 7/7] refactor(ai-service): drop the Python-function entry after the measured spike The Vercel Python-function route cannot work: the bundle is 1386 MB against a 500 MB function limit. The showcase runs the existing image as a container on Vercel Functions instead, so the entry module, its test, the root shim and [tool.vercel] are dead code. ADR-0001 amendment and runbook section 3 now describe the container route with the measured numbers. Refs #67 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/decisions/ADR-0001-pilot-architecture.md | 14 ++++--- docs/technical/deployment-vercel.md | 22 ++++++---- services/ai/app.py | 18 -------- services/ai/pyproject.toml | 5 --- services/ai/src/requestflow_ai/vercel_app.py | 10 ----- services/ai/tests/test_vercel_entry.py | 41 ------------------- 6 files changed, 21 insertions(+), 89 deletions(-) delete mode 100644 services/ai/app.py delete mode 100644 services/ai/src/requestflow_ai/vercel_app.py delete mode 100644 services/ai/tests/test_vercel_entry.py diff --git a/docs/decisions/ADR-0001-pilot-architecture.md b/docs/decisions/ADR-0001-pilot-architecture.md index d016c79..271fc38 100644 --- a/docs/decisions/ADR-0001-pilot-architecture.md +++ b/docs/decisions/ADR-0001-pilot-architecture.md @@ -5,7 +5,7 @@ the drafted recommendation (full TypeScript); the draft recommendation is kept as alternative 1 in D8 - **Amendment 2026-09-24 (D11, also touches D3/D5):** the showcase uses Supabase Postgres + Supabase Storage instead of Neon + R2 – see "Amendment 2026-09-24" at the end of D11 -- **Amendment 2026-09-26 (D11, touches D8):** AI service on Vercel (spike) and a Gemini API free-tier +- **Amendment 2026-09-26 (D11, touches D8):** AI service as a container on Vercel and a Gemini API free-tier key for the invite-only showcase as a temporary exception – see "Amendment 2026-09-26" at the end of D11 - **Deciders:** Fluory (orchestrator) · drafted by a Claude session - **Inputs:** `docs/input/2026-09-22-kundenanfrage.md` (customer request), `PROJECT-START.md` (discovery) @@ -657,10 +657,12 @@ enters the code: the app still talks plain PostgreSQL (Drizzle, pg-boss) and the ### Amendment 2026-09-26 – AI service on Vercel and a Gemini API free-tier key (decided by Fluory, orchestrator; #67) **Decision.** -1. On the showcase the AI service runs as a **second Vercel project** (Python runtime, root directory - `services/ai`, entry `requestflow_ai.vercel_app:app` via `[tool.vercel]` in `pyproject.toml`, - region `fra1`), PDF pipeline `textlines`, OCR off. It is a spike: bundle size, cold start and one - extraction are measured and recorded in the PR of #67. +1. On the showcase the AI service runs as a **second Vercel project** that runs the existing service + image as a **container on Vercel Functions** (beta; framework `container`, root directory + `services/ai`, `Dockerfile.vercel`, `PORT=8080`, region `fra1`), PDF pipeline `textlines`, OCR off. + Spike result (#67, 2026-09-26): the Python-function route fails – the bundle is **1386 MB** against a + **500 MB** function limit (deployment `dpl_EhZt9SnyAgsH84ayhjXWicgqanpA`); the container image builds + in about 4 minutes (image size limit 15 GB). Cold start and one extraction: recorded in the PR of #67. 2. Model calls use a **Gemini API key on the free tier** (`AI_ALLOW_GEMINI_API_DEV=true`, `GEMINI_API_KEY` set only in the Vercel project) – a temporary exception to D8. @@ -677,7 +679,7 @@ only, demo banner on; the exception expires before anyone else gets an account, **Alternatives.** Google Cloud Run + Vertex `eu` (runbook recommendation until now; needs GCP billing) · Hugging Face Space (existing Dockerfile unchanged; new account, hosting region not verified). -**Revisit when** the spike fails on size, cold start or duration (→ Cloud Run), or the exception expires. +**Revisit when** the container beta ends or changes its terms, the cold start makes the 60 s AI timeout fail regularly (→ Cloud Run), or the exception expires. --- diff --git a/docs/technical/deployment-vercel.md b/docs/technical/deployment-vercel.md index 9532a2a..21bb86e 100644 --- a/docs/technical/deployment-vercel.md +++ b/docs/technical/deployment-vercel.md @@ -42,27 +42,31 @@ process list. The last output lists `app_owner` and `app_rw` with `f | f | f` (no superuser, no RLS bypass, no role creation). The script is all-or-nothing; running it twice fails on "role already exists" – that is fine. -## 3. AI service (second Vercel project) +## 3. AI service (second Vercel project, container) -1. Import the same GitHub repository as a **second Vercel project** with **Root Directory `services/ai`**. - Vercel detects Python from `pyproject.toml`; the entry is `requestflow_ai.vercel_app:app` - (`[tool.vercel]`), the region `fra1` comes from `services/ai/vercel.json`. Keep Fluid compute on. -2. Variables (**Production** only): +1. Import the same GitHub repository as a **second Vercel project**: framework **`container`**, Root + Directory **`services/ai`**. Vercel builds `services/ai/Dockerfile.vercel` (same build as + `Dockerfile`) and runs it on Vercel Functions (container images, beta). A plain Python function does + not fit: the bundle is 1386 MB against the 500 MB function limit (ADR-0001 D11 amendment 2026-09-26). +2. Deployment Protection: **Vercel Authentication for preview deployments only** – the web app calls the + production URL server-side; the API itself is protected by `AI_SERVICE_TOKEN`. +3. Variables (**Production**): | Variable | Value | |---|---| + | `PORT` | `8080` – the image runs as a non-root user, which cannot bind Vercel's default port 80 | | `AI_SERVICE_TOKEN` | random, ≥ 24 chars – the **same** value as `AI_SERVICE_TOKEN` in the web app | | `AI_ALLOW_GEMINI_API_DEV` | `true` | - | `GEMINI_API_KEY` | entered by the orchestrator in the Vercel dashboard – never in a chat, the repo or an issue | + | `GEMINI_API_KEY` | the orchestrator's key – set it through `vercel env add GEMINI_API_KEY production --sensitive` with the value on stdin, never in a chat, the repo or an issue | | `AI_PDF_PIPELINE` / `AI_PDF_OCR` | `textlines` / `off` – no model downloads on the showcase | `VERTEX_PROJECT` stays **unset**: together with `AI_ALLOW_GEMINI_API_DEV=true` the service refuses to start. -3. **Exception (ADR-0001 D11 amendment 2026-09-26, exceptions register):** the Gemini API free tier is only +4. **Exception (ADR-0001 D11 amendment 2026-09-26, exceptions register):** the Gemini API free tier is only allowed while the showcase is invite-only for the orchestrator with synthetic data. Before anyone else gets a demo account (at the latest 2026-10-31): enable billing for the key (paid tier) or switch to Vertex `eu` (`VERTEX_PROJECT`, service account, `AI_ALLOW_GEMINI_API_DEV` removed). -4. `GET /healthz` → 200. Note the production URL → `AI_SERVICE_URL` of the web app (§4). - The API is protected by the bearer token; the web app calls it server-side only. +5. `GET /healthz` → 200. The production URL → `AI_SERVICE_URL` of the web app (§4). + Instances scale to zero after 5 minutes without traffic; the first call after that pays a cold start. ## 4. Vercel project diff --git a/services/ai/app.py b/services/ai/app.py deleted file mode 100644 index 9cc2d18..0000000 --- a/services/ai/app.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Vercel entry shim (ADR-0001 D11 amendment 2026-09-26). - -Vercel resolves ``[tool.vercel] entrypoint`` as a file next to pyproject.toml and does not know the -``src/`` layout (build error PYTHON_ENTRYPOINT_NOT_FOUND). This shim makes ``src/`` importable (a -no-op when the package is installed) and re-exports the tested app from -``requestflow_ai.vercel_app``. -""" - -import sys -from pathlib import Path - -_SRC = Path(__file__).resolve().parent / "src" -if str(_SRC) not in sys.path: - sys.path.insert(0, str(_SRC)) - -from requestflow_ai.vercel_app import app # noqa: E402 - -__all__ = ["app"] diff --git a/services/ai/pyproject.toml b/services/ai/pyproject.toml index 05d091a..ae9cc51 100644 --- a/services/ai/pyproject.toml +++ b/services/ai/pyproject.toml @@ -41,11 +41,6 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/requestflow_ai"] -# Vercel Python runtime (showcase, ADR-0001 D11 amendment 2026-09-26). The shim `app.py` bridges -# the src/ layout, which Vercel's entrypoint lookup does not know. -[tool.vercel] -entrypoint = "app:app" - # CPU-only torch: the service has no GPU; the default PyPI Linux wheels drag in ~3 GB of CUDA. [tool.uv.sources] torch = { index = "pytorch-cpu" } diff --git a/services/ai/src/requestflow_ai/vercel_app.py b/services/ai/src/requestflow_ai/vercel_app.py deleted file mode 100644 index f42fc8e..0000000 --- a/services/ai/src/requestflow_ai/vercel_app.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Entry point for Vercel's Python runtime (ADR-0001 D11 amendment 2026-09-26). - -Vercel imports a module-level ``app`` (``[tool.vercel] entrypoint`` in pyproject.toml). The -configuration comes from the environment exactly as for uvicorn's ``create_app`` factory, so a -missing or invalid setting fails at start (fail-closed) instead of serving a half-configured API. -""" - -from requestflow_ai.api.app import create_app - -app = create_app() diff --git a/services/ai/tests/test_vercel_entry.py b/services/ai/tests/test_vercel_entry.py deleted file mode 100644 index 5d94b0d..0000000 --- a/services/ai/tests/test_vercel_entry.py +++ /dev/null @@ -1,41 +0,0 @@ -"""The Vercel entry module exposes the configured app (ADR-0001 D11 amendment 2026-09-26).""" - -from __future__ import annotations - -import importlib -import sys -from types import ModuleType - -import pytest -from conftest import TEST_TOKEN -from fastapi import FastAPI -from pydantic import ValidationError - -ENTRY = "requestflow_ai.vercel_app" - - -def _import_entry() -> ModuleType: - sys.modules.pop(ENTRY, None) - return importlib.import_module(ENTRY) - - -def test_exposes_the_extract_api_as_app(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("AI_SERVICE_TOKEN", TEST_TOKEN) - monkeypatch.setenv("AI_ALLOW_GEMINI_API_DEV", "true") - monkeypatch.setenv("GEMINI_API_KEY", "synthetic-test-key-not-real") - monkeypatch.delenv("VERTEX_PROJECT", raising=False) - - module = _import_entry() - - app = module.app - assert isinstance(app, FastAPI) - assert "/v1/extract" in {getattr(route, "path", "") for route in app.routes} - - -def test_refuses_to_start_without_a_service_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("AI_SERVICE_TOKEN", raising=False) - monkeypatch.setenv("AI_ALLOW_GEMINI_API_DEV", "true") - monkeypatch.setenv("GEMINI_API_KEY", "synthetic-test-key-not-real") - - with pytest.raises(ValidationError, match="ai_service_token"): - _import_entry()