세션 계약과 메모리 경계 보강
This commit is contained in:
parent
391639c1de
commit
2bb052f624
12 changed files with 836 additions and 116 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue