Files
luke_scribe/src/luke_scribe/api/app.py
T
lukehemmin 20777386fe 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.
2026-08-12 17:32:18 +09:00

170 lines
6.1 KiB
Python

"""FastAPI 앱 팩토리.
Lifespan (plan §3.10a/§3.5):
- 시작: broker(Redis/in-proc) 생성, stale job reconciler 1회, EngineOwner 생성,
모델 프로비저닝(선택) — 준비 전 ``/health``는 ``model_ready=false``.
- 종료: 터널 종료, 열린 리소스 정리.
"""
from __future__ import annotations
import logging
import threading
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from ..config import Settings, get_settings
from ..engine.owner import EngineOwner
from ..errors import (
AuthError,
InvalidInput,
JobNotFound,
LukeScribeError,
QueueFull,
ScopeDenied,
UnsupportedInputEnvelope,
)
from ..results.store import ResultStore
from .deps import KeyStore
from .routes import admin, jobs, stream
logger = logging.getLogger("luke_scribe.api")
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
from ..jobqueue.broker import make_broker
broker = make_broker(settings)
store = ResultStore(settings.results_root)
owner = EngineOwner.get(settings)
keystore = KeyStore(settings)
# 스타트업 reconciler — stale processing 복구
try:
reclaimed = broker.reconcile_stale(60.0)
if reclaimed:
logger.warning("스타트업 reconciler: %d stale job 복구", len(reclaimed))
except Exception as exc:
logger.warning("reconciler 실패: %s", exc)
app.state.settings = settings
app.state.broker = broker
app.state.store = store
app.state.engine_owner = owner
app.state.keystore = keystore
app.state.model_ready = False
app.state.model_ready_model = None
from .routes.stream import _SessionGuard
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:
try:
from ..engine.model_registry import ModelRegistry
reg = ModelRegistry(settings)
path = reg.ensure_available(
settings.model_batch, cache_dir=model_cache, offline_ok=True
)
app.state.model_ready = True
app.state.model_ready_model = settings.model_batch
logger.info("모델 준비됨: %s (%s)", settings.model_batch, path)
except Exception as exc:
logger.warning("모델 프로비저닝 보류: %s", exc)
# 터널 (선택)
tunnel = None
if settings.tunnel != "none":
try:
from ..connectivity.tunnel import CloudflareTunnel
tunnel = CloudflareTunnel(settings)
tunnel.start()
app.state.tunnel_url = tunnel.public_url
logger.info("터널: %s", tunnel.public_url)
except Exception as exc:
logger.warning("터널 시작 실패: %s", exc)
yield
if tunnel is not None:
try:
tunnel.stop()
except Exception:
pass
if inproc_worker is not None:
# stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후
# 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전)
inproc_worker.stop()
owner.unload_all()
app = FastAPI(
title="luke_scribe",
version="0.1.0",
description="내부용 로컬 STT 전사 API — faster-whisper, hardware-adaptive, privacy-first",
lifespan=lifespan,
)
app.include_router(admin.router)
app.include_router(jobs.router)
app.include_router(stream.router)
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
@app.exception_handler(AuthError)
async def _auth_error(request: Request, exc: AuthError) -> JSONResponse:
return JSONResponse(status_code=401, content=exc.to_dict())
@app.exception_handler(ScopeDenied)
async def _scope_error(request: Request, exc: ScopeDenied) -> JSONResponse:
return JSONResponse(status_code=403, content=exc.to_dict())
@app.exception_handler(JobNotFound)
async def _job_not_found(request: Request, exc: JobNotFound) -> JSONResponse:
return JSONResponse(status_code=404, content=exc.to_dict())
@app.exception_handler(QueueFull)
async def _queue_full(request: Request, exc: QueueFull) -> JSONResponse:
return JSONResponse(status_code=429, content=exc.to_dict(), headers={"Retry-After": "5"})
@app.exception_handler(UnsupportedInputEnvelope)
async def _envelope(request: Request, exc: UnsupportedInputEnvelope) -> JSONResponse:
return JSONResponse(status_code=413, content=exc.to_dict())
@app.exception_handler(InvalidInput)
async def _invalid_input(request: Request, exc: InvalidInput) -> JSONResponse:
return JSONResponse(status_code=422, content=exc.to_dict())
@app.exception_handler(LukeScribeError)
async def _generic_error(request: Request, exc: LukeScribeError) -> JSONResponse:
return JSONResponse(status_code=500, content=exc.to_dict())
return app
app = create_app()