feat: implement full-platform STT API (v2.3 consensus plan)
Batch+realtime transcription API: faster-whisper engine w/ EngineOwner (single GPU owner, OOM downgrade chain, persisted attempted_profiles), hardware-adaptive device manager (T0-T3 VRAM tiers), Redis/in-proc job queue w/ leases + crash recovery, postprocess (glossary/rules/LLM w/ egress guard), privacy-first result store (UUID keys, source deleted after transcribe), retention sweeper, API-key auth (HMAC digests, scopes, job ownership), WebSocket realtime lane (LocalAgreement), CLI (detect/transcribe/bench/serve/key), Docker, benchmark runner. 127 mock-based tests pass; ruff clean. Includes verification checklist and autoplan review notes.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""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
|
||||
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)
|
||||
|
||||
# 모델 프로비저닝 (선택) — 설정된 경우에만
|
||||
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
|
||||
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()
|
||||
Reference in New Issue
Block a user