377 lines
16 KiB
Python
377 lines
16 KiB
Python
"""라이브 코칭 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_local_grounding_preserves_each_source_license_policy(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_pack(
|
|
path: Path,
|
|
*,
|
|
source_id: str,
|
|
license_class: str,
|
|
external_llm_ok: bool,
|
|
) -> None:
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"source": {
|
|
"source_id": source_id,
|
|
"title": source_id,
|
|
"kb_kind": "theory",
|
|
"license_class": license_class,
|
|
"external_llm_ok": external_llm_ok,
|
|
},
|
|
"chunks": [
|
|
{
|
|
"id": f"{source_id}-chunk",
|
|
"summary": f"{source_id} policy guidance",
|
|
"keywords": ["policy"],
|
|
}
|
|
],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
write_pack(
|
|
workbook_path,
|
|
source_id="allow-a",
|
|
license_class="A",
|
|
external_llm_ok=True,
|
|
)
|
|
for filename, source_id, license_class, external_llm_ok in (
|
|
("allow-b.json", "allow-b", "B", True),
|
|
("block-c.json", "block-c", "C", True),
|
|
("block-d.json", "block-d", "D", True),
|
|
("block-false.json", "block-false", "A", False),
|
|
):
|
|
write_pack(
|
|
source_dir / filename,
|
|
source_id=source_id,
|
|
license_class=license_class,
|
|
external_llm_ok=external_llm_ok,
|
|
)
|
|
|
|
item = live_coach.LiveCoachInput(
|
|
session_id="source-policy-session",
|
|
turn_seq=1,
|
|
stage="exploration",
|
|
effective_openness=0.5,
|
|
theory_mode="humanistic",
|
|
persona_code="P1",
|
|
persona_name="서연",
|
|
learner_text="policy",
|
|
)
|
|
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()
|
|
try:
|
|
grounding = live_coach._local_reference_grounding(item)
|
|
finally:
|
|
live_coach.clear_local_source_pack_cache()
|
|
|
|
by_source = {entry.source_id: entry for entry in grounding}
|
|
self.assertEqual(set(by_source), {"allow-a", "allow-b"})
|
|
self.assertEqual(by_source["allow-a"].license_class, "A")
|
|
self.assertEqual(by_source["allow-b"].license_class, "B")
|
|
self.assertTrue(all(entry.external_llm_ok for entry in grounding))
|
|
|
|
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()
|