Spaces:
Sleeping
Sleeping
Commit ·
db4ba8d
1
Parent(s): 3bde061
Deploy TradeFlow API to HF
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +24 -0
- LOCAL_SERVICES.md +67 -0
- pyproject.toml +142 -0
- pytest.ini +6 -0
- src/__init__.py +1 -0
- src/__pycache__/__init__.cpython-313.pyc +0 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/__pycache__/config.cpython-313.pyc +0 -0
- src/__pycache__/config.cpython-314.pyc +0 -0
- src/__pycache__/dependencies.cpython-313.pyc +0 -0
- src/__pycache__/dependencies.cpython-314.pyc +0 -0
- src/__pycache__/main.cpython-313.pyc +0 -0
- src/__pycache__/main.cpython-314.pyc +0 -0
- src/ai/__pycache__/graph.cpython-313.pyc +0 -0
- src/ai/__pycache__/graph.cpython-314.pyc +0 -0
- src/ai/__pycache__/mock_llm.cpython-314.pyc +0 -0
- src/ai/__pycache__/state.cpython-313.pyc +0 -0
- src/ai/__pycache__/state.cpython-314.pyc +0 -0
- src/ai/graph.py +131 -0
- src/ai/mock_llm.py +62 -0
- src/ai/nodes/__pycache__/extract.cpython-313.pyc +0 -0
- src/ai/nodes/__pycache__/extract.cpython-314.pyc +0 -0
- src/ai/nodes/__pycache__/fallback_ocr.cpython-313.pyc +0 -0
- src/ai/nodes/__pycache__/fallback_ocr.cpython-314.pyc +0 -0
- src/ai/nodes/__pycache__/human_review.cpython-313.pyc +0 -0
- src/ai/nodes/__pycache__/preprocess.cpython-313.pyc +0 -0
- src/ai/nodes/__pycache__/preprocess.cpython-314.pyc +0 -0
- src/ai/nodes/__pycache__/risk.cpython-313.pyc +0 -0
- src/ai/nodes/__pycache__/risk.cpython-314.pyc +0 -0
- src/ai/nodes/__pycache__/validate.cpython-313.pyc +0 -0
- src/ai/nodes/__pycache__/validate.cpython-314.pyc +0 -0
- src/ai/nodes/extract.py +199 -0
- src/ai/nodes/fallback_ocr.py +116 -0
- src/ai/nodes/human_review.py +59 -0
- src/ai/nodes/preprocess.py +68 -0
- src/ai/nodes/risk.py +159 -0
- src/ai/nodes/validate.py +24 -0
- src/ai/state.py +34 -0
- src/auth/__init__.py +26 -0
- src/auth/__pycache__/__init__.cpython-313.pyc +0 -0
- src/auth/__pycache__/__init__.cpython-314.pyc +0 -0
- src/auth/__pycache__/dependencies.cpython-313.pyc +0 -0
- src/auth/__pycache__/dependencies.cpython-314.pyc +0 -0
- src/auth/__pycache__/keycloak.cpython-313.pyc +0 -0
- src/auth/__pycache__/keycloak.cpython-314.pyc +0 -0
- src/auth/dependencies.py +90 -0
- src/auth/keycloak.py +128 -0
- src/config.py +192 -0
- src/dependencies.py +231 -0
- src/main.py +125 -0
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.13-slim-bookworm
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Fix Debian GPG key rotation, install uv, and system dependencies
|
| 6 |
+
RUN pip install uv && \
|
| 7 |
+
apt-get update -o Acquire::AllowInsecureRepositories=true 2>/dev/null; \
|
| 8 |
+
apt-get install -y --allow-unauthenticated debian-archive-keyring 2>/dev/null; \
|
| 9 |
+
apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
+
libgl1 \
|
| 11 |
+
libglib2.0-0 \
|
| 12 |
+
libmagic1 \
|
| 13 |
+
curl \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Install dependencies using uv (lockfile ensures deterministic resolution)
|
| 17 |
+
COPY pyproject.toml uv.lock ./
|
| 18 |
+
RUN uv pip install --system -r pyproject.toml
|
| 19 |
+
|
| 20 |
+
# Copy source code
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
# Run FastAPI via Uvicorn
|
| 24 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7860", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
LOCAL_SERVICES.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Local services required for full integration testing
|
| 2 |
+
|
| 3 |
+
Overview
|
| 4 |
+
- Unit tests run isolated with mocks; no services needed.
|
| 5 |
+
- For end-to-end / integration you should run the services below.
|
| 6 |
+
|
| 7 |
+
Core services (recommended minimal set)
|
| 8 |
+
- redis: Used by Celery broker and LangGraph `RedisSaver` checkpointer.
|
| 9 |
+
- minio: S3-compatible object storage when `STORAGE_BACKEND=minio`.
|
| 10 |
+
- supabase (postgres + storage + realtime + rest + kong): Optional but required if using Supabase storage or DB-backed features.
|
| 11 |
+
- keycloak: OIDC provider used for authentication (tests mock `get_current_user`).
|
| 12 |
+
- chromadb: Vector DB used by HS-code embeddings (optional unless running embedding flows).
|
| 13 |
+
|
| 14 |
+
Starting the minimal set with docker-compose (from repo root):
|
| 15 |
+
|
| 16 |
+
```powershell
|
| 17 |
+
# From repository root
|
| 18 |
+
docker-compose up -d redis minio chromadb supabase-db supabase-storage supabase-realtime supabase-rest supabase-kong keycloak
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Notes & env vars
|
| 22 |
+
- The repo `docker-compose.yml` already configures sensible defaults. Override with environment variables in `.env` at `apps/api/.env` or repo root.
|
| 23 |
+
- Typical overrides:
|
| 24 |
+
- `POSTGRES_PASSWORD` (postgres/supabase)
|
| 25 |
+
- `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`
|
| 26 |
+
- `SUPABASE_JWT_SECRET`
|
| 27 |
+
- `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD`
|
| 28 |
+
|
| 29 |
+
Quick checks
|
| 30 |
+
- Redis available at `redis://localhost:6379` (or `redis://redis:6379` inside Docker network).
|
| 31 |
+
- Minio console: http://localhost:9001, API: http://localhost:9000
|
| 32 |
+
- Supabase storage API: http://localhost:5000 (configured in compose)
|
| 33 |
+
- Keycloak admin console: http://localhost:8080
|
| 34 |
+
|
| 35 |
+
Integration test tips
|
| 36 |
+
- Many tests mock Supabase and Keycloak; only enable real services when running integration/e2e tests.
|
| 37 |
+
- If you want a minimal integration run, start `redis` and `minio` first and set `STORAGE_BACKEND=minio` in your `.env` before running the API.
|
| 38 |
+
|
| 39 |
+
Running integration tests
|
| 40 |
+
- Start the minimal services with the helper script from the repo root:
|
| 41 |
+
|
| 42 |
+
```powershell
|
| 43 |
+
.\scripts\integration-up.ps1
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
- Run only integration-marked tests:
|
| 47 |
+
|
| 48 |
+
```powershell
|
| 49 |
+
cd apps/api
|
| 50 |
+
.venv\Scripts\python.exe -m pytest -m integration -q
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
- A dedicated GitHub Actions workflow is available at `.github/workflows/integration.yml` for manual or main-branch integration runs.
|
| 54 |
+
|
| 55 |
+
- Run unit tests (fast, uses mocks):
|
| 56 |
+
|
| 57 |
+
```powershell
|
| 58 |
+
cd apps/api
|
| 59 |
+
.venv\Scripts\python.exe -m pytest tests/ -q
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
CI recommendations
|
| 63 |
+
- Run unit tests on every push; run integration tests in a separate CI job that brings up services (docker-compose or Testcontainers) and runs `pytest -m integration`.
|
| 64 |
+
- Use `pytest.ini` to declare markers (already added at `apps/api/pytest.ini`).
|
| 65 |
+
|
| 66 |
+
Next steps
|
| 67 |
+
- I can run a minimal `docker-compose up` for you and then run integration tests, or add a `docker-compose.integration.yml` with a trimmed service list. Which would you prefer?
|
pyproject.toml
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "tradeflow-api"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "TradeFlow AI — FastAPI Backend"
|
| 5 |
+
requires-python = ">=3.13,<3.14" # paddlepaddle does not yet have cp314 wheels
|
| 6 |
+
dependencies = [
|
| 7 |
+
# Web framework
|
| 8 |
+
"fastapi>=0.115.6",
|
| 9 |
+
"uvicorn[standard]>=0.34.0",
|
| 10 |
+
"python-multipart>=0.0.20",
|
| 11 |
+
"slowapi>=0.1.9", # Rate limiting
|
| 12 |
+
|
| 13 |
+
# Config & validation
|
| 14 |
+
"pydantic>=2.10.6",
|
| 15 |
+
"pydantic-settings>=2.7.1",
|
| 16 |
+
|
| 17 |
+
# Auth — Keycloak JWT validation
|
| 18 |
+
"python-jose[cryptography]>=3.3.0",
|
| 19 |
+
"httpx>=0.28.1",
|
| 20 |
+
|
| 21 |
+
# Database
|
| 22 |
+
"asyncpg>=0.30.0",
|
| 23 |
+
"sqlalchemy[asyncio]>=2.0.40",
|
| 24 |
+
"supabase>=2.13.0",
|
| 25 |
+
|
| 26 |
+
# Storage
|
| 27 |
+
"boto3>=1.37.8", # MinIO (S3-compatible)
|
| 28 |
+
"minio>=7.2.13",
|
| 29 |
+
"python-magic>=0.4.27", # File type validation
|
| 30 |
+
|
| 31 |
+
# Task queue
|
| 32 |
+
"celery>=5.5.0",
|
| 33 |
+
"kombu>=5.5.2",
|
| 34 |
+
"redis>=5.2.1",
|
| 35 |
+
|
| 36 |
+
# AI / LLM
|
| 37 |
+
"google-generativeai>=0.8.5",
|
| 38 |
+
"langchain-google-genai>=2.1.0",
|
| 39 |
+
"langgraph>=0.3.18",
|
| 40 |
+
"langgraph-checkpoint-redis>=0.0.6",
|
| 41 |
+
"langsmith>=0.3.11",
|
| 42 |
+
"openai>=1.58.1", # text-embedding-3-small
|
| 43 |
+
|
| 44 |
+
# OCR & document processing
|
| 45 |
+
"paddleocr>=2.9.1,<3.0",
|
| 46 |
+
"paddlepaddle>=2.6.2,<3.4", # cp313 wheels only; 3.3.1 is latest tested
|
| 47 |
+
"pymupdf>=1.25.3",
|
| 48 |
+
"pdfplumber>=0.11.4",
|
| 49 |
+
"openpyxl>=3.1.0",
|
| 50 |
+
"opencv-python-headless>=4.11.0.86",
|
| 51 |
+
"pillow>=11.1.0",
|
| 52 |
+
"lingua-language-detector>=2.0.2",
|
| 53 |
+
|
| 54 |
+
# ML
|
| 55 |
+
"xgboost>=2.1.4",
|
| 56 |
+
"scikit-learn>=1.6.1",
|
| 57 |
+
"joblib>=1.4.2",
|
| 58 |
+
"numpy>=2.2.3",
|
| 59 |
+
"pandas>=2.2.3",
|
| 60 |
+
|
| 61 |
+
# Vector store
|
| 62 |
+
"chromadb>=0.6.3",
|
| 63 |
+
|
| 64 |
+
# Azure DI (fallback OCR)
|
| 65 |
+
"azure-ai-documentintelligence>=1.0.0",
|
| 66 |
+
"azure-core>=1.32.0",
|
| 67 |
+
|
| 68 |
+
# Blockchain
|
| 69 |
+
"web3>=7.8.0",
|
| 70 |
+
"eth-account>=0.13.4",
|
| 71 |
+
|
| 72 |
+
# Notifications
|
| 73 |
+
"resend>=2.7.0",
|
| 74 |
+
|
| 75 |
+
# Observability
|
| 76 |
+
"sentry-sdk[fastapi]>=2.20.0",
|
| 77 |
+
"opentelemetry-sdk>=1.30.0",
|
| 78 |
+
"opentelemetry-instrumentation-fastapi>=0.51b0",
|
| 79 |
+
"opentelemetry-instrumentation-celery>=0.51b0",
|
| 80 |
+
"opentelemetry-instrumentation-httpx>=0.51b0",
|
| 81 |
+
"opentelemetry-instrumentation-redis>=0.51b0",
|
| 82 |
+
"prometheus-fastapi-instrumentator>=7.0.2",
|
| 83 |
+
|
| 84 |
+
# Utilities
|
| 85 |
+
"circuitbreaker>=2.0.0",
|
| 86 |
+
"cryptography>=44.0.0",
|
| 87 |
+
"python-dateutil>=2.9.0",
|
| 88 |
+
"pytz>=2025.1",
|
| 89 |
+
"tenacity>=9.0.0",
|
| 90 |
+
"structlog>=24.4.0",
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
[project.optional-dependencies]
|
| 94 |
+
dev = [
|
| 95 |
+
"pytest>=8.3.4",
|
| 96 |
+
"pytest-asyncio>=0.25.3",
|
| 97 |
+
"pytest-cov>=6.0.0",
|
| 98 |
+
"httpx>=0.28.1",
|
| 99 |
+
"ruff>=0.9.4",
|
| 100 |
+
"mypy>=1.14.1",
|
| 101 |
+
"factory-boy>=3.3.1",
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
[build-system]
|
| 105 |
+
requires = ["hatchling"]
|
| 106 |
+
build-backend = "hatchling.build"
|
| 107 |
+
|
| 108 |
+
[tool.ruff]
|
| 109 |
+
target-version = "py313"
|
| 110 |
+
line-length = 120
|
| 111 |
+
|
| 112 |
+
[tool.ruff.lint]
|
| 113 |
+
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "C4", "SIM"]
|
| 114 |
+
ignore = [
|
| 115 |
+
"E501", # allow lines longer than line-length
|
| 116 |
+
"S101", # allow assert in tests
|
| 117 |
+
"N802", # allow uppercase function names (pydantic property KEYCLOAK_JWKS_URL)
|
| 118 |
+
"N803", # allow uppercase argument names (ML convention: X)
|
| 119 |
+
"N806", # allow uppercase variable names (ML convention: X_train, X_valid)
|
| 120 |
+
"B008", # allow function calls in defaults (FastAPI Depends/File/Form pattern)
|
| 121 |
+
"S108", # allow /tmp paths (controlled usage in predictor_svc)
|
| 122 |
+
"S110", # allow try-except-pass (fire-and-forget status updates)
|
| 123 |
+
"SIM105", # allow explicit try-except-pass over contextlib.suppress
|
| 124 |
+
"SIM108", # allow if-else blocks over ternary (readability)
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
[tool.ruff.lint.per-file-ignores]
|
| 128 |
+
"src/tasks/*.py" = ["B904"] # Celery self.retry() cannot use raise-from
|
| 129 |
+
"src/auth/keycloak.py" = ["B904"]
|
| 130 |
+
"src/dependencies.py" = ["B904"]
|
| 131 |
+
|
| 132 |
+
[tool.mypy]
|
| 133 |
+
python_version = "3.13"
|
| 134 |
+
strict = true
|
| 135 |
+
ignore_missing_imports = true
|
| 136 |
+
|
| 137 |
+
[tool.pytest.ini_options]
|
| 138 |
+
asyncio_mode = "auto"
|
| 139 |
+
testpaths = ["tests"]
|
| 140 |
+
|
| 141 |
+
[tool.hatch.build.targets.wheel]
|
| 142 |
+
packages = ["src"]
|
pytest.ini
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
minversion = 7.0
|
| 3 |
+
addopts = -ra
|
| 4 |
+
testpaths = tests
|
| 5 |
+
markers =
|
| 6 |
+
integration: mark test as integration which requires external services
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""TradeFlow AI — API package init."""
|
src/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (189 Bytes). View file
|
|
|
src/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (191 Bytes). View file
|
|
|
src/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (8.74 kB). View file
|
|
|
src/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (8.91 kB). View file
|
|
|
src/__pycache__/dependencies.cpython-313.pyc
ADDED
|
Binary file (9.4 kB). View file
|
|
|
src/__pycache__/dependencies.cpython-314.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
src/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (5.17 kB). View file
|
|
|
src/__pycache__/main.cpython-314.pyc
ADDED
|
Binary file (5.72 kB). View file
|
|
|
src/ai/__pycache__/graph.cpython-313.pyc
ADDED
|
Binary file (4.24 kB). View file
|
|
|
src/ai/__pycache__/graph.cpython-314.pyc
ADDED
|
Binary file (5.35 kB). View file
|
|
|
src/ai/__pycache__/mock_llm.cpython-314.pyc
ADDED
|
Binary file (3.61 kB). View file
|
|
|
src/ai/__pycache__/state.cpython-313.pyc
ADDED
|
Binary file (1.62 kB). View file
|
|
|
src/ai/__pycache__/state.cpython-314.pyc
ADDED
|
Binary file (2.31 kB). View file
|
|
|
src/ai/graph.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — LangGraph Extraction Graph (Step 2 Assembly)
|
| 3 |
+
|
| 4 |
+
PRD §10 — Full LangGraph pipeline:
|
| 5 |
+
|
| 6 |
+
preprocess → llm_extraction → [fallback if needed] → validate
|
| 7 |
+
→ risk_assessment → [interrupt if review needed] → DONE
|
| 8 |
+
|
| 9 |
+
The graph is compiled with a Redis checkpointer for persistence
|
| 10 |
+
and resumability across server restarts.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import redis
|
| 16 |
+
import structlog
|
| 17 |
+
from langgraph.checkpoint.redis import RedisSaver
|
| 18 |
+
from langgraph.graph import END, StateGraph
|
| 19 |
+
|
| 20 |
+
from ..config import settings
|
| 21 |
+
from .nodes.extract import llm_extraction_node
|
| 22 |
+
from .nodes.fallback_ocr import fallback_ocr_node
|
| 23 |
+
from .nodes.human_review import human_review_node
|
| 24 |
+
from .nodes.preprocess import preprocess_documents_node
|
| 25 |
+
from .nodes.risk import risk_assessment_node
|
| 26 |
+
from .nodes.validate import validation_node
|
| 27 |
+
from .state import ExtractionGraphState
|
| 28 |
+
|
| 29 |
+
log = structlog.get_logger()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _needs_fallback(state: ExtractionGraphState) -> str:
|
| 33 |
+
"""Route to OCR ensemble fallback when quality, confidence, or data is weak."""
|
| 34 |
+
if settings.CLOUD_LLM_ONLY:
|
| 35 |
+
log.info("CLOUD_LLM_ONLY is active — bypassing heavy OCR ensemble fallback")
|
| 36 |
+
return "validate"
|
| 37 |
+
|
| 38 |
+
for doc in state.get("documents", []):
|
| 39 |
+
if doc.get("error") or not doc.get("extracted_data"):
|
| 40 |
+
return "fallback"
|
| 41 |
+
if doc.get("quality_score", 1.0) < settings.OCR_FALLBACK_TRIGGER_QUALITY:
|
| 42 |
+
return "fallback"
|
| 43 |
+
confidences = doc.get("field_confidences") or {}
|
| 44 |
+
if confidences and min(confidences.values()) < settings.OCR_FALLBACK_TRIGGER_CONFIDENCE:
|
| 45 |
+
return "fallback"
|
| 46 |
+
if doc.get("ocr_conflicts"):
|
| 47 |
+
return "fallback"
|
| 48 |
+
if len(doc.get("ocr_candidates") or {}) > 1:
|
| 49 |
+
return "fallback"
|
| 50 |
+
return "validate"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _needs_review(state: ExtractionGraphState) -> str:
|
| 54 |
+
"""Conditional edge: route to human review if flagged."""
|
| 55 |
+
if state.get("needs_human_review", False):
|
| 56 |
+
return "human_review"
|
| 57 |
+
return END
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def build_extraction_graph() -> StateGraph:
|
| 61 |
+
"""Build and compile the LangGraph extraction pipeline."""
|
| 62 |
+
workflow = StateGraph(ExtractionGraphState)
|
| 63 |
+
|
| 64 |
+
# ── Add nodes ────────────────────────────────────────────────
|
| 65 |
+
workflow.add_node("preprocess", preprocess_documents_node)
|
| 66 |
+
workflow.add_node("llm_extraction", llm_extraction_node)
|
| 67 |
+
workflow.add_node("fallback_ocr", fallback_ocr_node)
|
| 68 |
+
workflow.add_node("validate", validation_node)
|
| 69 |
+
workflow.add_node("risk_assessment", risk_assessment_node)
|
| 70 |
+
workflow.add_node("human_review", human_review_node)
|
| 71 |
+
|
| 72 |
+
# ── Entry point ───────────────────────────────────────────────
|
| 73 |
+
workflow.set_entry_point("preprocess")
|
| 74 |
+
|
| 75 |
+
# ── Edges ─────────────────────────────────────────────────────
|
| 76 |
+
workflow.add_edge("preprocess", "llm_extraction")
|
| 77 |
+
|
| 78 |
+
# After extraction: check if fallback needed
|
| 79 |
+
workflow.add_conditional_edges(
|
| 80 |
+
"llm_extraction",
|
| 81 |
+
_needs_fallback,
|
| 82 |
+
{
|
| 83 |
+
"fallback": "fallback_ocr",
|
| 84 |
+
"validate": "validate",
|
| 85 |
+
},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Fallback always proceeds to validate
|
| 89 |
+
workflow.add_edge("fallback_ocr", "validate")
|
| 90 |
+
|
| 91 |
+
# After validation: compute risk
|
| 92 |
+
workflow.add_edge("validate", "risk_assessment")
|
| 93 |
+
|
| 94 |
+
# After risk: check if human review needed
|
| 95 |
+
workflow.add_conditional_edges(
|
| 96 |
+
"risk_assessment",
|
| 97 |
+
_needs_review,
|
| 98 |
+
{
|
| 99 |
+
"human_review": "human_review",
|
| 100 |
+
END: END,
|
| 101 |
+
},
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# After human review: graph ends (operator approved)
|
| 105 |
+
workflow.add_edge("human_review", END)
|
| 106 |
+
|
| 107 |
+
return workflow
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def get_compiled_graph():
|
| 111 |
+
"""
|
| 112 |
+
Returns the compiled graph with Redis checkpointer.
|
| 113 |
+
The checkpointer enables:
|
| 114 |
+
- State persistence across Celery task restarts
|
| 115 |
+
- interrupt() resumability for human review
|
| 116 |
+
- LangSmith tracing integration
|
| 117 |
+
"""
|
| 118 |
+
workflow = build_extraction_graph()
|
| 119 |
+
|
| 120 |
+
# Redis checkpointer — stores full graph state per thread_id (batch_id)
|
| 121 |
+
redis_conn = redis.Redis.from_url(settings.REDIS_URL)
|
| 122 |
+
checkpointer = RedisSaver(redis_client=redis_conn)
|
| 123 |
+
|
| 124 |
+
graph = workflow.compile(checkpointer=checkpointer, interrupt_before=["human_review"])
|
| 125 |
+
|
| 126 |
+
log.info("LangGraph extraction graph compiled", nodes=list(workflow.nodes.keys()))
|
| 127 |
+
return graph
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ── Singleton (imported by Celery tasks) ──────────────────────────────────────
|
| 131 |
+
extraction_graph = get_compiled_graph()
|
src/ai/mock_llm.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Deterministic LLM stub for deterministic E2E and tests.
|
| 3 |
+
Returns predictable CEISAFields outputs so the LangGraph flow is repeatable.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DeterministicStructuredLLM:
|
| 13 |
+
def __init__(self, model_schema: type[BaseModel]):
|
| 14 |
+
self._schema = model_schema
|
| 15 |
+
|
| 16 |
+
async def ainvoke(self, messages: Any):
|
| 17 |
+
# Return a deterministic instance matching the pydantic output schema
|
| 18 |
+
# Use simple fixed safe defaults; tests relying on presence of fields
|
| 19 |
+
# can assert these exact values for determinism.
|
| 20 |
+
data = {}
|
| 21 |
+
# Pydantic v2 uses `model_fields`; v1 uses `__fields__` with different metadata
|
| 22 |
+
schema_fields = getattr(self._schema, 'model_fields', None) or getattr(self._schema, '__fields__', {})
|
| 23 |
+
for k, meta in schema_fields.items():
|
| 24 |
+
# Determine annotation/type across pydantic versions
|
| 25 |
+
if isinstance(meta, dict):
|
| 26 |
+
ftype = meta.get('annotation')
|
| 27 |
+
elif hasattr(meta, 'annotation'):
|
| 28 |
+
ftype = meta.annotation
|
| 29 |
+
elif hasattr(meta, 'outer_type_'):
|
| 30 |
+
ftype = meta.outer_type_
|
| 31 |
+
else:
|
| 32 |
+
ftype = None
|
| 33 |
+
|
| 34 |
+
# Provide reasonable deterministic defaults by common types
|
| 35 |
+
if ftype is str or getattr(ftype, '__name__', '') == 'str':
|
| 36 |
+
data[k] = f"det-{k}"
|
| 37 |
+
elif ftype is int or getattr(ftype, '__name__', '') == 'int':
|
| 38 |
+
data[k] = 1
|
| 39 |
+
elif ftype is float or getattr(ftype, '__name__', '') == 'float':
|
| 40 |
+
data[k] = 1.0
|
| 41 |
+
else:
|
| 42 |
+
data[k] = None
|
| 43 |
+
|
| 44 |
+
# Create a pydantic model instance if possible
|
| 45 |
+
try:
|
| 46 |
+
return self._schema.model_validate(data) if hasattr(self._schema, 'model_validate') else self._schema(**data)
|
| 47 |
+
except Exception:
|
| 48 |
+
# Last resort: return raw dict
|
| 49 |
+
return data
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class DeterministicLLM:
|
| 53 |
+
def __init__(self, *_, **__):
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
def with_structured_output(self, schema: type[BaseModel]):
|
| 57 |
+
return DeterministicStructuredLLM(schema)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# synchronous convenience factory
|
| 61 |
+
def create_deterministic_llm(*args, **kwargs):
|
| 62 |
+
return DeterministicLLM()
|
src/ai/nodes/__pycache__/extract.cpython-313.pyc
ADDED
|
Binary file (7.3 kB). View file
|
|
|
src/ai/nodes/__pycache__/extract.cpython-314.pyc
ADDED
|
Binary file (8.09 kB). View file
|
|
|
src/ai/nodes/__pycache__/fallback_ocr.cpython-313.pyc
ADDED
|
Binary file (1.4 kB). View file
|
|
|
src/ai/nodes/__pycache__/fallback_ocr.cpython-314.pyc
ADDED
|
Binary file (7.33 kB). View file
|
|
|
src/ai/nodes/__pycache__/human_review.cpython-313.pyc
ADDED
|
Binary file (1.95 kB). View file
|
|
|
src/ai/nodes/__pycache__/preprocess.cpython-313.pyc
ADDED
|
Binary file (1.11 kB). View file
|
|
|
src/ai/nodes/__pycache__/preprocess.cpython-314.pyc
ADDED
|
Binary file (2.74 kB). View file
|
|
|
src/ai/nodes/__pycache__/risk.cpython-313.pyc
ADDED
|
Binary file (5.77 kB). View file
|
|
|
src/ai/nodes/__pycache__/risk.cpython-314.pyc
ADDED
|
Binary file (7.02 kB). View file
|
|
|
src/ai/nodes/__pycache__/validate.cpython-313.pyc
ADDED
|
Binary file (1.01 kB). View file
|
|
|
src/ai/nodes/__pycache__/validate.cpython-314.pyc
ADDED
|
Binary file (1.16 kB). View file
|
|
|
src/ai/nodes/extract.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Primary LLM Extraction Node (Step 2.2)
|
| 3 |
+
|
| 4 |
+
Uses Gemini 2.0 Flash Exp for multimodal extraction.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import inspect
|
| 9 |
+
|
| 10 |
+
import structlog
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
# Optional production LLM — may be absent in lightweight test environments
|
| 14 |
+
try:
|
| 15 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 16 |
+
except Exception: # pragma: no cover - optional dependency
|
| 17 |
+
ChatGoogleGenerativeAI = None
|
| 18 |
+
|
| 19 |
+
from ...config import settings
|
| 20 |
+
from ..state import ExtractionGraphState
|
| 21 |
+
|
| 22 |
+
# Deterministic stub for tests/E2E
|
| 23 |
+
if settings.DETERMINISTIC_E2E:
|
| 24 |
+
try:
|
| 25 |
+
from ..mock_llm import DeterministicLLM as DeterministicLLM # type: ignore
|
| 26 |
+
except Exception:
|
| 27 |
+
DeterministicLLM = None
|
| 28 |
+
else:
|
| 29 |
+
DeterministicLLM = None
|
| 30 |
+
|
| 31 |
+
log = structlog.get_logger()
|
| 32 |
+
|
| 33 |
+
# Structured output schema
|
| 34 |
+
class CEISAFields(BaseModel):
|
| 35 |
+
importer_name: str | None = Field(description="Name of the importing company")
|
| 36 |
+
importer_npwp: str | None = Field(description="NPWP tax ID of the importer")
|
| 37 |
+
total_packages: int | None = Field(description="Total number of packages/koli")
|
| 38 |
+
gross_weight: float | None = Field(description="Total gross weight in KGM")
|
| 39 |
+
cif_value: float | None = Field(description="Total CIF value")
|
| 40 |
+
currency: str | None = Field(description="Currency code (e.g. USD, IDR)")
|
| 41 |
+
|
| 42 |
+
async def llm_extraction_node(state: ExtractionGraphState) -> dict:
|
| 43 |
+
"""
|
| 44 |
+
Step 2.2: Primary LLM Extraction using Gemini 2.0 Flash Exp.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
state: ExtractionGraphState with documents list containing doc_id, storage_path, pages
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
dict with:
|
| 51 |
+
- documents: Updated docs with extracted_data or error flag
|
| 52 |
+
- combined_data: Merged field values across docs
|
| 53 |
+
- steps: Execution trace
|
| 54 |
+
|
| 55 |
+
Raises:
|
| 56 |
+
Specific exceptions (GoogleAPIError, ValueError) — does NOT catch all exceptions
|
| 57 |
+
"""
|
| 58 |
+
log.info("Running llm_extraction_node", batch_id=state["batch_id"])
|
| 59 |
+
|
| 60 |
+
# Lazy LLM setup: only instantiate the LLM when we encounter the first document
|
| 61 |
+
# that actually needs LLM extraction (has pages). This keeps unit tests lightweight
|
| 62 |
+
# when LLM dependencies are not installed.
|
| 63 |
+
structured_llm = None
|
| 64 |
+
|
| 65 |
+
updated_docs = []
|
| 66 |
+
combined_data = {}
|
| 67 |
+
|
| 68 |
+
for doc in state["documents"]:
|
| 69 |
+
# Validate document state before processing
|
| 70 |
+
if not doc.get("doc_id") or not doc.get("pages"):
|
| 71 |
+
log.error(
|
| 72 |
+
"Invalid document state — missing required fields",
|
| 73 |
+
doc_id=doc.get("doc_id"),
|
| 74 |
+
batch_id=state["batch_id"]
|
| 75 |
+
)
|
| 76 |
+
updated_docs.append({
|
| 77 |
+
**doc,
|
| 78 |
+
"error": "Document missing required fields (doc_id, pages)",
|
| 79 |
+
"fallback_required": True,
|
| 80 |
+
"ocr_method": "failed"
|
| 81 |
+
})
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
# Initialize LLM on first real document that needs extraction
|
| 85 |
+
if structured_llm is None:
|
| 86 |
+
if settings.DETERMINISTIC_E2E:
|
| 87 |
+
if DeterministicLLM is None:
|
| 88 |
+
raise RuntimeError("DETERMINISTIC_E2E enabled but DeterministicLLM not available")
|
| 89 |
+
llm = DeterministicLLM()
|
| 90 |
+
else:
|
| 91 |
+
if ChatGoogleGenerativeAI is None:
|
| 92 |
+
raise RuntimeError("Production LLM dependency 'langchain_google_genai' is not installed")
|
| 93 |
+
primary_llm = ChatGoogleGenerativeAI(
|
| 94 |
+
model=settings.GEMINI_MODEL_PRIMARY,
|
| 95 |
+
temperature=0,
|
| 96 |
+
api_key=settings.GEMINI_API_KEY
|
| 97 |
+
)
|
| 98 |
+
fallback_llm = ChatGoogleGenerativeAI(
|
| 99 |
+
model=settings.GEMINI_MODEL_FALLBACK,
|
| 100 |
+
temperature=0,
|
| 101 |
+
api_key=settings.GEMINI_API_KEY
|
| 102 |
+
)
|
| 103 |
+
llm = primary_llm.with_fallbacks([fallback_llm])
|
| 104 |
+
|
| 105 |
+
structured_llm = llm.with_structured_output(CEISAFields)
|
| 106 |
+
# Support both synchronous return and awaitable (coroutine/AsyncMock)
|
| 107 |
+
if asyncio.iscoroutine(structured_llm) or inspect.isawaitable(structured_llm):
|
| 108 |
+
structured_llm = await structured_llm
|
| 109 |
+
# Validate document state before processing
|
| 110 |
+
if not doc.get("doc_id") or not doc.get("pages"):
|
| 111 |
+
log.error(
|
| 112 |
+
"Invalid document state — missing required fields",
|
| 113 |
+
doc_id=doc.get("doc_id"),
|
| 114 |
+
batch_id=state["batch_id"]
|
| 115 |
+
)
|
| 116 |
+
updated_docs.append({
|
| 117 |
+
**doc,
|
| 118 |
+
"error": "Document missing required fields (doc_id, pages)",
|
| 119 |
+
"fallback_required": True,
|
| 120 |
+
"ocr_method": "failed"
|
| 121 |
+
})
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# Avoid importing heavy langchain Core in deterministic/test mode
|
| 126 |
+
if settings.DETERMINISTIC_E2E:
|
| 127 |
+
messages = [{"type": "text", "text": "deterministic"}]
|
| 128 |
+
else:
|
| 129 |
+
try:
|
| 130 |
+
from langchain_core.messages import HumanMessage as _HumanMessage
|
| 131 |
+
except Exception:
|
| 132 |
+
class _HumanMessage: # lightweight fallback so tests don't require langchain_core
|
| 133 |
+
def __init__(self, content):
|
| 134 |
+
self.content = content
|
| 135 |
+
|
| 136 |
+
# Real LLM call with multimodal content
|
| 137 |
+
# Pass pages as base64-encoded images for extraction
|
| 138 |
+
messages = [
|
| 139 |
+
_HumanMessage(
|
| 140 |
+
content=[
|
| 141 |
+
{"type": "text", "text": "Extract all CEISA fields (importer name, NPWP, packages, weight, CIF value) from this document."},
|
| 142 |
+
{"type": "image_url", "image_url": {"url": doc["pages"][0]}} if doc["pages"] else {"type": "text", "text": "No pages available"}
|
| 143 |
+
]
|
| 144 |
+
)
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
result = await structured_llm.ainvoke(messages)
|
| 148 |
+
|
| 149 |
+
# Convert Pydantic model to dict
|
| 150 |
+
raw_extracted = result.model_dump(exclude_none=True) if hasattr(result, 'model_dump') else result
|
| 151 |
+
if asyncio.iscoroutine(raw_extracted):
|
| 152 |
+
raw_extracted = await raw_extracted
|
| 153 |
+
extracted = raw_extracted
|
| 154 |
+
|
| 155 |
+
candidates = dict(doc.get("ocr_candidates") or {})
|
| 156 |
+
candidates[settings.GEMINI_MODEL_PRIMARY] = {
|
| 157 |
+
"fields": extracted,
|
| 158 |
+
"confidence": 0.82,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
updated_docs.append({
|
| 162 |
+
**doc,
|
| 163 |
+
"extracted_data": extracted,
|
| 164 |
+
"ocr_method": settings.GEMINI_MODEL_PRIMARY,
|
| 165 |
+
"ocr_candidates": candidates,
|
| 166 |
+
"field_confidences": dict.fromkeys(extracted, 0.82),
|
| 167 |
+
})
|
| 168 |
+
|
| 169 |
+
combined_data.update(extracted)
|
| 170 |
+
|
| 171 |
+
except (ValueError, KeyError) as e:
|
| 172 |
+
# Expected errors — likely malformed input
|
| 173 |
+
log.exception(
|
| 174 |
+
"Gemini extraction failed — will retry with fallback",
|
| 175 |
+
doc_id=doc.get("doc_id"),
|
| 176 |
+
batch_id=state["batch_id"],
|
| 177 |
+
error_type=type(e).__name__
|
| 178 |
+
)
|
| 179 |
+
updated_docs.append({
|
| 180 |
+
**doc,
|
| 181 |
+
"error": str(e),
|
| 182 |
+
"fallback_required": True,
|
| 183 |
+
"ocr_method": "failed"
|
| 184 |
+
})
|
| 185 |
+
except Exception as e:
|
| 186 |
+
# Unexpected errors — log and re-raise to fail the batch
|
| 187 |
+
log.critical(
|
| 188 |
+
"Unexpected error in LLM extraction — batch will fail",
|
| 189 |
+
doc_id=doc.get("doc_id"),
|
| 190 |
+
batch_id=state["batch_id"],
|
| 191 |
+
error_type=type(e).__name__
|
| 192 |
+
)
|
| 193 |
+
raise
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"documents": updated_docs,
|
| 197 |
+
"combined_data": combined_data,
|
| 198 |
+
"steps": ["llm_extraction"]
|
| 199 |
+
}
|
src/ai/nodes/fallback_ocr.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Fallback OCR Node (Step 2.3)
|
| 3 |
+
|
| 4 |
+
Used when Gemini extraction fails or confidence is too low.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
import structlog
|
| 10 |
+
|
| 11 |
+
from ...config import settings
|
| 12 |
+
from ...services.ocr_conflict_svc import reconcile_ocr_candidates
|
| 13 |
+
from ..state import ExtractionGraphState
|
| 14 |
+
|
| 15 |
+
log = structlog.get_logger()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _needs_reconciliation(doc: dict) -> bool:
|
| 19 |
+
confidences = doc.get("field_confidences") or {}
|
| 20 |
+
return (
|
| 21 |
+
bool(doc.get("error"))
|
| 22 |
+
or not doc.get("extracted_data")
|
| 23 |
+
or doc.get("quality_score", 1.0) < settings.OCR_FALLBACK_TRIGGER_QUALITY
|
| 24 |
+
or bool(doc.get("ocr_conflicts"))
|
| 25 |
+
or len(doc.get("ocr_candidates") or {}) > 1
|
| 26 |
+
or bool(
|
| 27 |
+
confidences
|
| 28 |
+
and min(confidences.values()) < settings.OCR_FALLBACK_TRIGGER_CONFIDENCE
|
| 29 |
+
)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _rule_based_candidates(doc: dict) -> dict:
|
| 34 |
+
"""Extract obvious CEISA fields from embedded text without external OCR."""
|
| 35 |
+
text = "\n".join(str(page) for page in doc.get("pages", []) if isinstance(page, str))
|
| 36 |
+
text = "\n".join([doc.get("raw_text", ""), doc.get("text_layer", ""), text])
|
| 37 |
+
|
| 38 |
+
fields = {}
|
| 39 |
+
npwp = re.search(r"\b(?:NPWP|Tax\s*ID)\D*([0-9.\- ]{10,24})", text, re.IGNORECASE)
|
| 40 |
+
packages = re.search(r"\b(?:total\s+packages|packages|koli)\D*(\d{1,7})", text, re.IGNORECASE)
|
| 41 |
+
gross = re.search(r"\b(?:gross\s+weight|gross)\D*([0-9,.]+)", text, re.IGNORECASE)
|
| 42 |
+
cif = re.search(
|
| 43 |
+
r"\b(?:CIF|total\s+amount|invoice\s+value)\D*([A-Z]{3})?\s*([0-9,.]+)",
|
| 44 |
+
text,
|
| 45 |
+
re.IGNORECASE,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
if npwp:
|
| 49 |
+
fields["importer_npwp"] = npwp.group(1)
|
| 50 |
+
if packages:
|
| 51 |
+
fields["total_packages"] = int(packages.group(1))
|
| 52 |
+
if gross:
|
| 53 |
+
fields["gross_weight"] = float(gross.group(1).replace(",", ""))
|
| 54 |
+
if cif:
|
| 55 |
+
if cif.group(1):
|
| 56 |
+
fields["currency"] = cif.group(1).upper()
|
| 57 |
+
fields["cif_value"] = float(cif.group(2).replace(",", ""))
|
| 58 |
+
|
| 59 |
+
return {"fields": fields, "confidence": 0.68}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def fallback_ocr_node(state: ExtractionGraphState) -> dict:
|
| 63 |
+
"""
|
| 64 |
+
Step 2.3: Fallback OCR using Azure Document Intelligence or PaddleOCR.
|
| 65 |
+
"""
|
| 66 |
+
log.info("Running fallback_ocr_node", batch_id=state["batch_id"])
|
| 67 |
+
|
| 68 |
+
updated_docs = []
|
| 69 |
+
all_conflicts = list(state.get("ocr_conflicts", []))
|
| 70 |
+
combined_data = dict(state.get("combined_data", {}))
|
| 71 |
+
needs_review = state.get("needs_human_review", False)
|
| 72 |
+
field_confidences = dict(state.get("field_confidences", {}))
|
| 73 |
+
|
| 74 |
+
for doc in state["documents"]:
|
| 75 |
+
if _needs_reconciliation(doc):
|
| 76 |
+
log.info("Reconciling fallback OCR candidates for doc", doc_id=doc["doc_id"])
|
| 77 |
+
candidates = dict(doc.get("ocr_candidates") or {})
|
| 78 |
+
|
| 79 |
+
if doc.get("extracted_data"):
|
| 80 |
+
candidates[doc.get("ocr_method") or "gemini"] = {
|
| 81 |
+
"fields": doc["extracted_data"],
|
| 82 |
+
"confidence": 0.82,
|
| 83 |
+
}
|
| 84 |
+
if settings.ENABLE_DUAL_OCR and "azure-di" not in candidates:
|
| 85 |
+
log.warning(
|
| 86 |
+
"Azure DI candidate missing; preserving degraded OCR evidence",
|
| 87 |
+
doc_id=doc["doc_id"],
|
| 88 |
+
)
|
| 89 |
+
rule_candidate = _rule_based_candidates(doc)
|
| 90 |
+
if rule_candidate["fields"]:
|
| 91 |
+
candidates["rule_based"] = rule_candidate
|
| 92 |
+
|
| 93 |
+
reconciled = reconcile_ocr_candidates(candidates)
|
| 94 |
+
doc["ocr_method"] = "ensemble-reconciled"
|
| 95 |
+
doc["extracted_data"] = reconciled["fields"]
|
| 96 |
+
doc["field_confidences"] = reconciled["field_confidences"]
|
| 97 |
+
doc["ocr_conflicts"] = reconciled["conflicts"]
|
| 98 |
+
doc["error"] = None if reconciled["fields"] else "No OCR engine produced usable fields"
|
| 99 |
+
|
| 100 |
+
combined_data.update(reconciled["fields"])
|
| 101 |
+
field_confidences.update(reconciled["field_confidences"])
|
| 102 |
+
all_conflicts.extend(
|
| 103 |
+
{**conflict, "doc_id": doc["doc_id"]} for conflict in reconciled["conflicts"]
|
| 104 |
+
)
|
| 105 |
+
needs_review = needs_review or reconciled["needs_human_review"] or bool(doc["error"])
|
| 106 |
+
|
| 107 |
+
updated_docs.append(doc)
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
"documents": updated_docs,
|
| 111 |
+
"combined_data": combined_data,
|
| 112 |
+
"field_confidences": field_confidences,
|
| 113 |
+
"ocr_conflicts": all_conflicts,
|
| 114 |
+
"needs_human_review": needs_review,
|
| 115 |
+
"steps": ["fallback_ocr"]
|
| 116 |
+
}
|
src/ai/nodes/human_review.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Human Review Interrupt Node (Step 2.6)
|
| 3 |
+
|
| 4 |
+
PRD §0.2 Invariant #5: LangGraph's interrupt() is the ONLY mechanism
|
| 5 |
+
to pause execution for human review. No polling loops.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import structlog
|
| 11 |
+
from langgraph.types import interrupt
|
| 12 |
+
|
| 13 |
+
from ..state import ExtractionGraphState
|
| 14 |
+
|
| 15 |
+
log = structlog.get_logger()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def human_review_node(state: ExtractionGraphState) -> dict:
|
| 19 |
+
"""
|
| 20 |
+
Pause the graph and surface extracted fields for operator review.
|
| 21 |
+
|
| 22 |
+
The graph will resume when an operator POSTs to
|
| 23 |
+
POST /api/v1/batches/{batch_id}/review with their corrections.
|
| 24 |
+
|
| 25 |
+
`interrupt()` saves full graph state to Redis checkpoint store.
|
| 26 |
+
"""
|
| 27 |
+
log.info(
|
| 28 |
+
"Interrupting for human review",
|
| 29 |
+
batch_id=state["batch_id"],
|
| 30 |
+
risk_level=state.get("risk_level"),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Surfaces the current extraction output to the operator
|
| 34 |
+
review_payload = {
|
| 35 |
+
"batch_id": state["batch_id"],
|
| 36 |
+
"combined_data": state.get("combined_data", {}),
|
| 37 |
+
"validation_results": state.get("validation_results", []),
|
| 38 |
+
"risk_level": state.get("risk_level", "UNKNOWN"),
|
| 39 |
+
"message": "Dokumen ini memerlukan tinjauan manual sebelum dapat diajukan.",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# PRD §10: interrupt() — execution suspends here.
|
| 43 |
+
# The operator's corrections are returned as the `interrupt` return value.
|
| 44 |
+
corrections: dict = interrupt(review_payload)
|
| 45 |
+
|
| 46 |
+
# Merge operator corrections into combined_data
|
| 47 |
+
corrected_data = {**state.get("combined_data", {}), **corrections}
|
| 48 |
+
|
| 49 |
+
log.info(
|
| 50 |
+
"Human review complete — resuming graph",
|
| 51 |
+
batch_id=state["batch_id"],
|
| 52 |
+
corrections_count=len(corrections),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
"combined_data": corrected_data,
|
| 57 |
+
"needs_human_review": False,
|
| 58 |
+
"steps": ["human_review"],
|
| 59 |
+
}
|
src/ai/nodes/preprocess.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Preprocessing Node (Step 2.1)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import structlog
|
| 6 |
+
|
| 7 |
+
from ...services.ingest_svc import get_storage_service
|
| 8 |
+
from ...services.ocr_engine_svc import ocr_engine_service
|
| 9 |
+
from ..state import ExtractionGraphState
|
| 10 |
+
|
| 11 |
+
log = structlog.get_logger()
|
| 12 |
+
|
| 13 |
+
async def preprocess_documents_node(state: ExtractionGraphState) -> dict:
|
| 14 |
+
"""
|
| 15 |
+
Step 2.1: Document Preprocessing Node
|
| 16 |
+
- Checks document quality
|
| 17 |
+
- Converts PDFs to images if necessary
|
| 18 |
+
- Sets quality score
|
| 19 |
+
"""
|
| 20 |
+
log.info("Running preprocess_documents_node", batch_id=state["batch_id"])
|
| 21 |
+
|
| 22 |
+
updated_docs = []
|
| 23 |
+
for doc in state["documents"]:
|
| 24 |
+
storage_path = doc.get("storage_path")
|
| 25 |
+
if not storage_path:
|
| 26 |
+
updated_docs.append({
|
| 27 |
+
**doc,
|
| 28 |
+
"quality_score": 0.0,
|
| 29 |
+
"pages": [],
|
| 30 |
+
"ocr_candidates": {},
|
| 31 |
+
"error": "Document missing storage_path",
|
| 32 |
+
})
|
| 33 |
+
continue
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
file_bytes = await get_storage_service().download_document(storage_path)
|
| 37 |
+
prepared = await ocr_engine_service.prepare_document(
|
| 38 |
+
doc_id=doc["doc_id"],
|
| 39 |
+
storage_path=storage_path,
|
| 40 |
+
filename=doc.get("original_name") or storage_path,
|
| 41 |
+
file_bytes=file_bytes,
|
| 42 |
+
)
|
| 43 |
+
except Exception as exc:
|
| 44 |
+
log.exception(
|
| 45 |
+
"Document preprocessing/OCR failed",
|
| 46 |
+
batch_id=state["batch_id"],
|
| 47 |
+
doc_id=doc.get("doc_id"),
|
| 48 |
+
error=str(exc),
|
| 49 |
+
)
|
| 50 |
+
updated_docs.append({
|
| 51 |
+
**doc,
|
| 52 |
+
"quality_score": 0.0,
|
| 53 |
+
"pages": [],
|
| 54 |
+
"ocr_candidates": {},
|
| 55 |
+
"error": str(exc),
|
| 56 |
+
})
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
updated_docs.append({
|
| 60 |
+
**doc,
|
| 61 |
+
**prepared,
|
| 62 |
+
"ocr_method": "+".join(prepared["ocr_candidates"].keys()) or None,
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"documents": updated_docs,
|
| 67 |
+
"steps": ["preprocess"]
|
| 68 |
+
}
|
src/ai/nodes/risk.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Risk Assessment Node (Step 2.5)
|
| 3 |
+
|
| 4 |
+
Runs the XGBoost rejection predictor and computes the
|
| 5 |
+
Customs Readiness Score (CRS) for a batch.
|
| 6 |
+
|
| 7 |
+
PRD §13 — CRS = weighted average across 5 pillars:
|
| 8 |
+
(1) Document Quality 20%
|
| 9 |
+
(2) Data Completeness 25%
|
| 10 |
+
(3) Cross-document Consistency 30%
|
| 11 |
+
(4) Historical Performance 15%
|
| 12 |
+
(5) HS Code Confidence 10%
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import structlog
|
| 18 |
+
|
| 19 |
+
from ...services.predictor_svc import rejection_predictor
|
| 20 |
+
from ..state import ExtractionGraphState
|
| 21 |
+
|
| 22 |
+
log = structlog.get_logger()
|
| 23 |
+
|
| 24 |
+
# Pillar weights per PRD §13
|
| 25 |
+
PILLAR_WEIGHTS = {
|
| 26 |
+
"doc_quality": 0.20,
|
| 27 |
+
"completeness": 0.25,
|
| 28 |
+
"consistency": 0.30,
|
| 29 |
+
"historical": 0.15,
|
| 30 |
+
"hs_confidence": 0.10,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
REQUIRED_CEISA_FIELDS = [
|
| 34 |
+
"importer_name", "importer_npwp", "total_packages",
|
| 35 |
+
"gross_weight", "cif_value", "currency",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _compute_completeness(combined_data: dict) -> float:
|
| 40 |
+
filled = sum(1 for f in REQUIRED_CEISA_FIELDS if combined_data.get(f))
|
| 41 |
+
return filled / len(REQUIRED_CEISA_FIELDS)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _compute_consistency(validation_results: list[dict]) -> float:
|
| 45 |
+
if not validation_results:
|
| 46 |
+
return 1.0
|
| 47 |
+
passed = sum(1 for r in validation_results if r.get("severity") == "PASS")
|
| 48 |
+
return passed / len(validation_results)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _compute_doc_quality(documents: list[dict]) -> float:
|
| 52 |
+
scores = [d.get("quality_score", 0.8) for d in documents]
|
| 53 |
+
return sum(scores) / len(scores) if scores else 0.0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _crs_to_grade(score: float) -> str:
|
| 57 |
+
if score >= 90:
|
| 58 |
+
return "A"
|
| 59 |
+
if score >= 80:
|
| 60 |
+
return "B"
|
| 61 |
+
if score >= 70:
|
| 62 |
+
return "C"
|
| 63 |
+
if score >= 60:
|
| 64 |
+
return "D"
|
| 65 |
+
return "F"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _score_to_risk(score: float) -> str:
|
| 69 |
+
if score >= 80:
|
| 70 |
+
return "LOW"
|
| 71 |
+
if score >= 65:
|
| 72 |
+
return "MEDIUM"
|
| 73 |
+
if score >= 50:
|
| 74 |
+
return "HIGH"
|
| 75 |
+
return "CRITICAL"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _probability_to_risk(probability: float) -> str:
|
| 79 |
+
if probability < 0.15:
|
| 80 |
+
return "LOW"
|
| 81 |
+
if probability < 0.35:
|
| 82 |
+
return "MEDIUM"
|
| 83 |
+
if probability < 0.60:
|
| 84 |
+
return "HIGH"
|
| 85 |
+
return "CRITICAL"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def risk_assessment_node(state: ExtractionGraphState) -> dict:
|
| 89 |
+
"""
|
| 90 |
+
Compute CRS (0-100) and rejection probability (0-1).
|
| 91 |
+
|
| 92 |
+
XGBoost inference uses the shared predictor service, with heuristic
|
| 93 |
+
fallback when no trained model is available yet.
|
| 94 |
+
"""
|
| 95 |
+
log.info("Running risk_assessment_node", batch_id=state["batch_id"])
|
| 96 |
+
|
| 97 |
+
combined_data = state.get("combined_data", {})
|
| 98 |
+
validation_results = state.get("validation_results", [])
|
| 99 |
+
documents = state.get("documents", [])
|
| 100 |
+
|
| 101 |
+
# ── Pillar scores ──────────────────────────────────────────────
|
| 102 |
+
p_quality = _compute_doc_quality(documents)
|
| 103 |
+
p_completeness = _compute_completeness(combined_data)
|
| 104 |
+
p_consistency = _compute_consistency(validation_results)
|
| 105 |
+
p_historical = 0.80 # Stub — fetched from company submission history
|
| 106 |
+
p_hs_conf = 0.85 # Stub — from HS recommender confidence
|
| 107 |
+
|
| 108 |
+
# ── Weighted CRS ───────────────────────────────────────────────
|
| 109 |
+
crs_raw = (
|
| 110 |
+
p_quality * PILLAR_WEIGHTS["doc_quality"]
|
| 111 |
+
+ p_completeness * PILLAR_WEIGHTS["completeness"]
|
| 112 |
+
+ p_consistency * PILLAR_WEIGHTS["consistency"]
|
| 113 |
+
+ p_historical * PILLAR_WEIGHTS["historical"]
|
| 114 |
+
+ p_hs_conf * PILLAR_WEIGHTS["hs_confidence"]
|
| 115 |
+
)
|
| 116 |
+
crs_score = round(crs_raw * 100, 2)
|
| 117 |
+
crs_grade = _crs_to_grade(crs_score)
|
| 118 |
+
|
| 119 |
+
features = {
|
| 120 |
+
"doc_quality_score": p_quality,
|
| 121 |
+
"completeness_score": p_completeness,
|
| 122 |
+
"consistency_score": p_consistency,
|
| 123 |
+
"historical_rate": p_historical,
|
| 124 |
+
"hs_confidence": p_hs_conf,
|
| 125 |
+
"cif_value_usd": float(combined_data.get("cif_value") or 0.0),
|
| 126 |
+
"package_count": float(combined_data.get("total_packages") or 0.0),
|
| 127 |
+
"gross_weight_kg": float(combined_data.get("gross_weight") or 0.0),
|
| 128 |
+
}
|
| 129 |
+
rejection_prob = round(rejection_predictor.predict_proba(features), 4)
|
| 130 |
+
risk_level = _probability_to_risk(rejection_prob)
|
| 131 |
+
|
| 132 |
+
# PRD §13 Invariant: CRS < 70 → must NOT auto-submit
|
| 133 |
+
needs_human_review = (
|
| 134 |
+
state.get("needs_human_review", False)
|
| 135 |
+
or crs_score < 70.0
|
| 136 |
+
or rejection_prob >= 0.35
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
log.info(
|
| 140 |
+
"CRS computed",
|
| 141 |
+
batch_id=state["batch_id"],
|
| 142 |
+
crs=crs_score,
|
| 143 |
+
grade=crs_grade,
|
| 144 |
+
risk=risk_level,
|
| 145 |
+
rejection_prob=rejection_prob,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"risk_level": risk_level,
|
| 150 |
+
"needs_human_review": needs_human_review,
|
| 151 |
+
"steps": ["risk_assessment"],
|
| 152 |
+
# NOTE: crs_score and rejection_prob are persisted to DB in the
|
| 153 |
+
# caller task (ocr_tasks.assess_risk), not stored in graph state
|
| 154 |
+
# to keep the state lean per PRD §0.2 Invariant #5.
|
| 155 |
+
"_crs_score": crs_score,
|
| 156 |
+
"_crs_grade": crs_grade,
|
| 157 |
+
"_rejection_prob": rejection_prob,
|
| 158 |
+
"_risk_features": features,
|
| 159 |
+
}
|
src/ai/nodes/validate.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Cross-Document Validation Node (Step 2.4)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import structlog
|
| 6 |
+
|
| 7 |
+
from ...services.validation_rules_svc import validation_rules_service
|
| 8 |
+
from ..state import ExtractionGraphState
|
| 9 |
+
|
| 10 |
+
log = structlog.get_logger()
|
| 11 |
+
|
| 12 |
+
async def validation_node(state: ExtractionGraphState) -> dict:
|
| 13 |
+
"""
|
| 14 |
+
Step 2.4: Cross-Document Validation against JSON rules.
|
| 15 |
+
"""
|
| 16 |
+
log.info("Running validation_node", batch_id=state["batch_id"])
|
| 17 |
+
|
| 18 |
+
results, needs_review = validation_rules_service.evaluate(state)
|
| 19 |
+
|
| 20 |
+
return {
|
| 21 |
+
"validation_results": results,
|
| 22 |
+
"needs_human_review": needs_review,
|
| 23 |
+
"steps": ["validation"]
|
| 24 |
+
}
|
src/ai/state.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — LangGraph State Definition
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from operator import add
|
| 6 |
+
from typing import Annotated, TypedDict
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DocumentState(TypedDict):
|
| 10 |
+
doc_id: str
|
| 11 |
+
doc_type: str
|
| 12 |
+
storage_path: str
|
| 13 |
+
pages: list[str] # Base64 encoded images or temporary paths
|
| 14 |
+
extracted_data: dict | None
|
| 15 |
+
quality_score: float
|
| 16 |
+
ocr_method: str | None
|
| 17 |
+
error: str | None
|
| 18 |
+
ocr_candidates: dict
|
| 19 |
+
ocr_conflicts: list[dict]
|
| 20 |
+
field_confidences: dict[str, float]
|
| 21 |
+
|
| 22 |
+
class ExtractionGraphState(TypedDict):
|
| 23 |
+
"""The state of the document extraction LangGraph."""
|
| 24 |
+
batch_id: str
|
| 25 |
+
company_id: str
|
| 26 |
+
documents: list[DocumentState]
|
| 27 |
+
combined_data: dict
|
| 28 |
+
validation_results: list[dict]
|
| 29 |
+
needs_human_review: bool
|
| 30 |
+
risk_level: str
|
| 31 |
+
ocr_conflicts: list[dict]
|
| 32 |
+
field_confidences: dict[str, float]
|
| 33 |
+
# Keep track of which node executed
|
| 34 |
+
steps: Annotated[list[str], add]
|
src/auth/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auth package init."""
|
| 2 |
+
from .dependencies import (
|
| 3 |
+
CurrentUser,
|
| 4 |
+
RequireAdmin,
|
| 5 |
+
RequireOperator,
|
| 6 |
+
RequireSME,
|
| 7 |
+
RequireSupervisor,
|
| 8 |
+
get_current_user,
|
| 9 |
+
get_current_user_id,
|
| 10 |
+
require_roles,
|
| 11 |
+
)
|
| 12 |
+
from .keycloak import extract_roles, extract_user_id, verify_keycloak_token
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"verify_keycloak_token",
|
| 16 |
+
"extract_roles",
|
| 17 |
+
"extract_user_id",
|
| 18 |
+
"get_current_user",
|
| 19 |
+
"get_current_user_id",
|
| 20 |
+
"require_roles",
|
| 21 |
+
"CurrentUser",
|
| 22 |
+
"RequireOperator",
|
| 23 |
+
"RequireAdmin",
|
| 24 |
+
"RequireSME",
|
| 25 |
+
"RequireSupervisor",
|
| 26 |
+
]
|
src/auth/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (612 Bytes). View file
|
|
|
src/auth/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (609 Bytes). View file
|
|
|
src/auth/__pycache__/dependencies.cpython-313.pyc
ADDED
|
Binary file (4.63 kB). View file
|
|
|
src/auth/__pycache__/dependencies.cpython-314.pyc
ADDED
|
Binary file (6.04 kB). View file
|
|
|
src/auth/__pycache__/keycloak.cpython-313.pyc
ADDED
|
Binary file (5.63 kB). View file
|
|
|
src/auth/__pycache__/keycloak.cpython-314.pyc
ADDED
|
Binary file (6.69 kB). View file
|
|
|
src/auth/dependencies.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — FastAPI Auth Dependencies (T-009)
|
| 3 |
+
|
| 4 |
+
Provides reusable dependency functions for role-based access control.
|
| 5 |
+
All protected endpoints must use one of these dependencies.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Annotated, Any
|
| 11 |
+
|
| 12 |
+
from fastapi import Depends, HTTPException, Security, status
|
| 13 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 14 |
+
|
| 15 |
+
from .keycloak import extract_roles, extract_user_id, verify_keycloak_token
|
| 16 |
+
|
| 17 |
+
bearer_scheme = HTTPBearer(auto_error=True)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def get_current_token_payload(
|
| 21 |
+
credentials: Annotated[HTTPAuthorizationCredentials, Security(bearer_scheme)],
|
| 22 |
+
) -> dict[str, Any]:
|
| 23 |
+
"""Verify the Bearer JWT and return its decoded payload."""
|
| 24 |
+
return await verify_keycloak_token(credentials.credentials)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def get_current_user_id(
|
| 28 |
+
payload: Annotated[dict[str, Any], Depends(get_current_token_payload)],
|
| 29 |
+
) -> str:
|
| 30 |
+
"""Returns the authenticated user's Keycloak sub (UUID)."""
|
| 31 |
+
return extract_user_id(payload)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def get_current_roles(
|
| 35 |
+
payload: Annotated[dict[str, Any], Depends(get_current_token_payload)],
|
| 36 |
+
) -> list[str]:
|
| 37 |
+
"""Returns the list of Keycloak realm roles for the current user."""
|
| 38 |
+
return extract_roles(payload)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def require_roles(*allowed_roles: str):
|
| 42 |
+
"""
|
| 43 |
+
Dependency factory that enforces role-based access.
|
| 44 |
+
|
| 45 |
+
Usage:
|
| 46 |
+
@router.post("/submit")
|
| 47 |
+
async def submit(
|
| 48 |
+
_: None = Depends(require_roles("operator", "admin"))
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
async def _check_roles(
|
| 53 |
+
roles: Annotated[list[str], Depends(get_current_roles)],
|
| 54 |
+
) -> None:
|
| 55 |
+
if not any(role in roles for role in allowed_roles):
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 58 |
+
detail=f"Required roles: {list(allowed_roles)}",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return Depends(_check_roles)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Convenience singletons for common role checks
|
| 65 |
+
RequireOperator = require_roles("operator", "admin")
|
| 66 |
+
RequireAdmin = require_roles("admin")
|
| 67 |
+
RequireSME = require_roles("sme", "operator", "admin")
|
| 68 |
+
RequireSupervisor = require_roles("supervisor", "admin")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class CurrentUser:
|
| 72 |
+
"""Dependency class bundling user_id + roles in one inject."""
|
| 73 |
+
|
| 74 |
+
def __init__(self, user_id: str, roles: list[str]) -> None:
|
| 75 |
+
self.user_id = user_id
|
| 76 |
+
self.roles = roles
|
| 77 |
+
|
| 78 |
+
def has_role(self, *roles: str) -> bool:
|
| 79 |
+
return any(r in self.roles for r in roles)
|
| 80 |
+
|
| 81 |
+
def is_admin(self) -> bool:
|
| 82 |
+
return "admin" in self.roles
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def get_current_user(
|
| 86 |
+
user_id: Annotated[str, Depends(get_current_user_id)],
|
| 87 |
+
roles: Annotated[list[str], Depends(get_current_roles)],
|
| 88 |
+
) -> CurrentUser:
|
| 89 |
+
"""Returns a CurrentUser object with id and roles."""
|
| 90 |
+
return CurrentUser(user_id=user_id, roles=roles)
|
src/auth/keycloak.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Keycloak 26 JWT Authentication (T-008)
|
| 3 |
+
|
| 4 |
+
PRD Invariant #4: Keycloak 26 is the SOLE auth provider. No Supabase Auth.
|
| 5 |
+
Validates JWTs via JWKS endpoint with a 5-minute cache.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from fastapi import HTTPException, status
|
| 15 |
+
from jose import JWTError, jwk, jwt
|
| 16 |
+
|
| 17 |
+
from ..config import settings
|
| 18 |
+
|
| 19 |
+
# JWKS TTL — 5 minutes (SDD §4.1)
|
| 20 |
+
_JWKS_TTL_SECONDS = 300
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class JWKSCache:
|
| 24 |
+
"""Thread-safe JWKS cache with 5-minute TTL."""
|
| 25 |
+
|
| 26 |
+
def __init__(self) -> None:
|
| 27 |
+
self._keys: dict[str, Any] = {}
|
| 28 |
+
self._fetched_at: float = 0.0
|
| 29 |
+
|
| 30 |
+
def _is_stale(self) -> bool:
|
| 31 |
+
return time.monotonic() - self._fetched_at > _JWKS_TTL_SECONDS
|
| 32 |
+
|
| 33 |
+
async def get_keys(self) -> dict[str, Any]:
|
| 34 |
+
if not self._keys or self._is_stale():
|
| 35 |
+
await self._refresh()
|
| 36 |
+
return self._keys
|
| 37 |
+
|
| 38 |
+
async def _refresh(self) -> None:
|
| 39 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 40 |
+
resp = await client.get(settings.KEYCLOAK_JWKS_URL)
|
| 41 |
+
resp.raise_for_status()
|
| 42 |
+
jwks_data = resp.json()
|
| 43 |
+
|
| 44 |
+
# Build kid → key mapping
|
| 45 |
+
self._keys = {}
|
| 46 |
+
for key_data in jwks_data.get("keys", []):
|
| 47 |
+
kid = key_data.get("kid")
|
| 48 |
+
if kid:
|
| 49 |
+
self._keys[kid] = jwk.construct(key_data)
|
| 50 |
+
|
| 51 |
+
self._fetched_at = time.monotonic()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
_jwks_cache = JWKSCache()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def verify_keycloak_token(token: str) -> dict[str, Any]:
|
| 58 |
+
"""
|
| 59 |
+
Verify a Keycloak JWT token.
|
| 60 |
+
|
| 61 |
+
Returns the decoded payload (claims) on success.
|
| 62 |
+
Raises HTTP 401 on any failure.
|
| 63 |
+
"""
|
| 64 |
+
credentials_exception = HTTPException(
|
| 65 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 66 |
+
detail="Could not validate credentials",
|
| 67 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
# Step 1: Decode header to get kid without signature verification
|
| 72 |
+
unverified_header = jwt.get_unverified_header(token)
|
| 73 |
+
except JWTError:
|
| 74 |
+
raise credentials_exception
|
| 75 |
+
|
| 76 |
+
kid = unverified_header.get("kid")
|
| 77 |
+
if not kid:
|
| 78 |
+
raise credentials_exception
|
| 79 |
+
|
| 80 |
+
# Step 2: Get the signing key from JWKS cache
|
| 81 |
+
try:
|
| 82 |
+
keys = await _jwks_cache.get_keys()
|
| 83 |
+
except Exception:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 86 |
+
detail="Auth service temporarily unavailable",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
signing_key = keys.get(kid)
|
| 90 |
+
if not signing_key:
|
| 91 |
+
# Key not found — JWKS may have rotated, force refresh
|
| 92 |
+
await _jwks_cache._refresh()
|
| 93 |
+
keys = await _jwks_cache.get_keys()
|
| 94 |
+
signing_key = keys.get(kid)
|
| 95 |
+
if not signing_key:
|
| 96 |
+
raise credentials_exception
|
| 97 |
+
|
| 98 |
+
# Step 3: Verify signature + claims
|
| 99 |
+
try:
|
| 100 |
+
payload = jwt.decode(
|
| 101 |
+
token,
|
| 102 |
+
signing_key,
|
| 103 |
+
algorithms=["RS256"],
|
| 104 |
+
audience=settings.KEYCLOAK_CLIENT_ID,
|
| 105 |
+
issuer=settings.KEYCLOAK_ISSUER,
|
| 106 |
+
options={"verify_exp": True},
|
| 107 |
+
)
|
| 108 |
+
except JWTError as e:
|
| 109 |
+
raise credentials_exception from e
|
| 110 |
+
|
| 111 |
+
return payload
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def extract_roles(payload: dict[str, Any]) -> list[str]:
|
| 115 |
+
"""Extract realm-level roles from a decoded Keycloak token."""
|
| 116 |
+
realm_access = payload.get("realm_access", {})
|
| 117 |
+
return realm_access.get("roles", [])
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def extract_user_id(payload: dict[str, Any]) -> str:
|
| 121 |
+
"""Extract the user UUID (Keycloak sub claim)."""
|
| 122 |
+
sub = payload.get("sub")
|
| 123 |
+
if not sub:
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 126 |
+
detail="Token missing subject claim",
|
| 127 |
+
)
|
| 128 |
+
return sub
|
src/config.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — Application Configuration (T-004)
|
| 3 |
+
|
| 4 |
+
SDD §2.1 + PRD §22 Invariant: No bare os.getenv().
|
| 5 |
+
ALL environment variables are validated here via pydantic-settings.
|
| 6 |
+
This is the SINGLE source of truth for configuration.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from typing import Literal
|
| 13 |
+
|
| 14 |
+
from pydantic import AnyHttpUrl, Field, SecretStr, field_validator
|
| 15 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Settings(BaseSettings):
|
| 19 |
+
model_config = SettingsConfigDict(
|
| 20 |
+
env_file=".env",
|
| 21 |
+
env_file_encoding="utf-8",
|
| 22 |
+
case_sensitive=False,
|
| 23 |
+
extra="ignore",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# ── Application ───────────────────────────────────────────────────────────
|
| 27 |
+
ENVIRONMENT: Literal["development", "staging", "production"] = "development"
|
| 28 |
+
DEBUG: bool = False
|
| 29 |
+
SECRET_KEY: SecretStr = Field(..., min_length=32)
|
| 30 |
+
CORS_ORIGINS: list[str] = ["*"]
|
| 31 |
+
|
| 32 |
+
# ── Database ──────────────────────────────────────────────────────────────
|
| 33 |
+
DATABASE_URL: str # asyncpg connection string e.g. postgresql+asyncpg://...
|
| 34 |
+
|
| 35 |
+
# ── Supabase ──────────────────────────────────────────────────────────────
|
| 36 |
+
SUPABASE_URL: str
|
| 37 |
+
SUPABASE_ANON_KEY: str
|
| 38 |
+
SUPABASE_SERVICE_KEY: SecretStr
|
| 39 |
+
SUPABASE_JWT_SECRET: SecretStr
|
| 40 |
+
|
| 41 |
+
# ── Keycloak 26 — SOLE auth provider (Invariant #4) ───────────────────────
|
| 42 |
+
KEYCLOAK_SERVER_URL: str
|
| 43 |
+
KEYCLOAK_REALM: str = "tradeflow"
|
| 44 |
+
KEYCLOAK_CLIENT_ID: str = "tradeflow-api"
|
| 45 |
+
KEYCLOAK_CLIENT_SECRET: SecretStr
|
| 46 |
+
KEYCLOAK_ISSUER: str
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def KEYCLOAK_JWKS_URL(self) -> str:
|
| 50 |
+
base = self.KEYCLOAK_SERVER_URL.rstrip("/")
|
| 51 |
+
return f"{base}/realms/{self.KEYCLOAK_REALM}/protocol/openid-connect/certs"
|
| 52 |
+
|
| 53 |
+
# ── Redis 8 Standalone ────────────────────────────────────────────────────
|
| 54 |
+
REDIS_URL: str = "redis://localhost:6379/0"
|
| 55 |
+
REDIS_CELERY_DB: int = 0
|
| 56 |
+
REDIS_CACHE_DB: int = 1
|
| 57 |
+
|
| 58 |
+
# ── AI Inference Services (SDD §2.3–2.6) ─────────────────────────────────
|
| 59 |
+
CLOUD_LLM_ONLY: bool = False # Bypass heavy local models and use Gemini API instead
|
| 60 |
+
SURYA_INFERENCE_URL: AnyHttpUrl = "http://surya-svc:8001" # Agent A
|
| 61 |
+
OLM_INFERENCE_URL: AnyHttpUrl = "http://olm-inference:8000" # Agent D
|
| 62 |
+
PADDLEOCR_SVC_URL: AnyHttpUrl = "http://paddleocr-svc:8002" # Agent B
|
| 63 |
+
MINERU_SVC_URL: AnyHttpUrl = "http://mineru-svc:8003" # Preprocessor
|
| 64 |
+
OLM_BASE_MODEL: str = "allenai/olmOCR-2-7B-1025"
|
| 65 |
+
OLM_LORA_ADAPTER: str = "muhammadghiffari/olm-ocr-cipl-v1"
|
| 66 |
+
HF_TOKEN: SecretStr = "" # type: ignore[assignment]
|
| 67 |
+
|
| 68 |
+
# ── Azure Document Intelligence — Agent C ─────────────────────────────────
|
| 69 |
+
AZURE_DI_ENDPOINT: AnyHttpUrl = "" # type: ignore[assignment]
|
| 70 |
+
AZURE_DI_KEY: SecretStr = "" # type: ignore[assignment]
|
| 71 |
+
AZURE_DI_FREE_LIMIT: int = 5000 # Pages/month on F0 tier (Invariant #9)
|
| 72 |
+
|
| 73 |
+
# ── CEISA (Simulator in dev, real endpoint in prod) ───────────────────────
|
| 74 |
+
CEISA_BASE_URL: AnyHttpUrl = "http://simulator:8006"
|
| 75 |
+
CEISA_CLIENT_ID: str = ""
|
| 76 |
+
CEISA_CLIENT_SECRET: SecretStr = "" # type: ignore[assignment]
|
| 77 |
+
CEISA_REQUEST_TIMEOUT_SECONDS: int = 30
|
| 78 |
+
CEISA_POLL_INTERVAL_SECONDS: int = 30
|
| 79 |
+
CEISA_AES_KEY: SecretStr = "" # type: ignore[assignment] # AES-256-GCM key for payload encryption (base64)
|
| 80 |
+
|
| 81 |
+
# ── Blockchain ────────────────────────────────────────────────────────────
|
| 82 |
+
ENABLE_BLOCKCHAIN: bool = True
|
| 83 |
+
OPERATOR_WALLET_PRIVATE_KEY: SecretStr = "" # type: ignore[assignment] # Never log!
|
| 84 |
+
POLYGON_RPC_URL: str = "https://rpc-amoy.polygon.technology"
|
| 85 |
+
CONTRACT_ADDRESS: str = ""
|
| 86 |
+
PINATA_JWT: SecretStr = "" # type: ignore[assignment]
|
| 87 |
+
POLYGON_MAX_FEE_GWEI: int = 80
|
| 88 |
+
POLYGON_MAX_PRIORITY_FEE_GWEI: int = 3
|
| 89 |
+
POLYGON_ANCHOR_GAS_LIMIT: int = 250_000
|
| 90 |
+
|
| 91 |
+
# ── Notifications ─────────────────────────────────────────────────────────
|
| 92 |
+
RESEND_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 93 |
+
WHATSAPP_TOKEN: SecretStr = "" # type: ignore[assignment]
|
| 94 |
+
WHATSAPP_PHONE_NUMBER_ID: str = ""
|
| 95 |
+
NOTIFICATION_EMAIL_FROM: str = "noreply@tradeflow.ai"
|
| 96 |
+
|
| 97 |
+
# ── ChromaDB ──────────────────────────────────────────────────────────────
|
| 98 |
+
CHROMADB_HOST: str = "localhost"
|
| 99 |
+
CHROMADB_PORT: int = 8000
|
| 100 |
+
|
| 101 |
+
# ── AI / LLM ─────────────────────────────────────────────────────────────
|
| 102 |
+
GEMINI_API_KEY: SecretStr = Field(..., description="Google Gemini API key")
|
| 103 |
+
GEMINI_MODEL_PRIMARY: str = "gemini-3.1-pro"
|
| 104 |
+
GEMINI_MODEL_FALLBACK: str = "gemini-3.5-flash"
|
| 105 |
+
OPENAI_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 106 |
+
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
| 107 |
+
|
| 108 |
+
# LangSmith tracing
|
| 109 |
+
LANGCHAIN_TRACING_V2: bool = True
|
| 110 |
+
LANGCHAIN_PROJECT: str = "tradeflow-ai"
|
| 111 |
+
LANGCHAIN_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 112 |
+
|
| 113 |
+
# ── Feature Flags ─────────────────────────────────────────────────────────
|
| 114 |
+
ENABLE_SURYA_AGENT: bool = True
|
| 115 |
+
ENABLE_AZURE_DI_AGENT: bool = True
|
| 116 |
+
ENABLE_VESSEL_VALIDATION: bool = True
|
| 117 |
+
ENABLE_BLOCKCHAIN: bool = True # type: ignore[assignment] — redeclared intentionally
|
| 118 |
+
ENABLE_INSW_CHECK: bool = True
|
| 119 |
+
ENABLE_NOTIFICATIONS_WHATSAPP: bool = False
|
| 120 |
+
ENABLE_ADAPTIVE_LEARNING: bool = True
|
| 121 |
+
ENABLE_AI_COPILOT: bool = True
|
| 122 |
+
ENABLE_HS_RAG: bool = True
|
| 123 |
+
ENABLE_REJECTION_PREDICTION: bool = True
|
| 124 |
+
ENABLE_STATUS_POLLING: bool = True
|
| 125 |
+
ENABLE_MARITIME_DATA_FEATURES: bool = True
|
| 126 |
+
DETERMINISTIC_E2E: bool = False # Used in extract.py to swap LLM for deterministic mock
|
| 127 |
+
|
| 128 |
+
# ── Thresholds ────────────────────────────────────────────────────────────
|
| 129 |
+
OCR_FAST_PATH_QUALITY_THRESHOLD: float = 0.95
|
| 130 |
+
OCR_RECONCILIATION_DISAGREEMENT_THRESHOLD: float = 0.20
|
| 131 |
+
LLM_CONFIDENCE_REVIEW_THRESHOLD: float = 0.70
|
| 132 |
+
CRS_MIN_SUBMIT_THRESHOLD: int = 55
|
| 133 |
+
HS_CONFIDENCE_RAG_THRESHOLD: float = 0.75
|
| 134 |
+
XGB_MIN_SAMPLES_FOR_MODEL: int = 500
|
| 135 |
+
MAX_RESUBMIT_ATTEMPTS: int = 5
|
| 136 |
+
REJECTION_RISK_BLOCK_THRESHOLD: float = 0.70
|
| 137 |
+
|
| 138 |
+
# ── Validation rules ──────────────────────────────────────────────────────
|
| 139 |
+
VALIDATION_RULES_PATH: str = "packages/db/validation_rules.json"
|
| 140 |
+
CARRIER_PROFILES_PATH: str = "packages/db/carrier_profiles.json"
|
| 141 |
+
XGBOOST_MODEL_PATH: str = "models/rejection_predictor.json"
|
| 142 |
+
|
| 143 |
+
# ── Adaptive learning / drift ─────────────────────────────────────────────
|
| 144 |
+
RETRAIN_MIN_NEW_SAMPLES: int = 100
|
| 145 |
+
RETRAIN_MIN_TOTAL_SAMPLES: int = 500
|
| 146 |
+
DRIFT_LOOKBACK_DAYS: int = 30
|
| 147 |
+
DRIFT_CORRECTION_THRESHOLD: int = 50
|
| 148 |
+
|
| 149 |
+
# ── Celery ────────────────────────────────────────────────────────────────
|
| 150 |
+
CELERY_TASK_SOFT_TIME_LIMIT: int = 300
|
| 151 |
+
CELERY_TASK_TIME_LIMIT: int = 600
|
| 152 |
+
|
| 153 |
+
# ── Observability ─────────────────────────────────────────────────────────
|
| 154 |
+
SENTRY_DSN: str = ""
|
| 155 |
+
POSTHOG_API_KEY: str = ""
|
| 156 |
+
OTEL_ENABLED: bool = False
|
| 157 |
+
OTEL_EXPORTER_OTLP_ENDPOINT: str = ""
|
| 158 |
+
|
| 159 |
+
@field_validator("CORS_ORIGINS", mode="before")
|
| 160 |
+
@classmethod
|
| 161 |
+
def parse_cors_origins(cls, v: str | list[str]) -> list[str]:
|
| 162 |
+
if isinstance(v, str):
|
| 163 |
+
return [origin.strip() for origin in v.split(",")]
|
| 164 |
+
return v
|
| 165 |
+
|
| 166 |
+
@field_validator("DEBUG", mode="before")
|
| 167 |
+
@classmethod
|
| 168 |
+
def parse_debug(cls, v: bool | str) -> bool:
|
| 169 |
+
if isinstance(v, str) and v.lower() in {"release", "prod", "production"}:
|
| 170 |
+
return False
|
| 171 |
+
return bool(v)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class CeleryConfig:
|
| 175 |
+
"""Celery configuration — separate class for Celery's config_from_object."""
|
| 176 |
+
task_serializer = "json"
|
| 177 |
+
result_serializer = "json"
|
| 178 |
+
accept_content = ["json"]
|
| 179 |
+
timezone = "Asia/Jakarta"
|
| 180 |
+
enable_utc = True
|
| 181 |
+
task_soft_time_limit = 300
|
| 182 |
+
task_time_limit = 600
|
| 183 |
+
task_acks_late = True # Ack only after successful completion (NFR-016)
|
| 184 |
+
worker_prefetch_multiplier = 1 # One task at a time per worker
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@lru_cache
|
| 188 |
+
def get_settings() -> Settings:
|
| 189 |
+
return Settings() # type: ignore[call-arg]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
settings = get_settings()
|
src/dependencies.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — FastAPI Dependencies
|
| 3 |
+
|
| 4 |
+
Auth (Keycloak JWT), DB session, tier/role guards.
|
| 5 |
+
PRD §4 Decision 2: Keycloak 26 is the ONLY auth provider.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from typing import Annotated
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
import structlog
|
| 15 |
+
from fastapi import Depends, HTTPException, status
|
| 16 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 17 |
+
from jose import JWTError, jwt
|
| 18 |
+
from jose.exceptions import ExpiredSignatureError
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from supabase import AsyncClient, acreate_client
|
| 22 |
+
except Exception: # pragma: no cover - optional in lightweight test environments
|
| 23 |
+
AsyncClient = None
|
| 24 |
+
acreate_client = None
|
| 25 |
+
|
| 26 |
+
from .config import settings
|
| 27 |
+
|
| 28 |
+
log = structlog.get_logger()
|
| 29 |
+
|
| 30 |
+
# ── Supabase client (singleton) ───────────────────────────────────────────────
|
| 31 |
+
_supabase_client: AsyncClient | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def init_supabase() -> None:
|
| 35 |
+
global _supabase_client
|
| 36 |
+
if acreate_client is None:
|
| 37 |
+
log.info("Supabase client not available in this environment; skipping initialization")
|
| 38 |
+
_supabase_client = None
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
_supabase_client = await acreate_client(
|
| 42 |
+
settings.SUPABASE_URL,
|
| 43 |
+
settings.SUPABASE_SERVICE_KEY.get_secret_value(), # Service key for server-side ops
|
| 44 |
+
)
|
| 45 |
+
log.info("Supabase client initialized")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def close_supabase() -> None:
|
| 49 |
+
global _supabase_client
|
| 50 |
+
if _supabase_client:
|
| 51 |
+
await _supabase_client.aclose()
|
| 52 |
+
_supabase_client = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_supabase() -> AsyncClient:
|
| 56 |
+
if _supabase_client is None:
|
| 57 |
+
raise RuntimeError("Supabase client not initialized. Call init_supabase() first.")
|
| 58 |
+
return _supabase_client
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ── Keycloak JWKS cache ───────────────────────────────────────────────────────
|
| 62 |
+
_keycloak_jwks: dict | None = None
|
| 63 |
+
_keycloak_jwks_time: float = 0
|
| 64 |
+
KEYCLOAK_JWKS_TTL = 3600 # Refresh every hour
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_keycloak_jwks() -> dict:
|
| 68 |
+
"""Fetch Keycloak JWKS with TTL-based caching (refresh every hour)."""
|
| 69 |
+
global _keycloak_jwks, _keycloak_jwks_time
|
| 70 |
+
now = time.time()
|
| 71 |
+
|
| 72 |
+
if not _keycloak_jwks or (now - _keycloak_jwks_time) > KEYCLOAK_JWKS_TTL:
|
| 73 |
+
with httpx.Client() as client:
|
| 74 |
+
response = client.get(settings.KEYCLOAK_JWKS_URL)
|
| 75 |
+
response.raise_for_status()
|
| 76 |
+
_keycloak_jwks = response.json()
|
| 77 |
+
_keycloak_jwks_time = now
|
| 78 |
+
log.info("Refreshed Keycloak JWKS cache")
|
| 79 |
+
|
| 80 |
+
return _keycloak_jwks
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ── JWT Bearer scheme ─────────────────────────────────────────────────────────
|
| 84 |
+
bearer_scheme = HTTPBearer(auto_error=True)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class CurrentUser:
|
| 88 |
+
"""Decoded Keycloak JWT claims, enriched with Supabase profile."""
|
| 89 |
+
|
| 90 |
+
def __init__(
|
| 91 |
+
self,
|
| 92 |
+
sub: str,
|
| 93 |
+
email: str,
|
| 94 |
+
full_name: str,
|
| 95 |
+
roles: list[str],
|
| 96 |
+
tier: str,
|
| 97 |
+
company_id: str | None,
|
| 98 |
+
raw_token: str,
|
| 99 |
+
) -> None:
|
| 100 |
+
self.id = sub
|
| 101 |
+
self.sub = sub
|
| 102 |
+
self.email = email
|
| 103 |
+
self.full_name = full_name
|
| 104 |
+
self.roles = roles
|
| 105 |
+
self.tier = tier
|
| 106 |
+
self.company_id = company_id
|
| 107 |
+
self.raw_token = raw_token
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def is_enterprise(self) -> bool:
|
| 111 |
+
return self.tier == "enterprise"
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def is_admin(self) -> bool:
|
| 115 |
+
return "admin" in self.roles or "supervisor" in self.roles
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def role(self) -> str:
|
| 119 |
+
"""Primary role (first in list)."""
|
| 120 |
+
return self.roles[0] if self.roles else "operator"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
async def get_current_user(
|
| 124 |
+
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
|
| 125 |
+
supabase: Annotated[AsyncClient, Depends(get_supabase)],
|
| 126 |
+
) -> CurrentUser:
|
| 127 |
+
"""
|
| 128 |
+
Validate Keycloak RS256 JWT and return enriched user.
|
| 129 |
+
PRD §4 Decision 2: verify against Keycloak JWKS endpoint.
|
| 130 |
+
"""
|
| 131 |
+
token = credentials.credentials
|
| 132 |
+
|
| 133 |
+
credentials_exception = HTTPException(
|
| 134 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 135 |
+
detail="Invalid or expired authentication token",
|
| 136 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
jwks = get_keycloak_jwks()
|
| 141 |
+
payload = jwt.decode(
|
| 142 |
+
token,
|
| 143 |
+
jwks,
|
| 144 |
+
algorithms=["RS256"],
|
| 145 |
+
audience=settings.KEYCLOAK_CLIENT_ID,
|
| 146 |
+
issuer=settings.KEYCLOAK_ISSUER,
|
| 147 |
+
)
|
| 148 |
+
except ExpiredSignatureError:
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 151 |
+
detail="Token has expired",
|
| 152 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 153 |
+
)
|
| 154 |
+
except JWTError as e:
|
| 155 |
+
log.warning("JWT validation failed", error=str(e))
|
| 156 |
+
raise credentials_exception
|
| 157 |
+
|
| 158 |
+
sub: str = payload.get("sub", "")
|
| 159 |
+
if not sub:
|
| 160 |
+
raise credentials_exception
|
| 161 |
+
|
| 162 |
+
# Extract Keycloak realm roles
|
| 163 |
+
realm_access = payload.get("realm_access", {})
|
| 164 |
+
roles: list[str] = realm_access.get("roles", [])
|
| 165 |
+
# Filter to only TradeFlow roles
|
| 166 |
+
tradeflow_roles = [r for r in roles if r in ("operator", "admin", "supervisor", "importer")]
|
| 167 |
+
|
| 168 |
+
# Fetch profile from Supabase for tier + company_id
|
| 169 |
+
try:
|
| 170 |
+
result = await supabase.table("profiles").select(
|
| 171 |
+
"id, full_name, email, tier, role, company_id"
|
| 172 |
+
).eq("id", sub).single().execute()
|
| 173 |
+
profile = result.data
|
| 174 |
+
tier = profile.get("tier", "sme")
|
| 175 |
+
company_id = profile.get("company_id")
|
| 176 |
+
full_name = profile.get("full_name", payload.get("name", ""))
|
| 177 |
+
email = profile.get("email", payload.get("email", ""))
|
| 178 |
+
except Exception:
|
| 179 |
+
# Profile not yet created — use JWT claims as fallback
|
| 180 |
+
tier = "sme"
|
| 181 |
+
company_id = sub # Fallback to user's own ID so they can act as their own company
|
| 182 |
+
full_name = payload.get("name", "")
|
| 183 |
+
email = payload.get("email", "")
|
| 184 |
+
|
| 185 |
+
return CurrentUser(
|
| 186 |
+
sub=sub,
|
| 187 |
+
email=email,
|
| 188 |
+
full_name=full_name,
|
| 189 |
+
roles=tradeflow_roles if tradeflow_roles else ["operator"],
|
| 190 |
+
tier=tier,
|
| 191 |
+
company_id=company_id,
|
| 192 |
+
raw_token=token,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ── Role/tier guards ──────────────────────────────────────────────────────────
|
| 197 |
+
|
| 198 |
+
async def require_enterprise(
|
| 199 |
+
user: Annotated[CurrentUser, Depends(get_current_user)],
|
| 200 |
+
) -> CurrentUser:
|
| 201 |
+
"""Guard: Enterprise tier only."""
|
| 202 |
+
if not user.is_enterprise:
|
| 203 |
+
raise HTTPException(
|
| 204 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 205 |
+
detail="This feature requires an Enterprise tier subscription.",
|
| 206 |
+
)
|
| 207 |
+
return user
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
async def require_admin(
|
| 211 |
+
user: Annotated[CurrentUser, Depends(get_current_user)],
|
| 212 |
+
) -> CurrentUser:
|
| 213 |
+
"""Guard: Admin or Supervisor role only."""
|
| 214 |
+
if not user.is_admin:
|
| 215 |
+
raise HTTPException(
|
| 216 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 217 |
+
detail="This action requires Administrator or Supervisor role.",
|
| 218 |
+
)
|
| 219 |
+
return user
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def require_operator(
|
| 223 |
+
user: Annotated[CurrentUser, Depends(get_current_user)],
|
| 224 |
+
) -> CurrentUser:
|
| 225 |
+
"""Guard: Any authenticated user with operator/admin/supervisor role."""
|
| 226 |
+
if "importer" in user.roles and len(user.roles) == 1:
|
| 227 |
+
raise HTTPException(
|
| 228 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 229 |
+
detail="Importers cannot perform operator actions.",
|
| 230 |
+
)
|
| 231 |
+
return user
|
src/main.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TradeFlow AI — FastAPI Application Entry Point
|
| 3 |
+
|
| 4 |
+
PRD §1.4 — Main application setup with lifespan management,
|
| 5 |
+
middleware, and router registration.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from collections.abc import AsyncGenerator
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
import sentry_sdk
|
| 13 |
+
except Exception: # pragma: no cover - optional dependency for observability
|
| 14 |
+
sentry_sdk = None
|
| 15 |
+
|
| 16 |
+
import structlog
|
| 17 |
+
from fastapi import FastAPI
|
| 18 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
| 23 |
+
except Exception: # pragma: no cover - optional
|
| 24 |
+
FastAPIInstrumentor = None
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from prometheus_fastapi_instrumentator import Instrumentator
|
| 28 |
+
except Exception: # pragma: no cover - optional
|
| 29 |
+
Instrumentator = None
|
| 30 |
+
|
| 31 |
+
from .config import settings
|
| 32 |
+
from .dependencies import close_supabase, init_supabase
|
| 33 |
+
from .routers import admin, batches, blockchain, hs_recommend, vessel
|
| 34 |
+
from .utils.telemetry import setup_telemetry
|
| 35 |
+
|
| 36 |
+
log = structlog.get_logger()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@asynccontextmanager
|
| 40 |
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
| 41 |
+
"""Application lifespan — startup and shutdown."""
|
| 42 |
+
log.info("TradeFlow AI starting up", environment=settings.ENVIRONMENT)
|
| 43 |
+
|
| 44 |
+
# Initialize Sentry
|
| 45 |
+
if settings.SENTRY_DSN:
|
| 46 |
+
sentry_sdk.init(
|
| 47 |
+
dsn=settings.SENTRY_DSN,
|
| 48 |
+
traces_sample_rate=0.2,
|
| 49 |
+
profiles_sample_rate=0.1,
|
| 50 |
+
environment=settings.ENVIRONMENT,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Initialize Supabase client
|
| 54 |
+
await init_supabase()
|
| 55 |
+
|
| 56 |
+
log.info("TradeFlow AI ready", version="1.0.0")
|
| 57 |
+
yield
|
| 58 |
+
|
| 59 |
+
# Shutdown
|
| 60 |
+
await close_supabase()
|
| 61 |
+
log.info("TradeFlow AI shutdown complete")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def create_app() -> FastAPI:
|
| 65 |
+
app = FastAPI(
|
| 66 |
+
title="TradeFlow AI API",
|
| 67 |
+
description="Predictive Customs Intelligence Platform — Cikarang Dry Port",
|
| 68 |
+
version="1.0.0",
|
| 69 |
+
docs_url="/docs" if settings.ENVIRONMENT != "production" else None,
|
| 70 |
+
redoc_url="/redoc" if settings.ENVIRONMENT != "production" else None,
|
| 71 |
+
lifespan=lifespan,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Setup OpenTelemetry & Prometheus (Phase 6)
|
| 75 |
+
setup_telemetry(app)
|
| 76 |
+
|
| 77 |
+
# ── Middleware ────────────────────────────────────────────────
|
| 78 |
+
app.add_middleware(
|
| 79 |
+
CORSMiddleware,
|
| 80 |
+
allow_origins=settings.CORS_ORIGINS,
|
| 81 |
+
allow_credentials=False,
|
| 82 |
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
| 83 |
+
allow_headers=["Content-Type", "Authorization"],
|
| 84 |
+
expose_headers=["Content-Length"],
|
| 85 |
+
max_age=3600,
|
| 86 |
+
)
|
| 87 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 88 |
+
|
| 89 |
+
# Rate limiting middleware (optional)
|
| 90 |
+
try:
|
| 91 |
+
from slowapi import Limiter
|
| 92 |
+
from slowapi.util import get_remote_address
|
| 93 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 94 |
+
app.state.limiter = limiter
|
| 95 |
+
except Exception:
|
| 96 |
+
app.state.limiter = None
|
| 97 |
+
|
| 98 |
+
# ── Routers ───────────────────────────────────────────────────
|
| 99 |
+
app.include_router(batches.router, prefix="/api/v1", tags=["batches"])
|
| 100 |
+
app.include_router(hs_recommend.router, prefix="/api/v1", tags=["hs-recommend"])
|
| 101 |
+
app.include_router(blockchain.router, prefix="/api/v1", tags=["blockchain"])
|
| 102 |
+
app.include_router(vessel.router)
|
| 103 |
+
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
| 104 |
+
|
| 105 |
+
# ── Prometheus metrics (optional) ────────────────────────────
|
| 106 |
+
if Instrumentator is not None:
|
| 107 |
+
Instrumentator(
|
| 108 |
+
should_group_status_codes=True,
|
| 109 |
+
should_ignore_untemplated=True,
|
| 110 |
+
).instrument(app).expose(app, endpoint="/metrics")
|
| 111 |
+
|
| 112 |
+
# ── OpenTelemetry (optional) ─────────────────────────────────
|
| 113 |
+
if settings.OTEL_ENABLED and FastAPIInstrumentor is not None:
|
| 114 |
+
FastAPIInstrumentor().instrument_app(app)
|
| 115 |
+
|
| 116 |
+
return app
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
app = create_app()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@app.get("/health", tags=["health"])
|
| 123 |
+
async def health_check() -> dict[str, str]:
|
| 124 |
+
"""Health check endpoint — public, no auth required."""
|
| 125 |
+
return {"status": "ok", "version": "1.0.0", "environment": settings.ENVIRONMENT}
|