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
210
apps/api/app/routes/measurements.py
Normal file
210
apps/api/app/routes/measurements.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Outcome & Alliance OS measurement routes.
|
||||
|
||||
The route surface keeps the reveal order explicit: a learner must lock a
|
||||
self-assessment before the client-agent and independent-observer jobs are
|
||||
scheduled. Scores are returned as separate goal/task/bond dimensions and are
|
||||
never collapsed into a synthetic total.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..contracts.measurement import (
|
||||
AllianceCheckpoint,
|
||||
AllianceDimension,
|
||||
AllianceScores,
|
||||
MeasurementPerspective,
|
||||
MeasurementStatus,
|
||||
SourceKind,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import alliance_measurement
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["measurements"])
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.TEACHER, Role.ADMIN)),
|
||||
]
|
||||
|
||||
|
||||
class AlliancePulseCreateRequest(BaseModel):
|
||||
checkpoint: AllianceCheckpoint
|
||||
scores: AllianceScores
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(default=(), max_length=12)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class AlliancePulseAcceptedResponse(BaseModel):
|
||||
pulse_id: UUID
|
||||
status: Literal["awaiting_agents"] = "awaiting_agents"
|
||||
idempotent_replay: bool = False
|
||||
|
||||
|
||||
class AllianceEvidenceTurnResponse(BaseModel):
|
||||
turn_id: UUID
|
||||
seq: int
|
||||
speaker: str
|
||||
text: str
|
||||
|
||||
|
||||
class AllianceMeasurementResponse(BaseModel):
|
||||
measurement_id: UUID
|
||||
dimension: AllianceDimension
|
||||
perspective: MeasurementPerspective
|
||||
source_kind: SourceKind
|
||||
value: float | None = None
|
||||
confidence: float | None = None
|
||||
status: MeasurementStatus
|
||||
error_code: str | None = None
|
||||
rationale: str | None = None
|
||||
evidence: list[AllianceEvidenceTurnResponse] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AlliancePulseResponse(BaseModel):
|
||||
pulse_id: UUID
|
||||
checkpoint: AllianceCheckpoint
|
||||
status: Literal["awaiting_agents", "ready", "degraded", "error"]
|
||||
learner_locked_at: datetime
|
||||
revealed_at: datetime | None = None
|
||||
error_code: str | None = None
|
||||
self_scores: AllianceScores
|
||||
measurements: list[AllianceMeasurementResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlliancePulseListResponse(BaseModel):
|
||||
items: list[AlliancePulseResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SupervisorAllianceRatingRequest(BaseModel):
|
||||
scores: AllianceScores
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(min_length=1, max_length=12)
|
||||
note: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_evidence_turns(cls, value: tuple[UUID, ...]) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
@field_validator("note")
|
||||
@classmethod
|
||||
def strip_note(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("note must not be blank")
|
||||
return stripped
|
||||
|
||||
|
||||
class SupervisorAllianceRatingResponse(BaseModel):
|
||||
status: Literal["recorded"] = "recorded"
|
||||
|
||||
|
||||
def _measurement_http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, alliance_measurement.AlliancePulseNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, alliance_measurement.AlliancePulseConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, alliance_measurement.AlliancePulseStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/alliance-pulses",
|
||||
response_model=AlliancePulseAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def create_alliance_pulse(
|
||||
session_id: UUID,
|
||||
body: AlliancePulseCreateRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> AlliancePulseAcceptedResponse:
|
||||
try:
|
||||
result = await alliance_measurement.create_locked_pulse(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
checkpoint=body.checkpoint,
|
||||
scores=body.scores,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
)
|
||||
except (
|
||||
alliance_measurement.AlliancePulseNotFoundError,
|
||||
alliance_measurement.AlliancePulseConflictError,
|
||||
alliance_measurement.AlliancePulseStateError,
|
||||
) as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
|
||||
# The transaction above is committed before either independent agent can
|
||||
# run, so no model perspective can be revealed before learner lock-in.
|
||||
if not result.idempotent_replay:
|
||||
alliance_measurement.schedule_alliance_agents(result.pulse_id)
|
||||
return AlliancePulseAcceptedResponse(
|
||||
pulse_id=result.pulse_id,
|
||||
idempotent_replay=result.idempotent_replay,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/alliance-pulses",
|
||||
response_model=AlliancePulseListResponse,
|
||||
)
|
||||
async def get_alliance_pulses(
|
||||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> AlliancePulseListResponse:
|
||||
try:
|
||||
items = await alliance_measurement.list_alliance_pulses(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except alliance_measurement.AlliancePulseNotFoundError as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
return AlliancePulseListResponse.model_validate({"items": items})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/alliance-pulses/{pulse_id}/supervisor-rating",
|
||||
response_model=SupervisorAllianceRatingResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_supervisor_alliance_rating(
|
||||
session_id: UUID,
|
||||
pulse_id: UUID,
|
||||
body: SupervisorAllianceRatingRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> SupervisorAllianceRatingResponse:
|
||||
try:
|
||||
await alliance_measurement.add_supervisor_rating(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
pulse_id=pulse_id,
|
||||
scores=body.scores,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
note=body.note,
|
||||
)
|
||||
except (
|
||||
alliance_measurement.AlliancePulseNotFoundError,
|
||||
alliance_measurement.AlliancePulseConflictError,
|
||||
alliance_measurement.AlliancePulseStateError,
|
||||
) as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
return SupervisorAllianceRatingResponse()
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue