"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, `ai_level`, `summary_recipients`, `display_timezone`, `registration_team_choice`, `registration_email_domain`) — новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов (например, при рестарте backend) не перетирает уже сделанные администратором правки. Воркеры настройки только читают (`load_effective_config`); если строк ещё нет (воркер стартовал раньше backend) — fallback на `plugins.yaml` (т.к. воркеры в БД не пишут). """ import re from pathlib import Path from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from core.config import Settings from core.plugins.config import ( AiLevel, ChatConfig, InstanceConfig, PluginsConfig, SummarizerConfig, SummaryRecipientsMode, TranscriberConfig, load_plugins_config, ) from models.instance_setting import InstanceSetting from services.ai_levels import detect_ai_levels from services.ai_tiers import TIERS _KEY_TRANSCRIBER = "transcriber" _KEY_SUMMARIZER = "summarizer" _KEY_CHAT = "chat" _KEY_AI_LEVEL = "ai_level" _KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( _KEY_CHAT, _KEY_TRANSCRIBER, _KEY_SUMMARIZER, _KEY_AI_LEVEL, ) """Ключи, которыми управляет матрица «пресет → настройки» инсталлятора — переиспользуется `scripts/apply_preset_settings.py`, чтобы не дублировать список строковых имён ключей `instance_settings`.""" _DEFAULT_AI_LEVEL_VALUE = {"level": "min"} _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False} _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None} # Простой паттерн доменного имени: минимум один символ, минимум одна точка, # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), # без пробелов — валидация после нормализации (strip, «@», lower). _EMAIL_DOMAIN_PATTERN = re.compile( r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" ) class SettingsUpdateIn(BaseModel): """Частичное обновление настроек инстанса — все поля опциональны (PUT-патч). Используется и сервисным слоем (`InstanceSettingsService.update`), и (реэкспортом) админ-API Блока C (`api/admin.py`) как тело запроса `PUT /api/v1/admin/settings` — отдельная API-обёртка не нужна, схема один в один совпадает с контрактом. """ chat_enabled: bool | None = None transcription_enabled: bool | None = None ai_level: AiLevel | None = None summary_recipients: SummaryRecipientsMode | None = None display_timezone: str | None = None registration_team_choice: bool | None = None registration_email_domain_enabled: bool | None = None registration_email_domain: str | None = None class BootstrapOverrides(BaseModel): """Переопределения дефолтов бутстрапа по пресету инсталлятора (`BOOTSTRAP_*` в `.env`). Без них бутстрап `instance_settings` импортировал бы `plugins.yaml`, где всё `enabled: true`, — независимо от выбранного пресета поставки. Собирается `bootstrap_overrides_from_settings` и применяется ПОВЕРХ дефолтов `plugins.yaml` перед `INSERT ... ON CONFLICT DO NOTHING` (`ensure_bootstrapped`) — влияет только на чистую БД (первый запуск); принудительное обновление уже существующих строк на живой инсталляции — `scripts/apply_preset_settings.py`. """ chat_enabled: bool | None = None # Единый переключатель «транскрибация+суммаризация» — как `transcription_enabled` # в `SettingsUpdateIn`, управляет `transcriber.enabled` и `summarizer.enabled` вместе. ai_enabled: bool | None = None ai_level: AiLevel | None = None def bootstrap_overrides_from_settings(settings: Settings) -> BootstrapOverrides: """Собрать `BootstrapOverrides` из `BOOTSTRAP_*` полей `core.config.Settings`.""" return BootstrapOverrides( chat_enabled=settings.bootstrap_chat_enabled, ai_enabled=settings.bootstrap_transcription_enabled, ai_level=settings.bootstrap_ai_level, ) def build_bootstrap_defaults( plugins: PluginsConfig, overrides: BootstrapOverrides | None = None ) -> dict[str, dict[str, Any]]: """Собрать словарь дефолтов всех ключей `instance_settings` из `plugins.yaml`, применив `overrides` пресета инсталлятора поверх (`chat`/`transcriber`+`summarizer`/`ai_level`). Переиспользуется `ensure_bootstrapped` (чистая БД) и `scripts/apply_preset_settings.py` (принудительное обновление живой БД). """ defaults: dict[str, dict[str, Any]] = { _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"), _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"), _KEY_CHAT: plugins.chat.model_dump(mode="json"), _KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE), _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), } if overrides is None: return defaults if overrides.chat_enabled is not None: defaults[_KEY_CHAT] = {**defaults[_KEY_CHAT], "enabled": overrides.chat_enabled} if overrides.ai_enabled is not None: defaults[_KEY_TRANSCRIBER] = { **defaults[_KEY_TRANSCRIBER], "enabled": overrides.ai_enabled, } defaults[_KEY_SUMMARIZER] = { **defaults[_KEY_SUMMARIZER], "enabled": overrides.ai_enabled, } if overrides.ai_level is not None: defaults[_KEY_AI_LEVEL] = {"level": overrides.ai_level} return defaults class InvalidAiLevelError(ValueError): """Запрошенный уровень AI недоступен (см. `services.ai_levels.detect_ai_levels`).""" class InvalidTimezoneError(ValueError): """`display_timezone` не является валидным именем IANA-таймзоны.""" class InvalidEmailDomainError(ValueError): """Некорректная настройка верификации домена email при регистрации. Поднимается при попытке включить верификацию без домена (`enabled=true` и пустой/отсутствующий домен) либо при домене, не проходящем валидацию простым паттерном доменного имени. """ class InstanceSettingsService: """CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`.""" def __init__(self, session: AsyncSession) -> None: self._session = session async def ensure_bootstrapped( self, yaml_path: str | Path, overrides: BootstrapOverrides | None = None ) -> None: """Импортировать дефолты `plugins.yaml` в `instance_settings` (однократно, идемпотентно). `overrides` (матрица «пресет → настройки» инсталлятора, см. `bootstrap_overrides_from_settings`) подменяет `chat.enabled`, `transcriber.enabled`+`summarizer.enabled` и `ai_level` в дефолтах ДО `INSERT ... ON CONFLICT DO NOTHING` — влияет только на строки, которых ещё нет (чистая БД/первый запуск инсталлятора); уже существующие строки (живая инсталляция, возможно с ручными правками администратора) не трогает — `ON CONFLICT DO NOTHING` сохраняется как есть. """ plugins = load_plugins_config(yaml_path) defaults = build_bootstrap_defaults(plugins, overrides) for key, value in defaults.items(): stmt = ( pg_insert(InstanceSetting) .values(key=key, value=value) .on_conflict_do_nothing(index_elements=["key"]) ) await self._session.execute(stmt) await self._session.commit() async def get(self) -> InstanceConfig: """Собрать эффективную конфигурацию из текущих строк `instance_settings`.""" rows = await self._load_rows() return _build_config(rows) async def update(self, patch: SettingsUpdateIn) -> InstanceConfig: """Частично обновить настройки и вернуть новую эффективную конфигурацию. `transcription_enabled` пишет `enabled` сразу в обе секции (`transcriber`, `summarizer`) — это единый переключатель «транскрибация+суммаризация». """ rows = await self._load_rows() cfg = _build_config(rows) if patch.ai_level is not None: statuses = {status.level: status for status in detect_ai_levels(cfg)} if not statuses[patch.ai_level].available: raise InvalidAiLevelError( f"уровень AI {patch.ai_level!r} недоступен: {statuses[patch.ai_level].reason}" ) cfg.ai_level = patch.ai_level await self._set(_KEY_AI_LEVEL, {"level": patch.ai_level}) if patch.display_timezone is not None: _validate_timezone(patch.display_timezone) cfg.display_timezone = patch.display_timezone await self._set(_KEY_DISPLAY_TIMEZONE, {"tz": patch.display_timezone}) if patch.summary_recipients is not None: cfg.summary_recipients = patch.summary_recipients await self._set(_KEY_SUMMARY_RECIPIENTS, {"mode": patch.summary_recipients}) if patch.chat_enabled is not None: cfg.chat = ChatConfig(enabled=patch.chat_enabled) await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json")) if patch.registration_team_choice is not None: cfg.registration_team_choice = patch.registration_team_choice await self._set( _KEY_REGISTRATION_TEAM_CHOICE, {"enabled": patch.registration_team_choice} ) if ( patch.registration_email_domain_enabled is not None or patch.registration_email_domain is not None ): enabled = ( patch.registration_email_domain_enabled if patch.registration_email_domain_enabled is not None else cfg.registration_email_domain_enabled ) raw_domain = ( patch.registration_email_domain if patch.registration_email_domain is not None else cfg.registration_email_domain ) domain = _normalize_email_domain(raw_domain) if raw_domain else None if enabled and domain is None: raise InvalidEmailDomainError( "нельзя включить верификацию домена email без указания домена" ) cfg.registration_email_domain_enabled = enabled cfg.registration_email_domain = domain await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain}) if patch.transcription_enabled is not None: cfg.transcriber = cfg.transcriber.model_copy( update={"enabled": patch.transcription_enabled} ) cfg.summarizer = cfg.summarizer.model_copy( update={"enabled": patch.transcription_enabled} ) await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json")) await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json")) await self._session.commit() return cfg async def _load_rows(self) -> dict[str, Any]: result = await self._session.execute(select(InstanceSetting)) return {row.key: row.value for row in result.scalars().all()} async def _set(self, key: str, value: dict[str, Any]) -> None: stmt = ( pg_insert(InstanceSetting) .values(key=key, value=value) .on_conflict_do_update(index_elements=["key"], set_={"value": value}) ) await self._session.execute(stmt) async def load_effective_config(session: AsyncSession) -> InstanceConfig: """Загрузить эффективную конфигурацию для воркеров. Если `instance_settings` ещё пуста (воркер стартовал раньше бутстрапа backend) — fallback на `config/plugins.yaml` напрямую. Воркеры в БД не пишут — конкурентной гонки с бутстрапом нет. Уровни `medium`/`max` (ADR-004) перекрывают `transcriber`/ `summarizer` спекой `TIERS[ai_level]` — см. `_apply_tier_overrides`. """ result = await session.execute(select(InstanceSetting)) rows = {row.key: row.value for row in result.scalars().all()} if not rows: from core.config import get_settings plugins = load_plugins_config(get_settings().plugins_config_path) cfg = InstanceConfig( transcriber=plugins.transcriber, summarizer=plugins.summarizer, chat=plugins.chat, ) else: cfg = _build_config(rows) return _apply_tier_overrides(cfg) def _apply_tier_overrides(cfg: InstanceConfig) -> InstanceConfig: """Подменить `transcriber`/`summarizer` спекой `TIERS[ai_level]` для `medium`/`max`. `min` не переопределяется — использует дефолты `plugins.yaml`/правки администратора как есть (обратная совместимость, ADR-004: min остаётся конфигурируемым через существующий механизм). Флаг `enabled` (переключатель «транскрибация+суммаризация» в админке) сохраняется из текущей конфигурации — подмена per-tier затрагивает только provider/model/options, не должна повторно включать отключённый модуль. """ if cfg.ai_level in ("medium", "max"): spec = TIERS[cfg.ai_level] cfg.transcriber = spec.transcriber.model_copy(update={"enabled": cfg.transcriber.enabled}) cfg.summarizer = spec.summarizer.model_copy(update={"enabled": cfg.summarizer.enabled}) return cfg def _validate_timezone(tz: str) -> None: """Проверить, что `tz` — валидное имя IANA-таймзоны.""" try: ZoneInfo(tz) except ZoneInfoNotFoundError as exc: raise InvalidTimezoneError(f"неизвестная таймзона: {tz!r}") from exc def _normalize_email_domain(domain: str) -> str: """Нормализовать домен email (strip, убрать ведущую «@», lower) и провалидировать. Валидация — простым паттерном доменного имени (минимум одна точка, допустимые символы, без пробелов); иначе `InvalidEmailDomainError`. """ normalized = domain.strip() if normalized.startswith("@"): normalized = normalized[1:] normalized = normalized.lower() if not _EMAIL_DOMAIN_PATTERN.match(normalized): raise InvalidEmailDomainError(f"некорректный домен email: {domain!r}") return normalized def _build_config(rows: dict[str, Any]) -> InstanceConfig: """Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей. Отсутствие отдельного ключа (например, настройка добавлена уже после бутстрапа существующего инстанса) не должно ронять чтение конфигурации — используется дефолт соответствующей Pydantic-модели/константы. """ return InstanceConfig( transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})), summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})), chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})), ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"), summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get( "mode", "all" ), display_timezone=rows.get(_KEY_DISPLAY_TIMEZONE, _DEFAULT_DISPLAY_TIMEZONE_VALUE).get( "tz", "Europe/Moscow" ), registration_team_choice=rows.get( _KEY_REGISTRATION_TEAM_CHOICE, _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE ).get("enabled", False), registration_email_domain_enabled=rows.get( _KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE ).get("enabled", False), registration_email_domain=rows.get( _KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE ).get("domain"), )