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