Files
vidconf/backend/services/instance_settings.py

391 lines
19 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.
"""Хранилище настроек инстанса (`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"),
)