DealDocumentScreening/src/contract_check/core/config.py
febux b279d6c61a Switch observability to passive collection: remove OTLP push, Vector replaces otel-collector
- core/telemetry.py is now a no-op (no opentelemetry imports); entrypoints
  no longer call setup/shutdown telemetry
- Remove all opentelemetry-* deps from pyproject groups; regenerate uv.lock
- Drop otel_exporter_otlp_endpoint/otel_service_name settings; sentry and
  auth use "contract-check" instead
- Stop publishing worker metrics ports; bind API metrics to 127.0.0.1 by
  default via API_METRICS_BIND_HOST
- Replace otel-collector with Vector in observer profile (docker_logs +
  prometheus_scrape -> OpenObserve); add deploy/observability/vector-config.yaml
- Update .env.example, docs (ARCHITECTURE, DEPLOY), README, Makefile
- Rewrite tests/unit/test_telemetry.py for the no-op implementation
2026-09-06 19:17:08 +03:00

268 lines
9.5 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_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]