cec464ae2d
stt: - services/stt/server.py: lazy → eager preload in FastAPI lifespan. STT_PRELOAD=0 으로 lazy 강제 가능 (개발/테스트). preload 실패해도 프로세스는 살아 있고 /ready false 로 남아 healthcheck 가 unhealthy 처리. - docker-compose.yml: healthcheck /health → /ready. /health 는 단순 liveness 라 모델 미적재 상태도 healthy 로 잡혀 운영 신호 부적합. queue ORM: - app/models/queue.py: process_stage enum 에 'stt'/'thumbnail' 추가 + create_type=False (migration 150/151 가 DB enum 확장 담당). 이게 없으면 stt_worker INSERT 시 SQLAlchemy 가 enum value 를 거부. dashboard 강화 (§4 선제, §3 신규 stage 까지 자동 커버): - app/api/dashboard.py: category_counts + library_pending_suggestions + queue_lag (stage 별 pending/processing/failed + oldest_pending_age_sec). - frontend/src/lib/stores/system.ts: QueueLag 타입 + DashboardSummary 확장. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
4.6 KiB
Python
162 lines
4.6 KiB
Python
"""STT 마이크로서비스 — faster-whisper (GPU) 기반 음성 전사.
|
|
|
|
filePath → {text, segments:[{start,end,text}]}.
|
|
모델은 startup 에서 eager preload (Docker /ready healthcheck 가 모델 적재까지 검증).
|
|
기본 모델 large-v3 (VRAM ~3GB, float16). 환경변수로 교체 가능.
|
|
|
|
환경변수 `STT_PRELOAD=0` 으로 lazy 로 강제 가능 (개발/테스트용).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import unicodedata
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
|
|
logger = logging.getLogger("stt")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
# startup: 모델 eager preload 시도. 실패해도 프로세스는 살아 있고
|
|
# /ready 가 false 로 남아 healthcheck 가 unhealthy 처리.
|
|
if os.getenv("STT_PRELOAD", "1") != "0":
|
|
try:
|
|
_load_model()
|
|
logger.info("stt model preloaded: %s (%s, %s)", _MODEL_NAME, _DEVICE, _COMPUTE_TYPE)
|
|
except Exception as e:
|
|
logger.exception("stt model preload failed: %s", e)
|
|
yield
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
_model = None
|
|
_MODEL_NAME = os.getenv("WHISPER_MODEL", "large-v3")
|
|
_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
|
|
_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
|
|
|
|
|
|
def _resolve_path(file_path: str) -> Path | None:
|
|
"""NFC(DB) vs NFD(NFS) 한글 경로 정규화 차이 흡수. OCR 서비스와 동일 패턴."""
|
|
candidates = [
|
|
file_path,
|
|
unicodedata.normalize("NFD", file_path),
|
|
unicodedata.normalize("NFC", file_path),
|
|
]
|
|
for c in candidates:
|
|
p = Path(c)
|
|
if p.exists():
|
|
return p
|
|
# 마지막 fallback: parent 디렉토리에서 이름을 NFC 로 매칭
|
|
parent = Path(file_path).parent
|
|
if parent.exists():
|
|
target = unicodedata.normalize("NFC", Path(file_path).name)
|
|
for child in parent.iterdir():
|
|
if unicodedata.normalize("NFC", child.name) == target:
|
|
return child
|
|
return None
|
|
|
|
|
|
def _load_model():
|
|
"""faster-whisper lazy loading — 첫 호출 시만 VRAM 점유."""
|
|
global _model
|
|
if _model is not None:
|
|
return _model
|
|
from faster_whisper import WhisperModel
|
|
|
|
_model = WhisperModel(_MODEL_NAME, device=_DEVICE, compute_type=_COMPUTE_TYPE)
|
|
return _model
|
|
|
|
|
|
def _cuda_device_count() -> int:
|
|
try:
|
|
import ctranslate2
|
|
return ctranslate2.get_cuda_device_count()
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
"""Liveness — Docker healthcheck 용, 프로세스 생존 확인."""
|
|
return {"status": "ok", "service": "stt-faster-whisper"}
|
|
|
|
|
|
@app.get("/ready")
|
|
def ready():
|
|
"""Readiness — CUDA + 모델 상태. 배포 검증용."""
|
|
count = _cuda_device_count()
|
|
cuda_ok = count > 0
|
|
models_loaded = _model is not None
|
|
return {
|
|
"ready": cuda_ok and models_loaded,
|
|
"cuda": cuda_ok,
|
|
"cuda_device_count": count,
|
|
"models_loaded": models_loaded,
|
|
"model": _MODEL_NAME,
|
|
"compute_type": _COMPUTE_TYPE,
|
|
}
|
|
|
|
|
|
@app.post("/transcribe")
|
|
async def transcribe(body: dict):
|
|
"""오디오 파일 전사.
|
|
|
|
입력:
|
|
{
|
|
"filePath": "/documents/PKM/Recordings/2026-04-23_회의.mp3",
|
|
"langs": ["ko"]?, # 단일 언어 지정 or 생략(자동감지)
|
|
"beamSize": 5? # 기본 5
|
|
}
|
|
|
|
출력:
|
|
{
|
|
"text": "전체 전사 텍스트",
|
|
"segments": [{"start": 0.0, "end": 2.4, "text": "..."}, ...],
|
|
"language": "ko",
|
|
"language_probability": 0.99,
|
|
"duration": 1832.5
|
|
}
|
|
"""
|
|
raw_path = body["filePath"]
|
|
langs = body.get("langs")
|
|
beam_size = int(body.get("beamSize", 5))
|
|
|
|
resolved = _resolve_path(raw_path)
|
|
if resolved is None:
|
|
return {"error": f"파일 없음: {raw_path}", "text": "", "segments": []}
|
|
|
|
model = _load_model()
|
|
|
|
language = None
|
|
if isinstance(langs, list) and len(langs) == 1:
|
|
language = langs[0]
|
|
|
|
segments_iter, info = model.transcribe(
|
|
str(resolved),
|
|
beam_size=beam_size,
|
|
language=language,
|
|
vad_filter=True,
|
|
)
|
|
|
|
segments = []
|
|
parts = []
|
|
for seg in segments_iter:
|
|
segments.append({
|
|
"start": round(float(seg.start), 2),
|
|
"end": round(float(seg.end), 2),
|
|
"text": seg.text.strip(),
|
|
})
|
|
parts.append(seg.text)
|
|
|
|
return {
|
|
"text": " ".join(p.strip() for p in parts).strip(),
|
|
"segments": segments,
|
|
"language": getattr(info, "language", None),
|
|
"language_probability": float(getattr(info, "language_probability", 0.0) or 0.0),
|
|
"duration": float(getattr(info, "duration", 0.0) or 0.0),
|
|
}
|