DealDocumentScreening/src/contract_check/core/config.py

201 lines
6.8 KiB
Python

"""Typed configuration via pydantic-settings (12-factor).
Base `Settings` carries everything shared across services. A service imports
this and reads only the fields it needs. All values come from environment
variables (or `.env`); nothing is hardcoded.
Per-service entrypoints may subclass `Settings` to add their own fields
(e.g. the bot adds `BOT_TOKEN`, `API_URL`). See docs/ARCHITECTURE.md §11 for the
full env reference.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Annotated, Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
Env = Literal["dev", "staging", "prod"]
RefundPolicy = Literal["all", "infra_only"]
class Settings(BaseSettings):
"""Application configuration. See docs/ARCHITECTURE.md §11."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- runtime ---
env: Env = "dev"
log_level: str = "INFO"
# --- datastores / brokers ---
database_url: str = Field(..., description="async DSN: postgresql+asyncpg://...")
redis_url: str = "redis://redis:6379/0"
rabbitmq_url: str = Field(..., description="amqp://user:pass@host:5672//")
# --- RabbitMQ tuning ---
mq_prefetch_extract: int = 1
mq_prefetch_analyze: int = 3
mq_prefetch_notify: int = 5
mq_max_attempts: int = 5
mq_retry_base_ms: int = 2000
# --- object storage (MinIO) ---
s3_endpoint_url: str
s3_access_key: str
s3_secret_key: str
s3_bucket: str = "contract-check-docs"
s3_region: str = "us-east-1"
s3_server_side_encryption: bool = False
doc_retention_days: int = 7
text_retention_days: int = 30
# --- billing ---
refund_policy: RefundPolicy = "all"
# --- observability (empty disables) ---
sentry_dsn: str = ""
otel_exporter_otlp_endpoint: str = ""
otel_service_name: str = "contract-check"
# --- LLM provider ---
llm_provider: str = "ollama_cloud"
ollama_host: str = ""
ollama_api_key: str = ""
ollama_model: str = "qwen2.5:14b"
ollama_fallback_model: str = "qwen2.5:7b"
ollama_temperature: float = 0.2
ollama_num_predict: int = 3072
ollama_timeout: float = 120.0
ollama_max_concurrency: int = 3
# --- YandexGPT provider ---
yandexgpt_api_key: str = ""
yandexgpt_folder_id: str = ""
yandexgpt_model: str = "yandexgpt-lite"
yandexgpt_fallback_model: str = ""
yandexgpt_base_url: str = "https://llm.api.cloud.yandex.net"
yandexgpt_completion_path: str = "/foundationModels/v1/completion"
yandexgpt_temperature: float = 0.2
yandexgpt_max_tokens: int = 3072
yandexgpt_timeout: float = 120.0
yandexgpt_max_concurrency: int = 3
# --- API ---
api_host: str = "0.0.0.0"
api_port: int = 8000
api_metrics_port: int = 9100
b2b_default_rate_limit_rps: int = 3
# Comma-separated browser origins allowed to call the API (CORS).
# Empty disables CORS entirely (no browser clients).
cors_origins: Annotated[list[str], NoDecode] = []
@field_validator("cors_origins", mode="before")
@classmethod
def _split_cors_origins(cls, v: object) -> object:
"""Accept comma-separated strings (the documented .env format) or lists."""
if isinstance(v, str):
value = v.split("#", 1)[0] # tolerate inline comments
return [origin.strip() for origin in value.split(",") if origin.strip()]
return v
# --- auth (JWT + Telegram identity verification) ---
telegram_bot_token: str = Field(
"", description="Telegram bot token; used to verify Login Widget / Mini App signatures"
)
jwt_secret: str = Field(..., description="HS256 secret for signing user JWTs")
jwt_algorithm: str = "HS256"
jwt_access_ttl_minutes: int = 24 * 60 # 24 hours default; tune per env
jwt_refresh_ttl_days: int = 30 # refresh-token lifetime for webUI auth
# --- webUI auth (email + password) ---
web_auth_enabled: bool = Field(
default=True,
description="Toggle for /api/v1/auth/{register,login,...} email-password routes",
)
password_reset_ttl_minutes: int = 60 # reset-token validity window
web_app_base_url: str = Field(
default="http://localhost:5173",
description="Base URL of the webUI SPA — used to build password-reset links",
)
# Minimum password length enforced at register / reset.
password_min_length: int = 8
# --- admin panel (server-rendered, mounted at /admin in the api) ---
web_admin_enabled: bool = Field(
default=True,
description="Toggle for the /admin management UI (users, future subscriptions)",
)
admin_required_role: str = Field(
default="admin",
description="users.role value required to enter /admin (must match the DB CHECK constraint)",
)
admin_default_email: str = Field(
default="admin@contract-check.local",
description="Default admin panel login email (auto-created if no admin exists)",
)
admin_default_password: str = Field(
default="",
description="Default admin panel password. Empty disables auto-admin creation.",
)
# --- SMTP (notification transport; empty host disables sending) ---
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from: str = Field(
default="no-reply@contract-check.local",
description="From: address used by the notify worker",
)
smtp_use_tls: bool = True # STARTTLS on port 587; set false for plain SMTP
# --- logging ---
log_format: str = "json" # json | console
# --- analysis ---
chunk_size_chars: int = 10000
# --- prescreen ---
prescreen_enabled: bool = False
prescreen_confidence_threshold: float = 0.65
prescreen_high_value_threshold: float = 500_000.0
prescreen_auto_approve: bool = False
prescreen_llm_fallback_enabled: bool = True
prescreen_llm_fallback_threshold: float = 0.55
prescreen_llm_max_chars: int = 20_000
@property
def json_logs(self) -> bool:
"""JSON logs in staging/prod, pretty console in dev.
Override with LOG_FORMAT=json|console.
"""
fmt = self.log_format.lower()
if fmt == "console":
return False
if fmt == "json":
return True
return self.env != "dev"
@property
def log_format_value(self) -> str:
"""Resolved log format: json in prod/staging unless explicitly console."""
fmt = self.log_format.lower()
if fmt in ("json", "console"):
return fmt
return "json" if self.env != "dev" else "console"
@lru_cache
def get_settings() -> Settings:
"""Cached settings singleton. Call `get_settings.cache_clear()` to reset."""
return Settings() # type: ignore[call-arg]