"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, `ai_level`, `summary_recipients`, `display_timezone`, `registration_team_choice`, `registration_email_domain`, `contact_email`) — новая настройка не требует миграции, только новая строка. Бутстрап (`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, EmailStr, TypeAdapter from pydantic import ValidationError as PydanticValidationError 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" _KEY_CONTACT_EMAIL = "contact_email" 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, "domains": []} """Формат значения ключа `registration_email_domain` в БД. До версии с несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}` (один домен) — читающий код (`_extract_email_domains`) понимает обе формы для обратной совместимости с уже развёрнутыми инстансами; при первом же `update()` значение переписывается в новую форму (см. `update`).""" _DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": 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])?)+$" ) # Валидация формата контактного email — переиспользует тот же валидатор, # что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без # отдельного регэкспа под адрес целиком. _CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr) 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_domains: list[str] | None = None contact_email_enabled: bool | None = None contact_email: 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), _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_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 InvalidContactEmailError(ValueError): """Некорректная настройка контактного адреса инстанса. Поднимается при попытке включить контактный адрес без email (`enabled=true` и пустой/отсутствующий email) либо при email, не проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`. """ 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 and patch.ai_level != cfg.ai_level: # Валидация только при фактической смене уровня (сравнение с уже # сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий # ai_level вместе с любой другой правкой (см. `AdminSettingsTab`), # блокировал бы сохранение несвязанных настроек на слабом железе, # где текущий (давно и легитимно сохранённый) уровень недоступен # по факту заново переоценённых требований (RAM/модели). # # Не ослабляем проверку и при отключённых transcriber.enabled/ # summarizer.enabled (когда уровень AI сейчас ни на что не # влияет): если проверять по факту переключения — это осознанное # намерение администратора сменить уровень, и молчаливое # сохранение недоступного значения подставит администратора при # последующем включении AI неработающей конфигурацией. 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_domains 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_domains = ( patch.registration_email_domains if patch.registration_email_domains is not None else cfg.registration_email_domains ) domains = _normalize_email_domains(raw_domains) if enabled and not domains: raise InvalidEmailDomainError( "нельзя включить верификацию домена email без указания хотя бы одного домена" ) cfg.registration_email_domain_enabled = enabled cfg.registration_email_domains = domains await self._set( _KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains} ) if patch.contact_email_enabled is not None or patch.contact_email is not None: contact_enabled = ( patch.contact_email_enabled if patch.contact_email_enabled is not None else cfg.contact_email_enabled ) raw_contact_email = ( patch.contact_email if patch.contact_email is not None else cfg.contact_email ) contact_email = ( _normalize_contact_email(raw_contact_email) if raw_contact_email else None ) if contact_enabled and contact_email is None: raise InvalidContactEmailError( "нельзя включить контактный адрес без указания email" ) cfg.contact_email_enabled = contact_enabled cfg.contact_email = contact_email await self._set( _KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email} ) 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 _normalize_email_domains(domains: list[str]) -> list[str]: """Нормализовать список доменов: strip/lower/убрать «@» на каждом (см. `_normalize_email_domain`), отбросить пустые строки, убрать дубликаты (с сохранением порядка первого вхождения).""" normalized: list[str] = [] for raw in domains: if not raw.strip(): continue domain = _normalize_email_domain(raw) if domain not in normalized: normalized.append(domain) return normalized def _extract_email_domains(value: dict[str, Any]) -> list[str]: """Достать список доменов из значения ключа `registration_email_domain`, понимая и текущую форму (`domains: [...]`), и форму до многодоменной поддержки (`domain: str | None`, один домен) — на проде уже записано именно старое значение, миграция БД для этого не нужна: следующий же `update()` перепишет строку в новую форму (см. docstring `update`).""" if "domains" in value: return list(value["domains"]) legacy_domain = value.get("domain") return [legacy_domain] if legacy_domain else [] def _normalize_contact_email(email: str) -> str: """Нормализовать контактный email (strip, lower) и провалидировать формат.""" normalized = email.strip().lower() try: _CONTACT_EMAIL_ADAPTER.validate_python(normalized) except PydanticValidationError as exc: raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc 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_domains=_extract_email_domains( rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE) ), contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get( "enabled", False ), contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"), )