vignette/apps/api/app/contracts/multimodal_alliance.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

265 lines
10 KiB
Python

"""G7 Multimodal Alliance의 시간정렬·독립측정·보존 계약."""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
AllianceAxis = Literal["goal", "task", "bond"]
Modality = Literal["text", "voice"]
MeasurementStatus = Literal["ready", "missing", "error"]
VoiceEventType = Literal[
"silence", "overlap", "interruption", "prosody", "pace", "audio_quality"
]
class WordTimestamp(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
word_index: int = Field(ge=0)
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
speaker: Literal["learner", "client"]
token_hash: str = Field(pattern=r"^[a-f0-9]{64}$")
@model_validator(mode="after")
def require_positive_span(self) -> "WordTimestamp":
if self.end_ms <= self.start_ms:
raise ValueError("word timestamp end must follow start")
return self
class VoiceInteractionEvent(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
event_id: str = Field(pattern=r"^oas-g7-event-[a-z0-9-]+$")
event_type: VoiceEventType
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
actor: Literal["learner", "client", "both", "channel"]
observed_feature: str = Field(min_length=1, max_length=200)
uncertainty: float = Field(ge=0.0, le=1.0)
source: Literal["observed_audio_runtime", "stt_word_timestamps"]
claim_scope: Literal["interaction_signal"] = "interaction_signal"
clinical_claim_allowed: Literal[False] = False
@model_validator(mode="after")
def prevent_clinical_interpretation(self) -> "VoiceInteractionEvent":
if self.end_ms <= self.start_ms:
raise ValueError("voice interaction event end must follow start")
forbidden = {
"diagnosis",
"depression",
"anxiety disorder",
"is sad",
"is angry",
"feels anxious",
"진단",
"우울증",
"불안장애",
"자살 위험",
"감정은",
"감정이",
"기분이",
"슬픔을 느",
"불안을 느",
"화가 났",
}
normalized = self.observed_feature.casefold()
if any(term in normalized for term in forbidden):
raise ValueError("voice event must not infer a clinical condition")
return self
class AlignedVoiceTimeline(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
audio_duration_ms: int = Field(gt=0)
words: tuple[WordTimestamp, ...]
events: tuple[VoiceInteractionEvent, ...]
@model_validator(mode="after")
def require_ordered_in_bounds_timeline(self) -> "AlignedVoiceTimeline":
indices = [item.word_index for item in self.words]
if indices != list(range(len(indices))):
raise ValueError("word timestamps must be contiguous and ordered")
if [item.start_ms for item in self.words] != sorted(
item.start_ms for item in self.words
):
raise ValueError("word timestamps must be time ordered")
if any(item.end_ms > self.audio_duration_ms for item in self.words):
raise ValueError("word timestamp exceeds audio duration")
if any(item.end_ms > self.audio_duration_ms for item in self.events):
raise ValueError("voice event exceeds audio duration")
if len({item.event_id for item in self.events}) != len(self.events):
raise ValueError("voice event ids must be unique")
return self
class ModalityAxisMeasurement(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
measurement_id: str = Field(pattern=r"^oas-g7-measurement-[a-z0-9-]+$")
axis: AllianceAxis
modality: Modality
status: MeasurementStatus
value: float | None = Field(default=None, ge=0.0, le=1.0)
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_refs: tuple[str, ...] = ()
model_run_id: UUID | None = None
error_code: str | None = Field(default=None, max_length=120)
@model_validator(mode="after")
def preserve_modality_measurement_truth(self) -> "ModalityAxisMeasurement":
if self.status == "ready":
if (
self.value is None
or self.confidence is None
or not self.evidence_refs
or self.model_run_id is None
or self.error_code is not None
):
raise ValueError(
"ready modality measurement requires model and evidence"
)
else:
if self.value is not None or self.confidence is not None:
raise ValueError(
"missing/error modality measurement must remain scoreless"
)
if self.status == "error" and (
not self.error_code or self.uncertainty != 1.0
):
raise ValueError(
"error modality measurement requires maximum uncertainty"
)
return self
class FusionCalibration(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
calibration_id: str = Field(pattern=r"^oas-g7-fusion-[a-z0-9-]+$")
axis: AllianceAxis
text_weight: float = Field(ge=0.0, le=1.0)
voice_weight: float = Field(ge=0.0, le=1.0)
text_only_accuracy: float = Field(ge=0.0, le=1.0)
fused_accuracy: float = Field(ge=0.0, le=1.0)
benchmark_version: str = Field(min_length=1, max_length=80)
minimum_incremental_gain: float = Field(default=0.01, ge=0.0, le=1.0)
@model_validator(mode="after")
def require_normalized_weights(self) -> "FusionCalibration":
if abs(self.text_weight + self.voice_weight - 1.0) > 1e-9:
raise ValueError("fusion weights must sum to one")
return self
@property
def incremental_gain(self) -> float:
return self.fused_accuracy - self.text_only_accuracy
class CalibratedAxisReadModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
axis: AllianceAxis
status: MeasurementStatus
value: float | None = Field(default=None, ge=0.0, le=1.0)
uncertainty: float = Field(ge=0.0, le=1.0)
modalities_used: tuple[Modality, ...]
measurement_ids: tuple[str, ...]
fusion_applied: bool
fusion_calibration_id: str | None = None
incremental_gain: float | None = Field(default=None, ge=-1.0, le=1.0)
counterevidence: tuple[str, ...] = ()
@model_validator(mode="after")
def require_traceable_fusion(self) -> "CalibratedAxisReadModel":
if self.status != "ready" and self.value is not None:
raise ValueError("non-ready calibrated axis must remain scoreless")
if self.fusion_applied and (
set(self.modalities_used) != {"text", "voice"}
or self.fusion_calibration_id is None
or self.incremental_gain is None
):
raise ValueError(
"fusion requires both modalities and calibration provenance"
)
if not self.fusion_applied and self.fusion_calibration_id is not None:
raise ValueError("text-only read model cannot claim fusion calibration")
return self
class AudioRetentionRecord(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
session_id: str = Field(min_length=1, max_length=180)
consent_status: Literal["granted", "withdrawn", "not_granted"]
audio_ref: str | None = Field(default=None, max_length=300)
audio_sha256: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
retained_until_sequence: int | None = Field(default=None, ge=1)
deletion_event_id: str | None = Field(default=None, max_length=180)
transcript_retained: bool
@model_validator(mode="after")
def enforce_consent_and_deletion(self) -> "AudioRetentionRecord":
if self.consent_status == "granted":
if (
not self.audio_ref
or not self.audio_sha256
or self.retained_until_sequence is None
):
raise ValueError(
"granted audio retention requires ref, hash, and expiry"
)
if self.deletion_event_id is not None:
raise ValueError("retained audio cannot already have deletion evidence")
else:
if self.audio_ref or self.audio_sha256 or self.retained_until_sequence:
raise ValueError("unconsented audio must not retain audio material")
if self.consent_status == "withdrawn" and not self.deletion_event_id:
raise ValueError("withdrawn audio requires deletion evidence")
return self
class MultimodalBenchmarkCase(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
case_id: str = Field(pattern=r"^oas-g7-bench-[0-9]{3}$")
title_ko: str = Field(min_length=1, max_length=200)
text_measurement: ModalityAxisMeasurement
voice_measurement: ModalityAxisMeasurement
calibration: FusionCalibration
target_value: float = Field(ge=0.0, le=1.0)
expected_fusion_applied: bool
class MultimodalBenchmarkPack(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.multimodal-alliance-benchmark.v1"]
version: Literal["1.0.0"]
data_classification: Literal["synthetic_educational"]
clinical_claim_allowed: Literal[False]
cases: tuple[MultimodalBenchmarkCase, ...] = Field(min_length=3)
__all__ = [
"AlignedVoiceTimeline",
"AllianceAxis",
"AudioRetentionRecord",
"CalibratedAxisReadModel",
"FusionCalibration",
"MeasurementStatus",
"Modality",
"ModalityAxisMeasurement",
"MultimodalBenchmarkCase",
"MultimodalBenchmarkPack",
"VoiceEventType",
"VoiceInteractionEvent",
"WordTimestamp",
]