vignette/apps/api/app/contracts/rupture_repair.py
Yun Chan 16e791e044 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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

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",
]