fix: normalize faster-whisper segments to dicts + in-proc auto-worker

Colab run 3 (A100) surfaced three GPU/API-path bugs mocks couldn't catch:

1. faster-whisper yields namedtuple Segments, but batch/bench consume
   them as dicts (.get) -> AttributeError 'Segment' has no attribute
   'get' on every real transcription. Engine now normalizes segments
   to dicts (_to_dict_segments) at the boundary.

2. API TranscribeOptions carries engine-irrelevant keys (formats,
   timestamps, glossary_id, post_correction, diarize); worker's
   TranscriptionOptions(**job.options) crashed with TypeError. Worker
   now filters job.options to TranscriptionOptions.__slots__.

3. in-proc server never consumed its own queue (jobs stayed queued
   forever). Added opt-in Settings.auto_worker (default off): lifespan
   starts a daemon Worker thread for inproc backend, stopped on
   shutdown. Notebook enables it via LUKESCRIBE_AUTO_WORKER=true so the
   API upload -> completed flow works end to end.

Notebook: bench manifest now uses clips schema (audio_path/duration_sec/
entities); cell 22 reads error_message/error_code; upload poll window
raised to 4min (first-run model download).

+ 5 tests (namedtuple/dict segments, API-style options, auto_worker
on/off); 136 tests pass, ruff clean.
This commit is contained in:
2026-08-12 17:32:18 +09:00
parent 741bce9fc6
commit 20777386fe
9 changed files with 218 additions and 16 deletions
+28
View File
@@ -44,6 +44,34 @@ class TestHealth:
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
class TestAutoWorker:
def test_auto_worker_off_by_default(self, client: TestClient):
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
assert client.app.state.inproc_worker is None
def test_auto_worker_started_when_enabled(self, tmp_path):
"""auto_worker=true + inproc → lifespan이 워커 스레드를 시작하고 종료 시 정지."""
from luke_scribe.api.app import create_app
from luke_scribe.config import Settings
settings = Settings(
_env_file=None,
results_dir=str(tmp_path / "results"),
api_key_file=str(tmp_path / "api_keys.json"),
queue_backend="inproc",
model_cache_dir=None,
tunnel="none",
auto_worker=True,
)
app = create_app(settings)
with TestClient(app) as c:
w = c.app.state.inproc_worker
assert w is not None
assert not w._stop.is_set()
# lifespan 종료 → 워커 정지 요청
assert w._stop.is_set()
class TestAuth:
def test_missing_key_rejected(self, client: TestClient):
r = client.get("/v1/jobs")
+36
View File
@@ -30,6 +30,42 @@ def _job(**kw) -> Job:
class TestWorkerLifecycle:
def test_api_style_options_filtered(self, settings, tmp_path):
"""API TranscribeOptions(엔진 무관 키 포함) → 워커가 엔진 필드만 골라 처리.
Colab 실전에서 잡이 계속 실패한 원인: job.options에 formats/timestamps/
diarize 등이 포함돼 TranscriptionOptions(**job.options)가 TypeError를 냄.
"""
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(),
ingestor=FakeIngestor(),
)
job = _job(
options={
"language": "ko",
"device": "auto",
"timestamps": True,
"formats": ["json", "srt"],
"word_timestamps": False,
"vad": True,
"hotwords": [],
"glossary_id": None,
"post_correction": None,
"diarize": False,
}
)
broker.enqueue(job)
worker.drain()
assert broker.get(job.id).status == JobStatus.COMPLETED
result = store.read_result(job.id)
assert result is not None
assert result.status == "completed"
def test_complete_flow(self, settings, tmp_path):
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
broker = InProcBroker(settings)
+61 -2
View File
@@ -16,16 +16,17 @@ from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine
class FakeWhisperModel:
"""faster_whisper.WhisperModel 대체 — 생성 인자 기록한다."""
"""faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트를 기록한다."""
calls: list[dict] = []
segments: list = [] # transcribe()가 yield할 세그먼트 (기본: namedtuple)
def __init__(self, *args, **kwargs) -> None:
self.kwargs = kwargs
self.__class__.calls.append(kwargs)
def transcribe(self, audio_path, **kwargs):
return iter([]), {}
return iter(list(self.__class__.segments)), {}
def _install_fake(monkeypatch) -> None:
@@ -33,6 +34,7 @@ def _install_fake(monkeypatch) -> None:
mod.WhisperModel = FakeWhisperModel
monkeypatch.setitem(sys.modules, "faster_whisper", mod)
FakeWhisperModel.calls.clear()
FakeWhisperModel.segments = []
def _opts(**kw) -> TranscriptionOptions:
@@ -78,3 +80,60 @@ def test_split_device_helper():
assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0)
# 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit)
assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0)
def test_namedtuple_segments_normalized_to_dicts(monkeypatch):
"""faster-whisper Segment(namedtuple) → dict 정규화 (GPU 실전 버그)."""
from collections import namedtuple
_install_fake(monkeypatch)
Seg = namedtuple(
"Segment",
[
"id",
"seek",
"start",
"end",
"text",
"tokens",
"temperature",
"avg_logprob",
"compression_ratio",
"no_speech_prob",
],
)
FakeWhisperModel.segments = [
Seg(
id=0,
seek=0,
start=0.0,
end=2.5,
text="오늘 vLLM을 배포합니다.",
tokens=[1, 2],
temperature=0.0,
avg_logprob=-0.2,
compression_ratio=1.0,
no_speech_prob=0.01,
)
]
outcome = FasterWhisperEngine().transcribe(
"/tmp/x.wav", _opts(device="cpu", compute_type="int8")
)
segs = list(outcome.segments)
assert len(segs) == 1
seg = segs[0]
# dict 계약: .get() 사용 가능 (파이프라인이 이걸로 접근)
assert seg.get("text") == "오늘 vLLM을 배포합니다."
assert seg.get("end") == 2.5
assert seg.get("avg_logprob") == -0.2
def test_dict_segments_passthrough(monkeypatch):
"""이미 dict인 세그먼트는 그대로 (mock 계약과 호환)."""
_install_fake(monkeypatch)
FakeWhisperModel.segments = [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}]
outcome = FasterWhisperEngine().transcribe(
"/tmp/x.wav", _opts(device="cpu", compute_type="int8")
)
segs = list(outcome.segments)
assert segs == [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}]