음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -8,8 +8,9 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import persona_repository
from . import persona_repository, session_persistence
from .deps import Principal, Role
from .engine_client import GenerateResponse
from .persona_repository import PersonaDraftRecord, PersonaReviewItem
from .routes import personas, sessions
from .services import persona as persona_service
@ -23,6 +24,7 @@ def _principal(role: Role = Role.LEARNER) -> Principal:
email=f"{role.value}@example.test",
display_name=role.value.title(),
consent_at=1.0 if role == Role.LEARNER else None,
profile_completed_at=1.0 if role == Role.LEARNER else None,
)
@ -50,6 +52,7 @@ def _card_row(
"affect_baseline": dict(card.affect_baseline),
"ccd": dict(card.ccd),
"dsm5_dimensional": dict(card.dsm5_dimensional),
"triggers": dict(card.triggers),
"source_provenance": card.source_provenance,
"is_synthetic": card.is_synthetic,
"created_at": "2026-01-01T00:00:00",
@ -83,6 +86,24 @@ class _PersonaCardConn:
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
self.fetchrow_calls.append((query, args))
if "UPDATE app.persona_card" in query:
if "archived AS" in query:
persona_id = str(args[0])
target = next(
(row for row in self.rows if row["persona_id"] == persona_id and row["status"] != "archived"),
None,
)
if target is None:
return None
code = str(target["code"]).upper()
returned: dict[str, Any] | None = None
for row in self.rows:
if str(row["code"]).upper() == code and row["status"] != "archived":
row["status"] = "archived"
row["approved_by"] = None
row["approved_at"] = None
if row["persona_id"] == persona_id:
returned = row
return returned
if "display_name = $4" in query:
persona_id = str(args[0])
next_status = str(args[2])
@ -107,6 +128,7 @@ class _PersonaCardConn:
"dsm5_dimensional": dict(args[14]),
"source_provenance": args[15],
"is_synthetic": bool(args[16]),
"triggers": dict(args[17]),
"approved_by": None,
"approved_at": None,
}
@ -144,16 +166,30 @@ class _PersonaCardConn:
"dsm5_dimensional": dict(args[15]),
"source_provenance": args[16],
"is_synthetic": bool(args[17]),
"created_by": args[18],
"triggers": dict(args[18]),
"created_by": args[19],
"approved_by": None,
"created_at": "2026-01-04T00:00:00",
"approved_at": None,
}
self.rows.append(row)
return row
if "upper(code) = upper($1)" in query and "status IN ('draft', 'review')" in query:
code = str(args[0]).upper()
matches = [
row
for row in self.rows
if str(row["code"]).upper() == code and row["status"] in {"draft", "review"}
]
matches.sort(key=lambda row: int(row["version"]), reverse=True)
return matches[0] if matches else None
if "WHERE persona_id = $1::uuid" in query:
persona_id = str(args[0])
for row in self.rows:
if row["persona_id"] == persona_id and "status = 'approved'" in query:
if row["status"] == "approved":
return row
return None
if row["persona_id"] == persona_id and row["status"] in {"draft", "review"}:
return row
return None
@ -201,6 +237,7 @@ def _draft_payload(
affect_baseline=dict(card.affect_baseline),
ccd=dict(card.ccd),
dsm5_dimensional=dict(card.dsm5_dimensional),
triggers=dict(card.triggers),
source_provenance=card.source_provenance,
is_synthetic=card.is_synthetic,
submit_for_review=submit_for_review,
@ -235,8 +272,34 @@ class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
count = await persona_repository.materialize_seed_personas()
self.assertEqual(count, 7)
inserted_codes = [args[1] for _, args in conn.execute_calls]
persona_card_calls = [
(query, args)
for query, args in conn.execute_calls
if "INSERT INTO app.persona_card" in query
]
voice_map_calls = [
(query, args)
for query, args in conn.execute_calls
if "INSERT INTO app.persona_voice_map" in query
]
inserted_codes = [args[1] for _, args in persona_card_calls]
self.assertEqual(inserted_codes, ["P1", "P2", "P3", "P4", "P5", "P6", "P7"])
self.assertEqual(len(voice_map_calls), 7)
self.assertEqual(voice_map_calls[0][1][2], "coral")
self.assertEqual(voice_map_calls[0][1][3]["preset"], "soft-young-fem")
async def test_materialize_seed_personas_does_not_overwrite_existing_rows(self) -> None:
conn = _PersonaCardConn([])
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
await persona_repository.materialize_seed_personas()
query, _ = conn.execute_calls[0]
self.assertIn("ON CONFLICT (code, version) DO NOTHING", query)
self.assertNotIn("DO UPDATE SET", query)
async def test_catalog_repository_lists_only_approved_personas(self) -> None:
conn = _PersonaCardConn(
@ -325,6 +388,26 @@ class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
get_persona.assert_awaited_once_with("P2")
create_session.assert_not_awaited()
async def test_session_joined_card_hydrates_triggers_for_runtime_prompt(self) -> None:
row = _card_row(
persona_service.P2,
persona_id="00000000-0000-0000-0000-000000000107",
status="approved",
)
row["triggers"] = {
"sore_spots": ["무능하다는 낙인"],
"forbidden": ["비웃음"],
"reaction": "말수가 급격히 줄고 방어한다.",
}
joined_row = {f"card_{key}": value for key, value in row.items()}
card = session_persistence._card_from_joined_session_row(joined_row)
self.assertIsNotNone(card)
assert card is not None
self.assertEqual(card.triggers["sore_spots"], ["무능하다는 낙인"])
self.assertEqual(card.triggers["forbidden"], ["비웃음"])
class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
async def test_review_queue_repository_fetches_draft_and_review_for_teacher(self) -> None:
@ -414,6 +497,69 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(audit_args[1], "persona_draft_create")
self.assertEqual(audit_args[4]["next_status"], "review")
async def test_teacher_clones_approved_persona_into_new_draft_version(self) -> None:
author_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000501"
conn = _PersonaCardConn(
[
_card_row(
persona_service.P2,
persona_id=persona_id,
status="approved",
version=1,
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
record = await persona_repository.create_persona_revision_from_existing(
persona_id=persona_id,
author_id=author_id,
role="teacher",
)
self.assertIsNotNone(record)
assert record is not None
self.assertEqual(record.review.persona_id, persona_id)
self.assertEqual(record.review.code, "P2")
self.assertEqual(record.review.version, 2)
self.assertEqual(record.review.status, "draft")
self.assertEqual(record.card.display_name, persona_service.P2.display_name)
self.assertEqual(conn.fetchval_calls[0][1], ("P2",))
_, audit_args = conn.execute_calls[0]
self.assertEqual(audit_args[1], "persona_revision_create")
self.assertEqual(audit_args[4]["version"], 2)
async def test_clone_existing_approved_persona_reuses_open_draft(self) -> None:
author_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000501"
conn = _PersonaCardConn(
[
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=1),
_card_row(persona_service.P2, persona_id=persona_id, status="draft", version=2),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
record = await persona_repository.create_persona_revision_from_existing(
persona_id=persona_id,
author_id=author_id,
role="admin",
)
self.assertIsNotNone(record)
assert record is not None
self.assertEqual(record.review.version, 2)
self.assertEqual(record.review.status, "draft")
self.assertEqual(conn.fetchval_calls, [])
self.assertEqual(conn.execute_calls, [])
async def test_teacher_updates_persona_draft_and_submits_review(self) -> None:
author_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000502"
@ -519,6 +665,153 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(args["submit_for_review"])
self.assertEqual(args["card"].code, "P2")
async def test_teacher_generates_persona_draft_from_masked_source_notes(self) -> None:
captured: list[Any] = []
evidence = [
personas.PersonaGenerationEvidence(
chunk_id=44,
source_id="persona_authoring_test",
score=0.88,
kb_kind="supervisor_pattern",
heading_path="상담 기록",
excerpt="내담자 연락처 [PHONE]. 최근 이별 뒤 관계 불안을 호소함.",
)
]
source_ref = personas.PersonaSourceDocumentResponse(
source_id="persona_authoring_test",
doc_id=9,
doc_uri="persona-authoring/test/inline.txt",
title="상담 기록",
source_kind="client_record",
kb_kind="diagnostic",
license_class="B",
external_llm_ok=True,
content_hash="hash",
chunk_count=1,
chunks_indexed=1,
embedded=True,
pii_entities_masked=["PHONE"],
)
async def fake_generate(req: Any) -> GenerateResponse:
captured.append(req)
return GenerateResponse(
text="",
model="test-model",
provider="test",
structured={
"draft": {
"code": "P8",
"display_name": "자료 기반 내담자",
"difficulty": "moderate",
"theory_target": ["humanistic"],
"demographics": {"age_band": "20s"},
"presenting": {"complaint": "관계 갈등"},
"history": {"precipitant": "최근 이별"},
"big5": {"O": 0.5, "C": 0.5, "E": 0.4, "A": 0.5, "N": 0.7},
"resistance": {
"base_resistance": 0.45,
"unlock_rate": 0.12,
"decay_floor": 0.05,
},
"speech_style": {"register": "polite"},
"affect_baseline": {
"negative_affect": 0.55,
"hopelessness": 0.2,
"anxiety": 0.5,
"suicide_ideation_stage": 1,
},
"ccd": {"core_belief": "나는 버려질 수 있다"},
"dsm5_dimensional": {"anxiety": 0.5},
"triggers": {
"sore_spots": ["버림받음"],
"forbidden": ["그냥 잊으라는 조언"],
"reaction": "말수가 줄어든다.",
},
"source_provenance": "masked client_record",
"is_synthetic": True,
},
"source_summary": "관계 상실 후 불안이 높아진 사례",
"warnings": ["임상팀 검수 필요"],
},
)
with (
patch.object(personas, "_register_persona_source_document", AsyncMock(return_value=source_ref)),
patch.object(personas, "_retrieve_persona_generation_evidence", AsyncMock(return_value=evidence)),
patch.object(personas.engine_client, "generate", fake_generate),
):
response = await personas.generate_persona_draft_route(
personas.PersonaDraftGenerateRequest(
source_text="내담자 휴대폰 010-1234-5678. 최근 이별 뒤 관계 불안을 호소함.",
source_kind="client_record",
code_hint="P8",
),
_principal(Role.TEACHER),
)
self.assertEqual(response.draft.code, "P8")
self.assertEqual(response.draft.triggers["sore_spots"], ["버림받음"])
self.assertEqual(response.source_summary, "관계 상실 후 불안이 높아진 사례")
self.assertEqual(response.pii_entities_masked, ["PHONE"])
self.assertEqual(response.source_references[0].source_id, "persona_authoring_test")
self.assertEqual(response.evidence_chunks[0].chunk_id, 44)
self.assertIn("RAG sources=persona_authoring_test", response.draft.source_provenance)
self.assertIn("chunks=44", response.draft.source_provenance)
self.assertEqual(len(captured), 1)
sent_text = captured[0].messages[-1].content
self.assertNotIn("010-1234-5678", sent_text)
self.assertIn("[PHONE]", sent_text)
self.assertIn("RAG 근거 청크", sent_text)
async def test_teacher_registers_persona_source_as_evaluator_only_kb_document(self) -> None:
conn = _PersonaCardConn([])
captured_index: list[Any] = []
async def fake_index_document(_conn: Any, req: Any) -> Any:
captured_index.append(req)
return personas.rag.IndexResult(
doc_id=42,
chunks_indexed=len(req.chunks),
skipped_unchanged=False,
embedded=False,
degraded=True,
)
with (
patch.object(personas, "acquire", lambda **_: _Acquire(conn)),
patch.object(personas.rag, "index_document", fake_index_document),
):
response = await personas.create_persona_source_route(
personas.PersonaSourceDocumentRequest(
filename="case-note.txt",
source_kind="client_record",
text=(
"내담자 전화번호 010-1234-5678. 관계 단절 이후 불안을 호소함.\n\n"
"상담 장면에서는 조언을 들으면 침묵이 늘어남."
),
),
_principal(Role.TEACHER),
)
self.assertEqual(response.doc_id, 42)
self.assertEqual(response.source_kind, "client_record")
self.assertEqual(response.kb_kind, "diagnostic")
self.assertEqual(response.license_class, "B")
self.assertTrue(response.external_llm_ok)
self.assertEqual(response.pii_entities_masked, ["PHONE"])
self.assertEqual(len(captured_index), 1)
index_req = captured_index[0]
self.assertTrue(index_req.source_id.startswith("persona_authoring_"))
self.assertNotIn("010-1234-5678", index_req.chunks[0]["chunk_text"])
self.assertIn("[PHONE]", index_req.chunks[0]["chunk_text"])
self.assertEqual(index_req.chunks[0]["visible_to"], ["evaluator"])
self.assertEqual(index_req.chunks[0]["sensitivity"], 2)
self.assertEqual(index_req.chunks[0]["meta"]["source_kind"], "client_record")
source_query, source_args = conn.execute_calls[0]
self.assertIn("INSERT INTO kb.source", source_query)
self.assertEqual(source_args[2], "diagnostic")
async def test_learner_cannot_create_persona_draft_route(self) -> None:
with patch.object(
personas,
@ -681,6 +974,97 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(audit_args[1], "persona_reject")
self.assertEqual(audit_args[4]["next_status"], "draft")
async def test_archive_persona_family_archives_all_versions_and_audits(self) -> None:
archiver_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000501"
conn = _PersonaCardConn(
[
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=1),
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=2),
_card_row(
persona_service.P3,
persona_id="00000000-0000-0000-0000-000000000503",
status="approved",
version=1,
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
archived = await persona_repository.archive_persona_family(
persona_id=persona_id,
archiver_id=archiver_id,
role="teacher",
)
catalog = await persona_repository.list_approved_personas()
self.assertIsNotNone(archived)
assert archived is not None
self.assertEqual(archived.status, "archived")
self.assertTrue(
all(row["status"] == "archived" for row in conn.rows if str(row["code"]).upper() == "P2")
)
self.assertEqual([entry.card.code for entry in catalog], ["P3"])
_, audit_args = conn.execute_calls[0]
self.assertEqual(audit_args[1], "persona_archive")
self.assertEqual(audit_args[4]["scope"], "code_family")
async def test_archive_route_blocks_learner_before_repository_access(self) -> None:
with patch.object(
personas,
"archive_persona_family",
AsyncMock(side_effect=AssertionError("learner must not archive personas")),
) as archive:
with self.assertRaises(HTTPException) as caught:
await personas.archive_persona_route(
"00000000-0000-0000-0000-000000000501",
_principal(Role.LEARNER),
)
self.assertEqual(caught.exception.status_code, 403)
archive.assert_not_awaited()
async def test_teacher_revision_route_returns_editable_draft_detail(self) -> None:
record = PersonaDraftRecord(
review=PersonaReviewItem(
persona_id="00000000-0000-0000-0000-000000000503",
code="P3",
version=2,
status="draft",
display_name=persona_service.P3.display_name,
difficulty=persona_service.P3.difficulty,
theory_target=list(persona_service.P3.theory_target),
source_provenance=persona_service.P3.source_provenance,
is_synthetic=persona_service.P3.is_synthetic,
created_at="2026-01-04T00:00:00",
approved_at=None,
),
card=persona_service.P3,
)
with patch.object(
personas,
"create_persona_revision_from_existing",
AsyncMock(return_value=record),
) as create_revision:
response = await personas.create_persona_revision_route(
"00000000-0000-0000-0000-000000000503",
personas.PersonaRevisionRequest(),
_principal(Role.TEACHER),
)
self.assertEqual(response.status, "draft")
self.assertEqual(response.presenting, persona_service.P3.presenting)
create_revision.assert_awaited_once_with(
persona_id="00000000-0000-0000-0000-000000000503",
author_id="00000000-0000-0000-0000-000000000901",
role="teacher",
submit_for_review=False,
)
async def test_review_update_ignores_already_approved_persona(self) -> None:
conn = _PersonaCardConn(
[