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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
483
apps/api/app/routes/supervision_research.py
Normal file
483
apps/api/app/routes/supervision_research.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"""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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue