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