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 산출물은 커밋에서 제외했다.
541 lines
21 KiB
Python
541 lines
21 KiB
Python
"""Focused G2 persistence adapter and HTTP boundary tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import UUID
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import ValidationError
|
|
|
|
from .contracts.outcome_trajectory import LongitudinalOutcomeInput
|
|
from .deps import Principal, Role, get_current_principal
|
|
from .routes import outcome_trajectories
|
|
from .services import outcome_trajectory_store
|
|
from .services.outcome_trajectory import (
|
|
assess_longitudinal_outcome,
|
|
load_trajectory_benchmark,
|
|
)
|
|
|
|
|
|
SESSION_ID = UUID("00000000-0000-0000-0000-00000000b101")
|
|
MEASUREMENT_ID = UUID("00000000-0000-0000-0000-00000000b102")
|
|
TURN_ID = UUID("00000000-0000-0000-0000-00000000b103")
|
|
REVISION_ID = UUID("00000000-0000-0000-0000-00000000b104")
|
|
SUBMISSION_ID = UUID("00000000-0000-0000-0000-00000000b108")
|
|
MEMORY_EVENT_ID = UUID("00000000-0000-0000-0000-00000000b109")
|
|
BENCHMARK_PATH = (
|
|
Path(__file__).resolve().parent
|
|
/ "data"
|
|
/ "outcome_trajectory_benchmark_g2.v1.json"
|
|
)
|
|
|
|
|
|
def _principal(role: Role = Role.LEARNER) -> Principal:
|
|
return Principal(
|
|
user_id="00000000-0000-0000-0000-00000000b105",
|
|
role=role,
|
|
cohort_ids=["e2e-hanshin"],
|
|
)
|
|
|
|
|
|
def _measurement(**overrides: object) -> dict[str, object]:
|
|
row: dict[str, object] = {
|
|
"measurement_id": MEASUREMENT_ID,
|
|
"status": "ready",
|
|
"error_code": None,
|
|
"value": 6.0,
|
|
"scale_min": 0.0,
|
|
"scale_max": 10.0,
|
|
"confidence": 0.8,
|
|
"source_kind": "simulated_state",
|
|
"perspective": "client_simulation",
|
|
"instrument_id": "vignette-session-outcome",
|
|
"instrument_version": "1.0.0",
|
|
"model_run_id": None,
|
|
"evidence_turn_ids": (TURN_ID,),
|
|
"created_at": datetime(2026, 8, 6, 8, 0, tzinfo=UTC),
|
|
}
|
|
row.update(overrides)
|
|
return row
|
|
|
|
|
|
class OutcomeObservationAdapterTest(unittest.TestCase):
|
|
def test_ready_measurement_is_normalized_with_full_provenance(self) -> None:
|
|
observation, snapshot = outcome_trajectory_store._observation_from_measurement(
|
|
session_no=1,
|
|
axis="distress_load",
|
|
measurement=_measurement(),
|
|
)
|
|
|
|
self.assertEqual(observation.status, "observed")
|
|
self.assertEqual(observation.value, 0.6)
|
|
self.assertEqual(observation.evidence_refs, (str(TURN_ID),))
|
|
self.assertEqual(snapshot["raw_value"], 6.0)
|
|
self.assertEqual(snapshot["measurement_id"], MEASUREMENT_ID)
|
|
|
|
def test_simulated_state_without_turn_ids_uses_measurement_provenance(self) -> None:
|
|
observation, snapshot = outcome_trajectory_store._observation_from_measurement(
|
|
session_no=1,
|
|
axis="daily_functioning",
|
|
measurement=_measurement(evidence_turn_ids=()),
|
|
)
|
|
|
|
self.assertEqual(observation.status, "observed")
|
|
self.assertEqual(observation.evidence_refs, (f"measurement:{MEASUREMENT_ID}",))
|
|
self.assertEqual(snapshot["raw_value"], 6.0)
|
|
|
|
def test_learner_self_report_is_its_own_evidence_without_turn_ids(self) -> None:
|
|
observation, snapshot = outcome_trajectory_store._observation_from_measurement(
|
|
session_no=1,
|
|
axis="daily_functioning",
|
|
measurement=_measurement(
|
|
source_kind="learner_reported",
|
|
perspective="learner_self_report",
|
|
instrument_id="vignette-session-outcome-checkin",
|
|
evidence_turn_ids=(),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(observation.status, "observed")
|
|
self.assertEqual(
|
|
observation.evidence_refs,
|
|
(f"measurement:{MEASUREMENT_ID}",),
|
|
)
|
|
self.assertEqual(snapshot["evidence_refs"], observation.evidence_refs)
|
|
|
|
def test_model_observation_still_requires_transcript_evidence(self) -> None:
|
|
observation, _ = outcome_trajectory_store._observation_from_measurement(
|
|
session_no=1,
|
|
axis="daily_functioning",
|
|
measurement=_measurement(
|
|
source_kind="model_inferred",
|
|
perspective="independent_observer",
|
|
model_run_id=UUID("00000000-0000-0000-0000-00000000b110"),
|
|
evidence_turn_ids=(),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(observation.status, "missing")
|
|
self.assertEqual(observation.missing_reason, "measurement_evidence_missing")
|
|
|
|
def test_error_measurement_never_reuses_its_stale_value(self) -> None:
|
|
observation, snapshot = outcome_trajectory_store._observation_from_measurement(
|
|
session_no=1,
|
|
axis="learning_engagement",
|
|
measurement=_measurement(status="error", error_code="engine_timeout"),
|
|
)
|
|
|
|
self.assertEqual(observation.status, "error")
|
|
self.assertIsNone(observation.value)
|
|
self.assertEqual(
|
|
observation.missing_reason, "measurement_error:engine_timeout"
|
|
)
|
|
self.assertIsNone(snapshot["value"])
|
|
|
|
def test_fingerprint_changes_with_source_measurement_revision(self) -> None:
|
|
_, snapshot = outcome_trajectory_store._observation_from_measurement(
|
|
session_no=1,
|
|
axis="distress_load",
|
|
measurement=_measurement(),
|
|
)
|
|
changed = dict(snapshot)
|
|
changed["measurement_id"] = UUID(
|
|
"00000000-0000-0000-0000-00000000b106"
|
|
)
|
|
|
|
first = outcome_trajectory_store._evidence_fingerprint(
|
|
expected_arc_hash="a" * 64, snapshots=[snapshot]
|
|
)
|
|
second = outcome_trajectory_store._evidence_fingerprint(
|
|
expected_arc_hash="a" * 64, snapshots=[changed]
|
|
)
|
|
|
|
self.assertNotEqual(first, second)
|
|
|
|
def test_human_view_keeps_learner_and_supervisor_memory_separate(self) -> None:
|
|
self.assertEqual(outcome_trajectory_store._human_view(_principal()), "counselor")
|
|
self.assertEqual(
|
|
outcome_trajectory_store._human_view(_principal(Role.TEACHER)),
|
|
"supervisor",
|
|
)
|
|
|
|
def test_submission_idempotency_returns_same_three_measurements(self) -> None:
|
|
submission_hash = outcome_trajectory_store._submission_hash(
|
|
submission_id=SUBMISSION_ID,
|
|
scores={axis: 0.5 for axis in ("distress_load", "daily_functioning", "learning_engagement")},
|
|
confidences={axis: 0.8 for axis in ("distress_load", "daily_functioning", "learning_engagement")},
|
|
evidence_turn_ids=(),
|
|
)
|
|
ids = [
|
|
UUID("00000000-0000-0000-0000-00000000b11" + str(index))
|
|
for index in range(1, 4)
|
|
]
|
|
rows = [
|
|
{
|
|
"measurement_id": measurement_id,
|
|
"dimension": axis,
|
|
"metadata": {"submission_hash": submission_hash},
|
|
}
|
|
for axis, measurement_id in zip(
|
|
("distress_load", "daily_functioning", "learning_engagement"),
|
|
ids,
|
|
strict=True,
|
|
)
|
|
]
|
|
|
|
self.assertEqual(
|
|
outcome_trajectory_store._existing_submission_measurement_ids(
|
|
rows, submission_hash=submission_hash
|
|
),
|
|
ids,
|
|
)
|
|
with self.assertRaises(outcome_trajectory_store.OutcomeTrajectoryConflictError):
|
|
outcome_trajectory_store._existing_submission_measurement_ids(
|
|
rows, submission_hash="c" * 64
|
|
)
|
|
|
|
|
|
class OutcomeTrajectoryRouteTest(unittest.IsolatedAsyncioTestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
pack = load_trajectory_benchmark(BENCHMARK_PATH)
|
|
assessment = assess_longitudinal_outcome(
|
|
LongitudinalOutcomeInput(
|
|
expected_arc=pack.expected_arc,
|
|
sessions=(pack.cases[0].sessions[0],),
|
|
)
|
|
).model_dump(mode="json")
|
|
assessment["sessions"][0]["safety_signals"] = []
|
|
cls.payload = {
|
|
"session_id": SESSION_ID,
|
|
"revision_id": REVISION_ID,
|
|
"revision_no": 1,
|
|
"supersedes_revision_id": None,
|
|
"source_fingerprint": "b" * 64,
|
|
"recompute_reason": "initial_computation",
|
|
"computed_at": "2026-08-06T08:00:00+00:00",
|
|
"notice_ko": outcome_trajectory_store.NON_CLINICAL_NOTICE_KO,
|
|
"expected_arc": {
|
|
"schema_version": pack.expected_arc.schema_version,
|
|
"arc_id": pack.expected_arc.arc_id,
|
|
"title_ko": pack.expected_arc.title_ko,
|
|
"data_classification": "synthetic_educational",
|
|
"clinical_claim_allowed": False,
|
|
"provenance_note": pack.expected_arc.provenance_note,
|
|
"session_count": 5,
|
|
"distributions": [
|
|
item.model_dump(mode="json")
|
|
for item in pack.expected_arc.distributions
|
|
],
|
|
},
|
|
"assessment": assessment,
|
|
"next_questions": [],
|
|
"observations": [
|
|
{
|
|
"measurement_id": MEASUREMENT_ID,
|
|
"session_id": SESSION_ID,
|
|
"session_no": 1,
|
|
"axis": axis,
|
|
"status": "observed",
|
|
"value": 0.5,
|
|
"raw_value": 0.5,
|
|
"scale_min": 0.0,
|
|
"scale_max": 1.0,
|
|
"confidence": 0.8,
|
|
"source_kind": "simulated_state",
|
|
"perspective": "client_simulation",
|
|
"instrument_id": "vignette-session-outcome",
|
|
"instrument_version": "1.0.0",
|
|
"model_run_id": None,
|
|
"evidence_refs": [str(TURN_ID)],
|
|
"missing_reason": None,
|
|
"occurred_at": "2026-08-06T07:59:00+00:00",
|
|
}
|
|
for axis in (
|
|
"distress_load",
|
|
"daily_functioning",
|
|
"learning_engagement",
|
|
)
|
|
],
|
|
"safety_signals": [
|
|
{
|
|
"safety_event_id": "44",
|
|
"session_no": 1,
|
|
"risk_level": "high",
|
|
"escalated": True,
|
|
"evidence_refs": [str(TURN_ID)],
|
|
}
|
|
],
|
|
"relationship_memory": [],
|
|
}
|
|
|
|
async def test_get_exposes_synthetic_nonclinical_label_and_separate_safety(self) -> None:
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"read_outcome_trajectory",
|
|
AsyncMock(return_value=self.payload),
|
|
) as read:
|
|
response = await outcome_trajectories.get_outcome_trajectory(
|
|
SESSION_ID, _principal()
|
|
)
|
|
|
|
self.assertFalse(response.expected_arc.clinical_claim_allowed)
|
|
self.assertEqual(
|
|
response.expected_arc.data_classification, "synthetic_educational"
|
|
)
|
|
self.assertEqual(len(response.safety_signals), 1)
|
|
self.assertEqual(response.assessment.sessions[0].safety_signals, ())
|
|
read.assert_awaited_once_with(
|
|
principal=unittest.mock.ANY,
|
|
session_id=SESSION_ID,
|
|
)
|
|
|
|
async def test_recompute_always_requests_new_revision(self) -> None:
|
|
changed = dict(self.payload)
|
|
changed.update(
|
|
{
|
|
"revision_no": 2,
|
|
"supersedes_revision_id": REVISION_ID,
|
|
"revision_id": UUID("00000000-0000-0000-0000-00000000b107"),
|
|
"recompute_reason": "교수자 재검토",
|
|
}
|
|
)
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"read_outcome_trajectory",
|
|
AsyncMock(return_value=changed),
|
|
) as read:
|
|
response = await outcome_trajectories.recompute_outcome_trajectory(
|
|
SESSION_ID,
|
|
outcome_trajectories.OutcomeTrajectoryRecomputeRequest(
|
|
reason=" 교수자 재검토 "
|
|
),
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(response.revision_no, 2)
|
|
self.assertEqual(response.supersedes_revision_id, REVISION_ID)
|
|
read.assert_awaited_once_with(
|
|
principal=unittest.mock.ANY,
|
|
session_id=SESSION_ID,
|
|
force_recompute=True,
|
|
recompute_reason="교수자 재검토",
|
|
)
|
|
|
|
async def test_hidden_session_maps_to_404(self) -> None:
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"read_outcome_trajectory",
|
|
AsyncMock(
|
|
side_effect=outcome_trajectory_store.OutcomeTrajectoryNotFoundError(
|
|
"session not found or not visible"
|
|
)
|
|
),
|
|
):
|
|
with self.assertRaises(HTTPException) as raised:
|
|
await outcome_trajectories.get_outcome_trajectory(
|
|
SESSION_ID, _principal(Role.TEACHER)
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 404)
|
|
|
|
async def test_outcome_submission_returns_latest_trajectory_and_event_ids(self) -> None:
|
|
payload = dict(self.payload)
|
|
payload.update(
|
|
{
|
|
"submission_id": SUBMISSION_ID,
|
|
"submitted_measurement_ids": [
|
|
UUID("00000000-0000-0000-0000-00000000b111"),
|
|
UUID("00000000-0000-0000-0000-00000000b112"),
|
|
UUID("00000000-0000-0000-0000-00000000b113"),
|
|
],
|
|
}
|
|
)
|
|
body = outcome_trajectories.OutcomeObservationSubmissionRequest(
|
|
submission_id=SUBMISSION_ID,
|
|
scores=outcome_trajectories.OutcomeAxisValues(
|
|
distress_load=0.7,
|
|
daily_functioning=0.4,
|
|
learning_engagement=0.8,
|
|
),
|
|
confidences=outcome_trajectories.OutcomeAxisValues(
|
|
distress_load=0.9,
|
|
daily_functioning=0.8,
|
|
learning_engagement=0.85,
|
|
),
|
|
)
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"submit_outcome_observations",
|
|
AsyncMock(return_value=payload),
|
|
) as submit:
|
|
response = await outcome_trajectories.create_outcome_observations(
|
|
SESSION_ID, body, _principal()
|
|
)
|
|
|
|
self.assertEqual(response.submission_id, SUBMISSION_ID)
|
|
self.assertEqual(len(response.submitted_measurement_ids), 3)
|
|
self.assertEqual(response.revision_id, REVISION_ID)
|
|
submit.assert_awaited_once_with(
|
|
principal=unittest.mock.ANY,
|
|
session_id=SESSION_ID,
|
|
submission_id=SUBMISSION_ID,
|
|
scores={
|
|
"distress_load": 0.7,
|
|
"daily_functioning": 0.4,
|
|
"learning_engagement": 0.8,
|
|
},
|
|
confidences={
|
|
"distress_load": 0.9,
|
|
"daily_functioning": 0.8,
|
|
"learning_engagement": 0.85,
|
|
},
|
|
evidence_turn_ids=(),
|
|
)
|
|
|
|
async def test_submission_content_conflict_maps_to_409(self) -> None:
|
|
body = outcome_trajectories.OutcomeObservationSubmissionRequest(
|
|
submission_id=SUBMISSION_ID,
|
|
scores=outcome_trajectories.OutcomeAxisValues(
|
|
distress_load=0.5,
|
|
daily_functioning=0.5,
|
|
learning_engagement=0.5,
|
|
),
|
|
confidences=outcome_trajectories.OutcomeAxisValues(
|
|
distress_load=0.8,
|
|
daily_functioning=0.8,
|
|
learning_engagement=0.8,
|
|
),
|
|
)
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"submit_outcome_observations",
|
|
AsyncMock(
|
|
side_effect=outcome_trajectory_store.OutcomeTrajectoryConflictError(
|
|
"submission_id was already used"
|
|
)
|
|
),
|
|
):
|
|
with self.assertRaises(HTTPException) as raised:
|
|
await outcome_trajectories.create_outcome_observations(
|
|
SESSION_ID, body, _principal()
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 409)
|
|
|
|
async def test_relationship_memory_route_preserves_resolve_and_evidence(self) -> None:
|
|
body = outcome_trajectories.RelationshipMemoryCreateRequest(
|
|
event_type="repair_confirmed",
|
|
summaries={
|
|
"supervisor": "과제 부담을 재확인하고 더 작은 연습으로 합의했다."
|
|
},
|
|
evidence_turn_ids=(TURN_ID,),
|
|
resolves_event_id=MEMORY_EVENT_ID,
|
|
)
|
|
created_id = UUID("00000000-0000-0000-0000-00000000b114")
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"append_relationship_memory_event",
|
|
AsyncMock(return_value=created_id),
|
|
) as append:
|
|
response = await outcome_trajectories.create_relationship_memory_event(
|
|
SESSION_ID, body, _principal(Role.TEACHER)
|
|
)
|
|
|
|
self.assertEqual(response.memory_event_id, created_id)
|
|
append.assert_awaited_once_with(
|
|
principal=unittest.mock.ANY,
|
|
session_id=SESSION_ID,
|
|
event_type="repair_confirmed",
|
|
summaries={
|
|
"supervisor": "과제 부담을 재확인하고 더 작은 연습으로 합의했다."
|
|
},
|
|
evidence_turn_ids=(TURN_ID,),
|
|
resolves_event_id=MEMORY_EVENT_ID,
|
|
)
|
|
|
|
def test_relationship_request_rejects_invalid_resolve_contract(self) -> None:
|
|
with self.assertRaises(ValidationError):
|
|
outcome_trajectories.RelationshipMemoryCreateRequest(
|
|
event_type="goal_agreement",
|
|
summaries={"supervisor": "목표 합의"},
|
|
evidence_turn_ids=(TURN_ID,),
|
|
resolves_event_id=MEMORY_EVENT_ID,
|
|
)
|
|
with self.assertRaises(ValidationError):
|
|
outcome_trajectories.RelationshipMemoryCreateRequest(
|
|
event_type="repair_confirmed",
|
|
summaries={"supervisor": "복구 확인"},
|
|
evidence_turn_ids=(TURN_ID,),
|
|
)
|
|
|
|
async def test_learner_cannot_author_relationship_memory(self) -> None:
|
|
with self.assertRaises(outcome_trajectory_store.OutcomeTrajectoryStateError):
|
|
await outcome_trajectory_store.append_relationship_memory_event(
|
|
principal=_principal(Role.LEARNER),
|
|
session_id=SESSION_ID,
|
|
event_type="goal_agreement",
|
|
summaries={"counselor": "목표 합의"},
|
|
evidence_turn_ids=(TURN_ID,),
|
|
)
|
|
|
|
|
|
class OutcomeTrajectoryHttpRoleTest(unittest.TestCase):
|
|
def _client(self, principal: Principal) -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(outcome_trajectories.router)
|
|
app.dependency_overrides[get_current_principal] = lambda: principal
|
|
return TestClient(app)
|
|
|
|
def test_learner_is_denied_relationship_memory_authoring(self) -> None:
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"append_relationship_memory_event",
|
|
AsyncMock(),
|
|
) as append:
|
|
response = self._client(_principal(Role.LEARNER)).post(
|
|
f"/sessions/{SESSION_ID}/relationship-memory-events",
|
|
json={
|
|
"event_type": "goal_agreement",
|
|
"summaries": {"counselor": "회기 목표를 합의했다."},
|
|
"evidence_turn_ids": [str(TURN_ID)],
|
|
},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
append.assert_not_awaited()
|
|
|
|
def test_teacher_is_denied_learner_outcome_submission(self) -> None:
|
|
with patch.object(
|
|
outcome_trajectories.outcome_trajectory_store,
|
|
"submit_outcome_observations",
|
|
AsyncMock(),
|
|
) as submit:
|
|
response = self._client(_principal(Role.TEACHER)).post(
|
|
f"/sessions/{SESSION_ID}/outcome-observations",
|
|
json={
|
|
"submission_id": str(SUBMISSION_ID),
|
|
"scores": {
|
|
"distress_load": 0.5,
|
|
"daily_functioning": 0.5,
|
|
"learning_engagement": 0.5,
|
|
},
|
|
"confidences": {
|
|
"distress_load": 0.8,
|
|
"daily_functioning": 0.8,
|
|
"learning_engagement": 0.8,
|
|
},
|
|
},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
submit.assert_not_awaited()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|