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 산출물은 커밋에서 제외했다.
560 lines
20 KiB
Python
560 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from fastapi import FastAPI
|
|
from pydantic import ValidationError
|
|
|
|
from .deps import Principal, Role
|
|
from .routes import rupture_repairs
|
|
from .services import rupture_repair_store
|
|
|
|
|
|
def _principal(role: Role = Role.LEARNER) -> Principal:
|
|
return Principal(
|
|
user_id=str(uuid4()),
|
|
role=role,
|
|
cohort_ids=["g3-cohort"],
|
|
)
|
|
|
|
|
|
class RuptureRepairStoreContractTests(unittest.TestCase):
|
|
def test_canonical_hash_is_order_independent(self) -> None:
|
|
self.assertEqual(
|
|
rupture_repair_store._canonical_hash({"b": 2, "a": 1}),
|
|
rupture_repair_store._canonical_hash({"a": 1, "b": 2}),
|
|
)
|
|
|
|
def test_human_views_keep_learner_and_supervisor_projections_separate(self) -> None:
|
|
self.assertEqual(
|
|
rupture_repair_store._human_view(_principal(Role.LEARNER)),
|
|
"counselor",
|
|
)
|
|
self.assertEqual(
|
|
rupture_repair_store._human_view(_principal(Role.TEACHER)),
|
|
"supervisor",
|
|
)
|
|
|
|
def test_evidence_must_be_nonempty_and_unique(self) -> None:
|
|
with self.assertRaises(rupture_repair_store.RuptureRepairStateError):
|
|
rupture_repair_store._ensure_unique_nonempty_evidence(())
|
|
evidence = uuid4()
|
|
with self.assertRaises(rupture_repair_store.RuptureRepairStateError):
|
|
rupture_repair_store._ensure_unique_nonempty_evidence((evidence, evidence))
|
|
|
|
def test_internal_request_requires_explicit_evaluator_model_provenance(self) -> None:
|
|
with self.assertRaises(ValidationError):
|
|
rupture_repairs.InternalRuptureObservationRequest(
|
|
episode_key="episode-1",
|
|
idempotency_key=uuid4(),
|
|
event_kind="rupture.detected",
|
|
from_state=None,
|
|
to_state="onset",
|
|
rupture_type="withdrawal",
|
|
source_kind="model_inferred",
|
|
perspective="independent_observer",
|
|
ai_view="evaluator",
|
|
confidence=0.9,
|
|
uncertainty=0.1,
|
|
evidence_turn_ids=[uuid4()],
|
|
)
|
|
|
|
def test_runtime_request_cannot_claim_independent_observer_perspective(self) -> None:
|
|
with self.assertRaises(ValidationError):
|
|
rupture_repairs.InternalRuptureObservationRequest(
|
|
episode_key="episode-1",
|
|
idempotency_key=uuid4(),
|
|
event_kind="rupture.detected",
|
|
from_state=None,
|
|
to_state="onset",
|
|
rupture_type="withdrawal",
|
|
source_kind="observed_runtime",
|
|
perspective="independent_observer",
|
|
ai_view="evaluator",
|
|
confidence=0.9,
|
|
uncertainty=0.1,
|
|
evidence_turn_ids=[uuid4()],
|
|
)
|
|
|
|
def test_reconciliation_disposition_is_typed_against_deep_status(self) -> None:
|
|
with self.assertRaises(ValidationError):
|
|
rupture_repairs.InternalReconciliationRequest(
|
|
idempotency_key=uuid4(),
|
|
fast_warning_observation_id=uuid4(),
|
|
fast_warning_id="warning-1",
|
|
provisional_status="missed",
|
|
deep_status="partial",
|
|
disposition="superseded_resolved",
|
|
uncertainty=0.1,
|
|
model_run_id=uuid4(),
|
|
ai_view="evaluator",
|
|
)
|
|
|
|
def test_response_has_no_total_score_and_keeps_safety_as_reference(self) -> None:
|
|
payload = {
|
|
"session_id": uuid4(),
|
|
"requested_view": "counselor",
|
|
"clinical_claim_allowed": False,
|
|
"episodes": [],
|
|
}
|
|
response = rupture_repairs.RuptureRepairReadModelResponse.model_validate(payload)
|
|
dumped = response.model_dump(mode="json")
|
|
|
|
self.assertNotIn("total", dumped)
|
|
self.assertNotIn("score", dumped)
|
|
self.assertFalse(dumped["clinical_claim_allowed"])
|
|
|
|
|
|
class RuptureRepairStoreAsyncTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_existing_idempotency_key_returns_same_id(self) -> None:
|
|
identifier = uuid4()
|
|
conn = AsyncMock()
|
|
conn.fetchrow.return_value = {
|
|
"observation_id": identifier,
|
|
"content_hash": "a" * 64,
|
|
}
|
|
|
|
result = await rupture_repair_store._existing_by_idempotency(
|
|
conn,
|
|
table="app.rupture_observation_event",
|
|
id_column="observation_id",
|
|
session_id=uuid4(),
|
|
idempotency_key=uuid4(),
|
|
content_hash="a" * 64,
|
|
)
|
|
|
|
self.assertEqual(result, identifier)
|
|
|
|
async def test_idempotency_key_reuse_with_different_content_is_conflict(self) -> None:
|
|
conn = AsyncMock()
|
|
conn.fetchrow.return_value = {
|
|
"observation_id": uuid4(),
|
|
"content_hash": "a" * 64,
|
|
}
|
|
|
|
with self.assertRaises(rupture_repair_store.RuptureRepairConflictError):
|
|
await rupture_repair_store._existing_by_idempotency(
|
|
conn,
|
|
table="app.rupture_observation_event",
|
|
id_column="observation_id",
|
|
session_id=uuid4(),
|
|
idempotency_key=uuid4(),
|
|
content_hash="b" * 64,
|
|
)
|
|
|
|
async def test_evaluator_observation_creates_episode_and_event(self) -> None:
|
|
session_id = uuid4()
|
|
case_id = uuid4()
|
|
learner_id = uuid4()
|
|
episode_id = uuid4()
|
|
observation_id = uuid4()
|
|
turn_id = uuid4()
|
|
conn = AsyncMock()
|
|
conn.fetchrow.side_effect = [
|
|
{"id": session_id, "case_id": case_id, "learner_id": learner_id},
|
|
{"episode_id": episode_id},
|
|
None,
|
|
{"observation_id": observation_id},
|
|
]
|
|
|
|
result = await rupture_repair_store.append_evaluator_observation(
|
|
conn=conn,
|
|
session_id=session_id,
|
|
episode_key="episode-1",
|
|
idempotency_key=uuid4(),
|
|
event_kind="rupture.detected",
|
|
from_state=None,
|
|
to_state="onset",
|
|
rupture_type="withdrawal",
|
|
source_kind="observed_runtime",
|
|
perspective="runtime_observation",
|
|
ai_view="evaluator",
|
|
confidence=0.8,
|
|
uncertainty=0.2,
|
|
evidence_turn_ids=(turn_id,),
|
|
counterevidence=(),
|
|
model_run_id=None,
|
|
)
|
|
|
|
self.assertEqual(
|
|
result,
|
|
{"episode_id": episode_id, "observation_id": observation_id},
|
|
)
|
|
self.assertEqual(conn.fetchrow.await_count, 4)
|
|
|
|
async def test_evaluator_store_rejects_non_evaluator_ai_view_before_db(self) -> None:
|
|
conn = AsyncMock()
|
|
with self.assertRaisesRegex(
|
|
rupture_repair_store.RuptureRepairStateError,
|
|
"ai_view=evaluator",
|
|
):
|
|
await rupture_repair_store.append_evaluator_observation(
|
|
conn=conn,
|
|
session_id=uuid4(),
|
|
episode_key="episode-1",
|
|
idempotency_key=uuid4(),
|
|
event_kind="rupture.detected",
|
|
from_state=None,
|
|
to_state="onset",
|
|
rupture_type="withdrawal",
|
|
source_kind="observed_runtime",
|
|
perspective="runtime_observation",
|
|
ai_view="supervisor",
|
|
confidence=0.8,
|
|
uncertainty=0.2,
|
|
evidence_turn_ids=(uuid4(),),
|
|
counterevidence=(),
|
|
model_run_id=None,
|
|
)
|
|
conn.execute.assert_not_awaited()
|
|
|
|
async def test_learner_cannot_append_human_correction(self) -> None:
|
|
with self.assertRaisesRegex(
|
|
rupture_repair_store.RuptureRepairStateError,
|
|
"teacher or admin",
|
|
):
|
|
await rupture_repair_store.append_human_correction(
|
|
principal=_principal(Role.LEARNER),
|
|
session_id=uuid4(),
|
|
episode_id=uuid4(),
|
|
idempotency_key=uuid4(),
|
|
supersedes_observation_id=uuid4(),
|
|
rupture_type="withdrawal",
|
|
corrected_status="partial",
|
|
uncertainty=0.1,
|
|
evidence_turn_ids=(uuid4(),),
|
|
counterevidence=(),
|
|
correction_reason="근거 재평가",
|
|
)
|
|
|
|
async def test_teacher_correction_appends_human_rated_supersession(self) -> None:
|
|
principal = _principal(Role.TEACHER)
|
|
session_id = uuid4()
|
|
episode_id = uuid4()
|
|
target_id = uuid4()
|
|
correction_id = uuid4()
|
|
conn = AsyncMock()
|
|
conn.fetchrow.side_effect = [
|
|
{"id": session_id, "case_id": uuid4(), "learner_id": uuid4()},
|
|
{"to_state": "partial"},
|
|
None,
|
|
{"observation_id": correction_id},
|
|
]
|
|
|
|
@asynccontextmanager
|
|
async def fake_acquire(**kwargs):
|
|
self.assertEqual(kwargs["role"], "teacher")
|
|
self.assertEqual(kwargs["cohort_ids"], ["g3-cohort"])
|
|
yield conn
|
|
|
|
with patch.object(rupture_repair_store.db, "acquire", fake_acquire):
|
|
result = await rupture_repair_store.append_human_correction(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
episode_id=episode_id,
|
|
idempotency_key=uuid4(),
|
|
supersedes_observation_id=target_id,
|
|
rupture_type="withdrawal",
|
|
corrected_status="resolved",
|
|
uncertainty=0.1,
|
|
evidence_turn_ids=(uuid4(),),
|
|
counterevidence=(),
|
|
correction_reason="후속 반응 근거 확인",
|
|
)
|
|
|
|
self.assertEqual(result, correction_id)
|
|
insert_args = conn.fetchrow.await_args_list[-1].args
|
|
self.assertIn("human.corrected", insert_args[0])
|
|
self.assertIn("human_rated", insert_args[0])
|
|
self.assertEqual(insert_args[5], "partial")
|
|
self.assertEqual(insert_args[6], "resolved")
|
|
|
|
async def test_empty_read_model_is_role_safe_for_learner(self) -> None:
|
|
principal = _principal(Role.LEARNER)
|
|
session_id = uuid4()
|
|
conn = AsyncMock()
|
|
conn.fetchrow.return_value = {
|
|
"id": session_id,
|
|
"case_id": uuid4(),
|
|
"learner_id": UUID(principal.user_id),
|
|
}
|
|
conn.fetch.return_value = []
|
|
|
|
@asynccontextmanager
|
|
async def fake_acquire(**kwargs):
|
|
self.assertEqual(kwargs["role"], "learner")
|
|
yield conn
|
|
|
|
with patch.object(rupture_repair_store.db, "acquire", fake_acquire):
|
|
result = await rupture_repair_store.read_rupture_repairs(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
)
|
|
|
|
self.assertEqual(result["requested_view"], "counselor")
|
|
self.assertEqual(result["episodes"], [])
|
|
self.assertFalse(result["clinical_claim_allowed"])
|
|
episode_query = conn.fetch.await_args.args
|
|
self.assertEqual(episode_query[-1], "counselor")
|
|
|
|
async def test_get_route_maps_not_found_to_404(self) -> None:
|
|
with patch.object(
|
|
rupture_repairs.rupture_repair_store,
|
|
"read_rupture_repairs",
|
|
AsyncMock(
|
|
side_effect=rupture_repair_store.RuptureRepairNotFoundError(
|
|
"not visible"
|
|
)
|
|
),
|
|
):
|
|
with self.assertRaises(HTTPException) as captured:
|
|
await rupture_repairs.get_rupture_repairs(
|
|
session_id=uuid4(),
|
|
principal=_principal(),
|
|
)
|
|
self.assertEqual(captured.exception.status_code, 404)
|
|
|
|
async def test_internal_route_forwards_explicit_ai_view(self) -> None:
|
|
episode_id = uuid4()
|
|
observation_id = uuid4()
|
|
body = rupture_repairs.InternalRuptureObservationRequest(
|
|
episode_key="episode-1",
|
|
idempotency_key=uuid4(),
|
|
event_kind="rupture.detected",
|
|
from_state=None,
|
|
to_state="onset",
|
|
rupture_type="withdrawal",
|
|
source_kind="observed_runtime",
|
|
perspective="runtime_observation",
|
|
ai_view="evaluator",
|
|
confidence=0.8,
|
|
uncertainty=0.2,
|
|
evidence_turn_ids=[uuid4()],
|
|
)
|
|
mocked = AsyncMock(
|
|
return_value={
|
|
"episode_id": episode_id,
|
|
"observation_id": observation_id,
|
|
}
|
|
)
|
|
with patch.object(
|
|
rupture_repairs.rupture_repair_store,
|
|
"append_evaluator_observation",
|
|
mocked,
|
|
):
|
|
response = await rupture_repairs.create_internal_rupture_observation(
|
|
session_id=uuid4(),
|
|
body=body,
|
|
conn=AsyncMock(),
|
|
)
|
|
|
|
self.assertEqual(response.observation_id, observation_id)
|
|
self.assertEqual(mocked.await_args.kwargs["ai_view"], "evaluator")
|
|
|
|
|
|
class RuptureInternalAuthenticationTests(unittest.IsolatedAsyncioTestCase):
|
|
TOKEN = "g3-test-token-with-at-least-32-characters-0001"
|
|
|
|
@staticmethod
|
|
def _settings(token: str):
|
|
return rupture_repairs.Settings(
|
|
_env_file=None,
|
|
rupture_internal_token=token,
|
|
)
|
|
|
|
async def test_unconfigured_token_disables_endpoint_before_db_acquire(self) -> None:
|
|
reached = False
|
|
|
|
async def fake_provider():
|
|
nonlocal reached
|
|
reached = True
|
|
yield AsyncMock()
|
|
|
|
dependency = rupture_repairs.rupture_internal_evaluator_db(
|
|
settings=self._settings(""),
|
|
presented_token=None,
|
|
)
|
|
with patch.object(
|
|
rupture_repairs, "_evaluator_db_provider", fake_provider
|
|
):
|
|
with self.assertRaises(HTTPException) as captured:
|
|
await anext(dependency)
|
|
|
|
self.assertEqual(captured.exception.status_code, 503)
|
|
self.assertEqual(
|
|
captured.exception.detail,
|
|
"internal rupture ingestion is unavailable",
|
|
)
|
|
self.assertFalse(reached)
|
|
|
|
async def test_missing_header_is_401_before_db_acquire(self) -> None:
|
|
reached = False
|
|
|
|
async def fake_provider():
|
|
nonlocal reached
|
|
reached = True
|
|
yield AsyncMock()
|
|
|
|
dependency = rupture_repairs.rupture_internal_evaluator_db(
|
|
settings=self._settings(self.TOKEN),
|
|
presented_token=None,
|
|
)
|
|
with patch.object(
|
|
rupture_repairs, "_evaluator_db_provider", fake_provider
|
|
):
|
|
with self.assertRaises(HTTPException) as captured:
|
|
await anext(dependency)
|
|
|
|
self.assertEqual(captured.exception.status_code, 401)
|
|
self.assertFalse(reached)
|
|
|
|
async def test_mismatched_header_is_403_and_uses_compare_digest(self) -> None:
|
|
reached = False
|
|
|
|
async def fake_provider():
|
|
nonlocal reached
|
|
reached = True
|
|
yield AsyncMock()
|
|
|
|
dependency = rupture_repairs.rupture_internal_evaluator_db(
|
|
settings=self._settings(self.TOKEN),
|
|
presented_token="wrong-token-that-must-not-be-reflected",
|
|
)
|
|
with (
|
|
patch.object(rupture_repairs, "_evaluator_db_provider", fake_provider),
|
|
patch.object(
|
|
rupture_repairs.secrets,
|
|
"compare_digest",
|
|
wraps=rupture_repairs.secrets.compare_digest,
|
|
) as compared,
|
|
):
|
|
with self.assertRaises(HTTPException) as captured:
|
|
await anext(dependency)
|
|
|
|
self.assertEqual(captured.exception.status_code, 403)
|
|
self.assertEqual(captured.exception.detail, "internal authentication failed")
|
|
compared.assert_called_once_with(
|
|
"wrong-token-that-must-not-be-reflected", self.TOKEN
|
|
)
|
|
self.assertNotIn("wrong-token", str(captured.exception.detail))
|
|
self.assertNotIn(self.TOKEN, str(captured.exception.detail))
|
|
self.assertFalse(reached)
|
|
|
|
async def test_valid_token_reaches_evaluator_db_provider(self) -> None:
|
|
connection = AsyncMock()
|
|
reached = 0
|
|
|
|
async def fake_provider():
|
|
nonlocal reached
|
|
reached += 1
|
|
yield connection
|
|
|
|
dependency = rupture_repairs.rupture_internal_evaluator_db(
|
|
settings=self._settings(self.TOKEN),
|
|
presented_token=self.TOKEN,
|
|
)
|
|
with patch.object(
|
|
rupture_repairs, "_evaluator_db_provider", fake_provider
|
|
):
|
|
result = await anext(dependency)
|
|
await dependency.aclose()
|
|
|
|
self.assertIs(result, connection)
|
|
self.assertEqual(reached, 1)
|
|
|
|
async def test_short_configured_token_is_fail_closed_as_unavailable(self) -> None:
|
|
dependency = rupture_repairs.rupture_internal_evaluator_db(
|
|
settings=self._settings("too-short"),
|
|
presented_token="too-short",
|
|
)
|
|
with self.assertRaises(HTTPException) as captured:
|
|
await anext(dependency)
|
|
|
|
self.assertEqual(captured.exception.status_code, 503)
|
|
|
|
async def test_secret_setting_repr_and_json_do_not_expose_token(self) -> None:
|
|
settings = self._settings(self.TOKEN)
|
|
|
|
self.assertNotIn(self.TOKEN, repr(settings))
|
|
self.assertNotIn(self.TOKEN, settings.model_dump_json())
|
|
self.assertEqual(
|
|
settings.rupture_internal_token.get_secret_value(),
|
|
self.TOKEN,
|
|
)
|
|
|
|
async def test_openapi_documents_header_only_on_internal_writes(self) -> None:
|
|
app = FastAPI()
|
|
app.include_router(rupture_repairs.router)
|
|
paths = app.openapi()["paths"]
|
|
|
|
for path in (
|
|
"/internal/sessions/{session_id}/ruptures/observations",
|
|
"/internal/sessions/{session_id}/ruptures/{episode_id}/reconciliations",
|
|
):
|
|
header_names = {
|
|
item["name"]
|
|
for item in paths[path]["post"].get("parameters", [])
|
|
if item["in"] == "header"
|
|
}
|
|
self.assertEqual(
|
|
header_names,
|
|
{rupture_repairs.INTERNAL_TOKEN_HEADER},
|
|
)
|
|
|
|
for path, method in (
|
|
("/sessions/{session_id}/ruptures", "get"),
|
|
("/sessions/{session_id}/ruptures/{episode_id}/corrections", "post"),
|
|
):
|
|
header_names = {
|
|
item["name"]
|
|
for item in paths[path][method].get("parameters", [])
|
|
if item["in"] == "header"
|
|
}
|
|
self.assertNotIn(rupture_repairs.INTERNAL_TOKEN_HEADER, header_names)
|
|
|
|
|
|
class RuptureRepairSchemaStaticTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.sql = (
|
|
Path(__file__).resolve().parents[3]
|
|
/ "infra"
|
|
/ "db"
|
|
/ "init"
|
|
/ "09_rupture_repair.sql"
|
|
).read_text(encoding="utf-8")
|
|
|
|
def test_schema_has_four_append_only_ledgers_and_rls(self) -> None:
|
|
for table in (
|
|
"app.rupture_episode",
|
|
"app.rupture_observation_event",
|
|
"app.rupture_reconciliation_revision",
|
|
"app.rupture_safety_reference",
|
|
):
|
|
self.assertIn(f"CREATE TABLE IF NOT EXISTS {table}", self.sql)
|
|
self.assertIn(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY", self.sql)
|
|
self.assertEqual(self.sql.count("audit.reject_measurement_mutation()"), 4)
|
|
|
|
def test_schema_requires_turn_ownership_model_run_and_idempotency(self) -> None:
|
|
self.assertIn("rupture evidence turns must belong to its session", self.sql)
|
|
self.assertIn("rupture model_run must belong to its session", self.sql)
|
|
self.assertIn("reconciliation model_run must belong to its session", self.sql)
|
|
self.assertGreaterEqual(self.sql.count("UNIQUE (session_id, idempotency_key)"), 2)
|
|
|
|
def test_safety_table_is_reference_only(self) -> None:
|
|
safety_sql = self.sql.split(
|
|
"CREATE TABLE IF NOT EXISTS app.rupture_safety_reference", 1
|
|
)[1].split(");", 1)[0]
|
|
self.assertIn("safety_event_id", safety_sql)
|
|
self.assertNotIn("confidence", safety_sql)
|
|
self.assertNotIn("status", safety_sql)
|
|
self.assertNotIn("score", safety_sql)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|