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

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

@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from contextlib import asynccontextmanager
from unittest.mock import patch
from . import session_persistence
@ -129,6 +130,85 @@ class SessionMemoryPureTest(unittest.TestCase):
self.assertIn("[NAME]", result.digest)
self.assertNotIn("김서연", result.digest)
def test_llm_digest_worker_outcome_accepts_masked_contract_result(self) -> None:
digest_input = memory.build_session_digest_input(
session_id="00000000-0000-0000-0000-00000000feed",
case_id="00000000-0000-0000-0000-00000000ca5e",
session_no=7,
masked_turns=[
{"speaker": "client", "text": "저는 [NAME]이고 가족 이야기가 어렵습니다.", "visible_to": ["client"]},
{"speaker": "counselor", "text": "그 주제를 다음 회기에 이어가겠습니다.", "visible_to": ["client"]},
],
open_threads=["가족 갈등을 다음 회기에 이어가기"],
)
outcome = memory.build_llm_digest_worker_outcome(
digest_input,
(
"내담자는 [NAME]으로 지칭되며 가족 갈등을 조심스럽게 설명했다. "
"상담자는 감정 확인과 다음 회기에서 이어갈 주제를 함께 정리했다."
),
forbidden_substrings=("김서연",),
)
self.assertFalse(outcome.fallback_required)
self.assertTrue(outcome.quality.accepted)
self.assertEqual(outcome.quality.reason, "ok")
self.assertIsNotNone(outcome.result)
assert outcome.result is not None
self.assertEqual(outcome.result.source, "llm")
self.assertEqual(outcome.result.session_no, 7)
self.assertEqual(outcome.result.open_threads, ("가족 갈등을 다음 회기에 이어가기",))
self.assertTrue(outcome.result.digest.startswith("S7:"))
self.assertIn("[NAME]", outcome.result.digest)
self.assertNotIn("김서연", outcome.result.digest)
def test_llm_digest_quality_rejects_raw_forbidden_substring(self) -> None:
digest_input = memory.build_session_digest_input(
session_id="00000000-0000-0000-0000-00000000feed",
case_id="00000000-0000-0000-0000-00000000ca5e",
session_no=8,
masked_turns=[
{"speaker": "client", "text": "저는 [NAME]입니다.", "visible_to": ["client"]},
],
)
outcome = memory.build_llm_digest_worker_outcome(
digest_input,
"S8: 내담자 김서연은 가족 갈등을 설명했고 상담자는 다음 회기에서 이어갈 주제를 정리했다.",
forbidden_substrings=("김서연",),
)
self.assertTrue(outcome.fallback_required)
self.assertIsNone(outcome.result)
self.assertFalse(outcome.quality.accepted)
self.assertEqual(outcome.quality.reason, "forbidden_substring")
self.assertEqual(outcome.quality.details, ("김서연",))
def test_llm_digest_quality_rejects_internal_markers_and_wrong_session(self) -> None:
digest_input = memory.build_session_digest_input(
session_id="00000000-0000-0000-0000-00000000feed",
case_id="00000000-0000-0000-0000-00000000ca5e",
session_no=9,
masked_turns=[
{"speaker": "client", "text": "저는 [NAME]입니다.", "visible_to": ["client"]},
],
)
marker_outcome = memory.build_llm_digest_worker_outcome(
digest_input,
"S9: rapport_credit 수치와 evaluation payload를 근거로 요약을 작성했다. 다음 회기 주제를 유지한다.",
)
wrong_session_outcome = memory.build_llm_digest_worker_outcome(
digest_input,
"S8: 내담자는 [NAME]으로 지칭되며 가족 갈등을 설명했다. 상담자는 다음 회기 주제를 정리했다.",
)
self.assertTrue(marker_outcome.fallback_required)
self.assertEqual(marker_outcome.quality.reason, "internal_marker")
self.assertTrue(wrong_session_outcome.fallback_required)
self.assertEqual(wrong_session_outcome.quality.reason, "wrong_session_prefix")
def test_extract_pinned_fact_candidates_is_conservative_and_masked(self) -> None:
facts = memory.extract_pinned_fact_candidates(
[
@ -304,6 +384,190 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(sess.ended)
self.assertEqual(len(scheduled), 1)
async def test_end_persisted_session_schedules_digest_worker_only_when_enabled(self) -> None:
scheduled: list[str] = []
def fake_create_task(coro):
scheduled.append(coro.cr_code.co_name)
coro.close()
return None
sess = InProcSession(
session_id="00000000-0000-0000-0000-00000000feed",
case_id="00000000-0000-0000-0000-00000000ca5e",
learner_id="00000000-0000-0000-0000-000000000101",
persona_code=P1.code,
theory_mode="humanistic",
persona=P1,
state=state_machine.SessionState(),
session_no=1,
)
digest_input = memory.build_session_digest_input(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
masked_turns=[
{
"speaker": "client",
"text_masked": "다음 회기에 가족 이야기를 이어가고 싶어요.",
"visible_to": ["client"],
}
],
open_threads=["가족 이야기"],
)
carry = memory.CarryOver(
end_state={},
rapport_delta=0.0,
compression_job=memory.CompressionJob(digest_input=digest_input),
)
with patch.object(session_persistence, "end_session", return_value=True), patch.object(
sessions.settings,
"session_digest_worker_enabled",
True,
), patch.object(
sessions.asyncio,
"create_task",
fake_create_task,
):
await sessions._end_persisted_session(sess, carry)
self.assertEqual(
scheduled,
["_run_session_digest_worker_for_session", "_write_episodic_embeddings"],
)
async def test_end_persisted_session_keeps_digest_worker_default_off(self) -> None:
scheduled: list[str] = []
def fake_create_task(coro):
scheduled.append(coro.cr_code.co_name)
coro.close()
return None
sess = InProcSession(
session_id="00000000-0000-0000-0000-00000000feed",
case_id="00000000-0000-0000-0000-00000000ca5e",
learner_id="00000000-0000-0000-0000-000000000101",
persona_code=P1.code,
theory_mode="humanistic",
persona=P1,
state=state_machine.SessionState(),
session_no=1,
)
digest_input = memory.build_session_digest_input(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
masked_turns=[
{
"speaker": "client",
"text_masked": "다음 회기에 가족 이야기를 이어가고 싶어요.",
"visible_to": ["client"],
}
],
open_threads=["가족 이야기"],
)
carry = memory.CarryOver(
end_state={},
rapport_delta=0.0,
compression_job=memory.CompressionJob(digest_input=digest_input),
)
with patch.object(session_persistence, "end_session", return_value=True), patch.object(
sessions.settings,
"session_digest_worker_enabled",
False,
), patch.object(
sessions.asyncio,
"create_task",
fake_create_task,
):
await sessions._end_persisted_session(sess, carry)
self.assertEqual(scheduled, ["_write_episodic_embeddings"])
async def test_session_digest_worker_releases_db_connection_during_engine_call(self) -> None:
order: list[str] = []
digest_input = memory.build_session_digest_input(
session_id="00000000-0000-0000-0000-00000000feed",
case_id="00000000-0000-0000-0000-00000000ca5e",
session_no=1,
masked_turns=[
{
"speaker": "client",
"text_masked": "다음 회기에 가족 이야기를 이어가고 싶어요.",
"visible_to": ["client"],
}
],
open_threads=["가족 이야기"],
)
loaded = sessions.session_digest_worker.LoadedSessionDigestJob(
job=memory.CompressionJob(digest_input=digest_input),
existing_case_digest="S0: 이전",
learner_id="00000000-0000-0000-0000-000000000101",
)
class Worker:
apply_plan = object()
acquire_count = 0
@asynccontextmanager
async def fake_acquire(**kwargs):
nonlocal acquire_count
acquire_count += 1
label = "load" if acquire_count == 1 else "apply"
order.append(f"enter-{label}")
try:
yield object()
finally:
order.append(f"exit-{label}")
async def fake_load(conn, session_id: str):
order.append("load")
return loaded
async def fake_run(job, engine, *, existing_case_digest=None, model=None, audit_hook=None):
order.append("engine")
self.assertIs(engine, sessions.engine_client)
self.assertEqual(existing_case_digest, "S0: 이전")
self.assertIs(audit_hook, session_persistence.record_llm_call_audit)
return Worker()
async def fake_apply(conn, apply_plan, *, learner_id=None):
order.append("apply")
self.assertEqual(learner_id, loaded.learner_id)
return True
with patch.object(sessions.db, "get_pool", return_value=object()), patch.object(
sessions.db,
"acquire",
fake_acquire,
), patch.object(
sessions.session_digest_worker,
"load_session_digest_job",
fake_load,
), patch.object(
sessions.session_digest_worker,
"run_session_digest_worker",
fake_run,
), patch.object(
sessions.session_digest_worker,
"apply_session_digest_plan",
fake_apply,
), patch.object(
sessions.settings,
"session_digest_worker_model",
"",
):
await sessions._run_session_digest_worker_for_session(loaded.job.session_id)
self.assertEqual(
order,
["enter-load", "load", "exit-load", "engine", "enter-apply", "apply", "exit-apply"],
)
async def test_seed_recall_loads_case_digest_and_client_visible_pinned_facts(self) -> None:
test_case = self