런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -0,0 +1,312 @@
from __future__ import annotations
import unittest
from .contracts.engine_gateway import GenerateResponse
from .services import memory, session_digest_worker
SESSION_ID = "00000000-0000-0000-0000-00000000feed"
CASE_ID = "00000000-0000-0000-0000-00000000ca5e"
LEARNER_ID = "00000000-0000-0000-0000-000000000101"
def _digest_input(session_no: int = 2) -> memory.SessionDigestInput:
return memory.build_session_digest_input(
session_id=SESSION_ID,
case_id=CASE_ID,
session_no=session_no,
masked_turns=[
{
"speaker": "client",
"text": "저는 [NAME]이고 가족 갈등을 조심스럽게 설명했습니다.",
"visible_to": ["client", "evaluator"],
},
{
"speaker": "counselor",
"text": "그 이야기는 다음 회기에서 천천히 이어가겠습니다.",
"visible_to": ["client"],
},
{
"speaker": "client",
"text": "평가자 전용 발화",
"visible_to": ["evaluator"],
},
],
open_threads=["가족 갈등을 다음 회기에서 이어가기"],
)
def _accepted_text(session_no: int = 2) -> str:
return (
f"S{session_no}: 내담자는 [NAME]으로 지칭되며 가족 갈등을 조심스럽게 꺼냈다. "
"상담자는 감정을 서두르지 않고 확인했고 다음 회기에서 같은 주제를 이어가기로 했다. "
"내담자는 관계 이야기를 계속 다루는 데 약간의 부담과 기대를 함께 보였다."
)
class FakeEngine:
def __init__(self, text: str) -> None:
self.text = text
self.requests = []
async def generate(self, req):
self.requests.append(req)
return GenerateResponse(
text=self.text,
provider="openai",
model="gpt-4.1-mini",
tokens_in=20,
tokens_out=13,
cost_usd=0.001,
inference_geo="us",
)
class SessionDigestWorkerPureTest(unittest.IsolatedAsyncioTestCase):
def test_request_uses_gateway_contract_without_internal_state(self) -> None:
job = memory.CompressionJob(digest_input=_digest_input())
request = session_digest_worker.build_session_digest_request(job)
prompt = "\n".join(message.content for message in request.messages)
self.assertEqual(request.ai_role, "evaluator")
self.assertEqual(request.session_id, SESSION_ID)
self.assertEqual(request.metadata["loop"], "session_digest")
self.assertEqual(request.metadata["case_id"], CASE_ID)
self.assertEqual(request.metadata["session_no"], 2)
self.assertEqual([message.role for message in request.messages], ["system", "user"])
self.assertIn("[NAME]", prompt)
self.assertIn("가족 갈등을 다음 회기에서 이어가기", prompt)
self.assertNotIn("김서연", prompt)
self.assertNotIn("end_state", prompt)
self.assertNotIn("rapport_credit", prompt)
self.assertNotIn("CCD", prompt)
self.assertNotIn("평가자 전용", prompt)
async def test_run_builds_apply_plan_and_audits_accepted_digest(self) -> None:
job = memory.CompressionJob(digest_input=_digest_input())
engine = FakeEngine(_accepted_text())
audits = []
async def audit_hook(payload):
audits.append(payload)
run = await session_digest_worker.run_session_digest_worker(
job,
engine,
existing_case_digest="S1: 이전 회기\nS2: 오래된 요약",
audit_hook=audit_hook,
)
self.assertFalse(run.fallback_required)
self.assertIsNotNone(run.apply_plan)
assert run.apply_plan is not None
self.assertEqual(run.apply_plan.compressed_by, "llm:openai/gpt-4.1-mini")
self.assertEqual(run.apply_plan.token_count, 33)
self.assertIn("S1: 이전 회기", run.apply_plan.case_digest or "")
self.assertIn(_accepted_text(), run.apply_plan.case_digest or "")
self.assertNotIn("오래된 요약", run.apply_plan.case_digest or "")
self.assertEqual(audits[0]["session_id"], SESSION_ID)
self.assertEqual(audits[0]["provider"], "openai")
self.assertEqual(audits[0]["tokens_in"], 20)
self.assertEqual(len(engine.requests), 1)
async def test_rejected_digest_does_not_create_apply_plan(self) -> None:
job = memory.CompressionJob(digest_input=_digest_input(session_no=3))
engine = FakeEngine("S2: 내담자 김서연은 잘못된 회기 prefix와 raw 이름을 포함했다.")
run = await session_digest_worker.run_session_digest_worker(
job,
engine,
forbidden_substrings=("김서연",),
)
self.assertTrue(run.fallback_required)
self.assertIsNone(run.apply_plan)
self.assertTrue(run.outcome.fallback_required)
self.assertIn(run.outcome.quality.reason, {"wrong_session_prefix", "forbidden_substring"})
class SessionDigestWorkerPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def test_load_session_digest_job_rebuilds_masked_client_visible_input(self) -> None:
class FakeConn:
def __init__(self) -> None:
self.fetchrow_query = ""
async def fetchrow(self, query: str, *args):
self.fetchrow_query = query
return {
"session_id": SESSION_ID,
"case_id": CASE_ID,
"session_no": 4,
"open_threads": ["다음 회기 주제"],
"case_digest": "S3: 이전 회기",
"learner_id": LEARNER_ID,
}
async def fetch(self, query: str, *args):
return [
{
"id": "00000000-0000-0000-0000-000000000201",
"speaker": "client",
"text": "raw 김서연",
"text_masked": "저는 [NAME]입니다.",
"visible_to": ["client", "evaluator"],
},
{
"id": "00000000-0000-0000-0000-000000000202",
"speaker": "client",
"text": "평가자 전용 raw",
"text_masked": "평가자 전용 masked",
"visible_to": ["evaluator"],
},
{
"id": "00000000-0000-0000-0000-000000000203",
"speaker": "client",
"text": "raw 김서연",
"text_masked": "",
"visible_to": ["client"],
},
{
"id": "00000000-0000-0000-0000-000000000204",
"speaker": "system",
"text": "시스템 메모",
"text_masked": "시스템 메모",
"visible_to": ["client"],
},
]
conn = FakeConn()
loaded = await session_digest_worker.load_session_digest_job(conn, SESSION_ID)
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertIn("ss.compressed_by IS NULL", conn.fetchrow_query)
digest_input = loaded.job.digest_input
self.assertEqual(digest_input.session_no, 4)
self.assertEqual(digest_input.open_threads, ("다음 회기 주제",))
self.assertEqual(len(digest_input.masked_turns), 1)
self.assertEqual(digest_input.masked_turns[0].text, "저는 [NAME]입니다.")
self.assertNotIn("김서연", " ".join(turn.text for turn in digest_input.masked_turns))
self.assertEqual(loaded.existing_case_digest, "S3: 이전 회기")
self.assertEqual(loaded.learner_id, LEARNER_ID)
async def test_load_session_digest_job_skips_already_compressed_rows(self) -> None:
class FakeConn:
def __init__(self) -> None:
self.fetchrow_query = ""
self.fetch_called = False
async def fetchrow(self, query: str, *args):
self.fetchrow_query = query
return None
async def fetch(self, query: str, *args):
self.fetch_called = True
return []
conn = FakeConn()
loaded = await session_digest_worker.load_session_digest_job(conn, SESSION_ID)
self.assertIsNone(loaded)
self.assertIn("ss.compressed_by IS NULL", conn.fetchrow_query)
self.assertFalse(conn.fetch_called)
async def test_run_session_digest_once_applies_only_accepted_plan(self) -> None:
class FakeConn:
def __init__(self) -> None:
self.executed = []
async def fetchrow(self, query: str, *args):
return {
"session_id": SESSION_ID,
"case_id": CASE_ID,
"session_no": 5,
"open_threads": ["가족 갈등"],
"case_digest": "S4: 이전 회기\nS5: 오래된 요약",
"learner_id": LEARNER_ID,
}
async def fetch(self, query: str, *args):
return [
{
"id": "00000000-0000-0000-0000-000000000201",
"speaker": "client",
"text": "raw 김서연",
"text_masked": "저는 [NAME]이고 가족 갈등이 부담됩니다.",
"visible_to": ["client"],
},
{
"id": "00000000-0000-0000-0000-000000000202",
"speaker": "counselor",
"text": "다음 회기에 이어가겠습니다.",
"text_masked": "다음 회기에 이어가겠습니다.",
"visible_to": ["client"],
},
]
async def execute(self, query: str, *args):
self.executed.append((query, args))
return "UPDATE 1"
conn = FakeConn()
result = await session_digest_worker.run_session_digest_once(
conn,
session_id=SESSION_ID,
engine=FakeEngine(_accepted_text(session_no=5)),
)
self.assertTrue(result.found)
self.assertTrue(result.applied)
self.assertEqual(len(conn.executed), 2)
summary_query, summary_args = conn.executed[0]
self.assertIn("UPDATE app.session_summary", summary_query)
self.assertEqual(summary_args[0], SESSION_ID)
self.assertIn("[NAME]", summary_args[1])
self.assertEqual(summary_args[2], ["가족 갈등"])
self.assertEqual(summary_args[3], "llm:openai/gpt-4.1-mini")
self.assertEqual(summary_args[4], 33)
case_query, case_args = conn.executed[1]
self.assertIn("UPDATE app.case_profile", case_query)
self.assertEqual(case_args[0], CASE_ID)
self.assertEqual(case_args[1], LEARNER_ID)
self.assertIn("S4: 이전 회기", case_args[2])
self.assertIn(_accepted_text(session_no=5), case_args[2])
self.assertNotIn("오래된 요약", case_args[2])
async def test_apply_session_digest_plan_uses_cas_before_case_update(self) -> None:
class FakeConn:
def __init__(self) -> None:
self.executed = []
async def execute(self, query: str, *args):
self.executed.append((query, args))
return "UPDATE 0"
plan = session_digest_worker.SessionDigestApplyPlan(
result=memory.SessionDigestResult(
session_id=SESSION_ID,
case_id=CASE_ID,
session_no=5,
digest=_accepted_text(session_no=5),
open_threads=("가족 갈등",),
source="llm",
),
case_digest="S5: accepted",
compressed_by="llm:openai/gpt-4.1-mini",
token_count=33,
)
conn = FakeConn()
applied = await session_digest_worker.apply_session_digest_plan(
conn,
plan,
learner_id=LEARNER_ID,
)
self.assertFalse(applied)
self.assertEqual(len(conn.executed), 1)
summary_query, summary_args = conn.executed[0]
self.assertIn("AND compressed_by IS NULL", summary_query)
self.assertEqual(summary_args[0], SESSION_ID)