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.
170 lines
6.1 KiB
Python
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()
|