8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
319 lines
12 KiB
Python
319 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from contextlib import asynccontextmanager
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import uuid4
|
|
|
|
from .services import rupture_runtime
|
|
|
|
|
|
def _window(
|
|
*,
|
|
dimension: str | None = None,
|
|
severity: str | None = "moderate",
|
|
techniques: tuple[str, ...] = (),
|
|
states: tuple[str, ...] = ("defensive",),
|
|
appropriateness: float = 1.0,
|
|
rapport: float | None = -0.4,
|
|
evaluation_error: bool = False,
|
|
safety_event_ids: tuple[int, ...] = (),
|
|
) -> rupture_runtime.DurableTurnWindow:
|
|
return rupture_runtime.DurableTurnWindow(
|
|
counselor_turn_id=uuid4(),
|
|
client_turn_id=uuid4(),
|
|
counselor_seq=1,
|
|
client_seq=2,
|
|
appropriateness_score=appropriateness,
|
|
rapport_signal=rapport,
|
|
techniques=techniques,
|
|
client_states=states,
|
|
intent_dimension=dimension,
|
|
intent_severity=severity,
|
|
evaluation_error=evaluation_error,
|
|
safety_event_ids=safety_event_ids,
|
|
)
|
|
|
|
|
|
class RuptureRuntimeDetectionTests(unittest.TestCase):
|
|
def test_sessions_install_one_shared_finalize_hook_for_voice(self) -> None:
|
|
from .routes import sessions as session_routes
|
|
from .routes import voice as voice_routes
|
|
|
|
self.assertIs(
|
|
session_routes.turn_runtime.finalize_completed_turn,
|
|
voice_routes.turn_runtime.finalize_completed_turn,
|
|
)
|
|
self.assertTrue(
|
|
getattr(
|
|
voice_routes.turn_runtime.finalize_completed_turn,
|
|
"__rupture_runtime_hook__",
|
|
False,
|
|
)
|
|
)
|
|
|
|
def test_all_nine_rupture_types_have_conservative_structured_rules(self) -> None:
|
|
cases = {
|
|
"withdrawal": _window(dimension=None),
|
|
"confrontation": _window(dimension=None, techniques=("confrontation",)),
|
|
"goal_mismatch": _window(dimension="goal"),
|
|
"task_mismatch": _window(dimension="pacing"),
|
|
"empathic_miss": _window(dimension="empathy"),
|
|
"cultural_miss": _window(dimension="cultural_context"),
|
|
"boundary_tension": _window(dimension="confidentiality"),
|
|
"premature_advice": _window(
|
|
dimension="advice", techniques=("psychoeducation",)
|
|
),
|
|
"over_disclosure": _window(
|
|
dimension="self_disclosure", techniques=("self_disclosure",)
|
|
),
|
|
}
|
|
|
|
for expected, window in cases.items():
|
|
with self.subTest(expected=expected):
|
|
result = rupture_runtime.detect_rupture(window)
|
|
self.assertIsNotNone(result)
|
|
assert result is not None
|
|
self.assertEqual(result.rupture_type, expected)
|
|
|
|
def test_insufficient_fast_evidence_fails_closed_without_normal_rupture(
|
|
self,
|
|
) -> None:
|
|
self.assertIsNone(
|
|
rupture_runtime.detect_rupture(
|
|
_window(
|
|
dimension=None,
|
|
states=(),
|
|
appropriateness=3.0,
|
|
rapport=None,
|
|
)
|
|
)
|
|
)
|
|
self.assertIsNone(
|
|
rupture_runtime.detect_rupture(_window(dimension="goal", severity="minor"))
|
|
)
|
|
self.assertIsNone(
|
|
rupture_runtime.detect_rupture(
|
|
_window(dimension="goal", evaluation_error=True)
|
|
)
|
|
)
|
|
|
|
def test_advice_and_disclosure_require_matching_technique_evidence(self) -> None:
|
|
self.assertIsNone(rupture_runtime.detect_rupture(_window(dimension="advice")))
|
|
self.assertIsNone(
|
|
rupture_runtime.detect_rupture(_window(dimension="self_disclosure"))
|
|
)
|
|
|
|
def test_safety_references_do_not_change_classifier_output(self) -> None:
|
|
without_safety = _window(dimension="goal")
|
|
with_safety = rupture_runtime.DurableTurnWindow(
|
|
counselor_turn_id=without_safety.counselor_turn_id,
|
|
client_turn_id=without_safety.client_turn_id,
|
|
counselor_seq=without_safety.counselor_seq,
|
|
client_seq=without_safety.client_seq,
|
|
appropriateness_score=without_safety.appropriateness_score,
|
|
rapport_signal=without_safety.rapport_signal,
|
|
techniques=without_safety.techniques,
|
|
client_states=without_safety.client_states,
|
|
intent_dimension=without_safety.intent_dimension,
|
|
intent_severity=without_safety.intent_severity,
|
|
safety_event_ids=(41, 42),
|
|
)
|
|
|
|
self.assertEqual(
|
|
rupture_runtime.detect_rupture(without_safety),
|
|
rupture_runtime.detect_rupture(with_safety),
|
|
)
|
|
|
|
def test_follow_up_requires_behavior_and_client_response_for_resolution(
|
|
self,
|
|
) -> None:
|
|
original = rupture_runtime.DetectionCandidate(
|
|
rupture_type="empathic_miss", confidence=0.9, uncertainty=0.1
|
|
)
|
|
resolved = rupture_runtime.assess_follow_up(
|
|
original,
|
|
_window(
|
|
dimension=None,
|
|
techniques=("empathy", "facilitative_question"),
|
|
states=("defense_loosening",),
|
|
appropriateness=5.0,
|
|
rapport=0.5,
|
|
),
|
|
)
|
|
missed = rupture_runtime.assess_follow_up(
|
|
original,
|
|
_window(
|
|
dimension=None,
|
|
techniques=("empathy",),
|
|
states=("defensive",),
|
|
appropriateness=2.0,
|
|
rapport=-0.2,
|
|
),
|
|
)
|
|
|
|
self.assertIsNotNone(resolved)
|
|
self.assertEqual(resolved.status, "resolved") # type: ignore[union-attr]
|
|
self.assertIsNotNone(missed)
|
|
self.assertEqual(missed.status, "missed") # type: ignore[union-attr]
|
|
|
|
def test_uuid5_keys_are_stable_for_reprocessing(self) -> None:
|
|
first = rupture_runtime._stable_uuid("observation", "episode", "resolved")
|
|
second = rupture_runtime._stable_uuid("observation", "episode", "resolved")
|
|
different = rupture_runtime._stable_uuid("observation", "episode", "partial")
|
|
|
|
self.assertEqual(first, second)
|
|
self.assertNotEqual(first, different)
|
|
|
|
|
|
class RuptureRuntimeAsyncTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_scan_uses_same_process_evaluator_context_and_real_provenance(
|
|
self,
|
|
) -> None:
|
|
session_id = uuid4()
|
|
window = _window(dimension="goal")
|
|
snapshot = rupture_runtime.RuntimeSnapshot(
|
|
session_id=session_id,
|
|
ended=False,
|
|
windows=(window,),
|
|
)
|
|
conn = AsyncMock()
|
|
acquired: list[dict[str, object]] = []
|
|
|
|
@asynccontextmanager
|
|
async def fake_acquire(**kwargs: object):
|
|
acquired.append(kwargs)
|
|
yield conn
|
|
|
|
observation_id = uuid4()
|
|
episode_id = uuid4()
|
|
append = AsyncMock(
|
|
return_value={
|
|
"episode_id": episode_id,
|
|
"observation_id": observation_id,
|
|
}
|
|
)
|
|
with (
|
|
patch.object(rupture_runtime.db, "acquire", fake_acquire),
|
|
patch.object(
|
|
rupture_runtime,
|
|
"_load_snapshot",
|
|
AsyncMock(return_value=snapshot),
|
|
),
|
|
patch.object(
|
|
rupture_runtime,
|
|
"_load_runtime_episodes",
|
|
AsyncMock(return_value={}),
|
|
),
|
|
patch.object(
|
|
rupture_runtime.rupture_repair_store,
|
|
"append_evaluator_observation",
|
|
append,
|
|
),
|
|
):
|
|
result = await rupture_runtime.run_session_scan(
|
|
str(session_id), trigger="turn_persisted"
|
|
)
|
|
|
|
self.assertEqual(result.status, "recorded")
|
|
self.assertEqual(
|
|
acquired,
|
|
[{"ai_view": "evaluator", "ai_context": True}],
|
|
)
|
|
insert_sql = str(conn.execute.await_args.args[0])
|
|
self.assertIn("INSERT INTO audit.model_run", insert_sql)
|
|
kwargs = append.await_args.kwargs
|
|
self.assertEqual(kwargs["source_kind"], "model_inferred")
|
|
self.assertEqual(kwargs["perspective"], "independent_observer")
|
|
self.assertIsNotNone(kwargs["model_run_id"])
|
|
self.assertEqual(kwargs["safety_event_ids"], window.safety_event_ids)
|
|
|
|
async def test_reprocessing_reuses_the_same_observation_idempotency_key(
|
|
self,
|
|
) -> None:
|
|
session_id = uuid4()
|
|
window = _window(dimension="goal")
|
|
snapshot = rupture_runtime.RuntimeSnapshot(
|
|
session_id=session_id,
|
|
ended=False,
|
|
windows=(window,),
|
|
)
|
|
conn = AsyncMock()
|
|
|
|
@asynccontextmanager
|
|
async def fake_acquire(**kwargs: object):
|
|
yield conn
|
|
|
|
append = AsyncMock(
|
|
return_value={"episode_id": uuid4(), "observation_id": uuid4()}
|
|
)
|
|
with (
|
|
patch.object(rupture_runtime.db, "acquire", fake_acquire),
|
|
patch.object(
|
|
rupture_runtime,
|
|
"_load_snapshot",
|
|
AsyncMock(return_value=snapshot),
|
|
),
|
|
patch.object(
|
|
rupture_runtime,
|
|
"_load_runtime_episodes",
|
|
AsyncMock(side_effect=[{}, {}]),
|
|
),
|
|
patch.object(
|
|
rupture_runtime.rupture_repair_store,
|
|
"append_evaluator_observation",
|
|
append,
|
|
),
|
|
):
|
|
await rupture_runtime.run_session_scan(str(session_id), trigger="one")
|
|
await rupture_runtime.run_session_scan(str(session_id), trigger="two")
|
|
|
|
first = append.await_args_list[0].kwargs["idempotency_key"]
|
|
second = append.await_args_list[1].kwargs["idempotency_key"]
|
|
self.assertEqual(first, second)
|
|
|
|
async def test_failure_is_metadata_only_and_does_not_escape(self) -> None:
|
|
session_id = str(uuid4())
|
|
with patch.object(
|
|
rupture_runtime,
|
|
"_process_session_scan",
|
|
AsyncMock(side_effect=RuntimeError("masked transcript must not leak")),
|
|
):
|
|
result = await rupture_runtime.run_session_scan(
|
|
session_id, trigger="turn_persisted"
|
|
)
|
|
|
|
self.assertEqual(result.status, "error")
|
|
self.assertEqual(result.error_code, "runtime_runtimeerror")
|
|
self.assertNotIn("masked transcript", repr(result))
|
|
self.assertEqual(rupture_runtime.last_runtime_result(session_id), result)
|
|
|
|
async def test_finalize_hook_schedules_after_original_for_any_caller(self) -> None:
|
|
module = ModuleType("fake_turn_runtime")
|
|
order: list[str] = []
|
|
|
|
async def finalize(session: object, *_args: object, **_kwargs: object) -> str:
|
|
order.append("persisted")
|
|
return "learner-turn"
|
|
|
|
module.finalize_completed_turn = finalize # type: ignore[attr-defined]
|
|
rupture_runtime.install_turn_finalize_hook(module)
|
|
installed = module.finalize_completed_turn # type: ignore[attr-defined]
|
|
|
|
with patch.object(
|
|
rupture_runtime,
|
|
"schedule_session_scan",
|
|
side_effect=lambda *_args, **_kwargs: order.append("scheduled"),
|
|
):
|
|
result = await installed(SimpleNamespace(session_id=str(uuid4())))
|
|
|
|
self.assertEqual(result, "learner-turn")
|
|
self.assertEqual(order, ["persisted", "scheduled"])
|
|
before = module.finalize_completed_turn # type: ignore[attr-defined]
|
|
rupture_runtime.install_turn_finalize_hook(module)
|
|
self.assertIs(before, module.finalize_completed_turn) # type: ignore[attr-defined]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|