"""Принудительное применение матрицы «пресет → настройки» на живой инсталляции. Бутстрап (`main.py::lifespan`, `InstanceSettingsService.ensure_bootstrapped`) применяет overrides пресета инсталлятора (`BOOTSTRAP_CHAT_ENABLED`/`BOOTSTRAP_TRANSCRIPTION_ENABLED`/ `BOOTSTRAP_AI_LEVEL` в `.env`, см. `core.config.Settings`) только на чистой БД (`INSERT ... ON CONFLICT DO NOTHING`) — строки уже существующей инсталляции он не трогает. Этот скрипт запускает install.sh при ПОВТОРНОМ запуске с другим пресетом на живой инсталляции, после подтверждения в опроснике («обновить настройки модулей под пресет?») либо флага `--yes` самого install.sh. Управляет РОВНО 4 ключами (`chat`, `transcriber`, `summarizer`, `ai_level`, см. `services.instance_settings.BOOTSTRAP_MANAGED_KEYS`) — остальные настройки инстанса (таймзона, рассылка саммари, регистрация) этот скрипт не трогает. Без `--force` пишет только ОТСУТСТВУЮЩИЕ из них — страховка от молчаливой потери ручных правок администратора; с `--force` перезаписывает все 4 значениями текущего пресета (`transcriber`/`summarizer` — целиком, тем же дефолтом `plugins.yaml` + `enabled`, что и обычный бутстрап: подмена provider/model для уровней `medium`/`max` происходит при чтении конфигурации, см. `services.instance_settings.load_effective_config`, а не при записи). `ai_level` пишется напрямую в БД, минуя валидацию доступности (`services.ai_levels.detect_ai_levels`): модели уровня `medium`/`max` докачиваются отдельными профилями `docker-compose` уже ПОСЛЕ первого запуска install.sh, и жёсткая валидация здесь давала бы ложный отказ применения пресета, пока докачка не завершена — вместо этого пишем предупреждение в лог. Запустить: `uv run python -m scripts.apply_preset_settings [--force]` """ import argparse import asyncio import logging from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from core.config import get_settings from core.db import async_session_maker, engine from core.plugins.config import load_plugins_config from models.instance_setting import InstanceSetting from services.instance_settings import ( BOOTSTRAP_MANAGED_KEYS, bootstrap_overrides_from_settings, build_bootstrap_defaults, ) logger = logging.getLogger(__name__) async def apply_preset_settings(*, force: bool) -> list[str]: """Применить текущий пресет (`BOOTSTRAP_*`) к управляемым ключам `instance_settings`. Возвращает список фактически записанных ключей (пустой — писать было нечего: все 4 ключа уже существуют и `force=False`). """ settings = get_settings() plugins = load_plugins_config(settings.plugins_config_path) overrides = bootstrap_overrides_from_settings(settings) defaults = build_bootstrap_defaults(plugins, overrides) async with async_session_maker() as session: result = await session.execute( select(InstanceSetting.key).where(InstanceSetting.key.in_(BOOTSTRAP_MANAGED_KEYS)) ) existing_keys = set(result.scalars().all()) written: list[str] = [] for key in BOOTSTRAP_MANAGED_KEYS: if key in existing_keys and not force: continue value = defaults[key] stmt = ( pg_insert(InstanceSetting) .values(key=key, value=value) .on_conflict_do_update(index_elements=["key"], set_={"value": value}) ) await session.execute(stmt) written.append(key) await session.commit() if "ai_level" in written: logger.warning( "apply_preset_settings: ai_level=%r записан напрямую, без проверки доступности " "(detect_ai_levels) — модели уровня могут ещё докачиваться профилем docker-compose", defaults["ai_level"]["level"], ) logger.info( "apply_preset_settings: обновлены ключи %s (force=%s, пропущены как уже существующие: %s)", written or "нет", force, sorted(existing_keys - set(written)) or "нет", ) return written def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Применить матрицу «пресет → настройки» инсталлятора к instance_settings." ) parser.add_argument( "--force", action="store_true", help="перезаписать уже существующие ключи (без флага пишутся только отсутствующие)", ) return parser.parse_args() async def _main() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" ) args = _parse_args() await apply_preset_settings(force=args.force) await engine.dispose() if __name__ == "__main__": asyncio.run(_main())