"""라이브 코칭 source pack → RAG 색인 payload 변환 테스트.""" from __future__ import annotations import unittest from typing import Any from unittest.mock import AsyncMock, patch from .services import live_coach, rag, source_pack_sync class _SourcePackConn: def __init__(self, latest: dict[tuple[str, str], dict[str, Any]] | None = None) -> None: self.latest = latest or {} self.execute_calls: list[tuple[str, tuple[Any, ...]]] = [] self.fetchrow_calls: list[tuple[str, tuple[Any, ...]]] = [] async def execute(self, query: str, *args: Any) -> None: self.execute_calls.append((query, args)) async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None: self.fetchrow_calls.append((query, args)) return self.latest.get((str(args[0]), str(args[1]))) class LiveCoachSourcePackTest(unittest.TestCase): def test_source_packs_build_rag_rows_and_index_payloads(self) -> None: rows = live_coach.build_rag_source_rows() payloads = live_coach.build_rag_index_payloads() row_ids = {row["source_id"] for row in rows} payload_ids = {payload["source_id"] for payload in payloads} self.assertIn("workbook_0615_case_conceptualization", row_ids) self.assertIn("dsm5tr_case_formulation", row_ids) self.assertIn("official_suicide_risk_guidelines", row_ids) self.assertEqual(row_ids, payload_ids) for row in rows: self.assertTrue(row["title"]) self.assertIn(row["license_class"], {"A", "B", "C", "D"}) self.assertTrue(row["external_llm_ok"]) for payload in payloads: self.assertRegex(payload["content_hash"], r"^[0-9a-f]{64}$") self.assertGreater(payload["version"], 0) self.assertTrue(payload["chunks"]) for chunk in payload["chunks"]: self.assertTrue(chunk["chunk_text"]) self.assertEqual(chunk["visible_to"], ["evaluator"]) self.assertNotIn("client", chunk["visible_to"]) self.assertTrue(chunk["meta"]["live_coaching_source"]) self.assertTrue(chunk["meta"]["citation"]) dsm = next(payload for payload in payloads if payload["source_id"] == "dsm5tr_case_formulation") self.assertTrue(all(chunk["sensitivity"] == 2 for chunk in dsm["chunks"])) class RagIndexPolicyTest(unittest.IsolatedAsyncioTestCase): async def test_index_document_rejects_raw_source_chunks_before_db(self) -> None: class NoDbConn: async def fetchrow(self, *_args: Any, **_kwargs: Any) -> None: raise AssertionError("raw source validation must run before DB access") async def execute(self, *_args: Any, **_kwargs: Any) -> None: raise AssertionError("raw source validation must run before DB access") req = rag.IndexRequest( source_id="raw-source", doc_uri="raw-source.txt", chunks=[ { "seq": 0, "chunk_text": "원문 축어록", "visible_to": [], "sensitivity": 3, "meta": {"raw_source_artifact": True}, } ], ) with self.assertRaises(rag.IndexPolicyViolation) as caught: await rag.index_document(NoDbConn(), req) # type: ignore[arg-type] self.assertIn("raw source artifacts must not be indexed", str(caught.exception)) class RepoSourcePackSyncTest(unittest.IsolatedAsyncioTestCase): def _patch_manifest(self) -> Any: row = { "source_id": "source-a", "title": "Source A", "kb_kind": "theory", "license_class": "A", "origin_path": "data/source-a.json", "citation": "source-a", "external_llm_ok": True, } payload = { "source_id": "source-a", "doc_uri": "repo/source-a.json", "version": 1, "content_hash": "new-hash", "chunks": [{"seq": 0, "chunk_text": "body", "visible_to": ["evaluator"], "sensitivity": 2}], } return patch.object(source_pack_sync, "build_repo_source_pack_manifest", return_value=([row], [payload])) async def test_dry_run_reports_next_version_without_writes(self) -> None: conn = _SourcePackConn({("source-a", "repo/source-a.json"): {"doc_id": 10, "version": 2, "content_hash": "old-hash"}}) with self._patch_manifest(), patch.object(source_pack_sync.rag, "index_document", AsyncMock()) as index_document: result = await source_pack_sync.sync_repo_source_packs(conn, apply=False) self.assertFalse(result.applied) self.assertEqual(result.sources_upserted, 0) self.assertEqual(conn.execute_calls, []) index_document.assert_not_awaited() self.assertEqual(result.items[0].previous_version, 2) self.assertEqual(result.items[0].new_version, 3) self.assertFalse(result.items[0].skipped_unchanged) async def test_apply_bumps_changed_document_version(self) -> None: conn = _SourcePackConn({("source-a", "repo/source-a.json"): {"doc_id": 10, "version": 2, "content_hash": "old-hash"}}) seen_versions: list[int] = [] async def fake_index_document(_: Any, request: rag.IndexRequest) -> rag.IndexResult: seen_versions.append(request.version) return rag.IndexResult(doc_id=11, chunks_indexed=1, skipped_unchanged=False, embedded=False, degraded=True) with self._patch_manifest(), patch.object(source_pack_sync.rag, "index_document", fake_index_document): result = await source_pack_sync.sync_repo_source_packs(conn, apply=True) self.assertTrue(result.applied) self.assertEqual(result.sources_upserted, 1) self.assertEqual(len(conn.execute_calls), 1) self.assertEqual(seen_versions, [3]) self.assertEqual(result.items[0].doc_id, 11) self.assertEqual(result.items[0].chunks_indexed, 1) self.assertTrue(result.degraded) async def test_apply_skips_same_content_hash(self) -> None: conn = _SourcePackConn({("source-a", "repo/source-a.json"): {"doc_id": 10, "version": 2, "content_hash": "new-hash"}}) with self._patch_manifest(), patch.object(source_pack_sync.rag, "index_document", AsyncMock()) as index_document: result = await source_pack_sync.sync_repo_source_packs(conn, apply=True) index_document.assert_not_awaited() self.assertEqual(result.skipped_unchanged, 1) self.assertEqual(result.items[0].doc_id, 10) self.assertEqual(result.items[0].new_version, 2) if __name__ == "__main__": unittest.main()