런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue