회기 연속성과 멀티 케이스 계약을 영속화

This commit is contained in:
Yun Chan 2026-09-01 11:45:16 +09:00
parent be08c0b573
commit 72353ecd82
26 changed files with 2170 additions and 127 deletions

View file

@ -249,15 +249,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
version=3,
degraded=False,
)
case_context = sessions.session_persistence.CaseContext(
case_id=case_id,
last_session_no=1,
)
next_session_id = "00000000-0000-0000-0000-000000000702"
async def create_next_session(**kwargs: object) -> InProcSession:
self.assertEqual(kwargs["case_id"], case_id)
self.assertEqual(kwargs["session_no"], 2)
self.assertEqual(kwargs["start_mode"], "continue")
# The route now delegates durable case selection to the locked
# persistence transaction. It only provides a provisional first
# session number; the selected case counter makes this session #2.
self.assertEqual(kwargs["session_no"], 1)
state = await kwargs["locked_state_factory"](case_id, 2)
created = InProcSession(
session_id=next_session_id,
case_id=case_id,
@ -265,9 +266,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
persona_code=card.code,
theory_mode=str(kwargs["theory_mode"]),
persona=card,
state=kwargs["state"],
session_no=int(kwargs["session_no"]),
prev_rapport_credit=float(kwargs["carry_rapport"]),
state=state,
session_no=2,
prev_rapport_credit=state.rapport_credit,
)
durable_sessions[created.session_id] = created
return created
@ -283,11 +284,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
"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",
@ -301,7 +297,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
patch.object(sessions.asyncio, "create_task", close_background),
):
started = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
sessions.SessionStartRequest(
persona_code=card.code,
case_id=case_id,
),
principal,
)
@ -511,8 +510,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
return FakeTransaction()
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
if "FROM app.case_profile" in query and "FOR UPDATE" in query:
return {"case_id": args[0], "last_session_no": 1}
if "UPDATE app.case_profile" in query:
return {"last_session_no": 2}
if "FROM app.sessions" in query and "ended_at IS NULL" in query:
return None
if "INSERT INTO app.sessions" in query:
self.session_insert_args = args
return {
@ -532,6 +535,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
raise AssertionError(f"unexpected query: {query}")
async def execute(self, query: str, *args: object) -> str:
if "pg_advisory_xact_lock" in query:
return "SELECT 1"
self.assert_jsonb_object(query, args)
return "INSERT 0 1"
@ -1811,8 +1816,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def fake_create_session(**kwargs):
self.assertEqual(kwargs["case_id"], case_context.case_id)
self.assertEqual(kwargs["session_no"], 2)
self.assertGreater(kwargs["state"].rapport_credit, 0)
self.assertEqual(kwargs["start_mode"], "continue")
self.assertEqual(kwargs["session_no"], 1)
state = await kwargs["locked_state_factory"](
case_context.case_id,
2,
)
self.assertGreater(state.rapport_credit, 0)
return InProcSession(
session_id="stable-case-session",
case_id=kwargs["case_id"],
@ -1820,9 +1830,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
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"],
state=state,
session_no=2,
prev_rapport_credit=state.rapport_credit,
)
def close_background(coro):
@ -1833,11 +1843,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
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",
@ -1851,7 +1856,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
patch.object(sessions.asyncio, "create_task", close_background),
):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
sessions.SessionStartRequest(
persona_code=card.code,
case_id=case_context.case_id,
),
principal,
)
@ -1911,16 +1919,23 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
return None
async def fake_create_session(**kwargs):
test_case.assertEqual(kwargs["case_id"], case_context.case_id)
test_case.assertEqual(kwargs["start_mode"], "continue")
test_case.assertEqual(kwargs["session_no"], 1)
state = await kwargs["locked_state_factory"](
case_context.case_id,
2,
)
return InProcSession(
session_id="db-seed-recall-session",
case_id=kwargs["case_id"],
case_id=case_context.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"],
state=state,
session_no=2,
prev_rapport_credit=state.rapport_credit,
)
def close_background(coro):
@ -1931,11 +1946,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
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,
@ -1950,7 +1960,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
patch.object(sessions.asyncio, "create_task", close_background),
):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
sessions.SessionStartRequest(
persona_code=card.code,
case_id=case_context.case_id,
),
principal,
)
started = store.get(response.session_id)
@ -2743,10 +2756,43 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertIn("AI 평가 재시도가 필요합니다", response.summary)
self.assertNotIn("session evaluation timeout after 45s", response.summary)
self.assertIsNone(response.evaluationFailure)
self.assertEqual(response.rubric, [])
self.assertEqual(response.goodMoments, [])
self.assertEqual(response.growthPoints, [])
teacher_response = sessions.build_session_review(
sessions.SessionReviewReadInput(
session=sess,
evaluation_record=error_record,
evaluation_durable=True,
include_teacher_review=True,
)
)
self.assertIsNotNone(teacher_response.evaluationFailure)
assert teacher_response.evaluationFailure is not None
self.assertEqual(teacher_response.evaluationFailure.code, "timeout")
self.assertTrue(teacher_response.evaluationFailure.retryable)
self.assertNotIn("session evaluation timeout after 45s", teacher_response.model_dump_json())
oversized_input_record = {
**error_record,
"error": "engine_error: Agy CLI 프롬프트가 Windows 명령줄 안전 한도(24,000자)를 초과했습니다.",
}
oversized_response = sessions.build_session_review(
sessions.SessionReviewReadInput(
session=sess,
evaluation_record=oversized_input_record,
evaluation_durable=True,
include_teacher_review=True,
)
)
self.assertIsNotNone(oversized_response.evaluationFailure)
assert oversized_response.evaluationFailure is not None
self.assertEqual(oversized_response.evaluationFailure.code, "legacy_argv_limit")
self.assertTrue(oversized_response.evaluationFailure.retryable)
self.assertNotIn("24,000", oversized_response.model_dump_json())
def test_review_marks_stale_missing_session_evaluation_as_failed(self) -> None:
principal = _principal()
sess = _session(principal)
@ -2780,7 +2826,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
session=sess,
evaluation_record=None,
evaluation_durable=True,
now_ts=(sess.ended_at or sess.created_at) + 41.0,
# 평가 생성·감사 원장·결과 저장의 전체 예산을 넘긴 뒤에만 누락을 실패로 본다.
now_ts=(sess.ended_at or sess.created_at)
+ sessions.session_evaluation_stale_after_seconds()
+ 1.0,
)
)
finally: