전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -13,10 +13,18 @@ from .config import settings
|
|||
from . import session_persistence, turn_runtime
|
||||
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
|
||||
from .deps import Principal, Role
|
||||
from .engine_client import EngineError
|
||||
from .engine_client import EngineError, GenerateResponse
|
||||
from .routes import sessions
|
||||
from .routes import voice as voice_routes
|
||||
from .services import guardrail, live_coach, memory, orchestrator, persona as persona_service, rag, state_machine
|
||||
from .services import (
|
||||
guardrail,
|
||||
live_coach,
|
||||
memory,
|
||||
orchestrator,
|
||||
persona as persona_service,
|
||||
rag,
|
||||
state_machine,
|
||||
)
|
||||
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
|
||||
from .store import InProcSession, TurnRecord, store
|
||||
|
||||
|
|
@ -88,22 +96,32 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
store._sessions.clear()
|
||||
sessions._RECALL_CACHE.clear()
|
||||
sessions._KB_CUES_CACHE.clear()
|
||||
session_persistence._LIVE_COACH_EVENT_CACHE.clear()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
store._sessions.clear()
|
||||
sessions._RECALL_CACHE.clear()
|
||||
sessions._KB_CUES_CACHE.clear()
|
||||
session_persistence._LIVE_COACH_EVENT_CACHE.clear()
|
||||
|
||||
async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(self) -> None:
|
||||
async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
sess.ended = True
|
||||
sess.ended_at = 1_000.0
|
||||
|
||||
with (
|
||||
patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=sess)),
|
||||
patch.object(sessions, "_end_persisted_session", AsyncMock(return_value=None)),
|
||||
patch.object(sessions, "_schedule_session_evaluation") as schedule_session_evaluation,
|
||||
patch.object(
|
||||
sessions, "_load_session_or_404", AsyncMock(return_value=sess)
|
||||
),
|
||||
patch.object(
|
||||
sessions, "_end_persisted_session", AsyncMock(return_value=None)
|
||||
),
|
||||
patch.object(
|
||||
sessions, "_schedule_session_evaluation"
|
||||
) as schedule_session_evaluation,
|
||||
):
|
||||
response = await sessions.end_session(sess.session_id, principal)
|
||||
|
||||
|
|
@ -151,10 +169,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
provider_events=[{"type": "sigh", "confidence": 0.82}],
|
||||
)
|
||||
|
||||
with patch.object(session_persistence, "get_pool", return_value=object()), patch.object(
|
||||
session_persistence,
|
||||
"acquire",
|
||||
return_value=FakeAcquire(conn),
|
||||
with (
|
||||
patch.object(session_persistence, "get_pool", return_value=object()),
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"acquire",
|
||||
return_value=FakeAcquire(conn),
|
||||
),
|
||||
):
|
||||
ok = await session_persistence.append_turn(
|
||||
session_id="turn-persistence-session",
|
||||
|
|
@ -169,7 +190,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(conn.insert_args[17], list(turn.visible_to))
|
||||
self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000009999")
|
||||
|
||||
async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None:
|
||||
async def test_generate_turn_engine_failure_does_not_append_learner_turn(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
|
|
@ -296,11 +319,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(response.client_reply, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.")
|
||||
self.assertEqual(
|
||||
response.client_reply,
|
||||
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
|
||||
)
|
||||
self.assertEqual(len(sess.turns), 2)
|
||||
learner_turn, client_turn = sess.turns
|
||||
self.assertIsNone(learner_turn.llm_provider)
|
||||
self.assertEqual(client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.")
|
||||
self.assertEqual(
|
||||
client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요."
|
||||
)
|
||||
self.assertNotIn("김서연", client_turn.text_masked)
|
||||
self.assertNotIn("한신대학교", client_turn.text_masked)
|
||||
self.assertNotIn("상담심리학과", client_turn.text_masked)
|
||||
|
|
@ -312,7 +340,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(client_turn.tokens_out, 23)
|
||||
self.assertEqual(client_turn.cost_usd, 0.012345)
|
||||
|
||||
async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(self) -> None:
|
||||
async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
|
|
@ -349,7 +379,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
result = orchestrator.TurnResult(
|
||||
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
|
||||
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
|
||||
effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
|
||||
effective_openness=ctx.state_after.effective_openness
|
||||
if ctx.state_after
|
||||
else 0.0,
|
||||
client_reply=None,
|
||||
safety_flagged=True,
|
||||
state_after=ctx.state_after or sess.state,
|
||||
|
|
@ -368,10 +400,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def __aenter__(self) -> FakeConn:
|
||||
return FakeConn()
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
async def __aexit__(
|
||||
self, exc_type: object, exc: object, tb: object
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()) as acquire:
|
||||
with patch.object(
|
||||
turn_runtime.db, "acquire", return_value=FakeAcquire()
|
||||
) as acquire:
|
||||
await turn_runtime.record_safety_event(sess, ctx, result)
|
||||
|
||||
acquire.assert_called_once_with(ai_context=True)
|
||||
|
|
@ -394,7 +430,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(detail["crisis_resource"]["number"], "109")
|
||||
self.assertEqual(detail["alert_status"], "teacher_dashboard")
|
||||
|
||||
async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(self) -> None:
|
||||
async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
ctx = orchestrator.prepare_turn(
|
||||
|
|
@ -408,7 +446,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
result = orchestrator.TurnResult(
|
||||
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
|
||||
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
|
||||
effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
|
||||
effective_openness=ctx.state_after.effective_openness
|
||||
if ctx.state_after
|
||||
else 0.0,
|
||||
client_reply=None,
|
||||
safety_flagged=True,
|
||||
state_after=ctx.state_after or sess.state,
|
||||
|
|
@ -425,7 +465,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def __aenter__(self) -> FakeConn:
|
||||
return FakeConn()
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
async def __aexit__(
|
||||
self, exc_type: object, exc: object, tb: object
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
previous_environment = settings.environment
|
||||
|
|
@ -480,17 +522,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(client_turn.tokens_out, 37)
|
||||
self.assertEqual(client_turn.cost_usd, 0.023456)
|
||||
|
||||
async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(self) -> None:
|
||||
async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
def should_not_stream(*args, **kwargs):
|
||||
raise AssertionError("stream engine must not be called for learner_real crisis")
|
||||
raise AssertionError(
|
||||
"stream engine must not be called for learner_real crisis"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(sessions.engine_client, "stream", should_not_stream),
|
||||
patch.object(turn_runtime, "record_completed_turn", new_callable=AsyncMock) as completed_turn,
|
||||
patch.object(turn_runtime, "record_safety_event", new_callable=AsyncMock) as safety_event,
|
||||
patch.object(
|
||||
turn_runtime, "record_completed_turn", new_callable=AsyncMock
|
||||
) as completed_turn,
|
||||
patch.object(
|
||||
turn_runtime, "record_safety_event", new_callable=AsyncMock
|
||||
) as safety_event,
|
||||
):
|
||||
response = await sessions.stream_turn(
|
||||
sess.session_id,
|
||||
|
|
@ -511,7 +561,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertTrue(saved_result.conversation_stopped)
|
||||
self.assertEqual(saved_result.crisis_resource["number"], "109")
|
||||
|
||||
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
|
||||
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
|
|
@ -543,10 +595,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
"appropriateness_note": f"응답 반영: {client_reply}",
|
||||
}
|
||||
|
||||
with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
|
||||
sessions.evaluator,
|
||||
"make_eval_hook",
|
||||
return_value=fake_eval_hook,
|
||||
with (
|
||||
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
|
||||
patch.object(
|
||||
sessions.evaluator,
|
||||
"make_eval_hook",
|
||||
return_value=fake_eval_hook,
|
||||
),
|
||||
):
|
||||
response = await sessions.stream_turn(
|
||||
sess.session_id,
|
||||
|
|
@ -560,10 +615,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(learner_turn.speaker, "counselor")
|
||||
self.assertIsNotNone(learner_turn.evaluation)
|
||||
self.assertEqual(learner_turn.evaluation["appropriateness"], "pos")
|
||||
self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"])
|
||||
self.assertIn(
|
||||
"조금 말해볼게요", learner_turn.evaluation["appropriateness_note"]
|
||||
)
|
||||
self.assertIsNone(client_turn.evaluation)
|
||||
|
||||
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(self) -> None:
|
||||
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
|
|
@ -589,10 +648,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def failing_eval_hook(ctx, client_reply):
|
||||
raise RuntimeError("김서연 평가 timeout 010-1234-5678")
|
||||
|
||||
with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
|
||||
sessions.evaluator,
|
||||
"make_eval_hook",
|
||||
return_value=failing_eval_hook,
|
||||
with (
|
||||
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
|
||||
patch.object(
|
||||
sessions.evaluator,
|
||||
"make_eval_hook",
|
||||
return_value=failing_eval_hook,
|
||||
),
|
||||
):
|
||||
response = await sessions.stream_turn(
|
||||
sess.session_id,
|
||||
|
|
@ -622,13 +684,17 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIsNotNone(review_turn.note)
|
||||
assert review_turn.note is not None
|
||||
self.assertEqual(review_turn.note.title, "턴 직후 평가 실패")
|
||||
self.assertIn("fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body)
|
||||
self.assertIn(
|
||||
"fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body
|
||||
)
|
||||
self.assertIn("AI 평가 재시도가 필요합니다", review_turn.note.body)
|
||||
self.assertNotIn("RuntimeError", review_turn.note.body)
|
||||
self.assertNotIn("김서연", review_turn.note.body)
|
||||
self.assertNotIn("010-1234-5678", review_turn.note.body)
|
||||
|
||||
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(self) -> None:
|
||||
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
sess.turns.append(
|
||||
|
|
@ -638,18 +704,24 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
stage=sess.state.stage.value,
|
||||
text="그냥 학교는 가야 하는 거 아닐까요?",
|
||||
text_masked="그냥 학교는 가야 하는 거 아닐까요?",
|
||||
evaluation={"appropriateness": "warn", "appropriateness_note": "조언이 빠름"},
|
||||
evaluation={
|
||||
"appropriateness": "warn",
|
||||
"appropriateness_note": "조언이 빠름",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch.object(
|
||||
sessions.engine_client,
|
||||
"generate",
|
||||
AsyncMock(side_effect=EngineError("offline")),
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch.object(
|
||||
sessions.engine_client,
|
||||
"generate",
|
||||
AsyncMock(side_effect=EngineError("offline")),
|
||||
),
|
||||
):
|
||||
response = await sessions.live_coach_turn(
|
||||
sess.session_id,
|
||||
|
|
@ -667,7 +739,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn("조언", response.title + response.message)
|
||||
self.assertTrue(response.next_utterance)
|
||||
self.assertTrue(response.sources)
|
||||
self.assertEqual(response.sources[0].source_id, "workbook_0615_case_conceptualization")
|
||||
self.assertEqual(
|
||||
response.sources[0].source_id, "workbook_0615_case_conceptualization"
|
||||
)
|
||||
history = await sessions.list_live_coach_history(sess.session_id, principal)
|
||||
self.assertEqual(history.source, "runtime")
|
||||
self.assertEqual(len(history.events), 1)
|
||||
|
|
@ -676,11 +750,85 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(history.events[0].suggestion.status, "degraded")
|
||||
self.assertEqual(history.events[0].suggestion.title, response.title)
|
||||
self.assertIn("학교", history.events[0].learner_text_excerpt or "")
|
||||
session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = "unknown-stage"
|
||||
legacy_history = await sessions.list_live_coach_history(sess.session_id, principal)
|
||||
session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = (
|
||||
"unknown-stage"
|
||||
)
|
||||
legacy_history = await sessions.list_live_coach_history(
|
||||
sess.session_id, principal
|
||||
)
|
||||
self.assertIsNone(legacy_history.events[0].stage)
|
||||
|
||||
async def test_live_coach_rag_grounding_preserves_source_pack_metadata(self) -> None:
|
||||
async def test_live_coach_degrades_when_llm_audit_is_unavailable(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
sess.turns.append(
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="counselor",
|
||||
stage=sess.state.stage.value,
|
||||
text="그 마음이 컸겠네요.",
|
||||
text_masked="그 마음이 컸겠네요.",
|
||||
evaluation={
|
||||
"appropriateness": "pos",
|
||||
"appropriateness_note": "감정 반영",
|
||||
},
|
||||
)
|
||||
)
|
||||
engine_response = GenerateResponse(
|
||||
text="",
|
||||
model="fake-live-coach-model",
|
||||
provider="fake-provider",
|
||||
tokens_in=11,
|
||||
tokens_out=7,
|
||||
cost_usd=0.01,
|
||||
structured={
|
||||
"tone": "pos",
|
||||
"focus": "emotion",
|
||||
"title": "엔진 생성 코칭",
|
||||
"message": "감정 반영을 이어가세요.",
|
||||
"next_utterance": "그 마음이 가장 컸던 순간이 언제였나요?",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch.object(
|
||||
sessions.engine_client,
|
||||
"generate",
|
||||
AsyncMock(return_value=engine_response),
|
||||
) as generate,
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"record_llm_call_audit",
|
||||
AsyncMock(return_value=False),
|
||||
) as audit,
|
||||
):
|
||||
response = await sessions.live_coach_turn(
|
||||
sess.session_id,
|
||||
sessions.LiveCoachRequest(
|
||||
learner_text="그 마음이 컸겠네요.",
|
||||
client_reply="네, 아무도 몰라주는 것 같았어요.",
|
||||
turn_seq=1,
|
||||
),
|
||||
principal,
|
||||
)
|
||||
|
||||
generate.assert_awaited_once()
|
||||
audit.assert_awaited_once()
|
||||
self.assertEqual(response.status, "degraded")
|
||||
self.assertNotEqual(response.title, "엔진 생성 코칭")
|
||||
self.assertIn("응답 검증 기록", response.rationale or "")
|
||||
history = await sessions.list_live_coach_history(sess.session_id, principal)
|
||||
self.assertEqual(len(history.events), 1)
|
||||
self.assertEqual(history.events[0].suggestion.status, "degraded")
|
||||
|
||||
async def test_live_coach_rag_grounding_preserves_source_pack_metadata(
|
||||
self,
|
||||
) -> None:
|
||||
retrieval = rag.RetrievalResult(
|
||||
chunks=[
|
||||
rag.RetrievedChunk(
|
||||
|
|
@ -706,8 +854,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
|
||||
with (
|
||||
patch.object(sessions.db, "acquire", return_value=_AsyncConnContext(object())),
|
||||
patch.object(sessions.rag, "retrieve_eval_grounding", AsyncMock(return_value=retrieval)),
|
||||
patch.object(
|
||||
sessions.db, "acquire", return_value=_AsyncConnContext(object())
|
||||
),
|
||||
patch.object(
|
||||
sessions.rag,
|
||||
"retrieve_eval_grounding",
|
||||
AsyncMock(return_value=retrieval),
|
||||
),
|
||||
patch.object(sessions.rag, "log_retrieval", AsyncMock()),
|
||||
):
|
||||
grounding = await sessions._retrieve_live_coach_grounding(
|
||||
|
|
@ -740,19 +894,23 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
detail="live coach event save persistence unavailable; runtime fallback is disabled in prod",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch.object(
|
||||
sessions.live_coach,
|
||||
"generate_live_coaching",
|
||||
AsyncMock(return_value=suggestion),
|
||||
), patch.object(
|
||||
session_persistence,
|
||||
"save_live_coach_event",
|
||||
AsyncMock(side_effect=save_error),
|
||||
) as save_event:
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch.object(
|
||||
sessions.live_coach,
|
||||
"generate_live_coaching",
|
||||
AsyncMock(return_value=suggestion),
|
||||
),
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"save_live_coach_event",
|
||||
AsyncMock(side_effect=save_error),
|
||||
) as save_event,
|
||||
):
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.live_coach_turn(
|
||||
sess.session_id,
|
||||
|
|
@ -797,7 +955,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(coded.stage, "라포")
|
||||
self.assertIsNone(invalid.stage)
|
||||
|
||||
async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(self) -> None:
|
||||
async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(
|
||||
self,
|
||||
) -> None:
|
||||
item = live_coach.LiveCoachInput(
|
||||
session_id="risk-coach-session",
|
||||
turn_seq=3,
|
||||
|
|
@ -809,7 +969,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
learner_text="죽고 싶다는 생각이 들 때도 있나요?",
|
||||
client_reply="가끔 그런 생각이 들어요.",
|
||||
recent_turns=[],
|
||||
evaluation={"appropriateness": "warn", "appropriateness_note": "위험사정 필요"},
|
||||
evaluation={
|
||||
"appropriateness": "warn",
|
||||
"appropriateness_note": "위험사정 필요",
|
||||
},
|
||||
)
|
||||
|
||||
engine = SimpleNamespace(generate=AsyncMock(side_effect=EngineError("offline")))
|
||||
|
|
@ -818,7 +981,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(suggestion.focus, "risk")
|
||||
source_ids = [source.source_id for source in suggestion.sources]
|
||||
self.assertIn("official_suicide_risk_guidelines", source_ids)
|
||||
official = next(source for source in suggestion.sources if source.source_id == "official_suicide_risk_guidelines")
|
||||
official = next(
|
||||
source
|
||||
for source in suggestion.sources
|
||||
if source.source_id == "official_suicide_risk_guidelines"
|
||||
)
|
||||
self.assertEqual(official.source_type, "official_guideline")
|
||||
self.assertTrue(official.citation)
|
||||
engine.generate.assert_called_once()
|
||||
|
|
@ -865,19 +1032,27 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
coro.close()
|
||||
return None
|
||||
|
||||
with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object(
|
||||
sessions.session_persistence,
|
||||
"get_case_context",
|
||||
AsyncMock(return_value=case_context),
|
||||
), patch.object(
|
||||
sessions,
|
||||
"_build_seed_recall",
|
||||
AsyncMock(return_value=recall),
|
||||
), patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
), patch.object(sessions.asyncio, "create_task", close_background):
|
||||
with (
|
||||
patch.object(
|
||||
sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"get_case_context",
|
||||
AsyncMock(return_value=case_context),
|
||||
),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_build_seed_recall",
|
||||
AsyncMock(return_value=recall),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
),
|
||||
patch.object(sessions.asyncio, "create_task", close_background),
|
||||
):
|
||||
response = await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code=card.code),
|
||||
principal,
|
||||
|
|
@ -888,7 +1063,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(response.recall_summary, recall.recall_summary)
|
||||
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall)
|
||||
|
||||
async def test_next_session_turn_injects_seed_recall_into_engine_messages(self) -> None:
|
||||
async def test_next_session_turn_injects_seed_recall_into_engine_messages(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
card = persona_service.P1
|
||||
case_context = sessions.session_persistence.CaseContext(
|
||||
|
|
@ -931,7 +1108,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def __aenter__(self) -> FakeConn:
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
async def __aexit__(
|
||||
self, exc_type: object, exc: object, tb: object
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def fake_create_session(**kwargs):
|
||||
|
|
@ -951,19 +1130,28 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
coro.close()
|
||||
return None
|
||||
|
||||
with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object(
|
||||
sessions.session_persistence,
|
||||
"get_case_context",
|
||||
AsyncMock(return_value=case_context),
|
||||
), patch.object(sessions.db, "get_pool", return_value=object()), patch.object(
|
||||
sessions.db,
|
||||
"acquire",
|
||||
return_value=FakeAcquire(FakeConn()),
|
||||
), patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
), patch.object(sessions.asyncio, "create_task", close_background):
|
||||
with (
|
||||
patch.object(
|
||||
sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"get_case_context",
|
||||
AsyncMock(return_value=case_context),
|
||||
),
|
||||
patch.object(sessions.db, "get_pool", return_value=object()),
|
||||
patch.object(
|
||||
sessions.db,
|
||||
"acquire",
|
||||
return_value=FakeAcquire(FakeConn()),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
),
|
||||
patch.object(sessions.asyncio, "create_task", close_background),
|
||||
):
|
||||
response = await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code=card.code),
|
||||
principal,
|
||||
|
|
@ -985,10 +1173,15 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
state_after=ctx.state_after,
|
||||
)
|
||||
|
||||
with patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=started)), patch.object(
|
||||
sessions.orchestrator,
|
||||
"run_turn_generate",
|
||||
successful_turn,
|
||||
with (
|
||||
patch.object(
|
||||
sessions, "_load_session_or_404", AsyncMock(return_value=started)
|
||||
),
|
||||
patch.object(
|
||||
sessions.orchestrator,
|
||||
"run_turn_generate",
|
||||
successful_turn,
|
||||
),
|
||||
):
|
||||
await sessions.submit_turn(
|
||||
response.session_id,
|
||||
|
|
@ -1006,14 +1199,20 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertNotIn("김서연", message_blob)
|
||||
self.assertNotIn("박민수", message_blob)
|
||||
|
||||
async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
|
||||
async def test_start_session_requires_learner_consent_before_catalog_lookup(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
principal.consent_at = None
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"get_catalog_persona",
|
||||
AsyncMock(side_effect=AssertionError("consent gate must run before catalog lookup")),
|
||||
AsyncMock(
|
||||
side_effect=AssertionError(
|
||||
"consent gate must run before catalog lookup"
|
||||
)
|
||||
),
|
||||
) as get_persona:
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.start_session(
|
||||
|
|
@ -1025,14 +1224,20 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(caught.exception.detail, "consent_required")
|
||||
get_persona.assert_not_awaited()
|
||||
|
||||
async def test_start_session_requires_onboarding_before_consent_and_catalog_lookup(self) -> None:
|
||||
async def test_start_session_requires_onboarding_before_consent_and_catalog_lookup(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
principal.profile_completed_at = None
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"get_catalog_persona",
|
||||
AsyncMock(side_effect=AssertionError("onboarding gate must run before catalog lookup")),
|
||||
AsyncMock(
|
||||
side_effect=AssertionError(
|
||||
"onboarding gate must run before catalog lookup"
|
||||
)
|
||||
),
|
||||
) as get_persona:
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.start_session(
|
||||
|
|
@ -1121,13 +1326,17 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual([event.event for event in events], ["error"])
|
||||
self.assertIn("engine unavailable", events[0].data["detail"])
|
||||
|
||||
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
|
||||
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
async def failing_stream(*args, **kwargs):
|
||||
yield orchestrator.StreamEvent("token", {"text": "부분 응답"})
|
||||
yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"})
|
||||
yield orchestrator.StreamEvent(
|
||||
"error", {"detail": "engine unavailable: stream"}
|
||||
)
|
||||
|
||||
with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream):
|
||||
response = await sessions.stream_turn(
|
||||
|
|
@ -1147,7 +1356,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.client_state = voice_routes.WebSocketState.CONNECTED
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
import json
|
||||
|
||||
self.messages.append(json.loads(data))
|
||||
|
||||
|
|
@ -1162,10 +1370,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
):
|
||||
await voice_routes._run_turn_and_speak(
|
||||
websocket, # type: ignore[arg-type]
|
||||
session_id=sess.session_id,
|
||||
principal=principal,
|
||||
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
|
||||
learner_text="음성 실패 발화",
|
||||
voice_routes.VoiceSessionContext(
|
||||
session_id=sess.session_id,
|
||||
principal=principal,
|
||||
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
|
||||
),
|
||||
voice_routes.VoiceTurnInput(learner_text="음성 실패 발화"),
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
|
|
@ -1186,7 +1396,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.client_state = voice_routes.WebSocketState.CONNECTED
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
import json
|
||||
|
||||
self.messages.append(json.loads(data))
|
||||
|
||||
|
|
@ -1217,37 +1426,53 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
websocket = FakeWebSocket()
|
||||
audio = b"\x00\x80" * 1600
|
||||
|
||||
with patch.object(
|
||||
voice_routes.voice_service,
|
||||
"transcribe",
|
||||
AsyncMock(
|
||||
return_value=TranscriptResult(
|
||||
text="오늘은 좀 힘들었어요.",
|
||||
duration=2.0,
|
||||
provider_events=[
|
||||
{"kind": "voice_activity", "start_ms": 10, "raw_text": "drop"},
|
||||
],
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"transcribe",
|
||||
AsyncMock(
|
||||
return_value=TranscriptResult(
|
||||
text="오늘은 좀 힘들었어요.",
|
||||
duration=2.0,
|
||||
provider_events=[
|
||||
{
|
||||
"kind": "voice_activity",
|
||||
"start_ms": 10,
|
||||
"raw_text": "drop",
|
||||
},
|
||||
],
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.orchestrator,
|
||||
"run_turn_generate",
|
||||
successful_turn,
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"synthesize_stream",
|
||||
fake_synthesize_stream,
|
||||
),
|
||||
), patch.object(
|
||||
voice_routes.orchestrator,
|
||||
"run_turn_generate",
|
||||
successful_turn,
|
||||
), patch.object(
|
||||
voice_routes.voice_service,
|
||||
"synthesize_stream",
|
||||
fake_synthesize_stream,
|
||||
):
|
||||
await voice_routes._handle_utterance(
|
||||
websocket, # type: ignore[arg-type]
|
||||
session_id=sess.session_id,
|
||||
principal=principal,
|
||||
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
|
||||
audio=audio,
|
||||
fmt="webm",
|
||||
silence_ms=1234,
|
||||
barge_in=True,
|
||||
provider_events=[{"type": "sigh", "confidence": 0.82, "text": "drop"}],
|
||||
voice_routes.VoiceSessionContext(
|
||||
session_id=sess.session_id,
|
||||
principal=principal,
|
||||
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
|
||||
),
|
||||
voice_routes.VoiceAudioInput(
|
||||
audio=audio,
|
||||
fmt="webm",
|
||||
prosody=voice_routes.VoiceProsody(
|
||||
silence_ms=1234,
|
||||
barge_in=True,
|
||||
provider_events=[
|
||||
{"type": "sigh", "confidence": 0.82, "text": "drop"}
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(len(sess.turns), 2)
|
||||
|
|
@ -1276,7 +1501,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIsNone(client_turn.audio_ref)
|
||||
self.assertEqual(client_turn.provider_events, [])
|
||||
self.assertEqual(client_turn.llm_provider, "claude_cli")
|
||||
self.assertTrue(any(message.get("type") == "tts_end" for message in websocket.messages))
|
||||
self.assertTrue(
|
||||
any(message.get("type") == "tts_end" for message in websocket.messages)
|
||||
)
|
||||
|
||||
async def test_review_exposes_voice_nonverbal_events_on_learner_turn(self) -> None:
|
||||
principal = _principal()
|
||||
|
|
@ -1332,7 +1559,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
silence_ms=2500,
|
||||
speech_rate=180.0,
|
||||
barge_in=True,
|
||||
provider_events=[{"event_type": "cry", "category": "paralinguistic"}],
|
||||
provider_events=[
|
||||
{"event_type": "cry", "category": "paralinguistic"}
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
@ -1347,7 +1576,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
self.assertEqual(learner_turn.nonverbal[0].label, "침묵")
|
||||
self.assertEqual(learner_turn.nonverbal[0].detail, "1.2초")
|
||||
self.assertEqual([event.kind for event in learner_turn.nonverbal].count("silence"), 1)
|
||||
self.assertEqual(
|
||||
[event.kind for event in learner_turn.nonverbal].count("silence"), 1
|
||||
)
|
||||
self.assertEqual(learner_turn.nonverbal[1].detail, "분당 420자")
|
||||
self.assertEqual(learner_turn.nonverbal[4].label, "음성 단서")
|
||||
self.assertEqual(learner_turn.nonverbal[4].detail, "한숨 감지 · 신뢰도 82%")
|
||||
|
|
@ -1390,7 +1621,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertGreaterEqual(len(worksheet.sections), 5)
|
||||
exploration = worksheet.sections[0]
|
||||
self.assertEqual(exploration.key, "exploration_11")
|
||||
complaint = next(item for item in exploration.items if item.key == "presenting_complaint")
|
||||
complaint = next(
|
||||
item for item in exploration.items if item.key == "presenting_complaint"
|
||||
)
|
||||
self.assertEqual(complaint.confidence, "medium")
|
||||
self.assertEqual(complaint.evidence[0].turnId, "t2")
|
||||
self.assertIn("불안", complaint.value or "")
|
||||
|
|
@ -1509,14 +1742,18 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
AsyncMock(return_value=(review_status, True)),
|
||||
),
|
||||
):
|
||||
response = await sessions.get_session_review(sess.session_id, teacher_principal)
|
||||
response = await sessions.get_session_review(
|
||||
sess.session_id, teacher_principal
|
||||
)
|
||||
|
||||
self.assertIsNotNone(response.teacherReview)
|
||||
assert response.teacherReview is not None
|
||||
self.assertEqual(response.caseWorksheet.status, "saved_by_learner")
|
||||
self.assertEqual(response.teacherReview.worksheetStatus, "changes_requested")
|
||||
self.assertIn("주호소", response.teacherReview.worksheetNote)
|
||||
self.assertEqual(response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z")
|
||||
self.assertEqual(
|
||||
response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z"
|
||||
)
|
||||
|
||||
async def test_review_remasks_legacy_session_evaluation_payload(self) -> None:
|
||||
class FakeKoRecognizer:
|
||||
|
|
@ -1528,7 +1765,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
):
|
||||
start = text.find(value)
|
||||
if start >= 0:
|
||||
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
|
||||
spans.append(
|
||||
guardrail.PiiEntitySpan(
|
||||
entity_type, start, start + len(value)
|
||||
)
|
||||
)
|
||||
return spans
|
||||
|
||||
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
|
||||
|
|
@ -1668,7 +1909,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertFalse(response.reviewReady)
|
||||
self.assertTrue(response.degraded)
|
||||
self.assertEqual(response.supervisorState, "평가 실패")
|
||||
self.assertIn("deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary)
|
||||
self.assertIn(
|
||||
"deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary
|
||||
)
|
||||
self.assertIn("AI 평가 재시도가 필요합니다", response.summary)
|
||||
self.assertNotIn("session evaluation timeout after 45s", response.summary)
|
||||
self.assertEqual(response.rubric, [])
|
||||
|
|
@ -1743,15 +1986,18 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
limitations=["임상 루브릭 전"],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=sess),
|
||||
) as load_session, patch.object(
|
||||
sessions.session_persistence,
|
||||
"save_case_worksheet",
|
||||
AsyncMock(return_value=True),
|
||||
) as save_worksheet:
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=sess),
|
||||
) as load_session,
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"save_case_worksheet",
|
||||
AsyncMock(return_value=True),
|
||||
) as save_worksheet,
|
||||
):
|
||||
response = await sessions.save_session_review_worksheet(
|
||||
sess.session_id,
|
||||
request,
|
||||
|
|
@ -1769,7 +2015,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(save_kwargs["session_id"], sess.session_id)
|
||||
self.assertEqual(save_kwargs["learner_id"], principal.user_id)
|
||||
self.assertEqual(save_kwargs["payload"]["status"], "saved_by_learner")
|
||||
self.assertEqual(save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소")
|
||||
self.assertEqual(
|
||||
save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소"
|
||||
)
|
||||
self.assertEqual(response.status, "saved_by_learner")
|
||||
self.assertEqual(response.sections[0].items[0].value, "수정한 주호소")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue