vignette/apps/api/app/test_supervision_research_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

226 lines
7.9 KiB
Python

from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from fastapi import HTTPException
from pydantic import SecretStr
from .contracts.supervision_research import (
LearnerAttentionSignal,
LedgerEvidencePointer,
)
from .routes import supervision_research
from .services import supervision_research_store
def _pointer() -> LedgerEvidencePointer:
return LedgerEvidencePointer(
ledger="measurement_event",
event_id=str(uuid4()),
session_id=str(uuid4()),
route_hint="/sessions/{session_id}/measurements",
)
def _signal() -> LearnerAttentionSignal:
return LearnerAttentionSignal(
signal_id="oas-g6-signal-attention-one",
learner_ref="learner-alpha",
signal_type="deterioration",
severity="high",
state="active",
uncertainty=0.2,
observed_sequence=1,
evidence=(_pointer(),),
)
class SupervisionResearchStoreIdempotencyTests(unittest.IsolatedAsyncioTestCase):
async def test_same_submission_returns_stable_snapshot_without_children(self) -> None:
submission_id = uuid4()
snapshot_id = uuid4()
learner_id = uuid4()
conn = AsyncMock()
conn.fetchrow.return_value = {
"snapshot_id": snapshot_id,
"content_hash": "a" * 64,
}
with patch.object(
supervision_research_store, "_canonical_hash", return_value="a" * 64
):
result = await supervision_research_store.append_attention_snapshot(
conn,
submission_id=submission_id,
snapshot_id=snapshot_id,
cohort_id="cohort-a",
signals=[_signal()],
learner_ids_by_ref={"learner-alpha": learner_id},
)
self.assertEqual(result["snapshot_id"], snapshot_id)
self.assertTrue(result["idempotent_replay"])
self.assertFalse(result["clinical_claim_allowed"])
self.assertEqual(conn.execute.await_count, 1)
async def test_changed_submission_is_conflict(self) -> None:
conn = AsyncMock()
conn.fetchrow.return_value = {
"snapshot_id": uuid4(),
"content_hash": "b" * 64,
}
with patch.object(
supervision_research_store, "_canonical_hash", return_value="a" * 64
):
with self.assertRaises(
supervision_research_store.SupervisionResearchConflictError
):
await supervision_research_store.append_attention_snapshot(
conn,
submission_id=uuid4(),
snapshot_id=uuid4(),
cohort_id="cohort-a",
signals=[_signal()],
learner_ids_by_ref={"learner-alpha": uuid4()},
)
class SupervisionResearchInternalAuthenticationTests(
unittest.IsolatedAsyncioTestCase
):
TOKEN = "g6-supervision-research-token-at-least-32-characters"
async def _assert_rejected_before_db(
self,
dependency,
provider_name: str,
configured: str | None,
presented: str | None,
expected_status: int,
) -> None:
reached = False
async def fake_provider():
nonlocal reached
reached = True
yield AsyncMock()
settings = SimpleNamespace(
supervision_research_internal_token=SecretStr(configured or "")
)
with (
patch.object(supervision_research, provider_name, fake_provider),
):
generator = dependency(settings=settings, presented_token=presented)
with self.assertRaises(HTTPException) as captured:
await anext(generator)
self.assertEqual(captured.exception.status_code, expected_status)
self.assertFalse(reached)
async def test_supervisor_missing_configuration_is_503_before_db(self) -> None:
await self._assert_rejected_before_db(
supervision_research.supervision_research_internal_supervisor_db,
"_supervisor_db_provider",
None,
None,
503,
)
async def test_research_short_configuration_is_503_before_db(self) -> None:
await self._assert_rejected_before_db(
supervision_research.supervision_research_internal_research_db,
"_research_db_provider",
"short",
None,
503,
)
async def test_missing_header_is_401_before_db(self) -> None:
await self._assert_rejected_before_db(
supervision_research.supervision_research_internal_supervisor_db,
"_supervisor_db_provider",
self.TOKEN,
None,
401,
)
async def test_wrong_header_is_403_before_db(self) -> None:
await self._assert_rejected_before_db(
supervision_research.supervision_research_internal_research_db,
"_research_db_provider",
self.TOKEN,
"wrong-token",
403,
)
async def test_valid_tokens_reach_only_requested_ai_provider(self) -> None:
supervisor_conn = AsyncMock()
research_conn = AsyncMock()
async def fake_supervisor():
yield supervisor_conn
async def fake_research():
yield research_conn
with (
patch.object(
supervision_research, "_supervisor_db_provider", fake_supervisor
),
patch.object(supervision_research, "_research_db_provider", fake_research),
):
supervisor = (
supervision_research.supervision_research_internal_supervisor_db(
settings=SimpleNamespace(
supervision_research_internal_token=SecretStr(self.TOKEN)
),
presented_token=self.TOKEN
)
)
research = supervision_research.supervision_research_internal_research_db(
settings=SimpleNamespace(
supervision_research_internal_token=SecretStr(self.TOKEN)
),
presented_token=self.TOKEN
)
self.assertIs(await anext(supervisor), supervisor_conn)
self.assertIs(await anext(research), research_conn)
await supervisor.aclose()
await research.aclose()
class SupervisionResearchBoundaryTests(unittest.TestCase):
def test_changed_submission_maps_to_http_409(self) -> None:
with self.assertRaises(HTTPException) as captured:
supervision_research._raise_store_error(
supervision_research_store.SupervisionResearchConflictError(
"changed content"
)
)
self.assertEqual(captured.exception.status_code, 409)
def test_write_response_schemas_have_no_aggregate_score_fields(self) -> None:
forbidden = {"total", "total_score", "overall_score", "global_score"}
models = (
supervision_research.AttentionSnapshotResponse,
supervision_research.CurriculumGapResponse,
supervision_research.TeacherDisagreementResponse,
supervision_research.EvaluationComparisonResponse,
supervision_research.Phase3ManifestResponse,
)
for model in models:
self.assertTrue(
forbidden.isdisjoint(model.model_fields),
f"forbidden score field in {model.__name__}",
)
def test_teacher_request_forbids_transcript_payload(self) -> None:
schema_fields = supervision_research.TeacherDisagreementRequest.model_fields
self.assertNotIn("transcript", schema_fields)
self.assertNotIn("raw_transcript", schema_fields)
self.assertNotIn("utterance_text", schema_fields)
if __name__ == "__main__":
unittest.main()