vignette/apps/api/app/services/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

181 lines
6.4 KiB
Python

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