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 산출물은 커밋에서 제외했다.
226 lines
7.9 KiB
Python
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()
|