음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue