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
181
apps/api/app/services/multimodal_alliance.py
Normal file
181
apps/api/app/services/multimodal_alliance.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""G7 텍스트·음성 독립 측정과 검증된 경우에만 적용하는 보정 융합."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..contracts.multimodal_alliance import (
|
||||
AlignedVoiceTimeline,
|
||||
CalibratedAxisReadModel,
|
||||
FusionCalibration,
|
||||
ModalityAxisMeasurement,
|
||||
MultimodalBenchmarkPack,
|
||||
VoiceInteractionEvent,
|
||||
WordTimestamp,
|
||||
)
|
||||
|
||||
|
||||
def align_voice_timeline(
|
||||
*,
|
||||
audio_duration_ms: int,
|
||||
words: Iterable[WordTimestamp | Mapping[str, Any]],
|
||||
events: Iterable[VoiceInteractionEvent | Mapping[str, Any]],
|
||||
) -> AlignedVoiceTimeline:
|
||||
"""이미 추출된 관찰 이벤트를 오디오 시계에 정렬하고 범위를 검증한다."""
|
||||
|
||||
validated_words = tuple(WordTimestamp.model_validate(item) for item in words)
|
||||
validated_events = tuple(
|
||||
VoiceInteractionEvent.model_validate(item) for item in events
|
||||
)
|
||||
return AlignedVoiceTimeline(
|
||||
audio_duration_ms=audio_duration_ms,
|
||||
words=tuple(
|
||||
sorted(
|
||||
validated_words,
|
||||
key=lambda item: (item.start_ms, item.word_index),
|
||||
)
|
||||
),
|
||||
events=tuple(
|
||||
sorted(
|
||||
validated_events,
|
||||
key=lambda item: (item.start_ms, item.event_id),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_calibrated_axis_read_model(
|
||||
*,
|
||||
text: ModalityAxisMeasurement,
|
||||
voice: ModalityAxisMeasurement,
|
||||
calibration: FusionCalibration,
|
||||
) -> CalibratedAxisReadModel:
|
||||
if text.axis != voice.axis or text.axis != calibration.axis:
|
||||
raise ValueError("text, voice, and fusion calibration must target one axis")
|
||||
|
||||
if text.status != "ready":
|
||||
return CalibratedAxisReadModel(
|
||||
axis=text.axis,
|
||||
status=text.status,
|
||||
value=None,
|
||||
uncertainty=1.0,
|
||||
modalities_used=(),
|
||||
measurement_ids=(text.measurement_id,),
|
||||
fusion_applied=False,
|
||||
counterevidence=("text_measurement_not_ready",),
|
||||
)
|
||||
|
||||
assert text.value is not None
|
||||
voice_ready = voice.status == "ready" and voice.value is not None
|
||||
gain_sufficient = (
|
||||
calibration.incremental_gain >= calibration.minimum_incremental_gain
|
||||
)
|
||||
if not voice_ready or not gain_sufficient:
|
||||
counterevidence: list[str] = []
|
||||
if not voice_ready:
|
||||
counterevidence.append("voice_measurement_not_ready")
|
||||
if voice_ready and not gain_sufficient:
|
||||
counterevidence.append("voice_incremental_gain_not_demonstrated")
|
||||
return CalibratedAxisReadModel(
|
||||
axis=text.axis,
|
||||
status="ready",
|
||||
value=text.value,
|
||||
uncertainty=text.uncertainty,
|
||||
modalities_used=("text",),
|
||||
measurement_ids=(text.measurement_id,),
|
||||
fusion_applied=False,
|
||||
incremental_gain=calibration.incremental_gain,
|
||||
counterevidence=tuple(counterevidence),
|
||||
)
|
||||
|
||||
assert voice.value is not None
|
||||
fused = (
|
||||
text.value * calibration.text_weight + voice.value * calibration.voice_weight
|
||||
)
|
||||
uncertainty = min(
|
||||
1.0,
|
||||
text.uncertainty * calibration.text_weight
|
||||
+ voice.uncertainty * calibration.voice_weight,
|
||||
)
|
||||
return CalibratedAxisReadModel(
|
||||
axis=text.axis,
|
||||
status="ready",
|
||||
value=fused,
|
||||
uncertainty=uncertainty,
|
||||
modalities_used=("text", "voice"),
|
||||
measurement_ids=(text.measurement_id, voice.measurement_id),
|
||||
fusion_applied=True,
|
||||
fusion_calibration_id=calibration.calibration_id,
|
||||
incremental_gain=calibration.incremental_gain,
|
||||
)
|
||||
|
||||
|
||||
def load_multimodal_benchmark(path: str | Path) -> MultimodalBenchmarkPack:
|
||||
return MultimodalBenchmarkPack.model_validate_json(
|
||||
Path(path).read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def evaluate_multimodal_benchmark(pack: MultimodalBenchmarkPack) -> dict[str, object]:
|
||||
cases: list[dict[str, object]] = []
|
||||
correct = 0
|
||||
text_absolute_errors: list[float] = []
|
||||
calibrated_absolute_errors: list[float] = []
|
||||
minimum_gain = min(
|
||||
case.calibration.minimum_incremental_gain for case in pack.cases
|
||||
)
|
||||
for case in pack.cases:
|
||||
result = build_calibrated_axis_read_model(
|
||||
text=case.text_measurement,
|
||||
voice=case.voice_measurement,
|
||||
calibration=case.calibration,
|
||||
)
|
||||
matched = result.fusion_applied == case.expected_fusion_applied
|
||||
correct += matched
|
||||
assert case.text_measurement.value is not None
|
||||
assert result.value is not None
|
||||
text_absolute_error = abs(case.text_measurement.value - case.target_value)
|
||||
calibrated_absolute_error = abs(result.value - case.target_value)
|
||||
text_absolute_errors.append(text_absolute_error)
|
||||
calibrated_absolute_errors.append(calibrated_absolute_error)
|
||||
cases.append(
|
||||
{
|
||||
"case_id": case.case_id,
|
||||
"matched": matched,
|
||||
"target_value": case.target_value,
|
||||
"text_absolute_error": text_absolute_error,
|
||||
"calibrated_absolute_error": calibrated_absolute_error,
|
||||
"result": result.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
text_only_accuracy = 1.0 - sum(text_absolute_errors) / len(text_absolute_errors)
|
||||
calibrated_multimodal_accuracy = (
|
||||
1.0 - sum(calibrated_absolute_errors) / len(calibrated_absolute_errors)
|
||||
)
|
||||
measured_incremental_gain = calibrated_multimodal_accuracy - text_only_accuracy
|
||||
return {
|
||||
"schema_version": "vignette.multimodal-alliance-benchmark-report.v1",
|
||||
"data_classification": pack.data_classification,
|
||||
"clinical_claim_allowed": pack.clinical_claim_allowed,
|
||||
"fusion_decision_accuracy": correct / len(pack.cases),
|
||||
"voice_gain_benchmark": {
|
||||
"metric": "one_minus_mean_absolute_error",
|
||||
"observations": len(pack.cases),
|
||||
"text_only_accuracy": text_only_accuracy,
|
||||
"calibrated_multimodal_accuracy": calibrated_multimodal_accuracy,
|
||||
"measured_incremental_gain": measured_incremental_gain,
|
||||
"minimum_incremental_gain": minimum_gain,
|
||||
"voice_gain_demonstrated": measured_incremental_gain >= minimum_gain,
|
||||
},
|
||||
"cases": cases,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"align_voice_timeline",
|
||||
"build_calibrated_axis_read_model",
|
||||
"evaluate_multimodal_benchmark",
|
||||
"load_multimodal_benchmark",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue