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
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from .services.voice import (
|
||||
DEFAULT_OPENAI_VOICE,
|
||||
|
|
@ -14,8 +17,12 @@ from .services.voice import (
|
|||
TTS_MODEL_FALLBACK,
|
||||
HIGGS_TTS_ENDPOINT,
|
||||
HIGGS_TTS_MODEL,
|
||||
DEEPGRAM_STT_MODEL,
|
||||
DeepgramStreamingSession,
|
||||
LocalWhisperStreamingSession,
|
||||
VoicePreset,
|
||||
VoiceService,
|
||||
VoiceUnavailable,
|
||||
assess_end_of_turn,
|
||||
build_higgs_prompt,
|
||||
build_tts_payload,
|
||||
|
|
@ -318,6 +325,213 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(service.tts_provider(), "disabled-non-dev")
|
||||
|
||||
|
||||
class _FakeDeepgramSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[bytes | str] = []
|
||||
self.closed = False
|
||||
self._frames: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
self._interim_sent = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
frame = await self._frames.get()
|
||||
if frame is None:
|
||||
raise StopAsyncIteration
|
||||
return frame
|
||||
|
||||
async def send(self, value: bytes | str) -> None:
|
||||
self.sent.append(value)
|
||||
if isinstance(value, bytes) and not self._interim_sent:
|
||||
self._interim_sent = True
|
||||
await self._frames.put(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "Results",
|
||||
"is_final": False,
|
||||
"speech_final": False,
|
||||
"start": 0.0,
|
||||
"duration": 0.3,
|
||||
"channel": {
|
||||
"alternatives": [
|
||||
{"transcript": "안녕", "confidence": 0.72}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
if isinstance(value, str) and json.loads(value).get("type") == "CloseStream":
|
||||
final_frames = [
|
||||
{
|
||||
"type": "Results",
|
||||
"is_final": True,
|
||||
"speech_final": False,
|
||||
"start": 0.0,
|
||||
"duration": 0.45,
|
||||
"channel": {
|
||||
"alternatives": [
|
||||
{
|
||||
"transcript": "안녕하세요",
|
||||
"confidence": 0.94,
|
||||
"words": [
|
||||
{
|
||||
"word": "안녕하세요",
|
||||
"start": 0.0,
|
||||
"end": 0.45,
|
||||
"confidence": 0.94,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "Results",
|
||||
"is_final": True,
|
||||
"speech_final": True,
|
||||
"start": 0.5,
|
||||
"duration": 0.4,
|
||||
"channel": {
|
||||
"alternatives": [
|
||||
{
|
||||
"transcript": "반가워요",
|
||||
"confidence": 0.91,
|
||||
"words": [
|
||||
{
|
||||
"word": "반가워요",
|
||||
"start": 0.5,
|
||||
"end": 0.9,
|
||||
"confidence": 0.91,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{"type": "Metadata", "duration": 0.9},
|
||||
]
|
||||
for frame in final_frames:
|
||||
await self._frames.put(json.dumps(frame, ensure_ascii=False))
|
||||
await self._frames.put(None)
|
||||
|
||||
async def close(self, **_kwargs) -> None:
|
||||
self.closed = True
|
||||
await self._frames.put(None)
|
||||
|
||||
|
||||
class DeepgramStreamingSTTTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_idle_stream_sends_text_keepalive_without_audio(self) -> None:
|
||||
socket = _FakeDeepgramSocket()
|
||||
|
||||
async def on_event(_event) -> None:
|
||||
return None
|
||||
|
||||
stream = DeepgramStreamingSession(
|
||||
socket,
|
||||
model="nova-3",
|
||||
language="ko",
|
||||
on_event=on_event,
|
||||
keepalive_seconds=0.01,
|
||||
finalize_timeout_seconds=1,
|
||||
)
|
||||
for _ in range(20):
|
||||
if any(
|
||||
isinstance(item, str)
|
||||
and json.loads(item).get("type") == "KeepAlive"
|
||||
for item in socket.sent
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await stream.abort()
|
||||
|
||||
self.assertTrue(
|
||||
any(
|
||||
isinstance(item, str)
|
||||
and json.loads(item).get("type") == "KeepAlive"
|
||||
for item in socket.sent
|
||||
)
|
||||
)
|
||||
|
||||
async def test_streaming_listen_uses_token_header_and_accumulates_final_segments(
|
||||
self,
|
||||
) -> None:
|
||||
socket = _FakeDeepgramSocket()
|
||||
connect_calls: list[tuple[str, dict[str, object]]] = []
|
||||
updates = []
|
||||
|
||||
async def connect(url: str, **kwargs):
|
||||
connect_calls.append((url, kwargs))
|
||||
return socket
|
||||
|
||||
async def on_event(event) -> None:
|
||||
updates.append(event)
|
||||
|
||||
service = VoiceService(
|
||||
api_key="openai-fallback-key",
|
||||
stt_provider="deepgram",
|
||||
deepgram_api_key="deepgram-secret",
|
||||
deepgram_connect=connect,
|
||||
deepgram_finalize_timeout_seconds=2,
|
||||
)
|
||||
stream = await service.open_streaming_transcription(
|
||||
fmt="pcm",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
on_event=on_event,
|
||||
)
|
||||
await stream.send_audio(b"\x00\x00\xff\x7f")
|
||||
await asyncio.sleep(0)
|
||||
result = await stream.finish()
|
||||
|
||||
self.assertEqual(len(connect_calls), 1)
|
||||
url, kwargs = connect_calls[0]
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
self.assertEqual(urlsplit(url).scheme, "wss")
|
||||
self.assertEqual(query["model"], [DEEPGRAM_STT_MODEL])
|
||||
self.assertEqual(query["language"], ["ko"])
|
||||
self.assertEqual(query["encoding"], ["linear16"])
|
||||
self.assertEqual(query["sample_rate"], ["16000"])
|
||||
self.assertEqual(query["channels"], ["1"])
|
||||
self.assertEqual(query["interim_results"], ["true"])
|
||||
self.assertEqual(query["endpointing"], ["300"])
|
||||
self.assertEqual(query["utterance_end_ms"], ["1200"])
|
||||
self.assertEqual(query["mip_opt_out"], ["true"])
|
||||
self.assertNotIn("deepgram-secret", url)
|
||||
self.assertEqual(
|
||||
kwargs["additional_headers"],
|
||||
{"Authorization": "Token deepgram-secret"},
|
||||
)
|
||||
self.assertEqual(kwargs["max_queue"], 16)
|
||||
self.assertEqual(kwargs["write_limit"], 64 * 1024)
|
||||
self.assertEqual(result.text, "안녕하세요 반가워요")
|
||||
self.assertEqual(result.language, "ko")
|
||||
self.assertEqual(result.model, DEEPGRAM_STT_MODEL)
|
||||
self.assertAlmostEqual(result.duration or 0.0, 0.9)
|
||||
self.assertEqual([word.word for word in result.words], ["안녕하세요", "반가워요"])
|
||||
self.assertTrue(any(not event.final for event in updates))
|
||||
self.assertTrue(any(event.final and event.speech_final for event in updates))
|
||||
self.assertEqual(socket.sent[0], b"\x00\x00\xff\x7f")
|
||||
self.assertEqual(json.loads(str(socket.sent[-1])), {"type": "CloseStream"})
|
||||
self.assertFalse(
|
||||
any("word" in event or "text" in event for event in result.provider_events)
|
||||
)
|
||||
|
||||
async def test_selected_deepgram_without_key_keeps_openai_batch_fallback(self) -> None:
|
||||
service = VoiceService(
|
||||
api_key="openai-fallback-key",
|
||||
stt_provider="deepgram",
|
||||
deepgram_api_key="",
|
||||
)
|
||||
|
||||
self.assertFalse(service.streaming_stt_enabled())
|
||||
self.assertTrue(service.batch_stt_available())
|
||||
self.assertTrue(service.stt_available())
|
||||
self.assertEqual(service.stt_provider(), "openai-batch-fallback")
|
||||
self.assertEqual(service.stt_model(), "gpt-4o-transcribe")
|
||||
|
||||
|
||||
class EndOfTurnDecisionTest(unittest.TestCase):
|
||||
def test_end_of_turn_requires_silence_threshold(self) -> None:
|
||||
pending = assess_end_of_turn(
|
||||
|
|
@ -363,5 +577,327 @@ class EndOfTurnDecisionTest(unittest.TestCase):
|
|||
self.assertEqual(decision.reason, "empty_transcript")
|
||||
|
||||
|
||||
class _FakeLocalWhisperSocket:
|
||||
"""Loopback faster-whisper sidecar stand-in speaking the local protocol."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[bytes | str] = []
|
||||
self.closed = False
|
||||
self._frames: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
self._interim_sent = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
frame = await self._frames.get()
|
||||
if frame is None:
|
||||
raise StopAsyncIteration
|
||||
return frame
|
||||
|
||||
async def send(self, value: bytes | str) -> None:
|
||||
self.sent.append(value)
|
||||
if isinstance(value, bytes) and not self._interim_sent:
|
||||
self._interim_sent = True
|
||||
await self._frames.put(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": "안녕",
|
||||
"is_final": False,
|
||||
"speech_final": False,
|
||||
"confidence": 0.72,
|
||||
"duration": 0.3,
|
||||
"words": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
if isinstance(value, str) and json.loads(value).get("type") == "CloseStream":
|
||||
await self._frames.put(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": "안녕하세요",
|
||||
"is_final": True,
|
||||
"speech_final": True,
|
||||
"confidence": 0.91,
|
||||
"duration": 0.45,
|
||||
"words": [
|
||||
{
|
||||
"word": "안녕하세요",
|
||||
"start": 0.05,
|
||||
"end": 0.42,
|
||||
"confidence": 0.93,
|
||||
}
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
await self._frames.put(None)
|
||||
|
||||
async def close(self, code: int | None = None, reason: str | None = None) -> None:
|
||||
self.closed = True
|
||||
await self._frames.put(None)
|
||||
|
||||
|
||||
class _FakeLocalWhisperErrorSocket(_FakeLocalWhisperSocket):
|
||||
async def send(self, value: bytes | str) -> None:
|
||||
self.sent.append(value)
|
||||
await self._frames.put(
|
||||
json.dumps({"type": "error", "detail": "faster_whisper_unavailable"})
|
||||
)
|
||||
await self._frames.put(None)
|
||||
|
||||
|
||||
class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _service(self, connect, **overrides) -> VoiceService:
|
||||
options: dict[str, object] = {
|
||||
"api_key": "openai-fallback-key",
|
||||
"stt_provider": "local_whisper",
|
||||
"local_whisper_connect": connect,
|
||||
"local_whisper_finalize_timeout_seconds": 2,
|
||||
}
|
||||
options.update(overrides)
|
||||
return VoiceService(**options)
|
||||
|
||||
async def test_local_whisper_streams_interim_then_final_with_words(self) -> None:
|
||||
socket = _FakeLocalWhisperSocket()
|
||||
connect_calls: list[tuple[str, dict[str, object]]] = []
|
||||
updates = []
|
||||
|
||||
async def connect(url: str, **kwargs):
|
||||
connect_calls.append((url, kwargs))
|
||||
return socket
|
||||
|
||||
async def on_event(event) -> None:
|
||||
updates.append(event)
|
||||
|
||||
service = self._service(connect)
|
||||
stream = await service.open_streaming_transcription(
|
||||
fmt="pcm",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
on_event=on_event,
|
||||
)
|
||||
self.assertIsInstance(stream, LocalWhisperStreamingSession)
|
||||
await stream.send_audio(b"\x00\x00\xff\x7f")
|
||||
await asyncio.sleep(0)
|
||||
result = await stream.finish()
|
||||
|
||||
self.assertEqual(len(connect_calls), 1)
|
||||
url, _kwargs = connect_calls[0]
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
self.assertEqual(urlsplit(url).scheme, "ws")
|
||||
self.assertEqual(urlsplit(url).hostname, "127.0.0.1")
|
||||
self.assertEqual(query["model"], ["large-v3"])
|
||||
self.assertEqual(query["language"], ["ko"])
|
||||
self.assertEqual(query["sample_rate"], ["16000"])
|
||||
self.assertEqual(query["channels"], ["1"])
|
||||
self.assertEqual(query["endpointing"], ["300"])
|
||||
self.assertEqual(query["utterance_end_ms"], ["1200"])
|
||||
|
||||
self.assertEqual(result.text, "안녕하세요")
|
||||
self.assertEqual(result.model, "large-v3")
|
||||
self.assertEqual(result.language, "ko")
|
||||
self.assertEqual([word.word for word in result.words], ["안녕하세요"])
|
||||
self.assertAlmostEqual(result.words[0].start, 0.05, places=3)
|
||||
providers = {event["provider"] for event in result.provider_events}
|
||||
self.assertEqual(providers, {"local_whisper"})
|
||||
self.assertIn("stt_word", {event["type"] for event in result.provider_events})
|
||||
|
||||
self.assertTrue(updates)
|
||||
self.assertFalse(updates[0].final)
|
||||
self.assertTrue(updates[-1].final)
|
||||
self.assertTrue(updates[-1].speech_final)
|
||||
|
||||
async def test_second_utterance_word_timestamps_advance_by_the_first(self) -> None:
|
||||
"""Interim frames share the utterance clock; only finals advance it."""
|
||||
|
||||
frames = [
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": "첫째",
|
||||
"is_final": False,
|
||||
"speech_final": False,
|
||||
"duration": 0.3,
|
||||
"words": [],
|
||||
},
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": "첫째 발화",
|
||||
"is_final": True,
|
||||
"speech_final": True,
|
||||
"duration": 1.0,
|
||||
"words": [{"word": "첫째", "start": 0.1, "end": 0.5}],
|
||||
},
|
||||
{
|
||||
"type": "transcript",
|
||||
"text": "둘째 발화",
|
||||
"is_final": True,
|
||||
"speech_final": True,
|
||||
"duration": 0.8,
|
||||
"words": [{"word": "둘째", "start": 0.2, "end": 0.6}],
|
||||
},
|
||||
]
|
||||
|
||||
class _ScriptedSocket(_FakeLocalWhisperSocket):
|
||||
async def send(self, value: bytes | str) -> None:
|
||||
self.sent.append(value)
|
||||
if isinstance(value, str):
|
||||
for frame in frames:
|
||||
await self._frames.put(
|
||||
json.dumps(frame, ensure_ascii=False)
|
||||
)
|
||||
await self._frames.put(None)
|
||||
|
||||
socket = _ScriptedSocket()
|
||||
|
||||
async def connect(url: str, **kwargs):
|
||||
return socket
|
||||
|
||||
async def on_event(event) -> None:
|
||||
return None
|
||||
|
||||
service = self._service(connect)
|
||||
stream = await service.open_streaming_transcription(
|
||||
fmt="pcm",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
on_event=on_event,
|
||||
)
|
||||
result = await stream.finish()
|
||||
|
||||
self.assertEqual(result.text, "첫째 발화 둘째 발화")
|
||||
self.assertEqual([word.word for word in result.words], ["첫째", "둘째"])
|
||||
# 첫 발화 word 는 0 기준, 둘째 발화 word 는 첫 발화 길이(1.0)만큼 밀린다.
|
||||
self.assertAlmostEqual(result.words[0].start, 0.1, places=3)
|
||||
self.assertAlmostEqual(result.words[1].start, 1.2, places=3)
|
||||
self.assertAlmostEqual(result.duration or 0.0, 1.8, places=3)
|
||||
|
||||
async def test_local_whisper_never_sends_an_authorization_header(self) -> None:
|
||||
socket = _FakeLocalWhisperSocket()
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
async def connect(url: str, **kwargs):
|
||||
captured.append(kwargs)
|
||||
return socket
|
||||
|
||||
async def on_event(event) -> None:
|
||||
return None
|
||||
|
||||
service = self._service(connect)
|
||||
stream = await service.open_streaming_transcription(
|
||||
fmt="pcm",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
on_event=on_event,
|
||||
)
|
||||
await stream.abort()
|
||||
self.assertEqual(len(captured), 1)
|
||||
self.assertNotIn("additional_headers", captured[0])
|
||||
|
||||
async def test_sidecar_error_frame_fails_closed(self) -> None:
|
||||
socket = _FakeLocalWhisperErrorSocket()
|
||||
|
||||
async def connect(url: str, **kwargs):
|
||||
return socket
|
||||
|
||||
async def on_event(event) -> None:
|
||||
return None
|
||||
|
||||
service = self._service(connect)
|
||||
stream = await service.open_streaming_transcription(
|
||||
fmt="pcm",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
on_event=on_event,
|
||||
)
|
||||
await stream.send_audio(b"\x00\x00")
|
||||
await asyncio.sleep(0)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await stream.finish()
|
||||
|
||||
async def test_connection_failure_is_wrapped(self) -> None:
|
||||
async def connect(url: str, **kwargs):
|
||||
raise OSError("refused")
|
||||
|
||||
async def on_event(event) -> None:
|
||||
return None
|
||||
|
||||
service = self._service(connect)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
await service.open_streaming_transcription(
|
||||
fmt="pcm",
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
on_event=on_event,
|
||||
)
|
||||
self.assertIn("Local whisper", str(ctx.exception))
|
||||
|
||||
async def test_non_pcm_metadata_is_rejected_before_connecting(self) -> None:
|
||||
connected = False
|
||||
|
||||
async def connect(url: str, **kwargs):
|
||||
nonlocal connected
|
||||
connected = True
|
||||
return _FakeLocalWhisperSocket()
|
||||
|
||||
async def on_event(event) -> None:
|
||||
return None
|
||||
|
||||
service = self._service(connect)
|
||||
with self.assertRaises(VoiceUnavailable):
|
||||
await service.open_streaming_transcription(
|
||||
fmt="webm",
|
||||
sample_rate=None,
|
||||
channels=None,
|
||||
sample_width=None,
|
||||
on_event=on_event,
|
||||
)
|
||||
self.assertFalse(connected)
|
||||
|
||||
|
||||
class LocalWhisperProviderSelectionTest(unittest.TestCase):
|
||||
def test_local_whisper_needs_no_api_key(self) -> None:
|
||||
service = VoiceService(api_key="", stt_provider="local_whisper")
|
||||
self.assertTrue(service.streaming_stt_enabled())
|
||||
self.assertEqual(service.stt_provider(), "local_whisper")
|
||||
self.assertEqual(service.stt_model(), "large-v3")
|
||||
|
||||
def test_missing_sidecar_url_falls_back_to_batch(self) -> None:
|
||||
service = VoiceService(
|
||||
api_key="openai-key",
|
||||
stt_provider="local_whisper",
|
||||
local_whisper_stt_url="",
|
||||
)
|
||||
self.assertFalse(service.streaming_stt_enabled())
|
||||
self.assertEqual(service.stt_provider(), "openai-batch-fallback")
|
||||
|
||||
def test_missing_sidecar_and_no_openai_key_is_unavailable(self) -> None:
|
||||
service = VoiceService(
|
||||
api_key="",
|
||||
stt_provider="local_whisper",
|
||||
local_whisper_stt_url="",
|
||||
)
|
||||
self.assertEqual(service.stt_provider(), "unavailable")
|
||||
self.assertFalse(service.stt_available())
|
||||
|
||||
def test_deepgram_selection_is_untouched(self) -> None:
|
||||
service = VoiceService(
|
||||
api_key="openai-key",
|
||||
stt_provider="deepgram",
|
||||
deepgram_api_key="secret-value",
|
||||
)
|
||||
self.assertTrue(service.streaming_stt_enabled())
|
||||
self.assertEqual(service.stt_provider(), "deepgram")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue