Первоначальная версия 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

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
]