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:
@@ -9,6 +9,7 @@ Lifespan (plan §3.10a/§3.5):
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -63,6 +64,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
app.state.session_guard = _SessionGuard(settings)
|
||||
|
||||
# in-proc 백엔드 + auto_worker: 같은 프로세스 워커 스레드가 큐를 소비
|
||||
# (dev/Colab에서 별도 워커 프로세스 없이 업로드 → 완료 흐름이 가능하게 함)
|
||||
inproc_worker = None
|
||||
if settings.queue_backend == "inproc" and settings.auto_worker:
|
||||
from ..jobqueue.worker import Worker
|
||||
|
||||
inproc_worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=owner,
|
||||
worker_id="api-inproc",
|
||||
)
|
||||
threading.Thread(target=inproc_worker.run_forever, daemon=True).start()
|
||||
logger.info("in-proc auto-worker 스레드 시작 (queue_backend=inproc, auto_worker=true)")
|
||||
app.state.inproc_worker = inproc_worker
|
||||
|
||||
# 모델 프로비저닝 (선택) — 설정된 경우에만
|
||||
model_cache = settings.model_cache_dir
|
||||
if model_cache:
|
||||
@@ -99,6 +117,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
tunnel.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if inproc_worker is not None:
|
||||
# stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후
|
||||
# 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전)
|
||||
inproc_worker.stop()
|
||||
owner.unload_all()
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
@@ -58,6 +58,9 @@ class Settings(BaseSettings):
|
||||
job_timeout_hours: float = 4.0
|
||||
job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진
|
||||
|
||||
# ── 워커 ──
|
||||
auto_worker: bool = False # in-proc 백엔드: 서버 프로세스가 큐를 소비 (dev/Colab)
|
||||
|
||||
# ── 입력 상한 ──
|
||||
max_duration_sec: int = 14400 # 4h
|
||||
max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
|
||||
@@ -130,9 +130,45 @@ class FasterWhisperEngine(TranscriptionEngine):
|
||||
except Exception as exc:
|
||||
raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc
|
||||
|
||||
wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False))
|
||||
wrapped = _CancellableSegmentIterator(
|
||||
self._to_dict_segments(segments_iter), should_cancel or (lambda: False)
|
||||
)
|
||||
return TranscriptionOutcome(wrapped, info=info)
|
||||
|
||||
@staticmethod
|
||||
def _to_dict_segments(segments):
|
||||
"""faster-whisper Segment(namedtuple) → dict로 정규화.
|
||||
|
||||
다운스트림(배치 파이프라인/벤치/실시간)은 dict 계약을 쓴다 (mock과 동일).
|
||||
GPU 실전에서만 재현되는 버그: 'Segment' object has no attribute 'get'.
|
||||
"""
|
||||
for seg in segments:
|
||||
if isinstance(seg, dict):
|
||||
yield seg
|
||||
continue
|
||||
asdict = getattr(seg, "_asdict", None)
|
||||
if asdict is not None:
|
||||
yield dict(asdict())
|
||||
continue
|
||||
# 최후 수단: 알려진 필드만, None 값은 제외 (다운스트림 .get(key, default)가
|
||||
# key 존재+None으로 default를 무시하지 않도록)
|
||||
yield {
|
||||
k: v
|
||||
for k in (
|
||||
"id",
|
||||
"seek",
|
||||
"start",
|
||||
"end",
|
||||
"text",
|
||||
"tokens",
|
||||
"temperature",
|
||||
"avg_logprob",
|
||||
"compression_ratio",
|
||||
"no_speech_prob",
|
||||
)
|
||||
if (v := getattr(seg, k, None)) is not None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _split_device(device: str) -> tuple[str, int]:
|
||||
"""'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다.
|
||||
|
||||
@@ -100,7 +100,16 @@ class Worker:
|
||||
self._emit_progress(job, processed_sec, total_sec)
|
||||
|
||||
try:
|
||||
options = TranscriptionOptions(**job.options)
|
||||
# API의 TranscribeOptions에는 엔진과 무관한 키가 포함될 수 있다
|
||||
# (formats/timestamps/glossary_id/post_correction/diarize 등) —
|
||||
# 엔진 계약 필드만 골라 전달한다.
|
||||
options = TranscriptionOptions(
|
||||
**{
|
||||
k: v
|
||||
for k, v in (job.options or {}).items()
|
||||
if k in TranscriptionOptions.__slots__
|
||||
}
|
||||
)
|
||||
result = pipeline.run(job, options, progress_cb=progress_cb)
|
||||
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
|
||||
self.store.write_result(job.id, result)
|
||||
|
||||
Reference in New Issue
Block a user