Files
vidconf/backend/services/instance_settings.py
Max Ronzhin 10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00

529 lines
27 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`,
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`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,
HandQueueConfig,
InstanceConfig,
MediaLimitsConfig,
PluginsConfig,
PublishQualityCap,
StageMaxTiles,
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_HAND_QUEUE = "hand_queue"
_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"
_KEY_MEDIA_LIMITS = "media_limits"
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}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
# без пробелов — валидация после нормализации (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
hand_queue_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
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | 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_HAND_QUEUE: plugins.hand_queue.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),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_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.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.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"))
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
cap = (
patch.publish_quality_cap
if patch.publish_quality_cap is not None
else cfg.media_limits.publish_quality_cap
)
max_tiles = (
patch.stage_max_tiles
if patch.stage_max_tiles is not None
else cfg.media_limits.stage_max_tiles
)
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.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,
hand_queue=plugins.hand_queue,
)
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, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
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"),
media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
),
)