세션 메모리와 비언어 이벤트 저장
This commit is contained in:
parent
e8e08935ed
commit
50fa4ad432
12 changed files with 2848 additions and 1277 deletions
|
|
@ -7,7 +7,8 @@ import unittest
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from . import turn_runtime
|
||||
from . import session_persistence, turn_runtime
|
||||
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
|
||||
from .deps import Principal, Role
|
||||
from .engine_client import EngineError
|
||||
from .routes import sessions
|
||||
|
|
@ -17,6 +18,14 @@ from .services.voice import TTSChunk, TranscriptResult, VoicePreset
|
|||
from .store import InProcSession, TurnRecord, store
|
||||
|
||||
|
||||
async def _decoded_stream_packets(stream_engine, req):
|
||||
decoder = EngineGatewaySseLineDecoder()
|
||||
async for raw in stream_engine.stream(req):
|
||||
packet = decoder.feed_line(raw)
|
||||
if packet is not None:
|
||||
yield packet
|
||||
|
||||
|
||||
def _principal() -> Principal:
|
||||
return Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000101",
|
||||
|
|
@ -67,6 +76,65 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def asyncTearDown(self) -> None:
|
||||
store._sessions.clear()
|
||||
|
||||
async def test_append_turn_writes_provider_events_to_db(self) -> None:
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.insert_query = ""
|
||||
self.insert_args: tuple[object, ...] = ()
|
||||
|
||||
async def fetchval(self, query: str, *args: object) -> object:
|
||||
if "SELECT id FROM app.sessions" in query:
|
||||
return "turn-persistence-session"
|
||||
if "COALESCE(MAX(seq)" in query:
|
||||
return 1
|
||||
if "INSERT INTO app.turns" in query:
|
||||
self.insert_query = query
|
||||
self.insert_args = args
|
||||
return "00000000-0000-0000-0000-000000009999"
|
||||
return None
|
||||
|
||||
class FakeAcquire:
|
||||
def __init__(self, conn: FakeConn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> FakeConn:
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
conn = FakeConn()
|
||||
turn = TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="counselor",
|
||||
stage="rapport",
|
||||
text="voice text",
|
||||
text_masked="voice text",
|
||||
audio_ref="voice:webm:sha256:test",
|
||||
silence_ms=1234,
|
||||
speech_rate=210.0,
|
||||
barge_in=True,
|
||||
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),
|
||||
):
|
||||
ok = await session_persistence.append_turn(
|
||||
session_id="turn-persistence-session",
|
||||
learner_id=_principal().user_id,
|
||||
turn=turn,
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("provider_events", conn.insert_query)
|
||||
self.assertIn("$17::jsonb", conn.insert_query)
|
||||
self.assertEqual(conn.insert_args[16], [{"type": "sigh", "confidence": 0.82}])
|
||||
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:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
|
@ -96,7 +164,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
turn_seq=ctx.state_after.turn_seq,
|
||||
stage=ctx.state_after.stage.value,
|
||||
effective_openness=ctx.state_after.effective_openness,
|
||||
client_reply="괜찮아요. 천천히 말해볼게요.",
|
||||
client_reply="저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
|
||||
safety_flagged=False,
|
||||
state_after=ctx.state_after,
|
||||
llm_provider="claude_cli",
|
||||
|
|
@ -113,10 +181,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.assertNotIn("김서연", client_turn.text_masked)
|
||||
self.assertNotIn("한신대학교", client_turn.text_masked)
|
||||
self.assertNotIn("상담심리학과", client_turn.text_masked)
|
||||
self.assertIn("[NAME]", client_turn.text_masked)
|
||||
self.assertIn("[ORG]", client_turn.text_masked)
|
||||
self.assertEqual(client_turn.llm_provider, "claude_cli")
|
||||
self.assertEqual(client_turn.model, "gateway-default")
|
||||
self.assertEqual(client_turn.tokens_in, 17)
|
||||
|
|
@ -502,6 +576,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
'"tokens_in":5,"tokens_out":7,"cost_usd":0.034567}'
|
||||
)
|
||||
|
||||
async def stream_packets(self, req):
|
||||
async for packet in _decoded_stream_packets(self, req):
|
||||
yield packet
|
||||
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
ctx = orchestrator.prepare_turn(
|
||||
|
|
@ -537,6 +615,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
yield "event: error"
|
||||
yield 'data: {"detail":"engine unavailable: gateway"}'
|
||||
|
||||
async def stream_packets(self, req):
|
||||
async for packet in _decoded_stream_packets(self, req):
|
||||
yield packet
|
||||
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
ctx = orchestrator.prepare_turn(
|
||||
|
|
@ -655,7 +737,15 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
with patch.object(
|
||||
voice_routes.voice_service,
|
||||
"transcribe",
|
||||
AsyncMock(return_value=TranscriptResult(text="오늘은 좀 힘들었어요.", duration=2.0)),
|
||||
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",
|
||||
|
|
@ -674,6 +764,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
fmt="webm",
|
||||
silence_ms=1234,
|
||||
barge_in=True,
|
||||
provider_events=[{"type": "sigh", "confidence": 0.82, "text": "drop"}],
|
||||
)
|
||||
|
||||
self.assertEqual(len(sess.turns), 2)
|
||||
|
|
@ -682,7 +773,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(learner_turn.silence_ms, 1234)
|
||||
self.assertGreater(learner_turn.speech_rate or 0, 0)
|
||||
self.assertTrue(learner_turn.barge_in)
|
||||
self.assertEqual(
|
||||
learner_turn.provider_events,
|
||||
[
|
||||
{
|
||||
"type": "sigh",
|
||||
"confidence": 0.82,
|
||||
"event_type": "sigh",
|
||||
"category": "paralinguistic",
|
||||
},
|
||||
{
|
||||
"kind": "voice_activity",
|
||||
"start_ms": 10,
|
||||
"event_type": "voice_activity",
|
||||
"category": "speech_activity",
|
||||
},
|
||||
],
|
||||
)
|
||||
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))
|
||||
|
||||
|
|
@ -703,6 +812,26 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
silence_ms=1234,
|
||||
speech_rate=420.0,
|
||||
barge_in=True,
|
||||
provider_events=[
|
||||
{
|
||||
"event_type": "sigh",
|
||||
"category": "paralinguistic",
|
||||
"confidence": 0.82,
|
||||
"provider": "stt-provider",
|
||||
"type": "raw_sigh",
|
||||
},
|
||||
{
|
||||
"event_type": "speech_start",
|
||||
"category": "speech_activity",
|
||||
"start_ms": 100,
|
||||
},
|
||||
{
|
||||
"event_type": "background_noise",
|
||||
"category": "audio_quality",
|
||||
"score": 77,
|
||||
"label": "busy cafe",
|
||||
},
|
||||
],
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=2,
|
||||
|
|
@ -715,6 +844,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
silence_ms=2500,
|
||||
speech_rate=180.0,
|
||||
barge_in=True,
|
||||
provider_events=[{"event_type": "cry", "category": "paralinguistic"}],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
@ -723,10 +853,20 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertEqual(len(response.turns), 2)
|
||||
learner_turn, client_turn = response.turns
|
||||
self.assertEqual([event.kind for event in learner_turn.nonverbal], ["silence", "pace", "barge_in", "audio"])
|
||||
self.assertEqual(
|
||||
[event.kind for event in learner_turn.nonverbal],
|
||||
["silence", "pace", "barge_in", "audio", "paralinguistic", "audio_quality"],
|
||||
)
|
||||
self.assertEqual(learner_turn.nonverbal[0].label, "침묵")
|
||||
self.assertEqual(learner_turn.nonverbal[0].detail, "1.2초")
|
||||
self.assertEqual(learner_turn.nonverbal[1].detail, "분당 420자")
|
||||
self.assertEqual(learner_turn.nonverbal[4].label, "음성 단서")
|
||||
self.assertEqual(learner_turn.nonverbal[4].detail, "한숨 감지 · 신뢰도 82%")
|
||||
exposed_details = " ".join(event.detail for event in learner_turn.nonverbal)
|
||||
self.assertNotIn("stt-provider", exposed_details)
|
||||
self.assertNotIn("raw_sigh", exposed_details)
|
||||
self.assertEqual(learner_turn.nonverbal[5].label, "오디오 품질")
|
||||
self.assertEqual(learner_turn.nonverbal[5].detail, "배경 소음 · 신뢰도 77%")
|
||||
self.assertEqual(client_turn.nonverbal, [])
|
||||
|
||||
async def test_review_includes_case_formulation_worksheet_draft(self) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue