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
291
apps/api/app/routes/outcome_trajectories.py
Normal file
291
apps/api/app/routes/outcome_trajectories.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""HTTP boundary for G2 educational longitudinal outcome trajectories."""
|
||||
|
||||
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, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ..contracts.measurement import MeasurementPerspective, SourceKind
|
||||
from ..contracts.outcome_trajectory import (
|
||||
LongitudinalOutcomeAssessment,
|
||||
OutcomeAxis,
|
||||
RelationshipEventType,
|
||||
RelationshipMemoryProjection,
|
||||
SafetySignalReference,
|
||||
SyntheticExpectedDistribution,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import outcome_trajectory_store
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["outcome-trajectories"])
|
||||
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
||||
TeacherPrincipal = Annotated[
|
||||
Principal,
|
||||
Depends(require_role(Role.TEACHER, Role.ADMIN)),
|
||||
]
|
||||
|
||||
|
||||
class ExpectedArcLabelResponse(BaseModel):
|
||||
schema_version: Literal["vignette.synthetic-outcome-arc.v1"]
|
||||
arc_id: str
|
||||
title_ko: str
|
||||
data_classification: Literal["synthetic_educational"]
|
||||
clinical_claim_allowed: Literal[False]
|
||||
provenance_note: str
|
||||
session_count: Literal[5] = 5
|
||||
distributions: list[SyntheticExpectedDistribution] = Field(
|
||||
min_length=15, max_length=15
|
||||
)
|
||||
|
||||
|
||||
class OutcomeObservationResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
measurement_id: UUID | None = None
|
||||
session_id: UUID
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
axis: OutcomeAxis
|
||||
status: Literal["observed", "missing", "error"]
|
||||
value: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
raw_value: float | None = None
|
||||
scale_min: float | None = None
|
||||
scale_max: float | None = None
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
source_kind: SourceKind
|
||||
perspective: MeasurementPerspective
|
||||
instrument_id: str
|
||||
instrument_version: str
|
||||
model_run_id: UUID | None = None
|
||||
evidence_refs: list[str] = Field(default_factory=list)
|
||||
missing_reason: str | None = None
|
||||
occurred_at: datetime | None = None
|
||||
|
||||
|
||||
class OutcomeTrajectoryResponse(BaseModel):
|
||||
session_id: UUID
|
||||
revision_id: UUID
|
||||
revision_no: int = Field(ge=1)
|
||||
supersedes_revision_id: UUID | None = None
|
||||
source_fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
recompute_reason: str
|
||||
computed_at: datetime
|
||||
notice_ko: str
|
||||
expected_arc: ExpectedArcLabelResponse
|
||||
assessment: LongitudinalOutcomeAssessment
|
||||
next_questions: list[str] = Field(default_factory=list)
|
||||
observations: list[OutcomeObservationResponse]
|
||||
safety_signals: list[SafetySignalReference] = Field(default_factory=list)
|
||||
relationship_memory: list[RelationshipMemoryProjection] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class OutcomeTrajectoryRecomputeRequest(BaseModel):
|
||||
reason: str = Field(default="manual_recompute", min_length=1, max_length=300)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("reason must not be blank")
|
||||
return stripped
|
||||
|
||||
|
||||
class OutcomeAxisValues(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
distress_load: float = Field(ge=0.0, le=1.0)
|
||||
daily_functioning: float = Field(ge=0.0, le=1.0)
|
||||
learning_engagement: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class OutcomeObservationSubmissionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
submission_id: UUID
|
||||
scores: OutcomeAxisValues
|
||||
confidences: OutcomeAxisValues
|
||||
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 OutcomeObservationSubmissionResponse(OutcomeTrajectoryResponse):
|
||||
submission_id: UUID
|
||||
submitted_measurement_ids: list[UUID] = Field(min_length=3, max_length=3)
|
||||
|
||||
|
||||
RelationshipView = Literal[
|
||||
"client", "counselor", "evaluator", "supervisor", "research"
|
||||
]
|
||||
|
||||
|
||||
class RelationshipMemoryCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_type: RelationshipEventType
|
||||
summaries: dict[RelationshipView, str] = Field(min_length=1, max_length=5)
|
||||
evidence_turn_ids: tuple[UUID, ...] = Field(min_length=1, max_length=12)
|
||||
resolves_event_id: UUID | None = None
|
||||
|
||||
@field_validator("summaries")
|
||||
@classmethod
|
||||
def normalize_summaries(
|
||||
cls, value: dict[RelationshipView, str]
|
||||
) -> dict[RelationshipView, str]:
|
||||
normalized = {view: summary.strip() for view, summary in value.items()}
|
||||
if any(not summary for summary in normalized.values()):
|
||||
raise ValueError("relationship summaries must not be blank")
|
||||
return normalized
|
||||
|
||||
@field_validator("evidence_turn_ids")
|
||||
@classmethod
|
||||
def unique_relationship_evidence(
|
||||
cls, value: tuple[UUID, ...]
|
||||
) -> tuple[UUID, ...]:
|
||||
if len(set(value)) != len(value):
|
||||
raise ValueError("evidence_turn_ids must be unique")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_explicit_repair_target(self) -> "RelationshipMemoryCreateRequest":
|
||||
if self.event_type == "repair_confirmed" and self.resolves_event_id is None:
|
||||
raise ValueError("repair_confirmed requires resolves_event_id")
|
||||
if self.event_type != "repair_confirmed" and self.resolves_event_id is not None:
|
||||
raise ValueError("only repair_confirmed can resolve a relationship event")
|
||||
return self
|
||||
|
||||
|
||||
class RelationshipMemoryCreateResponse(BaseModel):
|
||||
memory_event_id: UUID
|
||||
status: Literal["recorded"] = "recorded"
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryConflictError):
|
||||
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, outcome_trajectory_store.OutcomeTrajectoryStateError):
|
||||
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
raise exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/outcome-trajectory",
|
||||
response_model=OutcomeTrajectoryResponse,
|
||||
)
|
||||
async def get_outcome_trajectory(
|
||||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return OutcomeTrajectoryResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/outcome-trajectory/recompute",
|
||||
response_model=OutcomeTrajectoryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def recompute_outcome_trajectory(
|
||||
session_id: UUID,
|
||||
body: OutcomeTrajectoryRecomputeRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
force_recompute=True,
|
||||
recompute_reason=body.reason,
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return OutcomeTrajectoryResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/outcome-observations",
|
||||
response_model=OutcomeObservationSubmissionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_outcome_observations(
|
||||
session_id: UUID,
|
||||
body: OutcomeObservationSubmissionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> OutcomeObservationSubmissionResponse:
|
||||
try:
|
||||
payload = await outcome_trajectory_store.submit_outcome_observations(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
submission_id=body.submission_id,
|
||||
scores=body.scores.model_dump(),
|
||||
confidences=body.confidences.model_dump(),
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return OutcomeObservationSubmissionResponse.model_validate(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/relationship-memory-events",
|
||||
response_model=RelationshipMemoryCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_relationship_memory_event(
|
||||
session_id: UUID,
|
||||
body: RelationshipMemoryCreateRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> RelationshipMemoryCreateResponse:
|
||||
try:
|
||||
memory_event_id = (
|
||||
await outcome_trajectory_store.append_relationship_memory_event(
|
||||
principal=principal,
|
||||
session_id=session_id,
|
||||
event_type=body.event_type,
|
||||
summaries=body.summaries,
|
||||
evidence_turn_ids=body.evidence_turn_ids,
|
||||
resolves_event_id=body.resolves_event_id,
|
||||
)
|
||||
)
|
||||
except (
|
||||
outcome_trajectory_store.OutcomeTrajectoryNotFoundError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryConflictError,
|
||||
outcome_trajectory_store.OutcomeTrajectoryStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return RelationshipMemoryCreateResponse(memory_event_id=memory_event_id)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue