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
410
apps/api/app/contracts/outcome_trajectory.py
Normal file
410
apps/api/app/contracts/outcome_trajectory.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
"""G2 Longitudinal Outcome Trajectory의 순수 도메인 계약.
|
||||
|
||||
이 계약은 교육용 합성 사례의 1~5회기 진행 궤적을 다룬다. 임상 진단·치료 효과
|
||||
예측 계약이 아니며, 실제 데이터 부재를 정상값이나 평균값으로 대체하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from .measurement import (
|
||||
AIView,
|
||||
MeasurementPerspective,
|
||||
SOURCE_PERSPECTIVE_COMPATIBILITY,
|
||||
SourceKind,
|
||||
)
|
||||
|
||||
|
||||
OUTCOME_AXES = ("distress_load", "daily_functioning", "learning_engagement")
|
||||
OutcomeAxis = Literal["distress_load", "daily_functioning", "learning_engagement"]
|
||||
|
||||
TRAJECTORY_STATUSES = (
|
||||
"on_track",
|
||||
"watch",
|
||||
"off_track",
|
||||
"deteriorating",
|
||||
"insufficient_evidence",
|
||||
)
|
||||
TrajectoryStatus = Literal[
|
||||
"on_track",
|
||||
"watch",
|
||||
"off_track",
|
||||
"deteriorating",
|
||||
"insufficient_evidence",
|
||||
]
|
||||
|
||||
OBSERVATION_STATUSES = ("observed", "missing", "error")
|
||||
ObservationStatus = Literal["observed", "missing", "error"]
|
||||
|
||||
RELATIONSHIP_EVENT_TYPES = (
|
||||
"goal_agreement",
|
||||
"task_agreement",
|
||||
"rupture_withdrawal",
|
||||
"rupture_confrontation",
|
||||
"repair_attempt",
|
||||
"repair_confirmed",
|
||||
"unresolved_rupture",
|
||||
)
|
||||
RelationshipEventType = Literal[
|
||||
"goal_agreement",
|
||||
"task_agreement",
|
||||
"rupture_withdrawal",
|
||||
"rupture_confrontation",
|
||||
"repair_attempt",
|
||||
"repair_confirmed",
|
||||
"unresolved_rupture",
|
||||
]
|
||||
|
||||
|
||||
class SyntheticExpectedDistribution(BaseModel):
|
||||
"""한 축·한 회기의 교육용 예상 분포.
|
||||
|
||||
``mean``은 관측값의 폴백이 아니다. 관측값이 없으면 비교 자체를 하지 않는다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
axis: OutcomeAxis
|
||||
mean: float = Field(ge=0.0, le=1.0)
|
||||
standard_deviation: float = Field(gt=0.0, le=0.5)
|
||||
lower_reference: float = Field(ge=0.0, le=1.0)
|
||||
upper_reference: float = Field(ge=0.0, le=1.0)
|
||||
sample_size: int = Field(ge=1)
|
||||
expected_direction: Literal["lower_is_better", "higher_is_better"]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def keep_reference_band_ordered(self) -> "SyntheticExpectedDistribution":
|
||||
if not self.lower_reference <= self.mean <= self.upper_reference:
|
||||
raise ValueError("expected mean must stay inside its reference band")
|
||||
return self
|
||||
|
||||
|
||||
class SyntheticExpectedArc(BaseModel):
|
||||
"""임상 오표시를 구조적으로 막는 5회기 교육용 예상 궤적."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
schema_version: Literal["vignette.synthetic-outcome-arc.v1"] = (
|
||||
"vignette.synthetic-outcome-arc.v1"
|
||||
)
|
||||
arc_id: str = Field(pattern=r"^oas-g2-arc-[0-9]{3}$")
|
||||
title_ko: str = Field(min_length=1, max_length=200)
|
||||
data_classification: Literal["synthetic_educational"] = "synthetic_educational"
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
provenance_note: str = Field(min_length=20, max_length=1000)
|
||||
distributions: tuple[SyntheticExpectedDistribution, ...]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_complete_five_session_distribution(self) -> "SyntheticExpectedArc":
|
||||
keys = {(item.session_no, item.axis) for item in self.distributions}
|
||||
expected = {
|
||||
(session_no, axis)
|
||||
for session_no in range(1, 6)
|
||||
for axis in OUTCOME_AXES
|
||||
}
|
||||
if keys != expected or len(self.distributions) != len(expected):
|
||||
raise ValueError("synthetic arc requires every outcome axis for sessions 1..5")
|
||||
for item in self.distributions:
|
||||
if item.axis == "distress_load" and item.expected_direction != "lower_is_better":
|
||||
raise ValueError("distress_load must use lower_is_better direction")
|
||||
if item.axis != "distress_load" and item.expected_direction != "higher_is_better":
|
||||
raise ValueError(f"{item.axis} must use higher_is_better direction")
|
||||
return self
|
||||
|
||||
def distribution_for(
|
||||
self, session_no: int, axis: OutcomeAxis
|
||||
) -> SyntheticExpectedDistribution:
|
||||
for item in self.distributions:
|
||||
if item.session_no == session_no and item.axis == axis:
|
||||
return item
|
||||
raise KeyError((session_no, axis))
|
||||
|
||||
|
||||
class OutcomeAxisObservation(BaseModel):
|
||||
"""실제 관측 여부를 보존하는 한 축의 회기 관측."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
axis: OutcomeAxis
|
||||
status: ObservationStatus = "observed"
|
||||
value: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
source_kind: SourceKind
|
||||
perspective: MeasurementPerspective = "client_simulation"
|
||||
instrument_id: str = Field(min_length=1, max_length=120)
|
||||
instrument_version: str = Field(min_length=1, max_length=40)
|
||||
model_run_id: UUID | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
missing_reason: str | None = Field(default=None, max_length=300)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def never_impute_missing_observations(self) -> "OutcomeAxisObservation":
|
||||
if self.status == "observed":
|
||||
if self.value is None or self.confidence is None:
|
||||
raise ValueError("observed outcomes require value and confidence")
|
||||
if self.missing_reason:
|
||||
raise ValueError("observed outcomes cannot carry missing_reason")
|
||||
if not self.evidence_refs:
|
||||
raise ValueError("observed outcomes require evidence_refs")
|
||||
else:
|
||||
if self.value is not None or self.confidence is not None:
|
||||
raise ValueError("missing/error outcomes must remain scoreless")
|
||||
if not self.missing_reason:
|
||||
raise ValueError("missing/error outcomes require missing_reason")
|
||||
if len(set(self.evidence_refs)) != len(self.evidence_refs):
|
||||
raise ValueError("outcome evidence_refs must be unique")
|
||||
allowed = SOURCE_PERSPECTIVE_COMPATIBILITY[self.source_kind]
|
||||
if self.perspective not in allowed:
|
||||
raise ValueError("outcome observation mixes source and perspective layers")
|
||||
if self.source_kind in {"model_inferred", "agent_reported"} and self.model_run_id is None:
|
||||
raise ValueError("model/agent outcome observations require model_run_id provenance")
|
||||
return self
|
||||
|
||||
|
||||
class SafetySignalReference(BaseModel):
|
||||
"""성과 악화와 별도로 전달되는 기존 safety 원장 참조."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
safety_event_id: str = Field(min_length=1, max_length=160)
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
risk_level: Literal["low", "moderate", "high", "imminent"]
|
||||
escalated: bool
|
||||
evidence_refs: tuple[str, ...] = Field(min_length=1)
|
||||
|
||||
|
||||
class RelationshipMemoryEvent(BaseModel):
|
||||
"""역할별 문장을 서로 섞지 않는 관계 기억 원본.
|
||||
|
||||
범용 ``metadata`` 필드를 의도적으로 두지 않는다. 각 역할은 자기 키의 summary만
|
||||
받을 수 있어 내담자 내부 설정이나 평가자 정답 라벨이 상담자 뷰로 새지 않는다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
event_id: str = Field(min_length=1, max_length=160)
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
event_type: RelationshipEventType
|
||||
visible_to: tuple[AIView, ...] = Field(min_length=1)
|
||||
summaries: dict[AIView, str] = Field(min_length=1)
|
||||
evidence_refs: tuple[str, ...] = Field(min_length=1)
|
||||
resolved_by_event_id: str | None = Field(default=None, max_length=160)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def enforce_role_safe_summary_keys(self) -> "RelationshipMemoryEvent":
|
||||
if len(set(self.visible_to)) != len(self.visible_to):
|
||||
raise ValueError("relationship visible_to must be unique")
|
||||
if set(self.summaries) != set(self.visible_to):
|
||||
raise ValueError("relationship summaries must exactly match visible_to roles")
|
||||
if any(not text.strip() for text in self.summaries.values()):
|
||||
raise ValueError("relationship summaries must not be blank")
|
||||
if len(set(self.evidence_refs)) != len(self.evidence_refs):
|
||||
raise ValueError("relationship evidence_refs must be unique")
|
||||
if self.resolved_by_event_id == self.event_id:
|
||||
raise ValueError("relationship event cannot resolve itself")
|
||||
return self
|
||||
|
||||
|
||||
class ObservedSessionOutcome(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
axes: tuple[OutcomeAxisObservation, ...] = Field(min_length=3, max_length=3)
|
||||
safety_signals: tuple[SafetySignalReference, ...] = ()
|
||||
relationship_events: tuple[RelationshipMemoryEvent, ...] = ()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_explicit_axis_presence(self) -> "ObservedSessionOutcome":
|
||||
axes = [item.axis for item in self.axes]
|
||||
if set(axes) != set(OUTCOME_AXES) or len(set(axes)) != len(OUTCOME_AXES):
|
||||
raise ValueError("every session must explicitly represent all outcome axes")
|
||||
if any(item.session_no != self.session_no for item in self.safety_signals):
|
||||
raise ValueError("safety references must belong to the observed session")
|
||||
if any(item.session_no != self.session_no for item in self.relationship_events):
|
||||
raise ValueError("relationship events must belong to the observed session")
|
||||
return self
|
||||
|
||||
def observation_for(self, axis: OutcomeAxis) -> OutcomeAxisObservation:
|
||||
for item in self.axes:
|
||||
if item.axis == axis:
|
||||
return item
|
||||
raise KeyError(axis)
|
||||
|
||||
|
||||
class LongitudinalOutcomeInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
expected_arc: SyntheticExpectedArc
|
||||
sessions: tuple[ObservedSessionOutcome, ...] = Field(min_length=1, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_contiguous_early_sessions(self) -> "LongitudinalOutcomeInput":
|
||||
session_numbers = [item.session_no for item in self.sessions]
|
||||
if session_numbers != list(range(1, len(self.sessions) + 1)):
|
||||
raise ValueError("outcome sessions must be contiguous and ordered from session 1")
|
||||
safety_ids = [
|
||||
signal.safety_event_id
|
||||
for session in self.sessions
|
||||
for signal in session.safety_signals
|
||||
]
|
||||
if len(set(safety_ids)) != len(safety_ids):
|
||||
raise ValueError("safety event references must be unique across the arc")
|
||||
events = [
|
||||
event
|
||||
for session in self.sessions
|
||||
for event in session.relationship_events
|
||||
]
|
||||
event_by_id = {event.event_id: event for event in events}
|
||||
if len(event_by_id) != len(events):
|
||||
raise ValueError("relationship event ids must be unique across the arc")
|
||||
for event in events:
|
||||
if not event.resolved_by_event_id:
|
||||
continue
|
||||
resolution = event_by_id.get(event.resolved_by_event_id)
|
||||
if resolution is None:
|
||||
raise ValueError("relationship resolution reference must exist in the arc")
|
||||
if resolution.event_type != "repair_confirmed":
|
||||
raise ValueError("relationship resolution must point to repair_confirmed")
|
||||
if resolution.session_no < event.session_no:
|
||||
raise ValueError("relationship resolution cannot precede the rupture")
|
||||
return self
|
||||
|
||||
|
||||
class AxisTrajectoryAssessment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
axis: OutcomeAxis
|
||||
status: TrajectoryStatus
|
||||
observed_value: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
expected_mean: float = Field(ge=0.0, le=1.0)
|
||||
adverse_z: float | None = None
|
||||
adverse_z_change: float | None = None
|
||||
uncertainty: float = Field(ge=0.0, le=1.0)
|
||||
decision_basis: tuple[str, ...] = Field(min_length=1)
|
||||
counterevidence: tuple[str, ...] = ()
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def keep_missing_assessment_scoreless(self) -> "AxisTrajectoryAssessment":
|
||||
if self.status == "insufficient_evidence":
|
||||
if self.observed_value is not None or self.adverse_z is not None:
|
||||
raise ValueError("insufficient evidence assessment must remain scoreless")
|
||||
if self.uncertainty != 1.0:
|
||||
raise ValueError("insufficient evidence must expose maximum uncertainty")
|
||||
elif self.observed_value is None or self.adverse_z is None:
|
||||
raise ValueError("classified trajectory axes require observed evidence")
|
||||
return self
|
||||
|
||||
|
||||
class SessionTrajectoryAssessment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
status: TrajectoryStatus
|
||||
axes: tuple[AxisTrajectoryAssessment, ...] = Field(min_length=3, max_length=3)
|
||||
missing_axes: tuple[OutcomeAxis, ...] = ()
|
||||
next_check_questions: tuple[str, ...] = ()
|
||||
safety_signals: tuple[SafetySignalReference, ...] = ()
|
||||
|
||||
|
||||
class LongitudinalOutcomeAssessment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
schema_version: Literal["vignette.outcome-trajectory-assessment.v1"] = (
|
||||
"vignette.outcome-trajectory-assessment.v1"
|
||||
)
|
||||
expected_arc_id: str
|
||||
data_classification: Literal["synthetic_educational"] = "synthetic_educational"
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
sessions: tuple[SessionTrajectoryAssessment, ...]
|
||||
|
||||
|
||||
class RelationshipMemoryProjection(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
event_id: str
|
||||
session_no: int
|
||||
event_type: RelationshipEventType
|
||||
summary: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
resolved_by_event_id: str | None = None
|
||||
|
||||
|
||||
class RoleSafeTrajectoryReadModel(BaseModel):
|
||||
"""성과·safety·관계 기억을 합산하지 않고 나란히 전달하는 읽기 모델."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
assessment: LongitudinalOutcomeAssessment
|
||||
safety_signals: tuple[SafetySignalReference, ...]
|
||||
relationship_memory: tuple[RelationshipMemoryProjection, ...]
|
||||
|
||||
|
||||
class TrajectoryBenchmarkExpectation(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
session_no: int = Field(ge=1, le=5)
|
||||
status: TrajectoryStatus
|
||||
|
||||
|
||||
class TrajectoryBenchmarkCase(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
case_id: str = Field(pattern=r"^oas-g2-bench-[0-9]{3}$")
|
||||
title_ko: str = Field(min_length=1, max_length=200)
|
||||
sessions: tuple[ObservedSessionOutcome, ...] = Field(min_length=5, max_length=5)
|
||||
expected: tuple[TrajectoryBenchmarkExpectation, ...] = Field(
|
||||
min_length=5, max_length=5
|
||||
)
|
||||
forbidden_claims: tuple[str, ...] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_complete_benchmark_timeline(self) -> "TrajectoryBenchmarkCase":
|
||||
session_numbers = [item.session_no for item in self.sessions]
|
||||
expectation_numbers = [item.session_no for item in self.expected]
|
||||
if session_numbers != [1, 2, 3, 4, 5]:
|
||||
raise ValueError("benchmark sessions must be ordered 1..5")
|
||||
if expectation_numbers != [1, 2, 3, 4, 5]:
|
||||
raise ValueError("benchmark expectations must be ordered 1..5")
|
||||
return self
|
||||
|
||||
|
||||
class TrajectoryBenchmarkPack(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
||||
|
||||
schema_version: Literal["vignette.outcome-trajectory-benchmark.v1"] = (
|
||||
"vignette.outcome-trajectory-benchmark.v1"
|
||||
)
|
||||
expected_arc: SyntheticExpectedArc
|
||||
cases: tuple[TrajectoryBenchmarkCase, ...] = Field(min_length=1)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OBSERVATION_STATUSES",
|
||||
"OUTCOME_AXES",
|
||||
"RELATIONSHIP_EVENT_TYPES",
|
||||
"TRAJECTORY_STATUSES",
|
||||
"AxisTrajectoryAssessment",
|
||||
"LongitudinalOutcomeAssessment",
|
||||
"LongitudinalOutcomeInput",
|
||||
"ObservedSessionOutcome",
|
||||
"OutcomeAxis",
|
||||
"OutcomeAxisObservation",
|
||||
"RelationshipMemoryEvent",
|
||||
"RelationshipMemoryProjection",
|
||||
"RoleSafeTrajectoryReadModel",
|
||||
"SafetySignalReference",
|
||||
"SessionTrajectoryAssessment",
|
||||
"SyntheticExpectedArc",
|
||||
"SyntheticExpectedDistribution",
|
||||
"TrajectoryBenchmarkCase",
|
||||
"TrajectoryBenchmarkExpectation",
|
||||
"TrajectoryBenchmarkPack",
|
||||
"TrajectoryStatus",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue