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 산출물은 커밋에서 제외했다.
410 lines
16 KiB
Python
410 lines
16 KiB
Python
"""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",
|
|
]
|