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 산출물은 커밋에서 제외했다.
483 lines
17 KiB
Python
483 lines
17 KiB
Python
"""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",
|
|
]
|