From be5f5054101a7731477b632b13fc4f105f890a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:39:15 +0900 Subject: [PATCH] fix: normalize faster-whisper TranscriptionInfo to dict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colab run 4 (A100): same namedtuple bug as segments — faster-whisper returns TranscriptionInfo (namedtuple) but batch.py reads outcome['info'].get('language') -> AttributeError 'TranscriptionInfo' object has no attribute 'get' on every real transcription, failing the CLI, API auto-worker job, worker drain, and bench clip alike. FasterWhisperEngine now normalizes info to a dict at the boundary (_to_dict_info: _asdict -> dataclasses.asdict -> known-field fallback). + 2 unit tests (namedtuple/dict info); 138 tests pass, ruff clean. --- .../engine/faster_whisper_engine.py | 28 ++++++++++++++- tests/unit/test_engine_faster_whisper.py | 36 +++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/luke_scribe/engine/faster_whisper_engine.py b/src/luke_scribe/engine/faster_whisper_engine.py index 31a8be0..0d2a914 100644 --- a/src/luke_scribe/engine/faster_whisper_engine.py +++ b/src/luke_scribe/engine/faster_whisper_engine.py @@ -133,7 +133,7 @@ class FasterWhisperEngine(TranscriptionEngine): wrapped = _CancellableSegmentIterator( self._to_dict_segments(segments_iter), should_cancel or (lambda: False) ) - return TranscriptionOutcome(wrapped, info=info) + return TranscriptionOutcome(wrapped, info=self._to_dict_info(info)) @staticmethod def _to_dict_segments(segments): @@ -169,6 +169,32 @@ class FasterWhisperEngine(TranscriptionEngine): if (v := getattr(seg, k, None)) is not None } + @staticmethod + def _to_dict_info(info): + """faster-whisper TranscriptionInfo(namedtuple) → dict. + + 다운스트림(배치 파이프라인)은 info를 dict 계약으로 접근한다 + (``info.get("language")``) — GPU 실전에서만 재현되는 버그: + 'TranscriptionInfo' object has no attribute 'get'. + """ + if info is None or isinstance(info, dict): + return info + asdict = getattr(info, "_asdict", None) + if asdict is not None: + return dict(asdict()) + try: + import dataclasses + + if dataclasses.is_dataclass(info): + return dataclasses.asdict(info) + except Exception: + pass + return { + k: v + for k in ("language", "language_probability", "duration", "duration_after_vad") + if (v := getattr(info, k, None)) is not None + } + @staticmethod def _split_device(device: str) -> tuple[str, int]: """'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다. diff --git a/tests/unit/test_engine_faster_whisper.py b/tests/unit/test_engine_faster_whisper.py index 081ca38..5ca618f 100644 --- a/tests/unit/test_engine_faster_whisper.py +++ b/tests/unit/test_engine_faster_whisper.py @@ -16,17 +16,18 @@ from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine class FakeWhisperModel: - """faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트를 기록한다.""" + """faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트/info를 기록한다.""" calls: list[dict] = [] segments: list = [] # transcribe()가 yield할 세그먼트 (기본: namedtuple) + info: object = None # 기본: TranscriptionInfo(namedtuple) 흉내 def __init__(self, *args, **kwargs) -> None: self.kwargs = kwargs self.__class__.calls.append(kwargs) def transcribe(self, audio_path, **kwargs): - return iter(list(self.__class__.segments)), {} + return iter(list(self.__class__.segments)), self.__class__.info def _install_fake(monkeypatch) -> None: @@ -35,6 +36,7 @@ def _install_fake(monkeypatch) -> None: monkeypatch.setitem(sys.modules, "faster_whisper", mod) FakeWhisperModel.calls.clear() FakeWhisperModel.segments = [] + FakeWhisperModel.info = None def _opts(**kw) -> TranscriptionOptions: @@ -137,3 +139,33 @@ def test_dict_segments_passthrough(monkeypatch): ) segs = list(outcome.segments) assert segs == [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}] + + +def test_namedtuple_info_normalized_to_dict(monkeypatch): + """faster-whisper TranscriptionInfo(namedtuple) → dict (GPU 실전 버그).""" + from collections import namedtuple + + _install_fake(monkeypatch) + Info = namedtuple( + "TranscriptionInfo", + ["language", "language_probability", "duration", "duration_after_vad"], + ) + FakeWhisperModel.info = Info( + language="ko", language_probability=0.99, duration=10.464, duration_after_vad=9.088 + ) + outcome = FasterWhisperEngine().transcribe( + "/tmp/x.wav", _opts(device="cpu", compute_type="int8") + ) + # dict 계약: .get() 사용 가능 (batch.py가 이걸로 접근) + assert outcome.info.get("language") == "ko" + assert outcome.info.get("duration") == 10.464 + + +def test_dict_info_passthrough(monkeypatch): + """이미 dict인 info는 그대로 (mock 계약과 호환).""" + _install_fake(monkeypatch) + FakeWhisperModel.info = {"language": "ko"} + outcome = FasterWhisperEngine().transcribe( + "/tmp/x.wav", _opts(device="cpu", compute_type="int8") + ) + assert outcome.info == {"language": "ko"}