first commit
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
2026-07-23 02:38:05 +03:00
commit 8757bec8ac
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
"""Тесты плагина `QwenLocal` (map-reduce) с мок-LLM-клиентом (без сети/токенизатора).
`QwenLocal._get_client()` подменяется фейковым клиентом с методом `complete`,
поэтому чанкер и подстановка промптов проверяются изолированно от
`OpenAICompatClient`/`httpx`. Подсчёт токенов подменяется простым фейком
(число слов), чтобы детерминированно управлять группировкой в reduce.
"""
from pathlib import Path
from typing import Any
import pytest
from core.plugins.qwen_local import QwenLocal
PROMPTS_DIR = Path(__file__).parent.parent.parent / "workers" / "summarizer" / "prompts"
class _FakeLlmClient:
"""Фейковый LLM-клиент: запоминает все промпты и возвращает по очереди ответы."""
def __init__(self, responses: list[str] | None = None) -> None:
self.prompts: list[str] = []
self.max_tokens_seen: list[int | None] = []
self._responses = responses
self.closed = False
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
self.prompts.append(prompt)
self.max_tokens_seen.append(max_tokens)
if self._responses is not None:
return self._responses[len(self.prompts) - 1]
return f"резюме №{len(self.prompts)}"
def close(self) -> None:
"""Заглушка `OpenAICompatClient.close()` — фиксирует факт закрытия."""
self.closed = True
def _make_plugin(fake_client: _FakeLlmClient, count_tokens: Any = None, **kwargs: Any) -> QwenLocal:
plugin = QwenLocal(prompts_dir=str(PROMPTS_DIR), **kwargs)
plugin._client = fake_client # type: ignore[assignment]
if count_tokens is not None:
plugin._count_tokens = count_tokens
return plugin
def _word_count(text: str) -> int:
return len(text.split())
def test_empty_transcript_returns_empty_string_without_llm_calls() -> None:
"""Пустой транскрипт → "" без единого обращения к LLM."""
fake_client = _FakeLlmClient()
plugin = _make_plugin(fake_client, count_tokens=_word_count)
result = plugin.summarize("")
assert result == ""
assert fake_client.prompts == []
def test_single_chunk_returns_map_result_without_reduce_call() -> None:
"""Транскрипт, помещающийся в один чанк, → результат map без reduce-вызова."""
fake_client = _FakeLlmClient(responses=["итоговое резюме одного чанка"])
plugin = _make_plugin(fake_client, count_tokens=_word_count, chunk_minutes=20)
transcript = "[Аня 00:00] привет всем\n[Боря 00:03] привет как дела"
result = plugin.summarize(transcript)
assert result == "итоговое резюме одного чанка"
assert len(fake_client.prompts) == 1
assert "привет всем" in fake_client.prompts[0]
assert "привет как дела" in fake_client.prompts[0]
# промпт собран из утверждённого map-шаблона (проверяем характерный заголовок)
assert "## Ключевые тезисы" in fake_client.prompts[0]
def test_multiple_chunks_are_mapped_then_reduced_once() -> None:
"""Несколько чанков → map каждого чанка, затем один reduce-вызов над частичными резюме."""
fake_client = _FakeLlmClient(
responses=["частичное резюме A", "частичное резюме B", "итоговое резюме"]
)
plugin = _make_plugin(fake_client, count_tokens=_word_count, chunk_minutes=1)
lines = [f"[Аня {minute:02d}:00] " + " ".join(["слово"] * 5) for minute in range(0, 4, 2)]
# Раздвигаем фразы на разные "чанки" по времени (chunk_minutes=1 закрывает
# чанк уже на второй фразе, т.к. между ними больше минуты).
transcript = "\n".join(lines)
result = plugin.summarize(transcript)
assert result == "итоговое резюме"
assert len(fake_client.prompts) == 3
map_prompts, reduce_prompt = fake_client.prompts[:2], fake_client.prompts[2]
assert all("## Ключевые тезисы" in p for p in map_prompts)
assert "частичное резюме A" in reduce_prompt
assert "частичное резюме B" in reduce_prompt
assert "Частичные резюме:" in reduce_prompt
def test_hierarchical_reduce_when_partial_summaries_exceed_token_budget() -> None:
"""Много крупных частичных резюме не влезают в бюджет одного reduce → иерархический reduce."""
# 10 чанков ⇒ 10 map-вызовов; каждое частичное резюме — 1000 "токенов"
# (по фейковому счётчику слов), бюджет reduce — 6000 → одной группой не
# свести, нужна как минимум одна промежуточная стадия.
big_word = "слово " * 1000
class _BigFakeLlmClient(_FakeLlmClient):
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
self.prompts.append(prompt)
self.max_tokens_seen.append(max_tokens)
call_no = len(self.prompts)
if "Транскрипт:" in prompt:
return big_word.strip()
return f"промежуточное резюме {call_no}"
fake_client = _BigFakeLlmClient()
plugin = _make_plugin(fake_client, count_tokens=_word_count, chunk_minutes=1)
lines = [f"[Аня {minute:02d}:00] реплика номер {minute}" for minute in range(0, 40, 2)]
transcript = "\n".join(lines)
result = plugin.summarize(transcript)
map_calls = [p for p in fake_client.prompts if "Транскрипт:" in p]
reduce_calls = [p for p in fake_client.prompts if "Частичные резюме:" in p]
assert len(map_calls) == 20 # по фразе на чанк (chunk_minutes=1, реплики каждые 2 мин)
assert len(reduce_calls) >= 2 # хотя бы одна промежуточная группа + финальный reduce
assert result.startswith("промежуточное резюме")
def test_map_prompt_uses_approved_template_placeholder() -> None:
"""Плейсхолдер `{transcript_chunk}` подставляется через .replace, не .format."""
fake_client = _FakeLlmClient(responses=["резюме"])
plugin = _make_plugin(fake_client, count_tokens=_word_count)
transcript = "[Аня 00:00] текст с фигурными скобками {как тут} и всё равно ок"
result = plugin.summarize(transcript)
assert result == "резюме"
assert "текст с фигурными скобками {как тут} и всё равно ок" in fake_client.prompts[0]
def test_map_and_reduce_use_different_max_tokens() -> None:
"""map и reduce вызываются с разными `max_tokens` (раздельные per-tier лимиты, ADR-004)."""
fake_client = _FakeLlmClient(
responses=["частичное резюме A", "частичное резюме B", "итоговое резюме"]
)
plugin = _make_plugin(
fake_client,
count_tokens=_word_count,
chunk_minutes=1,
max_tokens_map=1024,
max_tokens_reduce=2048,
)
lines = [f"[Аня {minute:02d}:00] " + " ".join(["слово"] * 5) for minute in range(0, 4, 2)]
transcript = "\n".join(lines)
plugin.summarize(transcript)
assert fake_client.max_tokens_seen == [1024, 1024, 2048]
def test_max_tokens_map_and_reduce_default_to_max_tokens_when_not_set() -> None:
"""Без явных `max_tokens_map`/`max_tokens_reduce` оба используют общий `max_tokens`
(обратная совместимость с конфигурацией)."""
fake_client = _FakeLlmClient(responses=["резюме"])
plugin = _make_plugin(fake_client, count_tokens=_word_count, max_tokens=777)
plugin.summarize("[Аня 00:00] короткая фраза")
assert fake_client.max_tokens_seen == [777]
assert plugin.max_tokens_map == 777
assert plugin.max_tokens_reduce == 777
def test_summarize_closes_llm_client_after_use() -> None:
"""Плагин закрывает HTTP-клиент LLM по завершении `summarize` (освобождение пула соединений)."""
fake_client = _FakeLlmClient(responses=["резюме"])
plugin = _make_plugin(fake_client, count_tokens=_word_count)
plugin.summarize("[Аня 00:00] короткая фраза")
assert fake_client.closed is True
assert plugin._client is None
def test_reduce_forces_pairwise_progress_when_summaries_exceed_budget_individually() -> None:
"""Если каждое частичное резюме само превышает бюджет reduce (например, из-за
неудачно большого `max_tokens` в конфиге), группировка по бюджету не сокращает
список — plugin обязан гарантировать прогресс (принудительное попарное
объединение), а не зависать в бесконечном цикле."""
class _HugeFakeLlmClient(_FakeLlmClient):
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
self.prompts.append(prompt)
self.max_tokens_seen.append(max_tokens)
call_no = len(self.prompts)
# Защита от регресса: если бы `_reduce` зациклился без прогресса,
# число вызовов росло бы неограниченно — превращаем зависание
# в быстрый явный отказ теста вместо вечного цикла.
if call_no > 50:
raise AssertionError("нет прогресса в _reduce — похоже на бесконечный цикл")
if "Транскрипт:" in prompt:
return f"огромное резюме {call_no}"
return f"свод {call_no}"
fake_client = _HugeFakeLlmClient()
# Фейковый счётчик токенов всегда возвращает значение >= бюджета reduce —
# ни одно частичное резюме "не помещается", группировка по бюджету
# вырождается в синглтоны на каждом уровне.
plugin = _make_plugin(fake_client, count_tokens=lambda _text: 7000, chunk_minutes=1)
lines = [f"[Аня {minute:02d}:00] реплика {minute}" for minute in range(0, 16, 2)]
transcript = "\n".join(lines)
result = plugin.summarize(transcript)
assert result.startswith("свод")
reduce_calls = [p for p in fake_client.prompts if "Частичные резюме:" in p]
assert len(reduce_calls) >= 1
def test_prompt_templates_are_loaded_lazily_and_cached(monkeypatch: pytest.MonkeyPatch) -> None:
"""Файл промпта читается с диска один раз (лениво) и переиспользуется при повторных вызовах."""
read_calls = {"count": 0}
original_read_text = Path.read_text
def counting_read_text(self: Path, *args: Any, **kwargs: Any) -> str:
read_calls["count"] += 1
result: str = original_read_text(self, *args, **kwargs)
return result
monkeypatch.setattr(Path, "read_text", counting_read_text)
plugin = QwenLocal(prompts_dir=str(PROMPTS_DIR))
plugin._map_prompt_template()
plugin._map_prompt_template()
plugin._reduce_prompt_template()
plugin._reduce_prompt_template()
assert read_calls["count"] == 2 # по одному чтению на файл: map и reduce