"""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_prefetch_prescreen: int = 1 mq_max_attempts: int = 5 mq_retry_base_ms: int = 2000 mq_management_enabled: bool = True mq_management_timeout: float = 10.0 mq_requeue_batch_size: int = 50 # --- 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 max_upload_bytes: int = Field( default=25 * 1024 * 1024, description="Maximum uploaded file size in bytes (default 25 MiB).", ) # --- billing --- refund_policy: RefundPolicy = "all" plans_enabled: bool = Field( default=False, description="Enable subscription quota logic (migration 0011). False keeps the legacy credits-only behavior.", ) yookassa_enabled: bool = Field(default=False) yookassa_shop_id: str = "" yookassa_secret_key: str = "" yookassa_return_base_url: str = "" price_per_doc_kopecks: int = 19900 billing_return_jwt_secret: str = Field( default="", description="HS256 secret for signing short-lived pay-page tokens.", ) billing_return_token_ttl_minutes: int = 15 refund_window_days: int = 14 refund_full_usage_threshold: float = 0.20 # --- observability (empty disables) --- sentry_dsn: str = "" # --- 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 metrics_bearer_token: str = Field( default="", description="Bearer token protecting /metrics. Empty leaves the endpoint open.", ) # SSE streaming of report status (GET /api/v1/reports/{id}/events). sse_poll_interval_seconds: float = 1.0 sse_max_stream_seconds: float = 300.0 # 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 # --- auth rate limiting --- auth_rate_limit_ip_rps: int = Field( default=5, description="Per-IP token-bucket rate for public auth endpoints (login, register, etc.).", ) auth_rate_limit_email_rps: int = Field( default=2, description="Per-recipient token-bucket rate for mail-sending auth endpoints.", ) # --- passkeys (WebAuthn) --- passkey_enabled: bool = Field( default=True, description="Toggle for /api/v1/auth/passkeys/* routes", ) passkey_rp_id: str = Field( default="localhost", description="WebAuthn Relying Party id (effective domain of the webUI)", ) passkey_rp_name: str = Field( default="Контракт-чек", description="Human-readable Relying Party name shown in the browser prompt", ) # Comma-separated origins allowed as WebAuthn callers (scheme://host:port). passkey_rp_origins: Annotated[list[str], NoDecode] = ["http://localhost:5173"] @field_validator("passkey_rp_origins", mode="before") @classmethod def _split_passkey_rp_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 passkey_challenge_ttl_seconds: int = 120 # ceremony challenge validity window # --- magic-link auth (passwordless email login) --- magic_link_enabled: bool = Field( default=True, description="Toggle for /api/v1/auth/magic-link/* routes", ) magic_link_ttl_minutes: int = 15 # magic-link token validity window # --- 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 --- # Defaults mirror .env.example (the documented production intent). prescreen_enabled: bool = True prescreen_confidence_threshold: float = 0.75 prescreen_high_value_threshold: float = 100_000.0 prescreen_auto_approve: bool = False prescreen_llm_fallback_enabled: bool = False prescreen_llm_fallback_threshold: float = 0.75 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" @lru_cache def get_settings() -> Settings: """Cached settings singleton. Call `get_settings.cache_clear()` to reset.""" return Settings() # type: ignore[call-arg]