vignette/apps/api/app/test_outcome_trajectory_store.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

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()