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 산출물은 커밋에서 제외했다.
265 lines
10 KiB
Python
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",
|
|
]
|