"""Standalone typed HTTP boundary for G6 Supervision & Research OS.""" from __future__ import annotations import secrets from collections.abc import AsyncIterator from typing import Annotated, Literal from uuid import UUID import asyncpg from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from .. import db from ..config import Settings, get_settings from ..contracts.supervision_research import ( EvaluationVersionBatch, LedgerEvidencePointer, LearnerAttentionSignal, Phase3EvidenceArtifact, TeacherAiDisagreement, ) from ..deps import HumanDB, Principal, Role, require_role from ..services import supervision_research_producer, supervision_research_store router = APIRouter(tags=["supervision-research"]) INTERNAL_TOKEN_HEADER = "X-Vignette-Supervision-Research-Token" MIN_INTERNAL_TOKEN_LENGTH = 32 async def _supervisor_db_provider() -> AsyncIterator[asyncpg.Connection]: async with db.acquire(ai_view="supervisor", ai_context=True) as conn: yield conn async def _research_db_provider() -> AsyncIterator[asyncpg.Connection]: async with db.acquire(ai_view="research", ai_context=True) as conn: yield conn def _authenticate_internal(settings: Settings, presented_token: str | None) -> None: configured_token = settings.supervision_research_internal_token.get_secret_value() if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="internal supervision research ingestion is unavailable", ) if presented_token is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="internal authentication required", ) if not secrets.compare_digest(presented_token, configured_token): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="internal authentication failed", ) async def supervision_research_internal_supervisor_db( settings: Annotated[Settings, Depends(get_settings)], presented_token: Annotated[ str | None, Header(alias=INTERNAL_TOKEN_HEADER) ] = None, ) -> AsyncIterator[asyncpg.Connection]: """Authenticate before acquiring a supervisor-view connection.""" _authenticate_internal(settings, presented_token) async for conn in _supervisor_db_provider(): yield conn async def supervision_research_internal_research_db( settings: Annotated[Settings, Depends(get_settings)], presented_token: Annotated[ str | None, Header(alias=INTERNAL_TOKEN_HEADER) ] = None, ) -> AsyncIterator[asyncpg.Connection]: """Authenticate before acquiring a research-view connection.""" _authenticate_internal(settings, presented_token) async for conn in _research_db_provider(): yield conn SupervisorDB = Annotated[ asyncpg.Connection, Depends(supervision_research_internal_supervisor_db) ] ResearchDB = Annotated[ asyncpg.Connection, Depends(supervision_research_internal_research_db) ] TeacherPrincipal = Annotated[ Principal, Depends(require_role(Role.TEACHER, Role.ADMIN)) ] class LearnerRefMapping(BaseModel): model_config = ConfigDict(extra="forbid") learner_ref: str = Field(pattern=r"^learner-[a-z0-9-]+$") learner_id: UUID class AttentionSnapshotRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID snapshot_id: UUID cohort_id: str = Field(min_length=1, max_length=120) signals: list[LearnerAttentionSignal] = Field(min_length=1, max_length=1000) learners: list[LearnerRefMapping] = Field(min_length=1, max_length=1000) @field_validator("learners") @classmethod def unique_learner_mapping( cls, value: list[LearnerRefMapping] ) -> list[LearnerRefMapping]: refs = [item.learner_ref for item in value] ids = [item.learner_id for item in value] if len(refs) != len(set(refs)) or len(ids) != len(set(ids)): raise ValueError("attention learner mappings must be one-to-one") return value class AttentionSnapshotResponse(BaseModel): submission_id: UUID snapshot_id: UUID item_count: int = Field(ge=1) idempotent_replay: bool clinical_claim_allowed: Literal[False] = False class ScopedEvidence(BaseModel): model_config = ConfigDict(extra="forbid") learner_id: UUID pointer: LedgerEvidencePointer class CurriculumGapRequest(BaseModel): model_config = ConfigDict(extra="forbid") submission_id: UUID gap_snapshot_id: UUID cohort_id: str = Field(min_length=1, max_length=120) competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$") gap_kind: Literal[ "coverage", "growth_stagnation", "rupture_repair", "transfer", "calibration" ] status: Literal["observed", "monitoring", "insufficient_evidence"] uncertainty: float = Field(ge=0.0, le=1.0) affected_learner_count: int = Field(ge=0) evidence: list[ScopedEvidence] = Field(default_factory=list, max_length=1000) @model_validator(mode="after") def preserve_insufficient_state(self) -> "CurriculumGapRequest": if self.status == "insufficient_evidence": if self.evidence or self.uncertainty != 1.0: raise ValueError("insufficient curriculum gap must remain evidence-free") elif not self.evidence: raise ValueError("classified curriculum gap requires evidence") return self class CurriculumGapResponse(BaseModel): submission_id: UUID gap_snapshot_id: UUID idempotent_replay: bool clinical_claim_allowed: Literal[False] = False class TeacherDisagreementRequest(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) submission_id: UUID disagreement_record_id: UUID dataset_row_id: UUID audit_event_id: UUID learner_id: UUID cohort_id: str = Field(min_length=1, max_length=120) disagreement: TeacherAiDisagreement class TeacherDisagreementResponse(BaseModel): submission_id: UUID disagreement_record_id: UUID dataset_row_hash: str = Field(pattern=r"^[a-f0-9]{64}$") idempotent_replay: bool raw_transcript_included: Literal[False] = False clinical_claim_allowed: Literal[False] = False class EvaluationEvidenceMapping(BaseModel): model_config = ConfigDict(extra="forbid") evidence_event_id: str = Field(min_length=1, max_length=180) learner_id: UUID pointer: LedgerEvidencePointer @model_validator(mode="after") def event_ids_match(self) -> "EvaluationEvidenceMapping": if self.evidence_event_id != self.pointer.event_id: raise ValueError("evaluation evidence event id must match pointer") return self class EvaluationComparisonRequest(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) submission_id: UUID drift_report_id: UUID baseline_submission_id: UUID baseline_batch_record_id: UUID candidate_submission_id: UUID candidate_batch_record_id: UUID cohort_id: str = Field(min_length=1, max_length=120) baseline: EvaluationVersionBatch candidate: EvaluationVersionBatch evidence: list[EvaluationEvidenceMapping] = Field(min_length=1, max_length=5000) @field_validator("evidence") @classmethod def unique_evaluation_evidence( cls, value: list[EvaluationEvidenceMapping] ) -> list[EvaluationEvidenceMapping]: keys = [item.evidence_event_id for item in value] if len(keys) != len(set(keys)): raise ValueError("evaluation evidence mappings must be unique") return value class EvaluationComparisonResponse(BaseModel): model_config = ConfigDict(protected_namespaces=()) submission_id: UUID drift_report_id: UUID status: Literal["stable", "drift_flagged", "insufficient_evidence"] matched_count: int = Field(ge=0) idempotent_replay: bool clinical_claim_allowed: Literal[False] = False class ManifestSourceMapping(BaseModel): model_config = ConfigDict(extra="forbid") domain: Literal["alliance", "rupture", "transfer", "calibration"] learner_id: UUID pointer: LedgerEvidencePointer class Phase3ManifestRequest(BaseModel): model_config = ConfigDict(extra="forbid", protected_namespaces=()) submission_id: UUID manifest_id: UUID cohort_id: str = Field(min_length=1, max_length=120) artifacts: list[Phase3EvidenceArtifact] = Field(min_length=4, max_length=4) sources: list[ManifestSourceMapping] = Field(min_length=4, max_length=4) @model_validator(mode="after") def complete_source_domains(self) -> "Phase3ManifestRequest": source_domains = [item.domain for item in self.sources] artifact_domains = [item.domain for item in self.artifacts] if len(set(source_domains)) != 4 or set(source_domains) != set(artifact_domains): raise ValueError("manifest sources must map all four unique domains") return self class Phase3ManifestResponse(BaseModel): submission_id: UUID manifest_id: UUID artifact_count: Literal[4] idempotent_replay: bool clinical_claim_allowed: Literal[False] = False class DerivedCycleRequest(BaseModel): """호출자는 범위만 고르고, 신호·격차·manifest는 원장에서 파생한다.""" model_config = ConfigDict(extra="forbid") cohort_id: str = Field(min_length=1, max_length=120) class DerivedCycleResponse(BaseModel): model_config = ConfigDict(extra="forbid") cohort_id: str derived_signal_count: int = Field(ge=0) attention_snapshot: dict[str, object] | None = None curriculum_gaps: list[dict[str, object]] phase3_manifest: dict[str, object] | None = None raw_transcript_included: Literal[False] = False clinical_claim_allowed: Literal[False] = False def _raise_store_error(exc: Exception) -> None: if isinstance(exc, supervision_research_store.SupervisionResearchConflictError): raise HTTPException(status_code=409, detail=str(exc)) from exc if isinstance(exc, supervision_research_store.SupervisionResearchNotFoundError): raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc @router.post( "/internal/supervision-research/derive-cycle", response_model=DerivedCycleResponse, status_code=201, ) async def derive_supervision_cycle( request: DerivedCycleRequest, conn: SupervisorDB, ) -> DerivedCycleResponse: try: result = await supervision_research_producer.produce_supervision_cycle( conn, cohort_id=request.cohort_id, ) except supervision_research_store.SupervisionResearchError as exc: _raise_store_error(exc) return DerivedCycleResponse.model_validate(result) @router.post( "/internal/supervision-research/attention-snapshots", response_model=AttentionSnapshotResponse, status_code=201, ) async def create_attention_snapshot( request: AttentionSnapshotRequest, conn: SupervisorDB ) -> AttentionSnapshotResponse: try: result = await supervision_research_store.append_attention_snapshot( conn, submission_id=request.submission_id, snapshot_id=request.snapshot_id, cohort_id=request.cohort_id, signals=request.signals, learner_ids_by_ref={item.learner_ref: item.learner_id for item in request.learners}, ) except supervision_research_store.SupervisionResearchError as exc: _raise_store_error(exc) return AttentionSnapshotResponse.model_validate(result) @router.post( "/internal/supervision-research/curriculum-gaps", response_model=CurriculumGapResponse, status_code=201, ) async def create_curriculum_gap( request: CurriculumGapRequest, conn: SupervisorDB ) -> CurriculumGapResponse: try: result = await supervision_research_store.append_curriculum_gap( conn, submission_id=request.submission_id, gap_snapshot_id=request.gap_snapshot_id, cohort_id=request.cohort_id, competency_id=request.competency_id, gap_kind=request.gap_kind, status=request.status, uncertainty=request.uncertainty, affected_learner_count=request.affected_learner_count, evidence=[(item.learner_id, item.pointer) for item in request.evidence], ) except supervision_research_store.SupervisionResearchError as exc: _raise_store_error(exc) return CurriculumGapResponse.model_validate(result) @router.post( "/supervision-research/teacher-disagreements", response_model=TeacherDisagreementResponse, status_code=201, ) async def create_teacher_disagreement( request: TeacherDisagreementRequest, principal: TeacherPrincipal, conn: HumanDB, ) -> TeacherDisagreementResponse: try: result = await supervision_research_store.append_teacher_disagreement( conn, submission_id=request.submission_id, disagreement_record_id=request.disagreement_record_id, dataset_row_id=request.dataset_row_id, audit_event_id=request.audit_event_id, learner_id=request.learner_id, cohort_id=request.cohort_id, actor_uid=UUID(principal.user_id), disagreement=request.disagreement, ) except supervision_research_store.SupervisionResearchError as exc: _raise_store_error(exc) return TeacherDisagreementResponse.model_validate(result) @router.post( "/internal/supervision-research/evaluation-comparisons", response_model=EvaluationComparisonResponse, status_code=201, ) async def create_evaluation_comparison( request: EvaluationComparisonRequest, conn: ResearchDB ) -> EvaluationComparisonResponse: pointer_map = {item.evidence_event_id: item.pointer for item in request.evidence} learner_map = {item.evidence_event_id: item.learner_id for item in request.evidence} try: result = await supervision_research_store.append_evaluation_comparison( conn, submission_id=request.submission_id, drift_report_id=request.drift_report_id, baseline_submission_id=request.baseline_submission_id, baseline_batch_record_id=request.baseline_batch_record_id, candidate_submission_id=request.candidate_submission_id, candidate_batch_record_id=request.candidate_batch_record_id, cohort_id=request.cohort_id, baseline=request.baseline, candidate=request.candidate, pointers_by_event_id=pointer_map, learner_ids_by_event_id=learner_map, ) except supervision_research_store.SupervisionResearchError as exc: _raise_store_error(exc) return EvaluationComparisonResponse.model_validate(result) @router.post( "/internal/supervision-research/phase3-manifests", response_model=Phase3ManifestResponse, status_code=201, ) async def create_phase3_manifest( request: Phase3ManifestRequest, conn: ResearchDB ) -> Phase3ManifestResponse: try: result = await supervision_research_store.append_phase3_manifest( conn, submission_id=request.submission_id, manifest_id=request.manifest_id, cohort_id=request.cohort_id, artifacts=request.artifacts, source_by_domain={ item.domain: (item.learner_id, item.pointer) for item in request.sources }, ) except supervision_research_store.SupervisionResearchError as exc: _raise_store_error(exc) return Phase3ManifestResponse.model_validate(result) @router.get("/internal/supervision-research/supervisor-view") async def read_internal_supervisor_view(conn: SupervisorDB) -> dict[str, object]: return await supervision_research_store.read_supervision_view(conn) @router.get("/internal/supervision-research/research-view") async def read_internal_research_view(conn: ResearchDB) -> dict[str, object]: return await supervision_research_store.read_research_view(conn) @router.get("/supervision-research/supervision-view") async def read_human_supervision_view( conn: HumanDB, _principal: TeacherPrincipal ) -> dict[str, object]: return await supervision_research_store.read_supervision_view(conn) @router.get("/supervision-research/research-view") async def read_human_research_view( conn: HumanDB, _principal: TeacherPrincipal ) -> dict[str, object]: return await supervision_research_store.read_research_view(conn) __all__ = [ "INTERNAL_TOKEN_HEADER", "router", "supervision_research_internal_research_db", "supervision_research_internal_supervisor_db", ]