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

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

View File

@@ -0,0 +1,518 @@
"""Тесты 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()