lct-hack/backend/app/config.py

156 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Настройки. Нормативы ГОСТ — в миллисекундах и из конфига, не из кода."""
import platform
from functools import lru_cache
from typing import Literal
from urllib.parse import unquote, urlsplit
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.domain.timers import NORMATIVES, TimerCode
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# `production` activates fail-closed checks for session signing and cookies.
app_env: Literal["development", "production"] = "development"
# Данные
database_url: str = "postgresql+asyncpg://lct:lct@localhost:5432/lct"
# HTTP auth and WebSocket handshakes both validate against PostgreSQL.
# Keep enough warm slots for concurrent classroom joins without tying up
# the whole server's PostgreSQL connection budget.
db_pool_size: int = Field(default=20, ge=1, le=100)
db_pool_max_overflow: int = Field(default=10, ge=0, le=100)
# Unique and stable per backend process/container. Cluster deployments
# should set this explicitly so a restart retains its session ownership.
backend_node_id: str = Field(
default_factory=lambda: platform.node() or "local",
min_length=1,
max_length=128,
)
# Только локальная демонстрация: живые занятия и отчёты в памяти, без
# Postgres и без долговременного журнала. Не включать на учебном стенде.
demo_no_db: bool = False
# Голосовой контур
models_dir: str = "models"
stt_model: str = "whisper-small"
# Локальный файл GGML Whisper small для whisper.cpp. Имя модели из сети
# не принимается: учебный контур не скачивает веса при запуске.
whisper_model_path: str = "models/whisper-small"
whisper_server_url: str = "http://127.0.0.1:18082"
endpointing_ms: int = 600
mic_sample_rate: int = 16_000
tts_sample_rate: int = 24_000
offline: bool = False
# STT/голос не входят в MVP ДДС; включаются лишь для отдельного эксперимента.
voice_enabled: bool = False
# Запись хранится локально и отдаётся только через авторизованный API.
record_calls: bool = True
recordings_dir: str = "recordings"
# Автокопирование полного стенда. Интервал по умолчанию ровно сутки;
# небольшой grace нужен только для отображения просрочки в мониторинге.
backup_interval_seconds: int = 86_400
backup_retry_seconds: int = 300
backup_keep: int = 14
# LLM. Провайдер меняется значением, не кодом (docs/arch/STACK.md).
# Имена COMPAT_MODEL_* принимаются тоже — так их выставляет командный сниппет.
llm_provider: str = "local"
llm_base_url: str = Field(
default="http://127.0.0.1:18080/v1",
validation_alias=AliasChoices("llm_base_url", "compat_model_url"),
)
llm_api_key: str = Field(
default="", validation_alias=AliasChoices("llm_api_key", "compat_model_api_key"),
repr=False,
)
llm_model_caller: str = Field(
default="Qwen3-1.7B", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
)
llm_model_judge: str = Field(
default="", validation_alias=AliasChoices("llm_model_judge", "compat_model_name")
)
judge_temperature: float = 0.0
# Vikhr — отдельный русскоязычный контрольный профиль диалога. Никакая
# модель не решает, какие факты раскрыть, как маршрутизировать карточку или
# какой балл поставить: это делает протокол и оценка в коде.
llm_control_base_url: str = "http://127.0.0.1:18081/v1"
llm_model_control: str = "Vikhr-1B"
grammar_llm_enabled: bool = False
assessment_feedback_enabled: bool = True
dialogue_model_mode: str = "dialogue" # dialogue | russian_control
# Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается
# только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal
# loopback и не может незаметно уйти во внешний API.
allow_docker_host_models: bool = False
# Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде:
# значение по умолчанию годится только для разработки, и администратор
# видит в состоянии сервисов, что оно не заменено.
session_secret: str = "dev-secret-поменять-на-стенде"
# В TLS-профиле cookie уходит только по HTTPS/WSS. Для нативной разработки
# на loopback флаг выключен, иначе браузер не сможет войти по HTTP.
secure_cookies: bool = False
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
dev_auth_bypass: bool = False
# Необязательная интеграция с локальным AD/LDAP-каталогом. Пароль
# сервисной учётки никогда не показывается в диагностиках/config repr.
# LDAP без TLS намеренно не поддерживается: используйте ldaps:// либо
# ldap:// с обязательным StartTLS.
ldap_enabled: bool = False
ldap_url: str = ""
ldap_base_dn: str = ""
ldap_bind_dn: str = ""
ldap_bind_password: str = Field(default="", repr=False)
ldap_user_filter: str = "(objectClass=person)"
ldap_login_attribute: str = "sAMAccountName"
ldap_role_groups: dict[str, str] = {}
ldap_service_groups: dict[str, str] = {}
ldap_ca_certs_file: str = ""
ldap_connect_timeout_seconds: int = Field(default=5, ge=1, le=30)
# Нормативы: переопределяют значения по умолчанию из domain/timers.py
timer_limits_ms: dict[TimerCode, int] = {}
def limit_ms(self, code: TimerCode) -> int:
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
# Пороги сигналов реестра живых занятий (пульт преподавателя).
signal_backlog_threshold: int = Field(default=4, ge=1)
signal_refusals_threshold: int = Field(default=2, ge=1)
signal_offline_window_seconds: int = Field(default=45, ge=1)
def validate_deployment_security(self) -> None:
"""Reject known development authentication defaults on a production app."""
if self.app_env != "production":
return
problems: list[str] = []
if self.demo_no_db:
problems.append("DEMO_NO_DB must be disabled")
if self.dev_auth_bypass:
problems.append("DEV_AUTH_BYPASS must be disabled")
if not self.offline:
problems.append("OFFLINE must be enabled for the local training deployment")
if self.llm_provider != "local":
problems.append("LLM_PROVIDER must be local for the local training deployment")
if self.session_secret == "dev-secret-поменять-на-стенде" or len(self.session_secret) < 32:
problems.append("SESSION_SECRET must be a unique value of at least 32 characters")
database_password = unquote(urlsplit(self.database_url).password or "")
if (len(database_password) < 32
or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._~-"
for char in database_password)):
problems.append("PostgreSQL password must contain at least 32 URL-safe characters")
if not self.secure_cookies:
problems.append("SECURE_COOKIES must be enabled (HTTPS/WSS required)")
if problems:
raise ValueError("unsafe production security configuration: " + "; ".join(problems))
@lru_cache
def get_settings() -> Settings:
return Settings()