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