Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Пакет периодических и фоновых задач Celery."""

72
workers/tasks/dispatch.py Normal file
View File

@@ -0,0 +1,72 @@
"""Общий хелпер безопасной постановки Celery-задачи по имени.
Вынесен из `_send_summarize_task` (`workers.tasks.pipeline`) как общая
защита от потери шага при недоступности Redis:
временная недоступность брокера в момент `app.send_task` не должна ронять
вызывающую задачу — предыдущий шаг (фразы/саммари) уже закоммичен, и
`failed` из-за одного лишь сбоя постановки СЛЕДУЮЩЕЙ задачи стёр бы уже
проделанную работу. Используется дважды: `workers.tasks.pipeline.run_pipeline`
(постановка `summarize_session`) и `workers.tasks.summarize.
summarize_session` (постановка `notify_session`) — оба вызывающих
места сами решают, что делать при `False` (не роняются, статус пайплайна не
откатывается и не проваливается; восстановление — соответствующая beat-задача
`workers.tasks.maintenance`, уровень 2 защиты).
"""
import asyncio
import logging
from kombu.exceptions import ConnectionError as KombuConnectionError
from kombu.exceptions import OperationalError as KombuOperationalError
logger = logging.getLogger(__name__)
SEND_TASK_MAX_ATTEMPTS = 3
"""Число попыток поставить задачу в очередь при недоступности брокера."""
SEND_TASK_BACKOFF_S = 2.0
"""Базовая пауза (сек) между попытками; растёт линейно с номером попытки."""
async def send_task_with_retry(task_name: str, args: list[str]) -> bool:
"""Поставить задачу `task_name` в очередь с несколькими попытками при сбое брокера.
`args` — позиционные аргументы задачи (как ожидает `Celery.send_task`).
Возвращает `True`, если постановка удалась хотя бы с одной попытки;
`False` — все попытки исчерпаны. Вызывающая сторона логирует контекст
(какой сеанс/задача) и оставляет восстановление уровню 2 защиты — не
откатывает и не проваливает уже проделанную работу из-за одного лишь
сбоя постановки следующей задачи.
"""
# Отложенный импорт `app`: на верхнем уровне модуля это создало бы цикл
# (`workers.celery_app` импортирует `workers.tasks.pipeline`/`summarize`,
# а те — `send_task_with_retry` из этого модуля) — импорт откладывается
# до первого вызова, когда `workers.celery_app` уже полностью загружен.
from workers.celery_app import app
for attempt in range(1, SEND_TASK_MAX_ATTEMPTS + 1):
try:
app.send_task(task_name, args=args)
except (KombuOperationalError, KombuConnectionError) as exc:
if attempt < SEND_TASK_MAX_ATTEMPTS:
logger.warning(
"send_task_with_retry: сбой постановки %s (попытка %d/%d): %s "
"— повтор через %.1fс",
task_name,
attempt,
SEND_TASK_MAX_ATTEMPTS,
exc,
SEND_TASK_BACKOFF_S * attempt,
)
await asyncio.sleep(SEND_TASK_BACKOFF_S * attempt)
continue
logger.error(
"send_task_with_retry: не удалось поставить %s за %d попыток (%s)",
task_name,
SEND_TASK_MAX_ATTEMPTS,
exc,
)
return False
else:
return True
return False # недостижимо: цикл либо возвращает, либо продолжает до предела

View File

@@ -0,0 +1,286 @@
"""Celery-задача рассылки .ics-приглашений на конференцию.
Ставится из `services.conferences.ConferenceService.create` (при создании
плановой/закреплённой конференции) и `.update` (при правке расписания —
`scheduled_at`/`duration_minutes`/`recurrence`/`title`, см. инкремент
`ics_sequence` там же, а также при изменении СОСТАВА участников без
изменения расписания — без инкремента `ics_sequence`)
через `services.invitations_producer.enqueue_invitations` — backend не
импортирует пакет `workers` напрямую (тот же приём, что
`services.pipeline_producer.enqueue_pipeline`). Получатели по умолчанию
(`emails=None`) — владелец конференции + приглашённые (`conference_invitees`,
ADR-003: зарегистрированные — по email пользователя, внешние — по указанному
email) + для закреплённых дополнительно уникальные участники её прошлых
сеансов (зарегистрированные пользователи и гости с указанным email).
Явный список `emails` — ручная рассылка администратором
(`POST /admin/conferences/{id}/invitations`).
Идемпотентность: `email_deliveries(kind='invitation')` — журнал БЕЗ unique
(переслать обновлённое приглашение при изменении расписания обязано снова
дойти до всех адресатов — задокументированный дубль лучше пропавшего
уведомления об изменении времени встречи).
"""
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from zoneinfo import ZoneInfo
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.plugins.config import InstanceConfig
from models.conference import Conference
from models.email_delivery import EmailDelivery
from models.guest import GuestAccess
from models.invitee import ConferenceInvitee
from models.participant import ConferenceParticipant
from models.session import ConferenceSession
from models.user import User
from services.email import EmailAttachment, EmailSendError, create_email_backend
from services.ics import ConferenceHasNoScheduleError, build_invite
from services.instance_settings import load_effective_config
from services.recurrence import RecurrenceRule, expand_occurrences
from workers.celery_app import app
from workers.db import open_session, run_async
# Горизонт поиска первого вхождения повторяющейся серии для текста письма —
# совпадает с `services.ics._OCCURRENCE_SEARCH_HORIZON` (не импортируется,
# приватный модульный атрибут, но значение то же).
_OCCURRENCE_SEARCH_HORIZON = timedelta(days=400)
logger = logging.getLogger(__name__)
RETRY_COUNTDOWN_BASE_S = 60
"""Базовая пауза (сек) перед повтором при временном сбое SMTP; фактический
countdown — `RETRY_COUNTDOWN_BASE_S * (attempt + 1)` (тот же паттерн, что в
`workers.tasks.notify`/`summarize`)."""
_ICS_ATTACHMENT_FILENAME = "invite.ics"
_ICS_MIME_TYPE = "text/calendar; method=REQUEST"
class RetryableTask(Protocol):
"""Минимальный протокол bound-задачи Celery (см. одноимённые протоколы
в `workers.tasks.summarize`/`notify`)."""
request: Any
def retry(self, countdown: int | None = None) -> None: ...
@app.task(
name="workers.tasks.invitations.send_invitations",
bind=True,
max_retries=5,
acks_late=True,
)
def send_invitations(
self: RetryableTask, conference_id: str, emails: list[str] | None = None
) -> None:
"""Точка входа Celery — синхронная обёртка над асинхронной логикой рассылки."""
run_async(lambda: send_invitations_async(self, uuid.UUID(conference_id), emails=emails))
async def send_invitations_async(
task: RetryableTask,
conference_id: uuid.UUID,
*,
emails: list[str] | None = None,
plugins_config: InstanceConfig | None = None,
) -> None:
"""Разослать .ics-приглашение на конференцию `conference_id` получателям.
Guard'ы:
1. Конференция не найдена — выход.
2. Нет ни `scheduled_at`, ни `recurrence` (расписания нет — строить
приглашение не из чего, например, конференция уже разоткреплена и
переведена в мгновенную) — выход без ошибки.
3. Получателей нет (ни явного списка, ни владельца/участников) — выход.
Постоянный отказ конкретного получателя не роняет рассылку остальным
(пропускается с предупреждением); временный сбой транспорта — `task.retry`
с нарастающим countdown, исчерпание попыток — только лог (в отличие от
`notify_session`, здесь нет `pipeline_status`, который можно провалить).
"""
async with open_session() as session:
conference = await session.get(Conference, conference_id)
if conference is None:
logger.warning("send_invitations: конференция %s не найдена", conference_id)
return
if conference.recurrence is None and conference.scheduled_at is None:
logger.info(
"send_invitations: у конференции %s нет расписания — no-op", conference_id
)
return
recipients = (
[email.lower() for email in emails]
if emails is not None
else await _resolve_default_recipients(session, conference)
)
if not recipients:
logger.info("send_invitations: у конференции %s нет получателей", conference_id)
return
cfg = plugins_config or await load_effective_config(session)
settings = get_settings()
join_url = f"{settings.frontend_url}/j/{conference.slug}"
organizer_email = await _resolve_owner_email(session, conference)
try:
ics_bytes = build_invite(
conference,
organizer_email=organizer_email,
join_url=join_url,
display_timezone=cfg.display_timezone,
)
except ConferenceHasNoScheduleError:
logger.warning(
"send_invitations: не удалось построить .ics для конференции %s", conference_id
)
return
attachment = EmailAttachment(
filename=_ICS_ATTACHMENT_FILENAME, content=ics_bytes, mime_type=_ICS_MIME_TYPE
)
title = conference.title or f"{conference.number}"
subject = f"Приглашение на конференцию: {title}"
when = _format_when(conference, display_timezone=cfg.display_timezone)
body_lines = [f"Вас пригласили на конференцию «{title}»."]
if when is not None:
body_lines.append(f"Дата и время: {when}")
body_lines.append(f"Ссылка для входа: {join_url}")
body_lines.append(f"Номер: {conference.number}")
# Состав участников — переиспользуем уже
# посчитанный список получателей, чтобы не делать лишний запрос.
body_lines.append(f"Участники: {', '.join(recipients)}")
body = "\n".join(body_lines)
backend = create_email_backend(settings)
for email in recipients:
try:
await backend.send(to=email, subject=subject, body=body, attachments=[attachment])
except EmailSendError as exc:
if not exc.retryable:
logger.warning(
"send_invitations: получатель %s конференции %s отклонён сервером — "
"пропущен без повторных попыток: %s",
email,
conference_id,
exc,
)
continue
attempt = getattr(task.request, "retries", 0)
countdown = RETRY_COUNTDOWN_BASE_S * (attempt + 1)
try:
task.retry(countdown=countdown)
except MaxRetriesExceededError:
logger.warning(
"send_invitations: исчерпаны попытки рассылки приглашения "
"конференции %s (SMTP недоступен: %s)",
conference_id,
exc,
)
# Реальный `Task.retry()` сам бросает исключение Retry (не
# возвращает управление) — до сюда доходим только с
# моком/заглушкой `task.retry` в тестах.
return
else:
session.add(
EmailDelivery(
conference_id=conference.id, recipient_email=email, kind="invitation"
)
)
await session.commit()
logger.info(
"send_invitations: конференция %s — приглашение отправлено %d получателям",
conference_id,
len(recipients),
)
def _format_when(conference: Conference, *, display_timezone: str) -> str | None:
"""Дата/время конференции в таймзоне отображения инстанса — для тела письма.
Разовая — `scheduled_at`; закреплённая с повторением — первое вхождение
от `anchor_date` (тот же горизонт поиска, что `services/ics.py`). `None`,
если расписания нет (проверяется раньше в вызывающем коде — сюда такая
конференция не доходит).
"""
dt: datetime
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
t_from = rule.local_datetime(rule.anchor_date).astimezone(UTC)
occurrences = expand_occurrences(rule, t_from, t_from + _OCCURRENCE_SEARCH_HORIZON)
if not occurrences:
return None
dt = occurrences[0]
elif conference.scheduled_at is not None:
dt = conference.scheduled_at
else:
return None
localized = dt.astimezone(ZoneInfo(display_timezone))
return localized.strftime("%d.%m.%Y %H:%M %Z")
async def _resolve_owner_email(session: AsyncSession, conference: Conference) -> str | None:
"""Email владельца конференции (используется как `ORGANIZER` .ics), либо `None`."""
if conference.owner_id is None:
return None
owner = await session.get(User, conference.owner_id)
return owner.email if owner is not None else None
async def _resolve_default_recipients(session: AsyncSession, conference: Conference) -> list[str]:
"""Получатели по умолчанию: владелец + приглашённые + (для закреплённых) участники прошлых сеансов.
Дедуп по `lower(email)`. Приглашённые
(`conference_invitees`, ADR-003) добавляются независимо от `is_pinned` —
состав задаётся на уровне конференции, а не сеанса. Для незакреплённой
(разовой плановой) конференции без приглашённых сеансов ещё нет —
получатель только один (владелец).
"""
recipients: dict[str, None] = {}
owner_email = await _resolve_owner_email(session, conference)
if owner_email:
recipients.setdefault(owner_email.lower(), None)
invitee_rows = await session.execute(
select(ConferenceInvitee.email, User.email)
.outerjoin(User, ConferenceInvitee.user_id == User.id)
.where(ConferenceInvitee.conference_id == conference.id)
)
for invitee_email, invitee_user_email in invitee_rows.all():
email = invitee_email or invitee_user_email
if email:
recipients.setdefault(email.lower(), None)
if conference.is_pinned:
user_rows = await session.execute(
select(User.email)
.join(ConferenceParticipant, ConferenceParticipant.user_id == User.id)
.join(ConferenceSession, ConferenceParticipant.session_id == ConferenceSession.id)
.where(ConferenceSession.conference_id == conference.id)
.distinct()
)
guest_rows = await session.execute(
select(GuestAccess.email)
.join(ConferenceParticipant, ConferenceParticipant.guest_id == GuestAccess.id)
.join(ConferenceSession, ConferenceParticipant.session_id == ConferenceSession.id)
.where(
ConferenceSession.conference_id == conference.id,
GuestAccess.email.isnot(None),
)
.distinct()
)
for email in [*user_rows.scalars().all(), *guest_rows.scalars().all()]:
if email:
recipients.setdefault(email.lower(), None)
return list(recipients.keys())

View File

@@ -0,0 +1,212 @@
"""Периодические задачи обслуживания конференций (beat), ADR-001.
Комнаты как отдельная сущность отсутствуют: закрываются зависшие
сеансы (пропущенный
`room_finished`) и завершаются просроченные незакреплённые плановые
конференции, за которые так никто и не подключился.
Также содержит `recover_stuck_summaries` — уровень 2
защиты от «зависания» сеанса в `pipeline_status='summarizing'` без
`summary_data`: если постановка `summarize_session` в очередь из
`workers.tasks.pipeline.run_pipeline` не удалась даже после её собственных
retry (брокер был недоступен дольше, чем длится backoff), эта задача находит
такие сеансы по расписанию и переставляет `summarize_session` повторно —
безопасно благодаря идемпотентным guard'ам самой задачи суммаризации.
И `recover_stuck_notifications` — тот же уровень 2 защиты,
но для следующего шага пайплайна: саммари уже готово (`summary_data IS NOT
NULL`), а `notify_session` из `workers.tasks.summarize.summarize_session_async`
не была поставлена (сбой брокера дольше её собственного retry в
`send_task_with_retry`) — эта задача переставляет `notify_session` повторно.
"""
import logging
from datetime import UTC, datetime, timedelta
from core.plugins.config import InstanceConfig
from repositories.conferences import ConferenceRepository, ConferenceSessionRepository
from services.instance_settings import load_effective_config
from workers.celery_app import app as app
from workers.db import open_session, run_async
from workers.livekit_client import delete_livekit_room
logger = logging.getLogger(__name__)
# Страховочный порог простоя сеанса: если он открыт (`t_end IS NULL`) без
# активных участников дольше этого времени — считаем его зависшим (например,
# если webhook `room_finished` потерялся) и закрываем напрямую.
IDLE_THRESHOLD = timedelta(minutes=10)
# Запас после конца планового окна (`scheduled_at` + `duration_minutes`),
# прежде чем считать незакреплённую плановую конференцию без единого сеанса
# просроченной и переводить её в `ended`.
SCHEDULED_GRACE = timedelta(minutes=60)
# Сколько сеанс может провисеть в `pipeline_status='summarizing'` без
# `summary_data`, прежде чем считать постановку `summarize_session` потерянной
# и переставить задачу повторно. 30 минут — заведомо больше суммарного
# backoff'а retry в `run_pipeline._send_summarize_task` (секунды) и
# retry самой `summarize_session` при обрыве LLM (минуты), так что recovery
# не пересекается с их штатной работой.
STUCK_SUMMARIZING_THRESHOLD = timedelta(minutes=30)
# Сколько сеанс может провисеть с готовым summary_data без перехода в
# 'notified', прежде чем считать постановку `notify_session` потерянной и
# переставить задачу повторно. Тот же порог, что у суммаризации (30 минут) —
# заведомо больше суммарного backoff'а `send_task_with_retry` и retry самой
# `notify_session` при временном сбое SMTP.
STUCK_NOTIFYING_THRESHOLD = timedelta(minutes=30)
@app.task(name="workers.tasks.maintenance.cleanup_conferences")
def cleanup_conferences() -> None:
"""Точка входа Celery beat — синхронная обёртка над асинхронной логикой."""
run_async(lambda: cleanup_conferences_async(datetime.now(UTC)))
async def cleanup_conferences_async(now: datetime) -> None:
"""Закрыть зависшие сеансы и завершить просроченные незакреплённые плановые конференции.
Идемпотентна: повторный запуск с теми же (или более поздними) данными в
БД — no-op, т.к. каждый шаг проверяет текущее состояние перед действием
(`t_end IS NULL`, наличие активных участников, `status='scheduled'`).
Два независимых шага:
1. Открытые сеансы (`t_end IS NULL`) без активных участников, идущие
дольше `IDLE_THRESHOLD` — закрываются напрямую (`t_end = now`), не
дожидаясь LiveKit; статус родительской конференции переводится в
`scheduled` (закреплённая) или `ended` (незакреплённая) — так же, как
это сделал бы webhook `room_finished`. Дополнительно запрашивается
удаление LiveKit-комнаты (идемпотентно) — страховка на случай, если
комната в LiveKit всё ещё существует.
2. Незакреплённые плановые конференции (`status='scheduled'`), чьё
плановое окно (`scheduled_at` + `duration_minutes` + запас) истекло,
а сеанс так и не появился — переводятся в `ended` напрямую.
"""
async with open_session() as session:
conferences = ConferenceRepository(session)
sessions = ConferenceSessionRepository(session)
for open_session_record in await sessions.list_open():
if open_session_record.t_start > now - IDLE_THRESHOLD:
continue
if await sessions.has_active_participants(open_session_record.id):
continue
conference = await conferences.get_by_id(open_session_record.conference_id)
await sessions.close(open_session_record, t_end=now)
await sessions.close_all_open_participants(
session_id=open_session_record.id, left_at=now
)
if conference is not None:
if conference.is_pinned:
conference.status = "scheduled"
else:
conference.status = "ended"
conference.ended_at = now
await delete_livekit_room(conference.slug)
logger.info(
"cleanup_conferences: сеанс %s конференции %s закрыт как простаивающий "
"(t_start=%s), новый статус=%s",
open_session_record.id,
conference.id,
open_session_record.t_start,
conference.status,
)
for conference in await conferences.list_expired_unpinned_scheduled(now=now):
assert conference.scheduled_at is not None # гарантировано запросом репозитория
grace_minutes = conference.duration_minutes or 0
expiry = conference.scheduled_at + timedelta(minutes=grace_minutes) + SCHEDULED_GRACE
if expiry >= now:
continue
conference.status = "ended"
conference.ended_at = now
logger.info(
"cleanup_conferences: незакреплённая плановая конференция %s просрочена без "
"сеансов (scheduled_at=%s) — переведена в ended",
conference.id,
conference.scheduled_at,
)
await session.commit()
@app.task(name="workers.tasks.maintenance.recover_stuck_summaries")
def recover_stuck_summaries() -> None:
"""Точка входа Celery beat — синхронная обёртка над асинхронной логикой."""
run_async(lambda: recover_stuck_summaries_async(datetime.now(UTC)))
async def recover_stuck_summaries_async(
now: datetime, *, plugins_config: InstanceConfig | None = None
) -> None:
"""Переставить `summarize_session` для сеансов, зависших в `summarizing` без summary.
Уровень 2 защиты от потери постановки задачи
суммаризации при недоступности брокера в `run_pipeline` (см. докстринг
`workers.tasks.dispatch.send_task_with_retry`). Идемпотентна: повторная
отправка `summarize_session` безопасна — задача сама завершится no-op,
если `summary_data` уже заполнен либо `pipeline_status` уже другой
(её собственные guard'ы).
Разница с `summarizer.enabled=false`: в этом случае `summarize_session`
тоже отвечает no-op, но КАЖДЫЙ запуск этой задачи заново слал бы её
впустую каждые `beat`-интервал — дёшево отличить заранее (эффективная
конфигурация, которую читает и сама `summarize_session`) и выйти
сразу, не выполняя запрос списка зависших сеансов.
"""
async with open_session() as session:
cfg = plugins_config or await load_effective_config(session)
if not cfg.summarizer.enabled:
logger.info("recover_stuck_summaries: summarizer отключён (enabled=false) — no-op")
return
sessions = ConferenceSessionRepository(session)
stuck = await sessions.list_stuck_summarizing(older_than=now - STUCK_SUMMARIZING_THRESHOLD)
for session_record in stuck:
app.send_task(
"workers.tasks.summarize.summarize_session", args=[str(session_record.id)]
)
logger.warning(
"recover_stuck_summaries: сеанс %s завис в pipeline_status=summarizing "
"(t_end=%s) без summary_data — summarize_session переставлена в очередь",
session_record.id,
session_record.t_end,
)
@app.task(name="workers.tasks.maintenance.recover_stuck_notifications")
def recover_stuck_notifications() -> None:
"""Точка входа Celery beat — синхронная обёртка над асинхронной логикой."""
run_async(lambda: recover_stuck_notifications_async(datetime.now(UTC)))
async def recover_stuck_notifications_async(now: datetime) -> None:
"""Переставить `notify_session` для сеансов, зависших с готовым summary без уведомления.
Уровень 2 защиты от потери постановки `notify_session`
при недоступности брокера в `summarize_session_async` (см. докстринг
`workers.tasks.dispatch.send_task_with_retry`). Идемпотентна: повторная
отправка `notify_session` безопасна — задача сама завершится no-op, если
`pipeline_status` уже `notified`/`failed` либо получатели уже все
уведомлены (её собственные guard'ы и таблица `email_deliveries`).
Разница с `summarizer.enabled=false`: в этом случае сеансов с
`summary_data IS NOT NULL` в статусе `summarizing` просто не появится
(summarize_session сама выходит раньше) — отдельная проверка конфигурации
не нужна, в отличие от `recover_stuck_summaries_async`.
"""
async with open_session() as session:
sessions = ConferenceSessionRepository(session)
stuck = await sessions.list_stuck_notifying(older_than=now - STUCK_NOTIFYING_THRESHOLD)
for session_record in stuck:
app.send_task("workers.tasks.notify.notify_session", args=[str(session_record.id)])
logger.warning(
"recover_stuck_notifications: сеанс %s завис в pipeline_status=summarizing "
"(t_end=%s) с готовым summary_data — notify_session переставлена в очередь",
session_record.id,
session_record.t_end,
)

325
workers/tasks/notify.py Normal file
View File

@@ -0,0 +1,325 @@
"""Celery-задача уведомления о готовом саммари — финальный шаг AI-пайплайна.
`notify_session` завершает пайплайн пост-обработки:
`pipeline_status='summarizing'` + `summary_data IS NOT NULL` → рассылка писем
получателям → `pipeline_status='notified'`. Получатели определяются
эффективным режимом рассылки `conference.summary_recipients or
cfg.summary_recipients`: `all` — зарегистрированные
участники сеанса и гости с указанным email; `owner` — только владелец
конференции. Идемпотентность — таблица `email_deliveries` с уникальным
частичным индексом `(session_id, recipient_email)` при `kind='summary'`:
повторный запуск (ретрай/восстановление) отправляет только тем, кому ещё не
доставлено; каждая успешная отправка коммитится немедленно — точка
возобновления при обрыве процесса или временном сбое SMTP (тот же паттерн,
что по-трековый/по-шаговый commit в `workers.tasks.pipeline`/`summarize`).
Постоянный отказ конкретного получателя (`EmailSendError(retryable=False)`,
например `SMTPRecipientsRefused`) не ставит retry всей задачи и не блокирует
переход в `notified` — такой адресат пропускается с предупреждением в лог
(письмо ему не доставлено, повторных попыток для него не будет).
Временный сбой транспорта (`retryable=True`) —
`task.retry` с нарастающим countdown, как в `workers.tasks.summarize`;
исчерпание попыток — `pipeline_status='failed'`.
"""
import logging
import uuid
from datetime import UTC
from typing import Any, Protocol
from zoneinfo import ZoneInfo
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.plugins.config import InstanceConfig
from models.conference import Conference
from models.email_delivery import EmailDelivery
from models.guest import GuestAccess
from models.participant import ConferenceParticipant
from models.session import ConferenceSession
from models.user import User
from services.email import EmailSendError, create_email_backend
from services.email_templates import SummaryEmailContext, build_summary_email
from services.instance_settings import load_effective_config
from workers.celery_app import app as app
from workers.db import open_session, run_async
logger = logging.getLogger(__name__)
RETRY_COUNTDOWN_BASE_S = 60
"""Базовая пауза (сек) перед повтором при временном сбое SMTP; фактический
countdown — `RETRY_COUNTDOWN_BASE_S * (attempt + 1)` (нарастающий backoff,
тот же паттерн, что в `workers.tasks.summarize`)."""
_DEFAULT_SPEAKER_NAME = "Участник"
"""Резервное имя участника — см. одноимённую константу в `workers.tasks.summarize`."""
class RetryableTask(Protocol):
"""Минимальный протокол bound-задачи Celery (см. одноимённый протокол
в `workers.tasks.summarize` — здесь та же причина: нужен `request.retries`)."""
request: Any
def retry(self, countdown: int | None = None) -> None: ...
@app.task(
name="workers.tasks.notify.notify_session",
bind=True,
max_retries=5,
acks_late=True,
)
def notify_session(self: RetryableTask, session_id: str) -> None:
"""Точка входа Celery — синхронная обёртка над асинхронной логикой уведомления."""
run_async(lambda: notify_session_async(self, uuid.UUID(session_id)))
async def notify_session_async(
task: RetryableTask,
session_id: uuid.UUID,
*,
plugins_config: InstanceConfig | None = None,
) -> None:
"""Разослать саммари сеанса `session_id` получателям и перевести пайплайн в `notified`.
Guard'ы (строго по порядку):
1. Сеанс не найден — выход.
2. `pipeline_status != 'summarizing'` — no-op (идемпотентность повторной
доставки задачи либо запуска раньше срока).
3. `summary_data IS NULL` — no-op (саммари ещё не готово).
Далее: собрать получателей по эффективному режиму рассылки, отфильтровать
уже получивших письмо (`email_deliveries`), разослать оставшимся —
commit после каждого успеха. Получателей не осталось (после фильтра или
изначально) → `pipeline_status='notified'`.
"""
async with open_session() as session:
session_record = await session.get(ConferenceSession, session_id)
if session_record is None:
logger.warning("notify_session: сеанс %s не найден", session_id)
return
if session_record.pipeline_status != "summarizing":
logger.info(
"notify_session: сеанс %s не на шаге уведомления (pipeline_status=%s) — no-op",
session_id,
session_record.pipeline_status,
)
return
if session_record.summary_data is None:
logger.info(
"notify_session: у сеанса %s ещё нет summary_data — no-op",
session_id,
)
return
conference = await session.get(Conference, session_record.conference_id)
if conference is None:
logger.warning(
"notify_session: конференция %s сеанса %s не найдена",
session_record.conference_id,
session_id,
)
return
cfg = plugins_config or await load_effective_config(session)
mode = conference.summary_recipients or cfg.summary_recipients
recipients = await _collect_recipients(session, session_record, conference, mode=mode)
already_sent = await _fetch_already_sent(session, session_id)
pending = [email for email in recipients if email not in already_sent]
if not pending:
session_record.pipeline_status = "notified"
await session.commit()
logger.info(
"notify_session: сеанс %s — получателей нет либо все уже уведомлены, "
"статус=notified",
session_id,
)
return
subject = _build_subject(session_record, conference, timezone=cfg.display_timezone)
participant_names = await _collect_participant_names(session, session_id)
text_body, html_body = build_summary_email(
_build_email_context(
session_record,
conference,
participant_names=participant_names,
timezone=cfg.display_timezone,
)
)
backend = create_email_backend(get_settings())
for email in pending:
try:
await backend.send(to=email, subject=subject, body=text_body, html_body=html_body)
except EmailSendError as exc:
if not exc.retryable:
logger.warning(
"notify_session: получатель %s сеанса %s отклонён сервером — "
"пропущен без повторных попыток: %s",
email,
session_id,
exc,
)
continue
attempt = getattr(task.request, "retries", 0)
countdown = RETRY_COUNTDOWN_BASE_S * (attempt + 1)
try:
task.retry(countdown=countdown)
except MaxRetriesExceededError:
session_record.pipeline_status = "failed"
await session.commit()
logger.warning(
"notify_session: исчерпаны попытки уведомления сеанса %s "
"(SMTP недоступен: %s) — pipeline failed",
session_id,
exc,
)
# Реальный `Task.retry()` сам бросает исключение Retry (не
# возвращает управление) — до сюда доходим только с
# моком/заглушкой `task.retry` в тестах.
return
else:
await _mark_delivered(session, session_id=session_id, recipient_email=email)
session_record.pipeline_status = "notified"
await session.commit()
logger.info(
"notify_session: сеанс %s — уведомлено %d получателей, статус=notified",
session_id,
len(pending),
)
async def _collect_recipients(
session: AsyncSession,
session_record: ConferenceSession,
conference: Conference,
*,
mode: str,
) -> list[str]:
"""Собрать email получателей саммари по режиму рассылки, дедуп по `lower(email)`.
`owner` — email владельца конференции (пусто, если владелец не задан —
`conferences.owner_id` nullable). `all` — email зарегистрированных
участников сеанса (`users.email`) и гостей с указанным email
(`guest_access.email IS NOT NULL`), объединённые и дедуплицированные.
"""
if mode == "owner":
if conference.owner_id is None:
return []
owner = await session.get(User, conference.owner_id)
return [owner.email.lower()] if owner is not None else []
user_rows = await session.execute(
select(User.email)
.join(ConferenceParticipant, ConferenceParticipant.user_id == User.id)
.where(ConferenceParticipant.session_id == session_record.id)
.distinct()
)
guest_rows = await session.execute(
select(GuestAccess.email)
.join(ConferenceParticipant, ConferenceParticipant.guest_id == GuestAccess.id)
.where(
ConferenceParticipant.session_id == session_record.id,
GuestAccess.email.isnot(None),
)
.distinct()
)
seen: dict[str, None] = {}
for email in [*user_rows.scalars().all(), *guest_rows.scalars().all()]:
if email:
seen.setdefault(email.lower(), None)
return list(seen.keys())
async def _fetch_already_sent(session: AsyncSession, session_id: uuid.UUID) -> set[str]:
"""Email, которым саммари этого сеанса уже отправлено (`email_deliveries`)."""
result = await session.execute(
select(EmailDelivery.recipient_email).where(
EmailDelivery.session_id == session_id,
EmailDelivery.kind == "summary",
)
)
return set(result.scalars().all())
async def _mark_delivered(
session: AsyncSession, *, session_id: uuid.UUID, recipient_email: str
) -> None:
"""Зафиксировать успешную отправку и закоммитить (точка возобновления)."""
stmt = (
pg_insert(EmailDelivery)
.values(session_id=session_id, recipient_email=recipient_email, kind="summary")
.on_conflict_do_nothing(
index_elements=["session_id", "recipient_email"],
index_where=EmailDelivery.__table__.c.kind == "summary",
)
)
await session.execute(stmt)
await session.commit()
async def _collect_participant_names(session: AsyncSession, session_id: uuid.UUID) -> list[str]:
"""Отображаемые имена участников сеанса (для тела письма), без дублей."""
result = await session.execute(
select(User.name_user, GuestAccess.display_name)
.select_from(ConferenceParticipant)
.outerjoin(User, ConferenceParticipant.user_id == User.id)
.outerjoin(GuestAccess, ConferenceParticipant.guest_id == GuestAccess.id)
.where(ConferenceParticipant.session_id == session_id)
)
names = {
(name_user or display_name or _DEFAULT_SPEAKER_NAME) for name_user, display_name in result
}
return sorted(names)
def _build_subject(
session_record: ConferenceSession, conference: Conference, *, timezone: str
) -> str:
"""Тема письма: «Саммари встречи {ДД.ММ.ГГГГ} {ЧЧ:ММ}{ЧЧ:ММ} — {title|номер}»."""
date_label, time_label = _format_session_time(session_record, timezone=timezone)
title = conference.title or f"{conference.number}"
return f"Саммари встречи {date_label} {time_label}{title}"
def _build_email_context(
session_record: ConferenceSession,
conference: Conference,
*,
participant_names: list[str],
timezone: str,
) -> SummaryEmailContext:
"""Собрать данные для рендера тела письма (`services.email_templates`)."""
date_label, time_label = _format_session_time(session_record, timezone=timezone)
t_end = session_record.t_end or session_record.t_start
duration_minutes = max(0, round((t_end - session_record.t_start).total_seconds() / 60))
title = conference.title or f"{conference.number}"
return SummaryEmailContext(
conference_title=title,
date_label=date_label,
time_label=time_label,
duration_minutes=duration_minutes,
participant_names=participant_names,
summary_text=session_record.summary_data or "",
)
def _format_session_time(
session_record: ConferenceSession, *, timezone: str
) -> tuple[str, str]:
"""Дата/время сеанса в `display_timezone` (в БД — только UTC)."""
tz = ZoneInfo(timezone)
t_start = session_record.t_start.astimezone(UTC).astimezone(tz)
t_end = (session_record.t_end or session_record.t_start).astimezone(UTC).astimezone(tz)
date_label = t_start.strftime("%d.%m.%Y")
time_label = f"{t_start.strftime('%H:%M')}{t_end.strftime('%H:%M')}"
return date_label, time_label

250
workers/tasks/pipeline.py Normal file
View File

@@ -0,0 +1,250 @@
"""Оркестрация AI-пайплайна пост-обработки сеанса.
Единственная задача-диспетчер `run_pipeline(session_id)`: смотрит текущее
`pipeline_status` сеанса и эффективную конфигурацию инстанса
(`services.instance_settings.load_effective_config` — настройки из БД
поверх дефолтов `config/plugins.yaml`), продолжает работу с
последнего успешного шага. Ожидание завершения
записи треков (`egress_ended` приходит позже `room_finished`) — через
celery-retry с backoff внутри самой задачи. Сама суммаризация здесь
не выполняется: `run_pipeline` доводит сеанс до `pipeline_status='summarizing'`
и передаёт эстафету задаче `workers.tasks.summarize.summarize_session` —
по имени через `app.send_task`, без импорта модуля суммаризации (транскрайбер-
процесс не должен тянуть его код).
Надёжность постановки `summarize_session`: временная
недоступность брокера Redis в момент `app.send_task` не должна ронять
`run_pipeline` — фразы к этому моменту уже закоммичены, и `failed` из-за
одного лишь сбоя постановки задачи стёр бы уже проделанную работу
транскрибации. Поэтому: (1) общий хелпер `workers.tasks.dispatch.
send_task_with_retry` делает несколько попыток с коротким backoff
(переиспользуется и `summarize_session` для постановки `notify_session`);
(2) если все попытки исчерпаны — ошибка логируется, но `pipeline_status`
остаётся `summarizing` (не откатывается, не переводится в `failed`), а
восстановление берёт на себя периодическая задача `workers.tasks.
maintenance.recover_stuck_summaries` (уровень 2 защиты).
"""
import logging
import uuid
from datetime import timedelta
from pathlib import Path
from typing import Protocol
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import delete
from core.plugins.config import InstanceConfig
from core.plugins.factory import create_transcriber
from core.plugins.transcriber import Segment
from models.audio_track import SessionAudioTrack
from models.phrase import Phrase
from models.session import ConferenceSession
from repositories.conferences import AudioTrackRepository
from services.instance_settings import load_effective_config
from workers.celery_app import app as app
from workers.db import open_session, run_async
from workers.tasks.dispatch import send_task_with_retry
from workers.transcription.phrases import build_phrases
logger = logging.getLogger(__name__)
RETRY_COUNTDOWN_S = 30
"""Пауза (сек) перед повторной попыткой, пока треки ещё дописываются egress'ом."""
_TRANSCRIBABLE_PIPELINE_STATUSES = frozenset({"recording", "transcribing"})
"""Статусы сеанса, с которых допустим (повторный) запуск шага транскрибации —
guard идемпотентности: если пайплайн уже ушёл дальше (`summarizing` и
позже) или зафиксирован как `failed`, повторный вызов задачи — no-op."""
class RetryableTask(Protocol):
"""Минимальный протокол объекта задачи с методом `retry` (для тестируемости).
Реальный `celery.Task` (bound self) удовлетворяет протоколу структурно —
отдельный импорт `celery.Task` как типа не нужен.
"""
def retry(self, countdown: int | None = None) -> None: ...
@app.task(name="workers.tasks.pipeline.run_pipeline", bind=True, max_retries=20, acks_late=True)
def run_pipeline(self: RetryableTask, session_id: str) -> None:
"""Точка входа Celery — синхронная обёртка над асинхронной логикой диспетчера."""
run_async(lambda: run_pipeline_async(self, uuid.UUID(session_id)))
async def run_pipeline_async(
task: RetryableTask,
session_id: uuid.UUID,
*,
plugins_config: InstanceConfig | None = None,
) -> None:
"""Диспетчер: продолжить AI-пайплайн сеанса `session_id` с последнего успешного шага.
Шаги:
1. Сеанс не найден либо ещё не завершён (`t_end IS NULL`) — выход.
2. `transcriber.enabled=false` — выход, `pipeline_status` не меняется.
3. Пайплайн уже прошёл шаг транскрибации (`pipeline_status` не в
`{recording, transcribing}`) — выход (идемпотентность повторного вызова).
4. Есть треки со статусом `recording` (egress ещё пишет) — `task.retry`;
исчерпание попыток — зависшие треки помечаются `failed`, работа
продолжается с остальными треками.
5. `pipeline_status='transcribing'`, commit.
6. Транскрибация треков со статусом `recorded` и `segments IS NULL`
(уже транскрибированные при прошлом прогоне — пропускаются); commit
ПОСЛЕ КАЖДОГО трека — точка возобновления при падении процесса
посередине (acks_late).
7. Ни одного трека не транскрибировано (все failed либо треков нет) —
`pipeline_status='failed'`, выход.
8. Реконструкция фраз (`build_phrases`, ТЗ §1.3) → DELETE+INSERT `phrases`
одной транзакцией → `pipeline_status='summarizing'`, commit → постановка
задачи `workers.tasks.summarize.summarize_session` в очередь по
умолчанию (её слушает базовый `worker`, а не `worker-transcriber`) —
через общий `workers.tasks.dispatch.send_task_with_retry` (retry на сбой
брокера, см. его докстринг и `workers.tasks.maintenance.recover_stuck_summaries`).
"""
async with open_session() as session:
session_record = await session.get(ConferenceSession, session_id)
if session_record is None or session_record.t_end is None:
logger.warning(
"run_pipeline: сеанс %s не найден либо ещё не завершён (t_end IS NULL)",
session_id,
)
return
cfg = plugins_config or await load_effective_config(session)
if not cfg.transcriber.enabled:
logger.info(
"run_pipeline: transcriber отключён (enabled=false) — сеанс %s пропущен",
session_id,
)
return
if session_record.pipeline_status not in _TRANSCRIBABLE_PIPELINE_STATUSES:
logger.info(
"run_pipeline: сеанс %s уже прошёл шаг транскрибации (pipeline_status=%s) — no-op",
session_id,
session_record.pipeline_status,
)
return
track_repo = AudioTrackRepository(session)
# Сортировка по `started_at` — детерминированный порядок обработки
# (точка возобновления по-трекового коммита должна быть
# предсказуемой между прогонами, см. тест идемпотентности №12).
tracks = sorted(await track_repo.list_by_session(session_id), key=lambda t: t.started_at)
still_recording = [track for track in tracks if track.status == "recording"]
if still_recording:
try:
task.retry(countdown=RETRY_COUNTDOWN_S)
except MaxRetriesExceededError:
for track in still_recording:
track.status = "failed"
await session.commit()
logger.warning(
"run_pipeline: исчерпаны попытки ожидания egress для %d треков сеанса %s "
"— помечены failed",
len(still_recording),
session_id,
)
else:
# Реальный `Task.retry()` сам бросает исключение Retry (не
# возвращает управление) — сюда попадаем только с
# моком/заглушкой `task.retry` в тестах.
return
session_record.pipeline_status = "transcribing"
await session.commit()
transcriber = create_transcriber(cfg.transcriber)
for track in tracks:
if track.status != "recorded" or track.segments is not None:
continue # уже транскрибирован на прошлом прогоне — идемпотентность
if not track.file_path or not Path(track.file_path).exists():
track.status = "failed"
logger.warning(
"run_pipeline: файл трека %s не найден (%s) — трек помечен failed",
track.id,
track.file_path,
)
await session.commit()
continue
segments = transcriber.transcribe(track.file_path, cfg.transcriber.language)
track.segments = [
{"start": segment.start, "end": segment.end, "text": segment.text}
for segment in segments
]
track.status = "transcribed"
await session.commit()
if not any(track.status == "transcribed" for track in tracks):
session_record.pipeline_status = "failed"
await session.commit()
logger.warning(
"run_pipeline: все треки сеанса %s провалены либо треков нет — pipeline failed",
session_id,
)
return
segments_by_participant, track_offsets = _collect_transcribed(tracks, session_record)
phrases = build_phrases(segments_by_participant, track_offsets)
await session.execute(delete(Phrase).where(Phrase.session_id == session_id))
for phrase in phrases:
session.add(
Phrase(
participant_id=phrase.participant_id,
session_id=session_id,
data=phrase.text,
t_start=session_record.t_start + timedelta(seconds=phrase.start),
t_end=session_record.t_start + timedelta(seconds=phrase.end),
)
)
session_record.pipeline_status = "summarizing"
await session.commit()
# По имени задачи, без импорта `workers.tasks.summarize` — модуль
# суммаризации не должен становиться зависимостью
# транскрайбер-процесса. Отправляем безусловно: если
# `summarizer.enabled=false`, задача сама завершится по своему guard'у
# (фразы уже сохранены, `pipeline_status='summarizing'` без summary —
# задокументированное завершение пайплайна после phrases).
sent = await send_task_with_retry(
"workers.tasks.summarize.summarize_session", args=[str(session_id)]
)
logger.info(
"run_pipeline: сеанс %s — реконструировано %d фраз, статус=summarizing, "
"постановка задачи суммаризации %s",
session_id,
len(phrases),
"выполнена" if sent else "не удалась (см. предыдущий error) — ждём recovery",
)
def _collect_transcribed(
tracks: list[SessionAudioTrack], session_record: ConferenceSession
) -> tuple[dict[uuid.UUID, list[Segment]], dict[uuid.UUID, float]]:
"""Собрать сегменты и смещения транскрибированных треков для `build_phrases`.
Смещение трека — `(track.started_at - session.t_start).total_seconds()`
(«Ключевые архитектурные решения», п.5). Ключ обоих словарей
— `participant_id`: одному участнику соответствует один аудиотрек сеанса
(одно окно присутствия — один микрофон, ADR-002).
"""
segments_by_participant: dict[uuid.UUID, list[Segment]] = {}
track_offsets: dict[uuid.UUID, float] = {}
for track in tracks:
if track.status != "transcribed" or not track.segments:
continue
offset = (track.started_at - session_record.t_start).total_seconds()
track_offsets[track.participant_id] = offset
segments_by_participant.setdefault(track.participant_id, []).extend(
Segment(start=raw["start"], end=raw["end"], text=raw["text"])
for raw in track.segments
)
return segments_by_participant, track_offsets

216
workers/tasks/summarize.py Normal file
View File

@@ -0,0 +1,216 @@
"""Celery-задача суммаризации сеанса.
Завершает AI-пайплайн после реконструкции фраз
(`workers.tasks.pipeline.run_pipeline`): собирает транскрипт сеанса из `phrases` и имён участников, вызывает
активный плагин `Summarizer` (`config/plugins.yaml`) и записывает результат в
`conference_sessions.summary_data`. Статус `pipeline_status` ОСТАЁТСЯ
`summarizing` после записи саммари; готовность к уведомлению определяется
парой `pipeline_status='summarizing'` И `summary_data IS NOT NULL`.
Значение `notified` ставит `workers.tasks.notify.
notify_session` — постановка по имени задачи через общий
`workers.tasks.dispatch.send_task_with_retry` сразу после коммита `summary_data`
(тот же паттерн защиты от потери шага при недоступности брокера, что у
`run_pipeline` при постановке этой самой задачи, см. `workers.tasks.pipeline`
и `workers.tasks.dispatch`). Если задача завершается no-op БЕЗ записи
`summary_data` (summarizer отключён, нет фраз, уже заполнено) — `notify_session`
не ставится.
"""
import logging
import uuid
from typing import Any, Protocol
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.plugins.config import InstanceConfig
from core.plugins.factory import create_summarizer
from core.summarization.llm_client import LlmUnavailableError
from models.guest import GuestAccess
from models.participant import ConferenceParticipant
from models.phrase import Phrase
from models.session import ConferenceSession
from models.user import User
from services.instance_settings import load_effective_config
from workers.celery_app import app as app
from workers.db import open_session, run_async
from workers.summarizer.transcript import TranscriptLine, build_transcript
from workers.tasks.dispatch import send_task_with_retry
logger = logging.getLogger(__name__)
RETRY_COUNTDOWN_BASE_S = 60
"""Базовая пауза (сек) перед повтором при обрыве LLM; фактический countdown —
`RETRY_COUNTDOWN_BASE_S * (attempt + 1)` (нарастающий backoff)."""
_DEFAULT_SPEAKER_NAME = "Участник"
"""Резервное имя говорящего на случай отсутствия и `users.name_user`, и
`guest_access.display_name` (не должно происходить при действующем
CHECK-constraint `conference_participants`, но защищает транскрипт от падения)."""
class RetryableTask(Protocol):
"""Минимальный протокол bound-задачи Celery, достаточный для тестируемости.
В отличие от одноимённого протокола в `workers.tasks.pipeline`, здесь
дополнительно нужен `request.retries` — номер уже сделанной попытки,
чтобы считать нарастающий countdown ретрая. Реальный `celery.Task`
(bound self) удовлетворяет протоколу структурно.
"""
request: Any
def retry(self, countdown: int | None = None) -> None: ...
@app.task(
name="workers.tasks.summarize.summarize_session",
bind=True,
max_retries=5,
acks_late=True,
)
def summarize_session(self: RetryableTask, session_id: str) -> None:
"""Точка входа Celery — синхронная обёртка над асинхронной логикой суммаризации."""
run_async(lambda: summarize_session_async(self, uuid.UUID(session_id)))
async def summarize_session_async(
task: RetryableTask,
session_id: uuid.UUID,
*,
plugins_config: InstanceConfig | None = None,
) -> None:
"""Суммаризировать сеанс `session_id`: собрать транскрипт → саммари → `summary_data`.
Guard'ы (строго по порядку):
1. Сеанс не найден либо ещё не завершён (`t_end IS NULL`) — выход.
2. `pipeline_status != 'summarizing'` — no-op (идемпотентность повторной
доставки задачи либо запуска раньше срока).
3. `summary_data IS NOT NULL` — no-op (сеанс уже готов к уведомлению).
4. `summarizer.enabled=false` — выход без изменений (пайплайн
задокументированно завершается после реконструкции фраз).
5. Фраз нет — выход, `summary_data` остаётся `NULL`.
Обрыв LLM (`LlmUnavailableError`) — `task.retry` с нарастающим countdown;
исчерпание попыток — `pipeline_status='failed'`. Один шаг — один commit.
"""
async with open_session() as session:
session_record = await session.get(ConferenceSession, session_id)
if session_record is None or session_record.t_end is None:
logger.warning(
"summarize_session: сеанс %s не найден либо ещё не завершён (t_end IS NULL)",
session_id,
)
return
if session_record.pipeline_status != "summarizing":
logger.info(
"summarize_session: сеанс %s не на шаге суммаризации "
"(pipeline_status=%s) — no-op",
session_id,
session_record.pipeline_status,
)
return
if session_record.summary_data is not None:
logger.info(
"summarize_session: сеанс %s уже готов к уведомлению "
"(summary_data заполнен) — no-op",
session_id,
)
return
cfg = plugins_config or await load_effective_config(session)
if not cfg.summarizer.enabled:
logger.info(
"summarize_session: summarizer отключён (enabled=false) — сеанс %s пропущен",
session_id,
)
return
lines = await _fetch_transcript_lines(session, session_record)
if not lines:
logger.warning(
"summarize_session: у сеанса %s нет фраз — summary_data остаётся NULL",
session_id,
)
return
transcript = build_transcript(lines)
summarizer = create_summarizer(cfg.summarizer)
try:
summary = summarizer.summarize(transcript)
except LlmUnavailableError as exc:
attempt = getattr(task.request, "retries", 0)
countdown = RETRY_COUNTDOWN_BASE_S * (attempt + 1)
try:
task.retry(countdown=countdown)
except MaxRetriesExceededError:
session_record.pipeline_status = "failed"
await session.commit()
logger.warning(
"summarize_session: исчерпаны попытки суммаризации сеанса %s "
"(LLM недоступен: %s) — pipeline failed",
session_id,
exc,
)
# Реальный `Task.retry()` сам бросает исключение Retry (не
# возвращает управление) — до сюда доходим только с
# моком/заглушкой `task.retry` в тестах.
return
session_record.summary_data = summary
await session.commit()
logger.info(
"summarize_session: сеанс %s — саммари сохранено (%d симв.)",
session_id,
len(summary),
)
# По имени задачи, без импорта `workers.tasks.notify` — разграничение
# процессов такое же, как у `run_pipeline` → `summarize_session`.
# Постановка защищена retry на сбой брокера
# (`send_task_with_retry`); неудача не роняет `summarize_session` и не
# трогает уже сохранённый `summary_data` — восстановление берёт на
# себя `workers.tasks.maintenance.recover_stuck_notifications`
# (уровень 2 защиты).
sent = await send_task_with_retry(
"workers.tasks.notify.notify_session", args=[str(session_id)]
)
logger.info(
"summarize_session: постановка задачи уведомления сеанса %s %s",
session_id,
"выполнена" if sent else "не удалась (см. предыдущий error) — ждём recovery",
)
async def _fetch_transcript_lines(
session: AsyncSession, session_record: ConferenceSession
) -> list[TranscriptLine]:
"""Собрать фразы сеанса с именами говорящих для построения транскрипта.
Имя говорящего — `users.name_user` для зарегистрированного участника либо
`guest_access.display_name` для гостя: ровно одно из `user_id`/`guest_id`
заполнено у `conference_participants` (CHECK-constraint, ADR-001 п.6).
`offset_s` — смещение начала фразы от `t_start` сеанса, как ожидает
`build_transcript`.
"""
stmt = (
select(Phrase.t_start, Phrase.data, User.name_user, GuestAccess.display_name)
.join(ConferenceParticipant, Phrase.participant_id == ConferenceParticipant.id)
.outerjoin(User, ConferenceParticipant.user_id == User.id)
.outerjoin(GuestAccess, ConferenceParticipant.guest_id == GuestAccess.id)
.where(Phrase.session_id == session_record.id)
.order_by(Phrase.t_start)
)
rows = (await session.execute(stmt)).all()
return [
TranscriptLine(
speaker=name_user or display_name or _DEFAULT_SPEAKER_NAME,
offset_s=(t_start - session_record.t_start).total_seconds(),
text=text,
)
for t_start, text, name_user, display_name in rows
]