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:
2026-08-12 16:01:21 +09:00
parent b7c30f8b71
commit 7327145d7a
82 changed files with 7351 additions and 0 deletions
+147
View File
@@ -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()