muhammadghiffari commited on
Commit
db4ba8d
·
1 Parent(s): 3bde061

Deploy TradeFlow API to HF

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +24 -0
  2. LOCAL_SERVICES.md +67 -0
  3. pyproject.toml +142 -0
  4. pytest.ini +6 -0
  5. src/__init__.py +1 -0
  6. src/__pycache__/__init__.cpython-313.pyc +0 -0
  7. src/__pycache__/__init__.cpython-314.pyc +0 -0
  8. src/__pycache__/config.cpython-313.pyc +0 -0
  9. src/__pycache__/config.cpython-314.pyc +0 -0
  10. src/__pycache__/dependencies.cpython-313.pyc +0 -0
  11. src/__pycache__/dependencies.cpython-314.pyc +0 -0
  12. src/__pycache__/main.cpython-313.pyc +0 -0
  13. src/__pycache__/main.cpython-314.pyc +0 -0
  14. src/ai/__pycache__/graph.cpython-313.pyc +0 -0
  15. src/ai/__pycache__/graph.cpython-314.pyc +0 -0
  16. src/ai/__pycache__/mock_llm.cpython-314.pyc +0 -0
  17. src/ai/__pycache__/state.cpython-313.pyc +0 -0
  18. src/ai/__pycache__/state.cpython-314.pyc +0 -0
  19. src/ai/graph.py +131 -0
  20. src/ai/mock_llm.py +62 -0
  21. src/ai/nodes/__pycache__/extract.cpython-313.pyc +0 -0
  22. src/ai/nodes/__pycache__/extract.cpython-314.pyc +0 -0
  23. src/ai/nodes/__pycache__/fallback_ocr.cpython-313.pyc +0 -0
  24. src/ai/nodes/__pycache__/fallback_ocr.cpython-314.pyc +0 -0
  25. src/ai/nodes/__pycache__/human_review.cpython-313.pyc +0 -0
  26. src/ai/nodes/__pycache__/preprocess.cpython-313.pyc +0 -0
  27. src/ai/nodes/__pycache__/preprocess.cpython-314.pyc +0 -0
  28. src/ai/nodes/__pycache__/risk.cpython-313.pyc +0 -0
  29. src/ai/nodes/__pycache__/risk.cpython-314.pyc +0 -0
  30. src/ai/nodes/__pycache__/validate.cpython-313.pyc +0 -0
  31. src/ai/nodes/__pycache__/validate.cpython-314.pyc +0 -0
  32. src/ai/nodes/extract.py +199 -0
  33. src/ai/nodes/fallback_ocr.py +116 -0
  34. src/ai/nodes/human_review.py +59 -0
  35. src/ai/nodes/preprocess.py +68 -0
  36. src/ai/nodes/risk.py +159 -0
  37. src/ai/nodes/validate.py +24 -0
  38. src/ai/state.py +34 -0
  39. src/auth/__init__.py +26 -0
  40. src/auth/__pycache__/__init__.cpython-313.pyc +0 -0
  41. src/auth/__pycache__/__init__.cpython-314.pyc +0 -0
  42. src/auth/__pycache__/dependencies.cpython-313.pyc +0 -0
  43. src/auth/__pycache__/dependencies.cpython-314.pyc +0 -0
  44. src/auth/__pycache__/keycloak.cpython-313.pyc +0 -0
  45. src/auth/__pycache__/keycloak.cpython-314.pyc +0 -0
  46. src/auth/dependencies.py +90 -0
  47. src/auth/keycloak.py +128 -0
  48. src/config.py +192 -0
  49. src/dependencies.py +231 -0
  50. 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}