"""라이브 코칭 source pack → RAG 색인 payload 변환 테스트.""" from __future__ import annotations import json import tempfile import unittest from pathlib import Path 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_manifest_refresh_reads_changed_repo_source_pack_after_cache_warm(self) -> None: with tempfile.TemporaryDirectory() as temp_root: root = Path(temp_root) workbook_path = root / "live_coaching_workbook_0615.json" source_dir = root / "live_coaching_sources" source_dir.mkdir() def write_workbook(summary: str) -> None: workbook_path.write_text( json.dumps( { "source": { "source_id": "source-a", "title": "Source A", "kb_kind": "theory", "license_class": "A", "citation": "source-a", "external_llm_ok": True, }, "chunks": [ { "id": "chunk-a", "summary": summary, "citation": "source-a", "visible_to": ["evaluator"], } ], }, ensure_ascii=False, ), encoding="utf-8", ) with ( patch.object(live_coach, "_REPO_ROOT", root), patch.object(live_coach, "_WORKBOOK_PATH", workbook_path), patch.object(live_coach, "_SOURCE_DIR", source_dir), ): live_coach.clear_local_source_pack_cache() write_workbook("first source pack summary") _, first_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True) first_hash = first_payloads[0]["content_hash"] write_workbook("changed source pack summary") _, stale_payloads = source_pack_sync.build_repo_source_pack_manifest() self.assertEqual(stale_payloads[0]["content_hash"], first_hash) _, refreshed_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True) self.assertNotEqual(refreshed_payloads[0]["content_hash"], first_hash) self.assertEqual(refreshed_payloads[0]["chunks"][0]["chunk_text"], "changed source pack summary") live_coach.clear_local_source_pack_cache() 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): async def test_sync_refresh_after_file_change_reports_next_document_version(self) -> None: with tempfile.TemporaryDirectory() as temp_root: root = Path(temp_root) workbook_path = root / "live_coaching_workbook_0615.json" source_dir = root / "live_coaching_sources" source_dir.mkdir() def write_workbook(summary: str) -> None: workbook_path.write_text( json.dumps( { "source": { "source_id": "source-a", "title": "Source A", "kb_kind": "theory", "license_class": "A", "citation": "source-a", "external_llm_ok": True, }, "chunks": [ { "id": "chunk-a", "summary": summary, "citation": "source-a", "visible_to": ["evaluator"], } ], }, ensure_ascii=False, ), encoding="utf-8", ) with ( patch.object(live_coach, "_REPO_ROOT", root), patch.object(live_coach, "_WORKBOOK_PATH", workbook_path), patch.object(live_coach, "_SOURCE_DIR", source_dir), ): live_coach.clear_local_source_pack_cache() write_workbook("first source pack summary") _, first_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True) first_payload = first_payloads[0] write_workbook("changed source pack summary") conn = _SourcePackConn( { ("source-a", first_payload["doc_uri"]): { "doc_id": 10, "version": 2, "content_hash": first_payload["content_hash"], } } ) result = await source_pack_sync.sync_repo_source_packs(conn, apply=False, refresh=True) self.assertFalse(result.applied) self.assertEqual(result.items[0].previous_version, 2) self.assertEqual(result.items[0].new_version, 3) self.assertFalse(result.items[0].skipped_unchanged) self.assertNotEqual(result.items[0].content_hash, first_payload["content_hash"]) live_coach.clear_local_source_pack_cache() def test_manifest_refresh_invalidates_live_coach_source_cache(self) -> None: with ( patch.object(source_pack_sync.live_coach, "clear_local_source_pack_cache") as clear_cache, patch.object(source_pack_sync.live_coach, "build_rag_source_rows", return_value=[]) as build_rows, patch.object(source_pack_sync.live_coach, "build_rag_index_payloads", return_value=[]) as build_payloads, ): source_pack_sync.build_repo_source_pack_manifest(refresh=True) clear_cache.assert_called_once_with() build_rows.assert_called_once_with() build_payloads.assert_called_once_with() def test_manifest_default_reuses_live_coach_source_cache(self) -> None: with ( patch.object(source_pack_sync.live_coach, "clear_local_source_pack_cache") as clear_cache, patch.object(source_pack_sync.live_coach, "build_rag_source_rows", return_value=[]), patch.object(source_pack_sync.live_coach, "build_rag_index_payloads", return_value=[]), ): source_pack_sync.build_repo_source_pack_manifest() clear_cache.assert_not_called() 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()