Первоначальная версия VidConf
This commit is contained in:
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