"""Тесты плагина `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