세션 메모리와 비언어 이벤트 저장

This commit is contained in:
Yun Chan 2026-06-28 20:12:50 +09:00
parent e8e08935ed
commit 50fa4ad432
12 changed files with 2848 additions and 1277 deletions

View file

@ -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: