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
434
apps/api/app/contracts/rupture_repair.py
Normal file
434
apps/api/app/contracts/rupture_repair.py
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
"""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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue