File size: 11,299 Bytes
db4ba8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd9584b
db4ba8d
 
 
 
 
 
 
 
 
 
621eb30
 
db4ba8d
621eb30
db4ba8d
 
621eb30
 
db4ba8d
 
 
 
 
 
 
 
 
 
 
 
 
dd9584b
 
 
db4ba8d
 
 
 
 
 
 
 
 
dd9584b
 
 
 
db4ba8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1cf88ff
 
 
 
 
db4ba8d
dd9584b
 
db4ba8d
 
 
 
 
 
 
 
 
dd9584b
 
 
db4ba8d
 
 
 
 
 
 
 
 
 
 
 
ae08068
 
 
dd9584b
 
ae08068
db4ba8d
 
 
 
 
 
 
 
 
 
 
 
2951010
 
 
 
 
 
 
db4ba8d
 
 
 
 
 
dd9584b
 
 
 
 
 
db4ba8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd9584b
 
db4ba8d
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""

TradeFlow AI — Application Configuration (T-004)



SDD §2.1 + PRD §22 Invariant: No bare os.getenv().

ALL environment variables are validated here via pydantic-settings.

This is the SINGLE source of truth for configuration.

"""

from __future__ import annotations

from functools import lru_cache
from typing import Literal

from pydantic import AnyHttpUrl, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",
    )

    # ── Application ───────────────────────────────────────────────────────────
    ENVIRONMENT: Literal["development", "staging", "production"] = "development"
    DEBUG: bool = False
    SECRET_KEY: SecretStr = Field(..., min_length=32)
    CORS_ORIGINS: list[str] = ["*"]

    # ── Database ──────────────────────────────────────────────────────────────
    DATABASE_URL: str  # asyncpg connection string e.g. postgresql+asyncpg://...

    # ── Supabase ──────────────────────────────────────────────────────────────
    SUPABASE_URL: str
    SUPABASE_ANON_KEY: str
    SUPABASE_SERVICE_KEY: SecretStr
    SUPABASE_JWT_SECRET: SecretStr

    DISABLE_AUTH: bool = False

    # ── Keycloak 26 — SOLE auth provider (Invariant #4) ───────────────────────
    KEYCLOAK_SERVER_URL: str = ""
    KEYCLOAK_REALM: str = "tradeflow"
    KEYCLOAK_CLIENT_ID: str = "tradeflow-api"
    KEYCLOAK_CLIENT_SECRET: SecretStr = ""  # type: ignore[assignment]
    KEYCLOAK_ISSUER: str = ""

    @property
    def KEYCLOAK_JWKS_URL(self) -> str:
        base = self.KEYCLOAK_SERVER_URL.rstrip("/")
        return f"{base}/realms/{self.KEYCLOAK_REALM}/protocol/openid-connect/certs"

    # ── Redis 8 Standalone ────────────────────────────────────────────────────
    REDIS_URL: str = "redis://localhost:6379/0"
    REDIS_CELERY_DB: int = 0
    REDIS_CACHE_DB: int = 1

    # ── AI Inference Services (SDD §2.3–2.6) ─────────────────────────────────
    CLOUD_LLM_ONLY: bool = False                                    # Bypass heavy local models and use Gemini API instead
    ENABLE_DUAL_OCR: bool = False                                   # Use both Surya and PaddleOCR for fallback/validation
    OCR_FALLBACK_TRIGGER_QUALITY: float = 0.85                      # Fallback threshold
    OCR_FALLBACK_TRIGGER_CONFIDENCE: float = 0.80
    SURYA_INFERENCE_URL: AnyHttpUrl = "http://surya-svc:8001"       # Agent A
    OLM_INFERENCE_URL: AnyHttpUrl = "http://olm-inference:8000"     # Agent D
    PADDLEOCR_SVC_URL: AnyHttpUrl = "http://paddleocr-svc:8002"     # Agent B
    MINERU_SVC_URL: AnyHttpUrl = "http://mineru-svc:8003"           # Preprocessor
    OLM_BASE_MODEL: str = "allenai/olmOCR-2-7B-1025"
    OLM_LORA_ADAPTER: str = "muhammadghiffari/olm-ocr-cipl-v1"
    HF_TOKEN: SecretStr = ""  # type: ignore[assignment]

    # ── Azure Document Intelligence — Agent C ─────────────────────────────────
    AZURE_DI_ENDPOINT: str | None = None
    AZURE_DI_KEY: SecretStr = ""  # type: ignore[assignment]
    AZURE_DI_MODEL_ID: str = "prebuilt-read"
    AZURE_DI_FREE_LIMIT: int = 5000   # Pages/month on F0 tier (Invariant #9)

    # ── CEISA (Simulator in dev, real endpoint in prod) ───────────────────────
    CEISA_BASE_URL: AnyHttpUrl = "http://simulator:8006"
    CEISA_CLIENT_ID: str = ""
    CEISA_CLIENT_SECRET: SecretStr = ""  # type: ignore[assignment]
    CEISA_REQUEST_TIMEOUT_SECONDS: int = 30
    CEISA_POLL_INTERVAL_SECONDS: int = 30
    CEISA_AES_KEY: SecretStr = ""  # type: ignore[assignment]  # AES-256-GCM key for payload encryption (base64)

    # ── Blockchain ────────────────────────────────────────────────────────────
    ENABLE_BLOCKCHAIN: bool = True
    OPERATOR_WALLET_PRIVATE_KEY: SecretStr = ""  # type: ignore[assignment]  # Never log!
    POLYGON_RPC_URL: str = "https://rpc-amoy.polygon.technology"
    CONTRACT_ADDRESS: str = ""
    PINATA_JWT: SecretStr = ""  # type: ignore[assignment]
    POLYGON_MAX_FEE_GWEI: int = 80
    POLYGON_MAX_PRIORITY_FEE_GWEI: int = 3
    POLYGON_ANCHOR_GAS_LIMIT: int = 250_000

    # ── Notifications ─────────────────────────────────────────────────────────
    RESEND_API_KEY: SecretStr = ""  # type: ignore[assignment]
    WHATSAPP_TOKEN: SecretStr = ""  # type: ignore[assignment]
    WHATSAPP_PHONE_NUMBER_ID: str = ""
    NOTIFICATION_EMAIL_FROM: str = "noreply@tradeflow.ai"

    # ── ChromaDB ──────────────────────────────────────────────────────────────
    CHROMADB_HOST: str = "localhost"
    CHROMADB_PORT: int = 8000

    # ── AI / LLM ─────────────────────────────────────────────────────────────
    USE_LOCAL_LLM: bool = False
    LOCAL_LLM_MODEL: str = "qwen2.5:7b"
    DIGITAL_PDF_SKIP_LLM: bool = True
    LLM_EXTRACTION_TIMEOUT_SECONDS: int = 25
    OLLAMA_BASE_URL: str = "http://host.docker.internal:11434/v1"
    GEMINI_API_KEY: SecretStr = Field(..., description="Google Gemini API key")
    GEMINI_MODEL_PRIMARY: str = "gemini-3.5-flash"
    GEMINI_MODEL_FALLBACK: str = "gemini-3.1-flash-lite"
    OPENAI_API_KEY: SecretStr = ""  # type: ignore[assignment]
    EMBEDDING_MODEL: str = "text-embedding-3-small"

    # LangSmith tracing
    LANGCHAIN_TRACING_V2: bool = True
    LANGCHAIN_PROJECT: str = "tradeflow-ai"
    LANGCHAIN_API_KEY: SecretStr = ""  # type: ignore[assignment]

    # ── Feature Flags ─────────────────────────────────────────────────────────
    ENABLE_SURYA_AGENT: bool = True
    ENABLE_AZURE_DI_AGENT: bool = True
    ENABLE_VESSEL_VALIDATION: bool = True
    ENABLE_BLOCKCHAIN: bool = True  # type: ignore[assignment] — redeclared intentionally
    ENABLE_INSW_CHECK: bool = True
    ENABLE_NOTIFICATIONS_WHATSAPP: bool = False
    ENABLE_ADAPTIVE_LEARNING: bool = True
    ENABLE_AI_COPILOT: bool = True
    ENABLE_HS_RAG: bool = True
    ENABLE_REJECTION_PREDICTION: bool = True
    ENABLE_STATUS_POLLING: bool = True
    ENABLE_MARITIME_DATA_FEATURES: bool = True
    DETERMINISTIC_E2E: bool = False  # Used in extract.py to swap LLM for deterministic mock

    # ── Thresholds ────────────────────────────────────────────────────────────
    OCR_MAX_RENDERED_PAGES: int = 10
    OCR_MAX_LLM_PAGES: int = 5
    OCR_FAST_PATH_QUALITY_THRESHOLD: float = 0.95
    OCR_PDF_TEXT_MIN_CHARS: int = 250
    OCR_PDF_TEXT_MIN_CHARS_PER_PAGE: int = 80
    OCR_RECONCILIATION_DISAGREEMENT_THRESHOLD: float = 0.20
    LLM_CONFIDENCE_REVIEW_THRESHOLD: float = 0.70
    CRS_MIN_SUBMIT_THRESHOLD: int = 55
    HS_CONFIDENCE_RAG_THRESHOLD: float = 0.75
    XGB_MIN_SAMPLES_FOR_MODEL: int = 500
    MAX_RESUBMIT_ATTEMPTS: int = 5
    REJECTION_RISK_BLOCK_THRESHOLD: float = 0.70

    # ── Validation rules ──────────────────────────────────────────────────────
    VALIDATION_RULES_PATH: str = "packages/db/validation_rules.json"
    CARRIER_PROFILES_PATH: str = "packages/db/carrier_profiles.json"
    XGBOOST_MODEL_PATH: str = "models/rejection_predictor.json"

    # ── Storage ───────────────────────────────────────────────────────────────
    STORAGE_BACKEND: Literal["supabase", "minio"] = "supabase"
    STORAGE_BUCKET_NAME: str = "tradeflow-docs"
    MINIO_ENDPOINT: str = "localhost:9000"
    MINIO_ACCESS_KEY: str = "minioadmin"
    MINIO_SECRET_KEY: SecretStr = ""  # type: ignore[assignment]

    # ── Adaptive learning / drift ─────────────────────────────────────────────
    RETRAIN_MIN_NEW_SAMPLES: int = 100
    RETRAIN_MIN_TOTAL_SAMPLES: int = 500
    DRIFT_LOOKBACK_DAYS: int = 30
    DRIFT_CORRECTION_THRESHOLD: int = 50

    # Celery Configuration
    CELERY_BROKER_URL: str = "redis://redis:6379/0"
    CELERY_RESULT_BACKEND: str = "redis://redis:6379/0"
    CELERY_TASK_SOFT_TIME_LIMIT: int = 1800  # 30 minutes for slow OCR models
    CELERY_TASK_TIME_LIMIT: int = 1900
    RUN_OCR_IN_API_BACKGROUND: bool = False

    # ── Observability ─────────────────────────────────────────────────────────
    SENTRY_DSN: str = ""
    POSTHOG_API_KEY: str = ""
    OTEL_ENABLED: bool = False
    OTEL_EXPORTER_OTLP_ENDPOINT: str = ""

    @field_validator("CORS_ORIGINS", mode="before")
    @classmethod
    def parse_cors_origins(cls, v: str | list[str]) -> list[str]:
        if isinstance(v, str):
            return [origin.strip() for origin in v.split(",")]
        return v

    @field_validator("DEBUG", mode="before")
    @classmethod
    def parse_debug(cls, v: bool | str) -> bool:
        if isinstance(v, str) and v.lower() in {"release", "prod", "production"}:
            return False
        return bool(v)


class CeleryConfig:
    """Celery configuration — separate class for Celery's config_from_object."""
    task_serializer = "json"
    result_serializer = "json"
    accept_content = ["json"]
    timezone = "Asia/Jakarta"
    enable_utc = True
    task_soft_time_limit = 600
    task_time_limit = 700
    task_acks_late = True           # Ack only after successful completion (NFR-016)
    worker_prefetch_multiplier = 1  # One task at a time per worker


@lru_cache
def get_settings() -> Settings:
    return Settings()  # type: ignore[call-arg]


settings = get_settings()