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 산출물은 커밋에서 제외했다.
434 lines
17 KiB
Python
434 lines
17 KiB
Python
"""G3 Rupture & Repair Lab의 버전 고정 순수 도메인 계약.
|
|
|
|
균열 유형, 탐지 근거, 상담자 복구 행동, 내담자 후속 반응을 서로 분리한다.
|
|
단일 총점으로 합산하지 않으며 safety 신호는 균열 판정의 입력 feature가 아니다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
from .measurement import (
|
|
MeasurementPerspective,
|
|
SOURCE_PERSPECTIVE_COMPATIBILITY,
|
|
SourceKind,
|
|
)
|
|
|
|
|
|
RUPTURE_TYPES = (
|
|
"withdrawal",
|
|
"confrontation",
|
|
"goal_mismatch",
|
|
"task_mismatch",
|
|
"empathic_miss",
|
|
"cultural_miss",
|
|
"boundary_tension",
|
|
"premature_advice",
|
|
"over_disclosure",
|
|
)
|
|
RuptureType = Literal[
|
|
"withdrawal",
|
|
"confrontation",
|
|
"goal_mismatch",
|
|
"task_mismatch",
|
|
"empathic_miss",
|
|
"cultural_miss",
|
|
"boundary_tension",
|
|
"premature_advice",
|
|
"over_disclosure",
|
|
]
|
|
|
|
RUPTURE_LIFECYCLE_STATES = (
|
|
"onset",
|
|
"recognized",
|
|
"repair_attempted",
|
|
"missed",
|
|
"partial",
|
|
"resolved",
|
|
)
|
|
RuptureLifecycleState = Literal[
|
|
"onset",
|
|
"recognized",
|
|
"repair_attempted",
|
|
"missed",
|
|
"partial",
|
|
"resolved",
|
|
]
|
|
FinalRuptureStatus = Literal[
|
|
"missed",
|
|
"partial",
|
|
"resolved",
|
|
"not_applicable",
|
|
"insufficient_evidence",
|
|
]
|
|
|
|
REPAIR_BEHAVIORS = (
|
|
"noticing",
|
|
"naming",
|
|
"curiosity",
|
|
"impact_acknowledgement",
|
|
"goal_reagreement",
|
|
"task_reagreement",
|
|
"follow_up_check",
|
|
)
|
|
RepairBehavior = Literal[
|
|
"noticing",
|
|
"naming",
|
|
"curiosity",
|
|
"impact_acknowledgement",
|
|
"goal_reagreement",
|
|
"task_reagreement",
|
|
"follow_up_check",
|
|
]
|
|
|
|
ClientRepairResponse = Literal[
|
|
"rejecting",
|
|
"withdrawn",
|
|
"compliance_only",
|
|
"mixed",
|
|
"engaged",
|
|
"explicit_alignment",
|
|
]
|
|
SignalLoop = Literal["fast", "deep"]
|
|
SignalStatus = Literal["detected", "not_detected", "error"]
|
|
ReconciliationDisposition = Literal[
|
|
"not_applicable",
|
|
"confirmed",
|
|
"superseded_resolved",
|
|
"superseded_partial",
|
|
"dismissed",
|
|
]
|
|
|
|
|
|
class RuptureEvidenceRef(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
ref_id: str = Field(min_length=1, max_length=180)
|
|
turn_index: int = Field(ge=0)
|
|
speaker: Literal["learner", "client", "observer", "runtime"]
|
|
|
|
|
|
class SafetySignalReference(BaseModel):
|
|
"""균열/복구 판정과 합산하지 않는 기존 safety 원장 포인터."""
|
|
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
safety_event_id: str = Field(min_length=1, max_length=180)
|
|
risk_level: Literal["low", "moderate", "high", "imminent"]
|
|
escalated: bool
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...] = Field(min_length=1)
|
|
|
|
|
|
class RuptureDetectionSignal(BaseModel):
|
|
"""fast/deep observer가 만든 독립 탐지 가설."""
|
|
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
signal_id: str = Field(min_length=1, max_length=180)
|
|
loop: SignalLoop
|
|
status: SignalStatus
|
|
rupture_type: RuptureType | None = None
|
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
uncertainty: float = Field(ge=0.0, le=1.0)
|
|
observed_at_turn: int = Field(ge=0)
|
|
source_kind: SourceKind
|
|
perspective: MeasurementPerspective
|
|
model_run_id: UUID | None = None
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...] = ()
|
|
counterevidence: tuple[str, ...] = ()
|
|
error_code: str | None = Field(default=None, max_length=120)
|
|
|
|
@model_validator(mode="after")
|
|
def enforce_detection_truth(self) -> "RuptureDetectionSignal":
|
|
allowed = SOURCE_PERSPECTIVE_COMPATIBILITY[self.source_kind]
|
|
if self.perspective not in allowed:
|
|
raise ValueError("rupture detection 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 rupture detection requires model_run_id")
|
|
if self.status == "detected":
|
|
if self.rupture_type is None or self.confidence is None or not self.evidence_refs:
|
|
raise ValueError("detected rupture requires type, confidence, and evidence")
|
|
if self.error_code:
|
|
raise ValueError("detected rupture cannot carry error_code")
|
|
elif self.status == "not_detected":
|
|
if self.rupture_type is not None or self.confidence is not None:
|
|
raise ValueError("not_detected rupture must remain type/scoreless")
|
|
if self.error_code:
|
|
raise ValueError("not_detected rupture cannot carry error_code")
|
|
else:
|
|
if self.rupture_type is not None or self.confidence is not None:
|
|
raise ValueError("error rupture signal must remain type/scoreless")
|
|
if not self.error_code:
|
|
raise ValueError("error rupture signal requires error_code")
|
|
if len({item.ref_id for item in self.evidence_refs}) != len(self.evidence_refs):
|
|
raise ValueError("rupture evidence refs must be unique")
|
|
return self
|
|
|
|
|
|
class FastLoopWarning(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
warning_id: str = Field(min_length=1, max_length=180)
|
|
signal_id: str = Field(min_length=1, max_length=180)
|
|
provisional_status: Literal["missed", "partial"]
|
|
emitted_at_turn: int = Field(ge=0)
|
|
|
|
|
|
class RepairAttemptObservation(BaseModel):
|
|
"""문구가 아니라 관찰된 복구 행동과 내담자 후속 반응을 보존한다."""
|
|
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
attempt_id: str = Field(min_length=1, max_length=180)
|
|
turn_index: int = Field(ge=0)
|
|
behaviors: tuple[RepairBehavior, ...] = ()
|
|
client_response: ClientRepairResponse
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...] = Field(min_length=1)
|
|
response_evidence_refs: tuple[RuptureEvidenceRef, ...] = Field(min_length=1)
|
|
counterevidence: tuple[str, ...] = ()
|
|
uncertainty: float = Field(ge=0.0, le=1.0)
|
|
utterance_template_id: str | None = Field(default=None, max_length=180)
|
|
|
|
@model_validator(mode="after")
|
|
def preserve_attempt_evidence(self) -> "RepairAttemptObservation":
|
|
if len(set(self.behaviors)) != len(self.behaviors):
|
|
raise ValueError("repair behaviors must be unique")
|
|
refs = (*self.evidence_refs, *self.response_evidence_refs)
|
|
if len({item.ref_id for item in refs}) != len(refs):
|
|
raise ValueError("repair attempt evidence refs must be unique")
|
|
if any(item.turn_index > self.turn_index for item in self.evidence_refs):
|
|
raise ValueError("repair behavior evidence cannot follow the attempt")
|
|
if any(item.turn_index <= self.turn_index for item in self.response_evidence_refs):
|
|
raise ValueError("client response evidence must follow the attempt")
|
|
return self
|
|
|
|
|
|
class RuptureEpisodeInput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
episode_id: str = Field(pattern=r"^oas-g3-episode-[a-z0-9-]+$")
|
|
detection_signals: tuple[RuptureDetectionSignal, ...] = Field(min_length=1)
|
|
fast_warning: FastLoopWarning | None = None
|
|
recognized_at_turn: int | None = Field(default=None, ge=0)
|
|
recognition_evidence_refs: tuple[RuptureEvidenceRef, ...] = ()
|
|
repair_attempts: tuple[RepairAttemptObservation, ...] = ()
|
|
safety_signals: tuple[SafetySignalReference, ...] = ()
|
|
|
|
@model_validator(mode="after")
|
|
def require_ordered_episode(self) -> "RuptureEpisodeInput":
|
|
signal_ids = [item.signal_id for item in self.detection_signals]
|
|
if len(set(signal_ids)) != len(signal_ids):
|
|
raise ValueError("rupture signal ids must be unique")
|
|
if self.fast_warning:
|
|
linked = next(
|
|
(item for item in self.detection_signals if item.signal_id == self.fast_warning.signal_id),
|
|
None,
|
|
)
|
|
if linked is None or linked.loop != "fast" or linked.status != "detected":
|
|
raise ValueError("fast warning must reference a detected fast-loop signal")
|
|
if self.fast_warning.emitted_at_turn < linked.observed_at_turn:
|
|
raise ValueError("fast warning cannot precede its signal")
|
|
if self.recognized_at_turn is None and self.recognition_evidence_refs:
|
|
raise ValueError("recognition evidence requires recognized_at_turn")
|
|
if self.recognized_at_turn is not None and not self.recognition_evidence_refs:
|
|
raise ValueError("recognized rupture requires recognition evidence")
|
|
attempts = [item.attempt_id for item in self.repair_attempts]
|
|
if len(set(attempts)) != len(attempts):
|
|
raise ValueError("repair attempt ids must be unique")
|
|
attempt_turns = [item.turn_index for item in self.repair_attempts]
|
|
if attempt_turns != sorted(attempt_turns):
|
|
raise ValueError("repair attempts must be ordered by turn")
|
|
if self.repair_attempts and self.recognized_at_turn is None:
|
|
raise ValueError("repair attempts require rupture recognition")
|
|
if self.recognized_at_turn is not None and any(
|
|
item.turn_index < self.recognized_at_turn for item in self.repair_attempts
|
|
):
|
|
raise ValueError("repair attempt cannot precede rupture recognition")
|
|
return self
|
|
|
|
|
|
class RuptureLedgerEntry(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
sequence_no: int = Field(ge=1)
|
|
event_name: Literal[
|
|
"rupture.detected",
|
|
"rupture.recognized",
|
|
"rupture.missed",
|
|
"repair.attempted",
|
|
"repair.partial",
|
|
"repair.resolved",
|
|
"repair.missed",
|
|
"rupture.reconciled",
|
|
]
|
|
from_state: RuptureLifecycleState | None = None
|
|
to_state: RuptureLifecycleState
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...]
|
|
counterevidence: tuple[str, ...] = ()
|
|
uncertainty: float = Field(ge=0.0, le=1.0)
|
|
source_ref_id: str = Field(min_length=1, max_length=180)
|
|
reconciles_event_id: str | None = Field(default=None, max_length=180)
|
|
|
|
|
|
class RepairAttemptAssessment(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
attempt_id: str
|
|
outcome: Literal["missed", "partial", "resolved"]
|
|
observed_behaviors: tuple[RepairBehavior, ...]
|
|
required_behaviors: tuple[RepairBehavior, ...]
|
|
missing_behaviors: tuple[RepairBehavior, ...]
|
|
client_response: ClientRepairResponse
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...]
|
|
counterevidence: tuple[str, ...]
|
|
uncertainty: float = Field(ge=0.0, le=1.0)
|
|
|
|
|
|
class FastDeepReconciliation(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
warning_id: str | None = None
|
|
disposition: ReconciliationDisposition
|
|
provisional_status: Literal["missed", "partial"] | None = None
|
|
deep_status: FinalRuptureStatus
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...] = ()
|
|
reason: str = Field(min_length=1, max_length=500)
|
|
|
|
|
|
class RuptureEpisodeAssessment(BaseModel):
|
|
"""점수 합산 없이 유형·상태·근거·반증을 나란히 반환한다."""
|
|
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
schema_version: Literal["vignette.rupture-repair-assessment.v1"] = (
|
|
"vignette.rupture-repair-assessment.v1"
|
|
)
|
|
episode_id: str
|
|
assessment_status: Literal["ready", "error"] = "ready"
|
|
detected: bool
|
|
rupture_type: RuptureType | None
|
|
final_status: FinalRuptureStatus
|
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
uncertainty: float = Field(ge=0.0, le=1.0)
|
|
evidence_refs: tuple[RuptureEvidenceRef, ...]
|
|
counterevidence: tuple[str, ...]
|
|
repair_attempts: tuple[RepairAttemptAssessment, ...]
|
|
ledger: tuple[RuptureLedgerEntry, ...]
|
|
reconciliation: FastDeepReconciliation
|
|
safety_signals: tuple[SafetySignalReference, ...]
|
|
|
|
@model_validator(mode="after")
|
|
def keep_negative_assessment_scoreless(self) -> "RuptureEpisodeAssessment":
|
|
if self.assessment_status == "error":
|
|
if self.detected or self.rupture_type is not None or self.confidence is not None:
|
|
raise ValueError("error assessment must remain detection/type/scoreless")
|
|
if self.final_status != "insufficient_evidence":
|
|
raise ValueError("error assessment requires insufficient_evidence status")
|
|
if self.repair_attempts or self.ledger:
|
|
raise ValueError("error assessment cannot fabricate lifecycle events")
|
|
return self
|
|
if not self.detected:
|
|
if self.rupture_type is not None or self.confidence is not None:
|
|
raise ValueError("not-detected assessment must remain type/scoreless")
|
|
if self.final_status != "not_applicable":
|
|
raise ValueError("not-detected assessment has no repair status")
|
|
if self.repair_attempts or self.ledger:
|
|
raise ValueError("not-detected assessment cannot fabricate lifecycle events")
|
|
elif self.rupture_type is None or self.confidence is None:
|
|
raise ValueError("detected assessment requires type and confidence")
|
|
return self
|
|
|
|
|
|
class RuptureBenchmarkExpectation(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
detected: bool
|
|
rupture_type: RuptureType | None
|
|
final_status: FinalRuptureStatus
|
|
|
|
@model_validator(mode="after")
|
|
def keep_expectation_consistent(self) -> "RuptureBenchmarkExpectation":
|
|
if self.detected and self.rupture_type is None:
|
|
raise ValueError("detected benchmark expectation requires rupture_type")
|
|
if not self.detected and (
|
|
self.rupture_type is not None or self.final_status != "not_applicable"
|
|
):
|
|
raise ValueError("negative benchmark expectation must be type/repair scoreless")
|
|
return self
|
|
|
|
|
|
class RuptureBenchmarkCase(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
case_id: str = Field(pattern=r"^oas-g3-bench-[0-9]{3}$")
|
|
title_ko: str = Field(min_length=1, max_length=200)
|
|
episode: RuptureEpisodeInput
|
|
expected: RuptureBenchmarkExpectation
|
|
critical: bool = False
|
|
tags: tuple[str, ...] = ()
|
|
forbidden_claims: tuple[str, ...] = Field(min_length=1)
|
|
|
|
|
|
class RuptureBenchmarkPack(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
|
|
|
|
schema_version: Literal["vignette.rupture-repair-benchmark.v1"] = (
|
|
"vignette.rupture-repair-benchmark.v1"
|
|
)
|
|
data_classification: Literal["synthetic_educational"] = "synthetic_educational"
|
|
clinical_claim_allowed: Literal[False] = False
|
|
version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
|
cases: tuple[RuptureBenchmarkCase, ...] = Field(min_length=1)
|
|
|
|
@model_validator(mode="after")
|
|
def require_adversarial_coverage(self) -> "RuptureBenchmarkPack":
|
|
case_ids = [item.case_id for item in self.cases]
|
|
if len(set(case_ids)) != len(case_ids):
|
|
raise ValueError("rupture benchmark case ids must be unique")
|
|
covered = {
|
|
item.expected.rupture_type for item in self.cases if item.expected.detected
|
|
}
|
|
if covered != set(RUPTURE_TYPES):
|
|
raise ValueError("rupture benchmark must cover every rupture type")
|
|
if not any("judge_gaming" in item.tags for item in self.cases):
|
|
raise ValueError("rupture benchmark requires judge_gaming cases")
|
|
if not any("memorized_phrase_trap" in item.tags for item in self.cases):
|
|
raise ValueError("rupture benchmark requires memorized phrase variants")
|
|
template_expectations: dict[str, set[FinalRuptureStatus]] = {}
|
|
for case in self.cases:
|
|
for attempt in case.episode.repair_attempts:
|
|
if attempt.utterance_template_id:
|
|
template_expectations.setdefault(attempt.utterance_template_id, set()).add(
|
|
case.expected.final_status
|
|
)
|
|
if not any(len(statuses) > 1 for statuses in template_expectations.values()):
|
|
raise ValueError("a memorized template must have different contextual outcomes")
|
|
return self
|
|
|
|
|
|
__all__ = [
|
|
"ClientRepairResponse",
|
|
"FastDeepReconciliation",
|
|
"FastLoopWarning",
|
|
"FinalRuptureStatus",
|
|
"REPAIR_BEHAVIORS",
|
|
"RUPTURE_LIFECYCLE_STATES",
|
|
"RUPTURE_TYPES",
|
|
"RepairAttemptAssessment",
|
|
"RepairAttemptObservation",
|
|
"RepairBehavior",
|
|
"RuptureBenchmarkCase",
|
|
"RuptureBenchmarkExpectation",
|
|
"RuptureBenchmarkPack",
|
|
"RuptureDetectionSignal",
|
|
"RuptureEpisodeAssessment",
|
|
"RuptureEpisodeInput",
|
|
"RuptureEvidenceRef",
|
|
"RuptureLedgerEntry",
|
|
"RuptureLifecycleState",
|
|
"RuptureType",
|
|
"SafetySignalReference",
|
|
]
|