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
378
scripts/test_local_whisper_stt_server.py
Normal file
378
scripts/test_local_whisper_stt_server.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("local-whisper-stt-server.py")
|
||||
SPEC = importlib.util.spec_from_file_location("local_whisper_stt_server", SCRIPT_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
SAMPLE_RATE = 16_000
|
||||
|
||||
|
||||
def pcm_ms(milliseconds: float, *, amplitude: int) -> bytes:
|
||||
"""Mono linear16 tone (or silence when amplitude is 0)."""
|
||||
|
||||
count = int(SAMPLE_RATE * milliseconds / 1000.0)
|
||||
if amplitude <= 0:
|
||||
return b"\x00\x00" * count
|
||||
samples = [
|
||||
int(amplitude * math.sin(2 * math.pi * 220 * index / SAMPLE_RATE))
|
||||
for index in range(count)
|
||||
]
|
||||
return struct.pack(f"<{count}h", *samples)
|
||||
|
||||
|
||||
SPEECH = lambda ms: pcm_ms(ms, amplitude=9000) # noqa: E731
|
||||
SILENCE = lambda ms: pcm_ms(ms, amplitude=0) # noqa: E731
|
||||
|
||||
|
||||
class FakeTranscriber:
|
||||
def __init__(self, text: str = "안녕하세요") -> None:
|
||||
self.text = text
|
||||
self.calls: list[tuple[int, bool]] = []
|
||||
|
||||
def transcribe(self, pcm: bytes, *, sample_rate: int, final: bool):
|
||||
self.calls.append((len(pcm), final))
|
||||
words = (
|
||||
(MODULE.Word(word=self.text, start=0.0, end=0.5, confidence=0.9),)
|
||||
if final
|
||||
else ()
|
||||
)
|
||||
return MODULE.Transcription(text=self.text, confidence=0.87, words=words)
|
||||
|
||||
|
||||
def default_options(**overrides):
|
||||
options = {
|
||||
"model": "large-v3",
|
||||
"language": "ko",
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"channels": 1,
|
||||
"endpointing_ms": MODULE.DEFAULT_ENDPOINTING_MS,
|
||||
"utterance_end_ms": MODULE.DEFAULT_UTTERANCE_END_MS,
|
||||
"interim_interval_ms": MODULE.DEFAULT_INTERIM_INTERVAL_MS,
|
||||
"silence_rms": MODULE.DEFAULT_SILENCE_RMS,
|
||||
}
|
||||
options.update(overrides)
|
||||
return options
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class FrameRmsTest(unittest.TestCase):
|
||||
def test_silence_and_speech_are_separated(self) -> None:
|
||||
self.assertEqual(MODULE.frame_rms(SILENCE(20)), 0)
|
||||
self.assertGreater(MODULE.frame_rms(SPEECH(20)), MODULE.DEFAULT_SILENCE_RMS)
|
||||
|
||||
def test_empty_and_odd_length_input_is_silence(self) -> None:
|
||||
self.assertEqual(MODULE.frame_rms(b""), 0)
|
||||
self.assertEqual(MODULE.frame_rms(b"\x01"), 0)
|
||||
|
||||
|
||||
class UtteranceBufferTest(unittest.TestCase):
|
||||
def test_duration_tracks_appended_bytes(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer()
|
||||
buffer.append(SPEECH(500))
|
||||
self.assertAlmostEqual(buffer.duration_ms(), 500.0, delta=1.0)
|
||||
|
||||
def test_silence_alone_never_finalizes(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer()
|
||||
buffer.append(SILENCE(3_000))
|
||||
self.assertFalse(buffer.should_finalize())
|
||||
self.assertFalse(buffer.should_emit_interim())
|
||||
self.assertTrue(buffer.is_stalled())
|
||||
|
||||
def test_speech_then_endpointing_silence_finalizes(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer(endpointing_ms=300)
|
||||
buffer.append(SPEECH(800))
|
||||
self.assertFalse(buffer.should_finalize())
|
||||
buffer.append(SILENCE(200))
|
||||
self.assertFalse(buffer.should_finalize())
|
||||
buffer.append(SILENCE(200))
|
||||
self.assertTrue(buffer.should_finalize())
|
||||
|
||||
def test_speech_after_silence_resets_the_silence_run(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer(endpointing_ms=300)
|
||||
buffer.append(SPEECH(400))
|
||||
buffer.append(SILENCE(200))
|
||||
buffer.append(SPEECH(100))
|
||||
buffer.append(SILENCE(200))
|
||||
self.assertFalse(buffer.should_finalize())
|
||||
|
||||
def test_interim_waits_for_the_configured_interval(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer(interim_interval_ms=700)
|
||||
buffer.append(SPEECH(300))
|
||||
self.assertFalse(buffer.should_emit_interim())
|
||||
buffer.append(SPEECH(500))
|
||||
self.assertTrue(buffer.should_emit_interim())
|
||||
buffer.mark_interim_emitted()
|
||||
self.assertFalse(buffer.should_emit_interim())
|
||||
|
||||
def test_very_long_utterance_finalizes_even_without_silence(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer()
|
||||
for _ in range(int(MODULE.MAX_UTTERANCE_SECONDS) + 1):
|
||||
buffer.append(SPEECH(1_000))
|
||||
self.assertTrue(buffer.should_finalize())
|
||||
|
||||
def test_reset_clears_audio_and_state(self) -> None:
|
||||
buffer = MODULE.UtteranceBuffer()
|
||||
buffer.append(SPEECH(500))
|
||||
buffer.reset()
|
||||
self.assertEqual(buffer.pcm, b"")
|
||||
self.assertFalse(buffer.saw_speech)
|
||||
self.assertEqual(buffer.duration_ms(), 0.0)
|
||||
|
||||
|
||||
class StreamOptionsTest(unittest.TestCase):
|
||||
def test_defaults_apply_without_a_query(self) -> None:
|
||||
options = MODULE.parse_stream_options("/v1/listen")
|
||||
self.assertEqual(options["model"], MODULE.DEFAULT_MODEL)
|
||||
self.assertEqual(options["language"], MODULE.DEFAULT_LANGUAGE)
|
||||
self.assertEqual(options["sample_rate"], MODULE.DEFAULT_SAMPLE_RATE)
|
||||
|
||||
def test_values_are_clamped_into_range(self) -> None:
|
||||
options = MODULE.parse_stream_options(
|
||||
"/v1/listen?sample_rate=999999&endpointing=1&utterance_end_ms=99999"
|
||||
)
|
||||
self.assertEqual(options["sample_rate"], 48_000)
|
||||
self.assertEqual(options["endpointing_ms"], 10)
|
||||
self.assertEqual(options["utterance_end_ms"], 10_000)
|
||||
|
||||
def test_unparsable_numbers_fall_back_to_defaults(self) -> None:
|
||||
options = MODULE.parse_stream_options("/v1/listen?sample_rate=abc")
|
||||
self.assertEqual(options["sample_rate"], MODULE.DEFAULT_SAMPLE_RATE)
|
||||
|
||||
def test_model_allowlist_and_language_shape_fail_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE.parse_stream_options("/v1/listen?model=../etc/passwd")
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE.parse_stream_options("/v1/listen?language=ko;DROP")
|
||||
|
||||
|
||||
class TranscriptPayloadTest(unittest.TestCase):
|
||||
def test_interim_payload_omits_word_timestamps(self) -> None:
|
||||
result = MODULE.Transcription(
|
||||
text="안녕", confidence=0.5, words=(MODULE.Word("안녕", 0.0, 0.4),)
|
||||
)
|
||||
payload = MODULE.transcript_payload(
|
||||
result, is_final=False, speech_final=False, duration_seconds=1.0
|
||||
)
|
||||
self.assertEqual(payload["words"], [])
|
||||
self.assertFalse(payload["is_final"])
|
||||
|
||||
def test_final_payload_carries_word_timestamps(self) -> None:
|
||||
result = MODULE.Transcription(
|
||||
text="안녕", confidence=None, words=(MODULE.Word("안녕", 0.0, 0.4, 0.8),)
|
||||
)
|
||||
payload = MODULE.transcript_payload(
|
||||
result, is_final=True, speech_final=True, duration_seconds=1.234
|
||||
)
|
||||
self.assertEqual(payload["words"][0]["word"], "안녕")
|
||||
self.assertEqual(payload["words"][0]["confidence"], 0.8)
|
||||
self.assertIsNone(payload["confidence"])
|
||||
self.assertEqual(payload["duration"], 1.234)
|
||||
|
||||
|
||||
class StreamSessionTest(unittest.TestCase):
|
||||
def _session(self, transcriber, sent, **overrides):
|
||||
async def send(payload):
|
||||
sent.append(payload)
|
||||
|
||||
return MODULE.StreamSession(
|
||||
options=default_options(**overrides),
|
||||
transcriber=transcriber,
|
||||
send=send,
|
||||
)
|
||||
|
||||
def test_silence_only_stream_emits_nothing(self) -> None:
|
||||
sent: list[dict] = []
|
||||
transcriber = FakeTranscriber()
|
||||
session = self._session(transcriber, sent)
|
||||
run(session.feed(SILENCE(2_000)))
|
||||
run(session.finalize(speech_final=True))
|
||||
self.assertEqual(sent, [])
|
||||
self.assertEqual(transcriber.calls, [])
|
||||
|
||||
def test_speech_emits_interim_then_final(self) -> None:
|
||||
sent: list[dict] = []
|
||||
session = self._session(FakeTranscriber(), sent)
|
||||
run(session.feed(SPEECH(800)))
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertFalse(sent[0]["is_final"])
|
||||
run(session.feed(SILENCE(400)))
|
||||
self.assertEqual(len(sent), 2)
|
||||
self.assertTrue(sent[1]["is_final"])
|
||||
self.assertTrue(sent[1]["speech_final"])
|
||||
self.assertTrue(sent[1]["words"])
|
||||
|
||||
def test_final_appends_to_the_transcript_and_resets_the_buffer(self) -> None:
|
||||
sent: list[dict] = []
|
||||
session = self._session(FakeTranscriber("첫 문장"), sent)
|
||||
run(session.feed(SPEECH(800)))
|
||||
run(session.feed(SILENCE(400)))
|
||||
self.assertEqual(session.final_segments, ["첫 문장"])
|
||||
self.assertEqual(session.buffer.pcm, b"")
|
||||
self.assertFalse(session.buffer.saw_speech)
|
||||
|
||||
def test_two_utterances_produce_two_finals(self) -> None:
|
||||
sent: list[dict] = []
|
||||
session = self._session(FakeTranscriber(), sent)
|
||||
for _ in range(2):
|
||||
run(session.feed(SPEECH(800)))
|
||||
run(session.feed(SILENCE(400)))
|
||||
finals = [item for item in sent if item["is_final"]]
|
||||
self.assertEqual(len(finals), 2)
|
||||
self.assertEqual(len(session.final_segments), 2)
|
||||
|
||||
def test_explicit_finalize_flushes_without_trailing_silence(self) -> None:
|
||||
sent: list[dict] = []
|
||||
session = self._session(FakeTranscriber(), sent)
|
||||
run(session.feed(SPEECH(400)))
|
||||
run(session.finalize(speech_final=True))
|
||||
self.assertTrue(sent[-1]["is_final"])
|
||||
|
||||
def test_blank_model_output_is_not_emitted(self) -> None:
|
||||
sent: list[dict] = []
|
||||
session = self._session(FakeTranscriber(text=" "), sent)
|
||||
run(session.feed(SPEECH(800)))
|
||||
run(session.feed(SILENCE(400)))
|
||||
self.assertEqual(sent, [])
|
||||
self.assertEqual(session.final_segments, [])
|
||||
|
||||
def test_oversized_frame_fails_closed(self) -> None:
|
||||
sent: list[dict] = []
|
||||
session = self._session(FakeTranscriber(), sent)
|
||||
with self.assertRaises(MODULE.TranscriptionError):
|
||||
run(session.feed(b"\x00" * (MODULE.MAX_FRAME_BYTES + 1)))
|
||||
|
||||
def test_final_transcription_requests_word_timestamps(self) -> None:
|
||||
sent: list[dict] = []
|
||||
transcriber = FakeTranscriber()
|
||||
session = self._session(transcriber, sent)
|
||||
run(session.feed(SPEECH(800)))
|
||||
run(session.feed(SILENCE(400)))
|
||||
self.assertIn(False, [final for _, final in transcriber.calls])
|
||||
self.assertIn(True, [final for _, final in transcriber.calls])
|
||||
|
||||
|
||||
class DeviceResolutionTest(unittest.TestCase):
|
||||
def test_cpu_request_uses_int8(self) -> None:
|
||||
self.assertEqual(MODULE.resolve_device("cpu"), ("cpu", "int8"))
|
||||
|
||||
def test_invalid_device_fails_closed(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE.resolve_device("tpu")
|
||||
|
||||
def test_auto_returns_a_supported_pair(self) -> None:
|
||||
device, compute = MODULE.resolve_device("auto")
|
||||
self.assertIn(device, {"cuda", "cpu"})
|
||||
self.assertIn(compute, {"float16", "int8"})
|
||||
|
||||
def test_auto_prefers_cuda_when_present(self) -> None:
|
||||
self.assertEqual(
|
||||
MODULE.resolve_device("auto", cuda_available=True), ("cuda", "float16")
|
||||
)
|
||||
|
||||
def test_explicit_cuda_without_a_device_fails(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
MODULE.resolve_device("cuda", cuda_available=False)
|
||||
|
||||
|
||||
class WarmupPcmTest(unittest.TestCase):
|
||||
def test_warmup_buffer_is_bytes_of_the_expected_length(self) -> None:
|
||||
pcm = MODULE.warmup_pcm(16_000, milliseconds=500)
|
||||
self.assertIsInstance(pcm, bytes)
|
||||
self.assertEqual(len(pcm), 16_000 // 2 * 2)
|
||||
self.assertEqual(MODULE.frame_rms(pcm), 0)
|
||||
|
||||
def test_warmup_buffer_is_never_empty(self) -> None:
|
||||
self.assertGreaterEqual(len(MODULE.warmup_pcm(8_000, milliseconds=0)), 2)
|
||||
|
||||
|
||||
class _FakeTranscriberFactory:
|
||||
"""Records constructions and can fail warmup on a chosen device."""
|
||||
|
||||
def __init__(self, *, failing_device: str | None = None) -> None:
|
||||
self.failing_device = failing_device
|
||||
self.built: list[tuple[str, str, str]] = []
|
||||
|
||||
def __call__(self, model: str, device: str, compute_type: str):
|
||||
self.built.append((model, device, compute_type))
|
||||
failing = self.failing_device
|
||||
|
||||
class _Transcriber:
|
||||
def __init__(self) -> None:
|
||||
self.device = device
|
||||
self.compute_type = compute_type
|
||||
self.model_name = model
|
||||
|
||||
def warmup(self) -> None:
|
||||
if failing is not None and device == failing:
|
||||
raise RuntimeError("Could not locate cudnn_ops64_9.dll")
|
||||
|
||||
return _Transcriber()
|
||||
|
||||
|
||||
class TranscriberBuildTest(unittest.TestCase):
|
||||
def test_auto_falls_back_to_cpu_when_cuda_warmup_fails(self) -> None:
|
||||
"""cuDNN 부재처럼 '장치는 보이지만 추론이 죽는' 경우를 잡는다."""
|
||||
|
||||
factory = _FakeTranscriberFactory(failing_device="cuda")
|
||||
transcriber = MODULE.build_transcriber(
|
||||
"small", "auto", factory=factory, cuda_available=True
|
||||
)
|
||||
self.assertEqual(transcriber.device, "cpu")
|
||||
self.assertEqual(transcriber.compute_type, "int8")
|
||||
self.assertEqual(
|
||||
factory.built, [("small", "cuda", "float16"), ("small", "cpu", "int8")]
|
||||
)
|
||||
|
||||
def test_auto_keeps_cuda_when_warmup_succeeds(self) -> None:
|
||||
factory = _FakeTranscriberFactory()
|
||||
transcriber = MODULE.build_transcriber(
|
||||
"small", "auto", factory=factory, cuda_available=True
|
||||
)
|
||||
self.assertEqual(transcriber.device, "cuda")
|
||||
self.assertEqual(len(factory.built), 1)
|
||||
|
||||
def test_explicit_cuda_never_downgrades_silently(self) -> None:
|
||||
factory = _FakeTranscriberFactory(failing_device="cuda")
|
||||
with self.assertRaises(MODULE.TranscriptionError):
|
||||
MODULE.build_transcriber(
|
||||
"small", "cuda", factory=factory, cuda_available=True
|
||||
)
|
||||
self.assertEqual(factory.built, [("small", "cuda", "float16")])
|
||||
|
||||
def test_cpu_warmup_failure_is_not_retried(self) -> None:
|
||||
factory = _FakeTranscriberFactory(failing_device="cpu")
|
||||
with self.assertRaises(MODULE.TranscriptionError):
|
||||
MODULE.build_transcriber(
|
||||
"small", "auto", factory=factory, cuda_available=False
|
||||
)
|
||||
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
||||
|
||||
|
||||
class CliTest(unittest.TestCase):
|
||||
def test_server_refuses_to_start_without_enable(self) -> None:
|
||||
self.assertEqual(MODULE.main([]), 2)
|
||||
|
||||
def test_host_is_loopback_only(self) -> None:
|
||||
parser = MODULE.build_parser()
|
||||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["--host", "0.0.0.0"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue