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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
Reference in New Issue
Block a user