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
454
apps/api/app/test_multimodal_alliance_store.py
Normal file
454
apps/api/app/test_multimodal_alliance_store.py
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from .contracts.multimodal_alliance import VoiceInteractionEvent
|
||||
from .deps import Principal, Role
|
||||
from .routes import multimodal_alliance as multimodal_routes
|
||||
from .routes import voice as voice_routes
|
||||
from .services import multimodal_alliance_store
|
||||
from .services.multimodal_alliance import align_voice_timeline
|
||||
|
||||
|
||||
SESSION_ID = UUID("71000000-0000-4000-8000-000000000001")
|
||||
LEARNER_ID = UUID("71000000-0000-4000-8000-000000000002")
|
||||
CONSENT_ID = UUID("71000000-0000-4000-8000-000000000003")
|
||||
|
||||
|
||||
def _principal(role: Role = Role.LEARNER) -> Principal:
|
||||
return Principal(
|
||||
user_id=str(LEARNER_ID),
|
||||
role=role,
|
||||
cohort_ids=["g7-cohort"],
|
||||
consent_at=1.0,
|
||||
profile_completed_at=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _synthetic_wav(duration_ms: int = 1000) -> bytes:
|
||||
sample_rate = 16_000
|
||||
frame_count = sample_rate * duration_ms // 1000
|
||||
payload = io.BytesIO()
|
||||
with wave.open(payload, "wb") as writer:
|
||||
writer.setnchannels(1)
|
||||
writer.setsampwidth(2)
|
||||
writer.setframerate(sample_rate)
|
||||
frames = bytearray()
|
||||
for index in range(frame_count):
|
||||
sample = int(4000 * math.sin(2 * math.pi * 220 * index / sample_rate))
|
||||
frames.extend(struct.pack("<h", sample))
|
||||
writer.writeframes(bytes(frames))
|
||||
return payload.getvalue()
|
||||
|
||||
|
||||
def _timeline():
|
||||
return align_voice_timeline(
|
||||
audio_duration_ms=1000,
|
||||
words=(
|
||||
{
|
||||
"word_index": 0,
|
||||
"start_ms": 40,
|
||||
"end_ms": 180,
|
||||
"speaker": "learner",
|
||||
"token_hash": "a" * 64,
|
||||
},
|
||||
{
|
||||
"word_index": 1,
|
||||
"start_ms": 410,
|
||||
"end_ms": 590,
|
||||
"speaker": "client",
|
||||
"token_hash": "b" * 64,
|
||||
},
|
||||
),
|
||||
events=(
|
||||
{
|
||||
"event_id": "oas-g7-event-silence-synthetic",
|
||||
"event_type": "silence",
|
||||
"start_ms": 180,
|
||||
"end_ms": 410,
|
||||
"actor": "both",
|
||||
"observed_feature": "230ms turn transition silence",
|
||||
"uncertainty": 0.05,
|
||||
"source": "stt_word_timestamps",
|
||||
},
|
||||
{
|
||||
"event_id": "oas-g7-event-overlap-synthetic",
|
||||
"event_type": "overlap",
|
||||
"start_ms": 590,
|
||||
"end_ms": 650,
|
||||
"actor": "both",
|
||||
"observed_feature": "60ms simultaneous speech segment",
|
||||
"uncertainty": 0.1,
|
||||
"source": "observed_audio_runtime",
|
||||
},
|
||||
{
|
||||
"event_id": "oas-g7-event-interruption-synthetic",
|
||||
"event_type": "interruption",
|
||||
"start_ms": 650,
|
||||
"end_ms": 720,
|
||||
"actor": "learner",
|
||||
"observed_feature": "learner speech began before client segment ended",
|
||||
"uncertainty": 0.12,
|
||||
"source": "observed_audio_runtime",
|
||||
},
|
||||
{
|
||||
"event_id": "oas-g7-event-prosody-synthetic",
|
||||
"event_type": "prosody",
|
||||
"start_ms": 720,
|
||||
"end_ms": 900,
|
||||
"actor": "learner",
|
||||
"observed_feature": "median intensity decreased by 3dB",
|
||||
"uncertainty": 0.2,
|
||||
"source": "observed_audio_runtime",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _ReplayConnection:
|
||||
def __init__(self, *, content_hash: str) -> None:
|
||||
self.content_hash = content_hash
|
||||
self.execute_calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
async def execute(self, query: str, *args: object) -> str:
|
||||
self.execute_calls.append((query, args))
|
||||
return "SELECT 1"
|
||||
|
||||
async def fetchrow(self, query: str, *args: object):
|
||||
if "FROM app.sessions" in query:
|
||||
return {"id": SESSION_ID, "learner_id": LEARNER_ID}
|
||||
if "FROM app.multimodal_consent_snapshot" in query:
|
||||
return {
|
||||
"consent_snapshot_id": CONSENT_ID,
|
||||
"learner_id": LEARNER_ID,
|
||||
"sequence_no": 1,
|
||||
"consent_status": "granted",
|
||||
"retain_audio": True,
|
||||
"retain_derived_features": True,
|
||||
"transcript_retained": True,
|
||||
"retention_days": 30,
|
||||
"policy_version": "g7-test-v1",
|
||||
}
|
||||
if "FROM app.multimodal_ingestion_request" in query:
|
||||
return {
|
||||
"request_kind": "timeline",
|
||||
"content_hash": self.content_hash,
|
||||
"session_id": SESSION_ID,
|
||||
"learner_id": LEARNER_ID,
|
||||
"result_id": UUID("71000000-0000-4000-8000-000000000004"),
|
||||
}
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class MultimodalRouteSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_internal_token_fails_before_db_provider(self) -> None:
|
||||
provider_called = False
|
||||
|
||||
async def provider():
|
||||
nonlocal provider_called
|
||||
provider_called = True
|
||||
yield object()
|
||||
|
||||
with (
|
||||
patch.object(multimodal_routes, "_evaluator_db_provider", provider),
|
||||
):
|
||||
dependency = multimodal_routes.multimodal_internal_evaluator_db(
|
||||
settings=SimpleNamespace(
|
||||
multimodal_alliance_internal_token=SecretStr("too-short")
|
||||
),
|
||||
presented_token="too-short"
|
||||
)
|
||||
with self.assertRaises(HTTPException) as captured:
|
||||
await anext(dependency)
|
||||
self.assertEqual(captured.exception.status_code, 503)
|
||||
self.assertFalse(provider_called)
|
||||
|
||||
async def test_independent_strong_token_enters_evaluator_db(self) -> None:
|
||||
sentinel = object()
|
||||
|
||||
async def provider():
|
||||
yield sentinel
|
||||
|
||||
token = "g7-internal-token-0123456789abcdef"
|
||||
with (
|
||||
patch.object(multimodal_routes, "_evaluator_db_provider", provider),
|
||||
):
|
||||
dependency = multimodal_routes.multimodal_internal_evaluator_db(
|
||||
settings=SimpleNamespace(
|
||||
multimodal_alliance_internal_token=SecretStr(token)
|
||||
),
|
||||
presented_token=token
|
||||
)
|
||||
self.assertIs(await anext(dependency), sentinel)
|
||||
await dependency.aclose()
|
||||
|
||||
async def test_teacher_cannot_read_raw_audio_asset(self) -> None:
|
||||
with self.assertRaises(multimodal_alliance_store.MultimodalAllianceStateError):
|
||||
await multimodal_alliance_store.read_raw_audio_asset(
|
||||
principal=_principal(Role.TEACHER),
|
||||
session_id=SESSION_ID,
|
||||
audio_asset_id=UUID("71000000-0000-4000-8000-000000000099"),
|
||||
)
|
||||
|
||||
async def test_role_safe_playback_resolves_private_file_without_exposing_handle(self) -> None:
|
||||
audio_asset_id = UUID("71000000-0000-4000-8000-000000000099")
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
audio_root = Path(temporary_directory) / "multimodal-audio"
|
||||
audio_root.mkdir(parents=True)
|
||||
audio_path = audio_root / "scene.wav"
|
||||
audio_path.write_bytes(_synthetic_wav())
|
||||
asset = {
|
||||
"audio_asset_id": audio_asset_id,
|
||||
"audio_ref": "private://scene.wav",
|
||||
"media_type": "audio/wav",
|
||||
}
|
||||
with patch.object(
|
||||
multimodal_alliance_store,
|
||||
"read_raw_audio_asset",
|
||||
AsyncMock(return_value=asset),
|
||||
):
|
||||
response = await multimodal_routes.play_multimodal_raw_audio(
|
||||
session_id=SESSION_ID,
|
||||
audio_asset_id=audio_asset_id,
|
||||
settings=SimpleNamespace(user_upload_dir=temporary_directory),
|
||||
principal=_principal(),
|
||||
)
|
||||
self.assertEqual(Path(response.path), audio_path)
|
||||
self.assertEqual(response.media_type, "audio/wav")
|
||||
self.assertEqual(response.headers["cache-control"], "private, no-store")
|
||||
self.assertNotIn("private://", str(response.headers))
|
||||
|
||||
def test_private_audio_ref_cannot_escape_storage_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
with self.assertRaises(HTTPException) as captured:
|
||||
multimodal_routes._resolve_private_audio_ref(
|
||||
SimpleNamespace(user_upload_dir=temporary_directory),
|
||||
"private://../outside.wav",
|
||||
)
|
||||
self.assertEqual(captured.exception.status_code, 404)
|
||||
|
||||
def test_raw_audio_listing_never_serializes_private_storage_handle(self) -> None:
|
||||
payload = multimodal_routes.RawAudioAccessResponse.model_validate(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"audio_asset_id": "71000000-0000-4000-8000-000000000099",
|
||||
"session_id": str(SESSION_ID),
|
||||
"learner_id": str(LEARNER_ID),
|
||||
"audio_ref": "private://must-not-cross-http.wav",
|
||||
"audio_sha256": "d" * 64,
|
||||
"media_type": "audio/wav",
|
||||
"byte_size": 32044,
|
||||
"duration_ms": 1000,
|
||||
"retained_until": "2026-09-06T00:00:00Z",
|
||||
"created_at": "2026-08-07T00:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
item = payload["items"][0]
|
||||
self.assertNotIn("audio_ref", item)
|
||||
self.assertNotIn("audio_sha256", item)
|
||||
self.assertNotIn("learner_id", item)
|
||||
|
||||
|
||||
class MultimodalTimelinePersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_synthetic_wav_and_all_required_events_share_one_clock(self) -> None:
|
||||
audio = _synthetic_wav()
|
||||
with wave.open(io.BytesIO(audio), "rb") as reader:
|
||||
duration_ms = round(reader.getnframes() / reader.getframerate() * 1000)
|
||||
timeline = _timeline()
|
||||
request = multimodal_routes.MultimodalTimelineRequest(
|
||||
submission_id=uuid4(),
|
||||
timeline=timeline,
|
||||
audio_asset={
|
||||
"audio_ref": "g7-synthetic://one-second.wav",
|
||||
"audio_sha256": hashlib.sha256(audio).hexdigest(),
|
||||
"media_type": "audio/wav",
|
||||
"byte_size": len(audio),
|
||||
},
|
||||
)
|
||||
self.assertEqual(duration_ms, request.timeline.audio_duration_ms)
|
||||
self.assertEqual(
|
||||
{event.event_type for event in request.timeline.events},
|
||||
{"silence", "overlap", "interruption", "prosody"},
|
||||
)
|
||||
self.assertTrue(all(event.end_ms <= duration_ms for event in timeline.events))
|
||||
|
||||
async def test_stable_timeline_submission_replays_same_result(self) -> None:
|
||||
timeline = _timeline()
|
||||
audio_asset = {
|
||||
"audio_ref": "g7-synthetic://one-second.wav",
|
||||
"audio_sha256": "c" * 64,
|
||||
"media_type": "audio/wav",
|
||||
"byte_size": 32044,
|
||||
}
|
||||
payload = {
|
||||
"session_id": str(SESSION_ID),
|
||||
"timeline": timeline.model_dump(mode="json"),
|
||||
"audio_asset": audio_asset,
|
||||
"consent_snapshot_id": str(CONSENT_ID),
|
||||
}
|
||||
conn = _ReplayConnection(
|
||||
content_hash=multimodal_alliance_store._canonical_hash(payload)
|
||||
)
|
||||
response = await multimodal_alliance_store.append_timeline(
|
||||
conn=conn, # type: ignore[arg-type]
|
||||
session_id=SESSION_ID,
|
||||
submission_id=uuid4(),
|
||||
timeline=timeline,
|
||||
audio_asset=audio_asset,
|
||||
)
|
||||
self.assertTrue(response["idempotent_replay"])
|
||||
self.assertFalse(any("INSERT INTO" in query for query, _ in conn.execute_calls))
|
||||
lock_keys = [
|
||||
args[0]
|
||||
for query, args in conn.execute_calls
|
||||
if "pg_advisory_xact_lock" in query
|
||||
]
|
||||
self.assertEqual(
|
||||
lock_keys,
|
||||
[
|
||||
f"multimodal-consent:{SESSION_ID}",
|
||||
f"multimodal-timeline:{SESSION_ID}",
|
||||
],
|
||||
)
|
||||
|
||||
async def test_changed_timeline_reuse_is_conflict(self) -> None:
|
||||
conn = _ReplayConnection(content_hash="0" * 64)
|
||||
with self.assertRaisesRegex(
|
||||
multimodal_alliance_store.MultimodalAllianceConflictError,
|
||||
"different multimodal content",
|
||||
):
|
||||
await multimodal_alliance_store.append_timeline(
|
||||
conn=conn, # type: ignore[arg-type]
|
||||
session_id=SESSION_ID,
|
||||
submission_id=uuid4(),
|
||||
timeline=_timeline(),
|
||||
audio_asset={
|
||||
"audio_ref": "g7-synthetic://changed.wav",
|
||||
"audio_sha256": "d" * 64,
|
||||
"media_type": "audio/wav",
|
||||
"byte_size": 32044,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MultimodalPrivacyBoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_non_dev_missing_consent_store_fails_closed(self) -> None:
|
||||
@asynccontextmanager
|
||||
async def unavailable_store(**_kwargs):
|
||||
raise RuntimeError("database unavailable")
|
||||
yield # pragma: no cover
|
||||
|
||||
with (
|
||||
patch.object(multimodal_alliance_store.db, "acquire", unavailable_store),
|
||||
patch.object(multimodal_alliance_store.settings, "environment", "prod"),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
multimodal_alliance_store.MultimodalAllianceStateError,
|
||||
"voice processing is blocked",
|
||||
):
|
||||
await multimodal_alliance_store.assert_voice_processing_allowed(
|
||||
principal=_principal(),
|
||||
session_id=str(SESSION_ID),
|
||||
)
|
||||
|
||||
async def test_dev_missing_consent_store_preserves_legacy_voice_fallback(self) -> None:
|
||||
@asynccontextmanager
|
||||
async def unavailable_store(**_kwargs):
|
||||
raise RuntimeError("database unavailable")
|
||||
yield # pragma: no cover
|
||||
|
||||
with (
|
||||
patch.object(multimodal_alliance_store.db, "acquire", unavailable_store),
|
||||
patch.object(multimodal_alliance_store.settings, "environment", "dev"),
|
||||
):
|
||||
result = await multimodal_alliance_store.assert_voice_processing_allowed(
|
||||
principal=_principal(),
|
||||
session_id=str(SESSION_ID),
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
async def test_withdrawal_blocks_before_voice_processing(self) -> None:
|
||||
websocket = SimpleNamespace()
|
||||
sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_json(_websocket, payload):
|
||||
sent.append(payload)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
multimodal_alliance_store,
|
||||
"assert_voice_processing_allowed",
|
||||
AsyncMock(
|
||||
side_effect=multimodal_alliance_store.MultimodalConsentWithdrawnError(
|
||||
"withdrawn"
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(voice_routes, "_safe_send_json", send_json),
|
||||
):
|
||||
allowed = await voice_routes._multimodal_voice_processing_allowed(
|
||||
websocket, # type: ignore[arg-type]
|
||||
session_id=str(SESSION_ID),
|
||||
principal=_principal(),
|
||||
)
|
||||
self.assertFalse(allowed)
|
||||
self.assertEqual(sent[0]["code"], "multimodal_consent_withdrawn")
|
||||
self.assertEqual(sent[-1], {"type": "state", "state": "idle"})
|
||||
|
||||
def test_emotion_certainty_is_rejected_but_observable_prosody_is_allowed(
|
||||
self,
|
||||
) -> None:
|
||||
base = {
|
||||
"event_id": "oas-g7-event-prosody-boundary",
|
||||
"event_type": "prosody",
|
||||
"start_ms": 100,
|
||||
"end_ms": 300,
|
||||
"actor": "client",
|
||||
"uncertainty": 0.2,
|
||||
"source": "observed_audio_runtime",
|
||||
}
|
||||
with self.assertRaisesRegex(ValidationError, "clinical condition"):
|
||||
VoiceInteractionEvent(
|
||||
**base,
|
||||
observed_feature="내담자의 감정이 슬픔으로 확정됨",
|
||||
)
|
||||
event = VoiceInteractionEvent(
|
||||
**base,
|
||||
observed_feature="median pitch decreased by 12Hz",
|
||||
)
|
||||
self.assertFalse(event.clinical_claim_allowed)
|
||||
self.assertEqual(event.claim_scope, "interaction_signal")
|
||||
|
||||
def test_request_json_contains_hashes_not_raw_transcript_tokens(self) -> None:
|
||||
body = multimodal_routes.MultimodalTimelineRequest(
|
||||
submission_id=uuid4(),
|
||||
timeline=_timeline(),
|
||||
audio_asset=None,
|
||||
).model_dump(mode="json")
|
||||
serialized = json.dumps(body, ensure_ascii=False)
|
||||
self.assertIn("token_hash", serialized)
|
||||
self.assertNotIn('"token":', serialized)
|
||||
self.assertNotIn('"transcript":', serialized)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue