"""End-to-end тест: ingest → search → context → chat-stream. Не мокает движок — ставит заглушку только на OpenCode-клиент. """ import tempfile import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from src.rag.qmd import indexer as qmd_indexer from src.rag.qmd import query as qmd_query SAMPLE_MEETING = ( "# Совещание 2026-06-10\n\n" "Участники: Иванов, Петров, Сидорова.\n\n" "## Повестка\n" "1. Ход строительства 3-й очереди.\n" "2. Авторизация подрядчиков в системе.\n" "3. Сроки сдачи.\n\n" "## Решения\n" "- Завершить фундамент до 15 июля.\n" "- Выдать JWT-токены подрядчикам.\n" "- Срок сдачи — 30 ноября 2027.\n" ) class E2ETestCase(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): from src.rag.qmd.collections import get_project_collection_dir self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name) self.coll_dir = get_project_collection_dir("merakom", "2026", self.tmp) self.coll_dir.mkdir(parents=True, exist_ok=True) self.engines = [] async def asyncTearDown(self): from src.rag.engine import invalidate_engine for eng in self.engines: eng.close() invalidate_engine(self.coll_dir) import time time.sleep(0.05) try: self._tmp.cleanup() except (PermissionError, OSError): pass async def test_e2e_meeting_index_search_chat(self): # 1. Ingest через Engine.index_file напрямую (в self.coll_dir). from src.rag.engine import get_or_create_engine eng = get_or_create_engine(self.coll_dir) eng.warmup() self.engines.append(eng) body = self.tmp / "meeting.txt" summary = self.tmp / "meeting_summary.md" body.write_text(SAMPLE_MEETING, encoding="utf-8") summary.write_text("# Краткое\nОбсуждали строительство и JWT.", encoding="utf-8") r1 = eng.index_file(body) r2 = eng.index_file(summary) self.assertFalse(r1.skipped) self.assertFalse(r2.skipped) # 2. Search через тот же engine. hits = eng.query("сдача объекта", limit=3, use_rerank=False) self.assertGreater(len(hits), 0) # top hit должен относиться к meeting.txt self.assertTrue(any("meeting.txt" in h.file_path for h in hits)) # 3. Chat-stream с подменой OpenCode. fake_chunks = [ MagicMock(choices=[MagicMock(delta=MagicMock(content="Сдача "))]), MagicMock(choices=[MagicMock(delta=MagicMock(content="30 ноября "))]), MagicMock(choices=[MagicMock(delta=MagicMock(content="2027."))]), ] class _FakeStream: def __init__(self): self._i = 0 def __aiter__(self): return self async def __anext__(self): if self._i >= len(fake_chunks): raise StopAsyncIteration item = fake_chunks[self._i] self._i += 1 return item async def _fake_create(*args, **kwargs): if kwargs.get("stream"): return _FakeStream() return MagicMock(choices=[MagicMock(message=MagicMock(content="final"))]) with patch("src.rag.qmd.query.AsyncOpenAI") as fake_cls: fake_instance = MagicMock() fake_instance.chat = MagicMock() fake_instance.chat.completions = MagicMock() fake_instance.chat.completions.create = AsyncMock(side_effect=_fake_create) fake_cls.return_value = fake_instance events = [] async for ev in qmd_query.qmd_chat_stream( question="Когда сдача?", org_slug="merakom", history=[], project_slug="2026", api_key="test-key", use_rerank=False, ): events.append(ev) types = [e["type"] for e in events] self.assertEqual(types[0], "context") self.assertIn("chunk", types) self.assertEqual(types[-1], "done") self.assertEqual(events[-1]["answer"], "Сдача 30 ноября 2027.") # Контекст непустой и содержит source-annotation self.assertIn("[source:", events[0]["context"])