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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View 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",
]