세션 계약과 메모리 경계 보강

This commit is contained in:
Yun Chan 2026-06-28 23:52:18 +09:00
parent 391639c1de
commit 2bb052f624
12 changed files with 836 additions and 116 deletions

View file

@ -72,9 +72,13 @@ async def _consume_event_source(response: object) -> bytes:
class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
store._sessions.clear()
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
async def test_append_turn_writes_provider_events_to_db(self) -> None:
class FakeConn:
@ -157,9 +161,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def test_generate_turn_persists_client_engine_telemetry(self) -> None:
principal = _principal()
sess = _session(principal)
sessions._RECALL_CACHE[sess.session_id] = memory.RecallContext(
recall_summary="직전 회기에서 김서연은 가족 이야기를 열어두었다.",
pinned_facts=["박민수와 주 1회 상담 약속"],
)
async def successful_turn(ctx, engine, **kwargs):
assert ctx.state_after is not None
self.assertIn("[NAME]", ctx.recall_summary or "")
self.assertNotIn("김서연", ctx.recall_summary or "")
self.assertEqual(ctx.pinned_facts, ["[NAME]와 주 1회 상담 약속"])
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
@ -429,8 +440,41 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(history.source, "runtime")
self.assertEqual(len(history.events), 1)
self.assertEqual(history.events[0].turn_seq, 1)
self.assertEqual(history.events[0].stage, "라포")
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)
self.assertIsNone(legacy_history.events[0].stage)
async def test_live_coach_event_normalizes_legacy_stage_values(self) -> None:
suggestion = live_coach.LiveCoachSuggestion(
status="degraded",
tone="neutral",
focus="exploration",
title="코칭",
message="다음 발화를 준비하세요.",
)
coded = live_coach.LiveCoachEvent(
event_id="event-1",
session_id="session-1",
turn_seq=1,
stage="rapport",
created_at="2026-06-28T00:00:00Z",
suggestion=suggestion,
)
invalid = live_coach.LiveCoachEvent(
event_id="event-2",
session_id="session-1",
turn_seq=2,
stage="unknown-stage",
created_at="2026-06-28T00:00:00Z",
suggestion=suggestion,
)
self.assertEqual(coded.stage, "라포")
self.assertIsNone(invalid.stage)
async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(self) -> None:
item = live_coach.LiveCoachInput(
@ -523,6 +567,124 @@ 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:
principal = _principal()
card = persona_service.P1
case_context = sessions.session_persistence.CaseContext(
case_id="00000000-0000-0000-0000-00000000ca5e",
last_session_no=1,
)
catalog_persona = SimpleNamespace(
card=card,
persona_id="00000000-0000-0000-0000-0000000000a1",
version=3,
degraded=False,
)
test_case = self
class FakeConn:
async def fetchrow(self, query: str, *args: object):
if "FROM app.case_profile" in query:
return {"case_digest": "S1: 김서연은 가족 이야기를 열어두었다."}
if "FROM app.session_summary" in query:
return {
"digest": "직전 회기에서 김서연은 침묵 이후 학교 이야기를 꺼냈다.",
"open_threads": ["다음 회기에서 상담 지속 의사를 확인하기"],
"end_state": {
"rapport_credit": 0.55,
"resistance": card.base_resistance(),
"ideation_stage": card.ideation_baseline(),
},
}
return None
async def fetch(self, query: str, *args: object):
test_case.assertIn("FROM app.pinned_fact", query)
test_case.assertIn("$2 = ANY(visible_to)", query)
return [{"value": "박민수와 주 1회 상담 약속"}]
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: object, exc: object, tb: object) -> None:
return None
async def fake_create_session(**kwargs):
return InProcSession(
session_id="db-seed-recall-session",
case_id=kwargs["case_id"],
learner_id=principal.user_id,
persona_code=card.code,
theory_mode=kwargs["theory_mode"],
persona=card,
state=kwargs["state"],
session_no=kwargs["session_no"],
prev_rapport_credit=kwargs["carry_rapport"],
)
def close_background(coro):
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):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
principal,
)
started = store.get(response.session_id)
self.assertIsNotNone(started)
captured_messages: list[str] = []
async def successful_turn(ctx, engine, **kwargs):
assert ctx.state_after is not None
captured_messages.extend(message.content for message in ctx.messages)
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="조금 더 이야기해볼게요.",
safety_flagged=False,
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,
):
await sessions.submit_turn(
response.session_id,
sessions.TurnRequest(text="지난번 이야기를 이어가도 괜찮을까요?"),
principal,
)
message_blob = "\n".join(captured_messages)
self.assertIn("[L2 회상", message_blob)
self.assertIn("[케이스 큰그림]", message_blob)
self.assertIn("[직전 회기 요약]", message_blob)
self.assertIn("다음 회기에서 상담 지속 의사를 확인하기", message_blob)
self.assertIn("[L4 고정 사실", message_blob)
self.assertIn("[NAME]와 주 1회 상담 약속", message_blob)
self.assertNotIn("김서연", message_blob)
self.assertNotIn("박민수", message_blob)
async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
principal = _principal()
principal.consent_at = None
@ -960,6 +1122,75 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(worksheet.limitations, ["학습자 저장본"])
self.assertEqual(worksheet.savedAt, "2026-06-27T10:00:00+00:00")
async def test_teacher_review_includes_manual_worksheet_decision(self) -> None:
learner = _principal()
teacher_principal = Principal(
user_id="00000000-0000-0000-0000-000000000902",
role=Role.TEACHER,
)
sess = _session(learner)
sess.ended = True
sess.ended_at = sess.created_at + 600
sess.turns.append(
TurnRecord(
turn_seq=1,
speaker="client",
stage=sess.state.stage.value,
text="저장본 검수를 확인합니다.",
text_masked="저장본 검수를 확인합니다.",
created_at=sess.created_at + 1,
)
)
saved_payload = {
"status": "saved_by_learner",
"generatedBy": "learner-edited worksheet",
"sections": [],
"limitations": ["학습자 저장본"],
"savedAt": "2026-06-27T10:00:00+00:00",
}
review_status = {
"session_id": sess.session_id,
"reviewer_id": teacher_principal.user_id,
"status": "viewed",
"note": "회기 전체 검토 메모",
"worksheet_status": "changes_requested",
"worksheet_note": "주호소 근거를 더 명확히 쓰도록 지도",
"worksheet_reviewed_at": "2026-06-27T10:05:00Z",
"reviewed_at": "",
"updated_at": "2026-06-27T10:05:00Z",
}
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=sess),
),
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(saved_payload, True)),
),
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
"load_session_review_status",
AsyncMock(return_value=(review_status, True)),
),
):
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")
async def test_learner_can_save_case_formulation_worksheet(self) -> None:
principal = _principal()
sess = _session(principal)