Первоначальная версия VidConf
This commit is contained in:
1
workers/tasks/__init__.py
Normal file
1
workers/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Пакет периодических и фоновых задач Celery."""
|
||||
72
workers/tasks/dispatch.py
Normal file
72
workers/tasks/dispatch.py
Normal 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 # недостижимо: цикл либо возвращает, либо продолжает до предела
|
||||
286
workers/tasks/invitations.py
Normal file
286
workers/tasks/invitations.py
Normal 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())
|
||||
212
workers/tasks/maintenance.py
Normal file
212
workers/tasks/maintenance.py
Normal 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
325
workers/tasks/notify.py
Normal 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
250
workers/tasks/pipeline.py
Normal 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
216
workers/tasks/summarize.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user