519 lines
21 KiB
Python
519 lines
21 KiB
Python
"""Тесты Celery-задачи суммаризации (`workers.tasks.summarize.summarize_session_async`).
|
||
|
||
Как и `test_pipeline.py`, не использует savepoint-фикстуру `db_session`:
|
||
`summarize_session_async` открывает собственную сессию с отдельным engine
|
||
(`workers/db.py::open_session`), которая не видит незакоммиченные изменения
|
||
другой сессии. Тестовые данные заводятся и коммитятся напрямую через
|
||
`core.db.engine`, LLM — фейковый `Summarizer` за контрактом `create_summarizer`
|
||
(без реального `QwenLocal`/HTTP).
|
||
"""
|
||
|
||
import uuid
|
||
from collections.abc import AsyncGenerator
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import cast
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from celery.exceptions import MaxRetriesExceededError
|
||
from sqlalchemy import text
|
||
|
||
from core.db import engine
|
||
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
|
||
from core.plugins.transcriber import Segment
|
||
from core.summarization.llm_client import LlmUnavailableError
|
||
from services.conference_ids import generate_number, generate_slug
|
||
from workers.tasks import pipeline as pipeline_module
|
||
from workers.tasks import summarize as summarize_module
|
||
from workers.tasks.pipeline import run_pipeline_async
|
||
from workers.tasks.summarize import summarize_session_async
|
||
|
||
NOW = datetime.now(UTC)
|
||
|
||
|
||
class _Request:
|
||
"""Заглушка `celery.Task.request`: нужен только счётчик уже сделанных попыток."""
|
||
|
||
def __init__(self, retries: int = 0) -> None:
|
||
self.retries = retries
|
||
|
||
|
||
class _FakeTask:
|
||
"""Заглушка bound-задачи Celery: фиксирует вызовы `retry`, не бросает исключение."""
|
||
|
||
def __init__(self, retries: int = 0) -> None:
|
||
self.request = _Request(retries)
|
||
self.retry = MagicMock()
|
||
|
||
|
||
class _ExhaustedRetryTask:
|
||
"""Заглушка bound-задачи: `retry` всегда бросает `MaxRetriesExceededError`."""
|
||
|
||
def __init__(self, retries: int = 0) -> None:
|
||
self.request = _Request(retries)
|
||
self.retry_calls = 0
|
||
|
||
def retry(self, countdown: int | None = None) -> None:
|
||
self.retry_calls += 1
|
||
raise MaxRetriesExceededError("исчерпаны попытки суммаризации")
|
||
|
||
|
||
class _FakeSummarizer:
|
||
"""Фейковый `Summarizer`: запоминает транскрипт, опционально падает N раз подряд."""
|
||
|
||
provider = "fake"
|
||
|
||
def __init__(self, result: str = "Итоговое саммари", fail_times: int = 0) -> None:
|
||
self.result = result
|
||
self._fail_times = fail_times
|
||
self.calls = 0
|
||
self.transcript: str | None = None
|
||
|
||
def summarize(self, transcript: str) -> str:
|
||
self.calls += 1
|
||
self.transcript = transcript
|
||
if self._fail_times > 0:
|
||
self._fail_times -= 1
|
||
raise LlmUnavailableError("LLM недоступен (тест)")
|
||
return self.result
|
||
|
||
|
||
class _Fixture:
|
||
"""Id тестовой конференции/сеанса/участников с уже вставленными фразами."""
|
||
|
||
def __init__(self) -> None:
|
||
self.conference_id = uuid.uuid4()
|
||
self.session_id = uuid.uuid4()
|
||
self.user_id = uuid.uuid4()
|
||
self.guest_id = uuid.uuid4()
|
||
self.user_participant_id = uuid.uuid4()
|
||
self.guest_participant_id = uuid.uuid4()
|
||
self.number = generate_number()
|
||
self.slug = generate_slug()
|
||
self.t_start = NOW - timedelta(minutes=30)
|
||
self.t_end = NOW
|
||
|
||
|
||
@pytest.fixture
|
||
async def fx() -> AsyncGenerator[_Fixture, None]:
|
||
f = _Fixture()
|
||
|
||
async with engine.connect() as conn:
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO users (id, email, name_user, password_hash) "
|
||
"VALUES (:id, :email, 'Иван Тестов', 'x')"
|
||
),
|
||
{"id": f.user_id, "email": f"summarize-{f.user_id}@example.com"},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
|
||
"VALUES (:id, :number, :slug, 'Summarize test', 'ended', false)"
|
||
),
|
||
{"id": f.conference_id, "number": f.number, "slug": f.slug},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO guest_access (id, conference_id, display_name) "
|
||
"VALUES (:id, :conference_id, 'Гость Тестов')"
|
||
),
|
||
{"id": f.guest_id, "conference_id": f.conference_id},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conference_sessions "
|
||
"(id, conference_id, title, t_start, t_end, pipeline_status) "
|
||
"VALUES (:id, :conference_id, 'Summarize session', :t_start, :t_end, "
|
||
"'summarizing')"
|
||
),
|
||
{
|
||
"id": f.session_id,
|
||
"conference_id": f.conference_id,
|
||
"t_start": f.t_start,
|
||
"t_end": f.t_end,
|
||
},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) "
|
||
"VALUES (:id, :session_id, :user_id, :joined_at, :left_at)"
|
||
),
|
||
{
|
||
"id": f.user_participant_id,
|
||
"session_id": f.session_id,
|
||
"user_id": f.user_id,
|
||
"joined_at": f.t_start,
|
||
"left_at": f.t_end,
|
||
},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conference_participants "
|
||
"(id, session_id, guest_id, joined_at, left_at) "
|
||
"VALUES (:id, :session_id, :guest_id, :joined_at, :left_at)"
|
||
),
|
||
{
|
||
"id": f.guest_participant_id,
|
||
"session_id": f.session_id,
|
||
"guest_id": f.guest_id,
|
||
"joined_at": f.t_start,
|
||
"left_at": f.t_end,
|
||
},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO phrases (participant_id, session_id, data, t_start, t_end) "
|
||
"VALUES (:participant_id, :session_id, 'Обсудили бюджет', :t_start, :t_end)"
|
||
),
|
||
{
|
||
"participant_id": f.user_participant_id,
|
||
"session_id": f.session_id,
|
||
"t_start": f.t_start + timedelta(seconds=5),
|
||
"t_end": f.t_start + timedelta(seconds=7),
|
||
},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO phrases (participant_id, session_id, data, t_start, t_end) "
|
||
"VALUES (:participant_id, :session_id, 'Утвердили сроки', :t_start, :t_end)"
|
||
),
|
||
{
|
||
"participant_id": f.guest_participant_id,
|
||
"session_id": f.session_id,
|
||
"t_start": f.t_start + timedelta(seconds=10),
|
||
"t_end": f.t_start + timedelta(seconds=12),
|
||
},
|
||
)
|
||
await conn.commit()
|
||
yield f
|
||
async with engine.connect() as conn:
|
||
await conn.execute(text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_id})
|
||
await conn.execute(
|
||
text("DELETE FROM conference_participants WHERE session_id = :id"), {"id": f.session_id}
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
|
||
{"id": f.conference_id},
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM guest_access WHERE conference_id = :id"), {"id": f.conference_id}
|
||
)
|
||
await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id})
|
||
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
|
||
await conn.commit()
|
||
|
||
|
||
def _cfg(*, enabled: bool = True) -> InstanceConfig:
|
||
"""Конфиг для тестов: реальный `Summarizer` не создаётся — провайдер
|
||
подменяется через monkeypatch `summarize_module.create_summarizer`."""
|
||
return InstanceConfig(
|
||
transcriber=TranscriberConfig(),
|
||
summarizer=SummarizerConfig(enabled=enabled, provider="fake"),
|
||
chat=ChatConfig(),
|
||
)
|
||
|
||
|
||
async def _fetch_session(session_id: uuid.UUID) -> tuple[str, str | None]:
|
||
async with engine.connect() as conn:
|
||
result = await conn.execute(
|
||
text("SELECT pipeline_status, summary_data FROM conference_sessions WHERE id = :id"),
|
||
{"id": session_id},
|
||
)
|
||
row = result.one()
|
||
return cast("str", row[0]), cast("str | None", row[1])
|
||
|
||
|
||
async def test_summarize_session_fills_summary_data_and_keeps_summarizing(
|
||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(a) phrases → summary_data заполнен, статус — 'summarizing', 'notified' не ставится
|
||
(его ставит отдельная задача `notify_session`, см. её постановку ниже)."""
|
||
fake = _FakeSummarizer(result="Итоговое саммари встречи")
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
|
||
mock_send_task = MagicMock()
|
||
monkeypatch.setattr(summarize_module.app, "send_task", mock_send_task)
|
||
|
||
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
|
||
|
||
status, summary = await _fetch_session(fx.session_id)
|
||
assert status == "summarizing"
|
||
assert summary == "Итоговое саммари встречи"
|
||
assert fake.calls == 1
|
||
mock_send_task.assert_called_once_with(
|
||
"workers.tasks.notify.notify_session", args=[str(fx.session_id)]
|
||
)
|
||
|
||
|
||
async def test_summarize_session_noop_when_summary_already_present(
|
||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(b) повторный вызов при уже заполненном summary_data — no-op."""
|
||
monkeypatch.setattr(summarize_module.app, "send_task", MagicMock())
|
||
first = _FakeSummarizer(result="Первое саммари")
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: first)
|
||
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
|
||
|
||
second = _FakeSummarizer(result="Второе саммари (не должно попасть в БД)")
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: second)
|
||
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
|
||
|
||
status, summary = await _fetch_session(fx.session_id)
|
||
assert status == "summarizing"
|
||
assert summary == "Первое саммари"
|
||
assert second.calls == 0
|
||
|
||
|
||
async def test_summarize_session_noop_when_disabled(
|
||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(c) summarizer.enabled=false → выход, summary_data NULL, статус не тронут."""
|
||
mock_factory = MagicMock()
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", mock_factory)
|
||
|
||
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg(enabled=False))
|
||
|
||
mock_factory.assert_not_called()
|
||
status, summary = await _fetch_session(fx.session_id)
|
||
assert status == "summarizing"
|
||
assert summary is None
|
||
|
||
|
||
async def test_summarize_session_retries_on_llm_unavailable_then_succeeds(
|
||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(d) LlmUnavailableError на первом вызове → retry без потери шага →
|
||
успех со второй попытки."""
|
||
monkeypatch.setattr(summarize_module.app, "send_task", MagicMock())
|
||
fake = _FakeSummarizer(result="Саммари после повтора", fail_times=1)
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
|
||
|
||
first_task = _FakeTask(retries=0)
|
||
await summarize_session_async(first_task, fx.session_id, plugins_config=_cfg())
|
||
|
||
first_task.retry.assert_called_once_with(countdown=60)
|
||
status, summary = await _fetch_session(fx.session_id)
|
||
assert status == "summarizing"
|
||
assert summary is None
|
||
assert fake.calls == 1
|
||
|
||
# Повторная доставка задачи celery-ретраем (acks_late) — шаг не потерян.
|
||
second_task = _FakeTask(retries=1)
|
||
await summarize_session_async(second_task, fx.session_id, plugins_config=_cfg())
|
||
|
||
second_task.retry.assert_not_called()
|
||
status, summary = await _fetch_session(fx.session_id)
|
||
assert status == "summarizing"
|
||
assert summary == "Саммари после повтора"
|
||
assert fake.calls == 2
|
||
|
||
|
||
async def test_summarize_session_marks_failed_when_retries_exhausted(
|
||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(e) исчерпание retry → pipeline_status='failed'."""
|
||
fake = _FakeSummarizer(result="не важно", fail_times=999)
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
|
||
task = _ExhaustedRetryTask(retries=5)
|
||
|
||
await summarize_session_async(task, fx.session_id, plugins_config=_cfg())
|
||
|
||
assert task.retry_calls == 1
|
||
status, summary = await _fetch_session(fx.session_id)
|
||
assert status == "failed"
|
||
assert summary is None
|
||
|
||
|
||
async def test_summarize_session_transcript_includes_guest_display_name(
|
||
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(f) имена гостей (guest_access.display_name) попадают в транскрипт."""
|
||
monkeypatch.setattr(summarize_module.app, "send_task", MagicMock())
|
||
fake = _FakeSummarizer()
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
|
||
|
||
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
|
||
|
||
assert fake.transcript is not None
|
||
assert "Гость Тестов" in fake.transcript
|
||
assert "Иван Тестов" in fake.transcript
|
||
|
||
|
||
async def test_summarize_session_noop_when_no_phrases(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Guard №5: у сеанса нет фраз → warning-выход, summary_data остаётся NULL."""
|
||
f = _Fixture()
|
||
async with engine.connect() as conn:
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
|
||
"VALUES (:id, :number, :slug, 'No phrases', 'ended', false)"
|
||
),
|
||
{"id": f.conference_id, "number": f.number, "slug": f.slug},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conference_sessions "
|
||
"(id, conference_id, title, t_start, t_end, pipeline_status) "
|
||
"VALUES (:id, :conference_id, 'No phrases session', :t_start, :t_end, "
|
||
"'summarizing')"
|
||
),
|
||
{
|
||
"id": f.session_id,
|
||
"conference_id": f.conference_id,
|
||
"t_start": f.t_start,
|
||
"t_end": f.t_end,
|
||
},
|
||
)
|
||
await conn.commit()
|
||
|
||
mock_factory = MagicMock()
|
||
monkeypatch.setattr(summarize_module, "create_summarizer", mock_factory)
|
||
|
||
try:
|
||
await summarize_session_async(_FakeTask(), f.session_id, plugins_config=_cfg())
|
||
|
||
mock_factory.assert_not_called()
|
||
status, summary = await _fetch_session(f.session_id)
|
||
assert status == "summarizing"
|
||
assert summary is None
|
||
finally:
|
||
async with engine.connect() as conn:
|
||
await conn.execute(
|
||
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
|
||
{"id": f.conference_id},
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id}
|
||
)
|
||
await conn.commit()
|
||
|
||
|
||
class _FakeTranscriber:
|
||
"""Заглушка `Transcriber`: канонические сегменты по пути файла (см. `test_pipeline.py`)."""
|
||
|
||
def __init__(self, canned: dict[str, list[Segment]]) -> None:
|
||
self._canned = canned
|
||
self.calls: list[str] = []
|
||
|
||
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
|
||
self.calls.append(audio_path)
|
||
return self._canned[audio_path]
|
||
|
||
|
||
def _touch(path: str) -> None:
|
||
"""Создать пустой файл трека (синхронно, вне тела async-теста — ASYNC240)."""
|
||
Path(path).write_bytes(b"")
|
||
|
||
|
||
async def test_run_pipeline_sends_summarize_task_after_summarizing(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""(g) run_pipeline отправляет `summarize_session` по имени после перехода
|
||
в 'summarizing'."""
|
||
f = _Fixture()
|
||
track_path = str(tmp_path / "track.ogg")
|
||
_touch(track_path)
|
||
|
||
async with engine.connect() as conn:
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO users (id, email, name_user, password_hash) "
|
||
"VALUES (:id, :email, 'Иван Тестов', 'x')"
|
||
),
|
||
{"id": f.user_id, "email": f"pipeline-send-{f.user_id}@example.com"},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
|
||
"VALUES (:id, :number, :slug, 'Send task test', 'ended', false)"
|
||
),
|
||
{"id": f.conference_id, "number": f.number, "slug": f.slug},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) "
|
||
"VALUES (:id, :conference_id, 'Send task session', :t_start, :t_end)"
|
||
),
|
||
{
|
||
"id": f.session_id,
|
||
"conference_id": f.conference_id,
|
||
"t_start": f.t_start,
|
||
"t_end": f.t_end,
|
||
},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) "
|
||
"VALUES (:id, :session_id, :user_id, :joined_at, :left_at)"
|
||
),
|
||
{
|
||
"id": f.user_participant_id,
|
||
"session_id": f.session_id,
|
||
"user_id": f.user_id,
|
||
"joined_at": f.t_start,
|
||
"left_at": f.t_end,
|
||
},
|
||
)
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO session_audio_tracks "
|
||
"(id, session_id, participant_id, track_sid, egress_id, "
|
||
"file_path, status, started_at) "
|
||
"VALUES (:id, :session_id, :participant_id, 'TR_USER', 'EG_TR_USER', "
|
||
":file_path, 'recorded', :started_at)"
|
||
),
|
||
{
|
||
"id": uuid.uuid4(),
|
||
"session_id": f.session_id,
|
||
"participant_id": f.user_participant_id,
|
||
"file_path": track_path,
|
||
"started_at": f.t_start + timedelta(seconds=5),
|
||
},
|
||
)
|
||
await conn.commit()
|
||
|
||
fake_transcriber = _FakeTranscriber(canned={track_path: [Segment(0.0, 2.0, "привет")]})
|
||
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake_transcriber)
|
||
mock_send_task = MagicMock()
|
||
monkeypatch.setattr(pipeline_module.app, "send_task", mock_send_task)
|
||
|
||
try:
|
||
await run_pipeline_async(
|
||
_FakeTask(),
|
||
f.session_id,
|
||
plugins_config=InstanceConfig(
|
||
transcriber=TranscriberConfig(enabled=True, provider="fake", language="ru"),
|
||
summarizer=SummarizerConfig(),
|
||
chat=ChatConfig(),
|
||
),
|
||
)
|
||
|
||
mock_send_task.assert_called_once_with(
|
||
"workers.tasks.summarize.summarize_session", args=[str(f.session_id)]
|
||
)
|
||
status, _ = await _fetch_session(f.session_id)
|
||
assert status == "summarizing"
|
||
finally:
|
||
async with engine.connect() as conn:
|
||
await conn.execute(
|
||
text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_id}
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM session_audio_tracks WHERE session_id = :id"),
|
||
{"id": f.session_id},
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM conference_participants WHERE session_id = :id"),
|
||
{"id": f.session_id},
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
|
||
{"id": f.conference_id},
|
||
)
|
||
await conn.execute(
|
||
text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id}
|
||
)
|
||
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
|
||
await conn.commit()
|