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.
257 lines
8.8 KiB
Python
257 lines
8.8 KiB
Python
"""luke_scribe CLI — detect / transcribe / bench / serve / key.
|
|
|
|
Exit codes (design doc §8):
|
|
- 0 성공, 2 입력 오류(CLI 문법·invalid_input·probe·범위), 3 모델/장치/결정 오류,
|
|
4 추론/OOM 오류, 5 결과 파일 write 오류, 130 인터럽트.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
|
|
from .config import Settings, get_settings
|
|
from .errors import LukeScribeError, OutputWriteError
|
|
from .results.models import TranscriptResult
|
|
from .results.store import AtomicFileWriter
|
|
|
|
app = typer.Typer(help="내부용 로컬 STT 전사 API — faster-whisper, hardware-adaptive")
|
|
console = Console(stderr=True)
|
|
|
|
EXIT_OK = 0
|
|
EXIT_INPUT = 2
|
|
EXIT_MODEL_DEVICE = 3
|
|
EXIT_TRANSCRIPTION = 4
|
|
EXIT_WRITE = 5
|
|
|
|
|
|
def _error_code_for(exc: LukeScribeError) -> int:
|
|
if exc.code in ("config_error",):
|
|
return EXIT_INPUT
|
|
if exc.code in (
|
|
"invalid_input",
|
|
"audio_probe_failed",
|
|
"unsupported_input_envelope",
|
|
"job_not_found",
|
|
"job_cancelled",
|
|
"job_already_terminal",
|
|
"queue_full",
|
|
):
|
|
return EXIT_INPUT
|
|
if exc.code in (
|
|
"device_unavailable",
|
|
"model_download_failed",
|
|
"model_unavailable_offline",
|
|
"model_load_failed",
|
|
"invalid_model_decision",
|
|
"model_decision_blocked",
|
|
):
|
|
return EXIT_MODEL_DEVICE
|
|
if exc.code in ("out_of_memory", "transcription_failed"):
|
|
return EXIT_TRANSCRIPTION
|
|
if exc.code == "output_write_failed":
|
|
return EXIT_WRITE
|
|
return EXIT_TRANSCRIPTION
|
|
|
|
|
|
@app.command()
|
|
def detect(
|
|
device: str = typer.Option("auto", help="auto | cpu | cuda | cuda:N"),
|
|
compute_type: str | None = typer.Option(None, help="float16 | int8_float16 | int8 | auto"),
|
|
json_output: bool = typer.Option(True, "--json", help="JSON 출력"),
|
|
) -> None:
|
|
"""하드웨어 감지 → 능력 등급/정밀도/워커수 출력."""
|
|
from .devices.manager import DeviceManager
|
|
|
|
manager = DeviceManager()
|
|
profile = manager.detect(device=device, compute_type=compute_type)
|
|
print(json.dumps(profile.to_dict(), ensure_ascii=False, indent=2))
|
|
raise typer.Exit(EXIT_OK)
|
|
|
|
|
|
@app.command()
|
|
def transcribe(
|
|
source: Path = typer.Argument(..., help="WAV/MP3 파일 경로"),
|
|
language: str = typer.Option("ko", help="언어 (auto = 자동 감지)"),
|
|
model: str | None = typer.Option(
|
|
None, help="large-v3-turbo | large-v3 (기본: 결정 파일 또는 turbo)"
|
|
),
|
|
device: str = typer.Option("auto", help="auto | cpu | cuda | cuda:N"),
|
|
compute_type: str | None = typer.Option(
|
|
None, help="float16 | int8_float16 | int8 (기본: 자동)"
|
|
),
|
|
vad: bool = typer.Option(True, "--vad/--no-vad"),
|
|
hotword: list[str] = typer.Option([], "--hotword", help="반복 가능"),
|
|
output: Path | None = typer.Option(None, "--output", "-", help="결과 파일 (기본 stdout)"),
|
|
force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"),
|
|
word_timestamps: bool = typer.Option(False, "--word-timestamps"),
|
|
log_level: str = typer.Option("INFO", "--log-level"),
|
|
) -> None:
|
|
"""단일 파일 전사 → TranscriptResult JSON."""
|
|
from .engine.base import TranscriptionOptions
|
|
from .pipeline.batch import BatchPipeline
|
|
|
|
settings = get_settings()
|
|
_setup_logging(settings, log_level)
|
|
|
|
if output is not None and output.exists() and not force:
|
|
_fail(EXIT_INPUT, f"출력 파일이 이미 존재합니다: {output} (--force로 덮어쓰기)")
|
|
|
|
from .jobqueue.cancel import CancellationToken
|
|
|
|
token = CancellationToken()
|
|
options = TranscriptionOptions(
|
|
model=model or (settings.model_rt if False else settings.model_batch),
|
|
language=None if language == "auto" else language,
|
|
device=device,
|
|
compute_type=compute_type,
|
|
vad=vad,
|
|
hotwords=hotword,
|
|
word_timestamps=word_timestamps,
|
|
)
|
|
try:
|
|
pipeline = BatchPipeline(settings=settings, token=token)
|
|
result = pipeline.run(source, options, source_name=source.name)
|
|
except LukeScribeError as exc:
|
|
if output is not None:
|
|
failed = TranscriptResult(
|
|
status="failed",
|
|
source={"name": source.name} if source.exists() else None,
|
|
error={"code": exc.code, "message": exc.message, "retryable": exc.retryable},
|
|
)
|
|
try:
|
|
AtomicFileWriter.write(
|
|
output, json.dumps(failed.model_dump(), ensure_ascii=False, indent=2)
|
|
)
|
|
except OutputWriteError as wexc:
|
|
_fail(EXIT_WRITE, str(wexc))
|
|
_fail(_error_code_for(exc), exc.message)
|
|
|
|
payload = json.dumps(result.model_dump(), ensure_ascii=False, indent=2)
|
|
if output is not None:
|
|
try:
|
|
AtomicFileWriter.write(output, payload)
|
|
except OutputWriteError as exc:
|
|
_fail(EXIT_WRITE, str(exc))
|
|
else:
|
|
print(payload)
|
|
raise typer.Exit(EXIT_OK)
|
|
|
|
|
|
@app.command()
|
|
def bench(
|
|
manifest: Path = typer.Argument(..., help="benchmark manifest (YAML/JSON)"),
|
|
models: str = typer.Option("large-v3-turbo,large-v3", help="모델 목록 (쉼표)"),
|
|
device: str = typer.Option("auto", help="auto | cpu | cuda"),
|
|
compute_type: str | None = typer.Option(None, help="정밀도 override"),
|
|
repeats: int = typer.Option(3, "--repeats"),
|
|
output: Path = typer.Option(..., "--output", help="report JSON 경로"),
|
|
decision: Path | None = typer.Option(None, "--decision", help="모델 결정 artifact 경로"),
|
|
hotwords: list[str] = typer.Option([], "--hotword"),
|
|
) -> None:
|
|
"""turbo vs large-v3 도메인 벤치마크 (하이브리드 게이트)."""
|
|
from .benchmark.runner import run_benchmark
|
|
|
|
settings = get_settings()
|
|
_setup_logging(settings, "INFO")
|
|
try:
|
|
report = run_benchmark(
|
|
manifest=manifest,
|
|
models=[m.strip() for m in models.split(",") if m.strip()],
|
|
device=device,
|
|
compute_type=compute_type,
|
|
repeats=repeats,
|
|
output=output,
|
|
hotwords=hotwords,
|
|
decision=decision,
|
|
settings=settings,
|
|
)
|
|
except LukeScribeError as exc:
|
|
_fail(_error_code_for(exc), exc.message)
|
|
print(f"benchmark report: {output}")
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
raise typer.Exit(EXIT_OK)
|
|
|
|
|
|
@app.command()
|
|
def serve(
|
|
host: str = typer.Option(None, help="bind host"),
|
|
port: int = typer.Option(None, help="bind port"),
|
|
tunnel: str | None = typer.Option(None, help="none | cloudflare"),
|
|
workers: int = typer.Option(0, help="배치 워커 수 (0=자동)"),
|
|
) -> None:
|
|
"""FastAPI 서버 시작 (in-proc 또는 Redis 큐)."""
|
|
import uvicorn
|
|
|
|
from .api.app import create_app
|
|
|
|
settings = get_settings()
|
|
_setup_logging(settings, settings.log_level)
|
|
if tunnel:
|
|
settings.tunnel = tunnel # type: ignore[assignment]
|
|
if workers:
|
|
settings.workers = workers
|
|
app_obj = create_app(settings)
|
|
queue_backend = settings.queue_backend
|
|
console.print(
|
|
f"[green]luke-scribe serve[/] profile=dev queue={queue_backend} "
|
|
f"host={host or settings.host} port={port or settings.port}"
|
|
)
|
|
uvicorn.run(
|
|
app_obj,
|
|
host=host or settings.host,
|
|
port=port or settings.port,
|
|
log_level=settings.log_level.lower(),
|
|
)
|
|
|
|
|
|
@app.command()
|
|
def key(
|
|
create: bool = typer.Option(False, "--create", help="새 API 키 생성 (1회만 출력)"),
|
|
scopes: str = typer.Option("transcribe", "--scopes", help="쉼표 구분 스코프"),
|
|
file: Path | None = typer.Option(None, "--file", help="키 저장 파일"),
|
|
) -> None:
|
|
"""API 키 관리 (DX: 생성 시 1회 출력 + 다이제스트 저장)."""
|
|
from .api.deps import KeyStore
|
|
|
|
settings = get_settings()
|
|
keystore = KeyStore(settings)
|
|
if create:
|
|
created = keystore.create_key(
|
|
[s.strip() for s in scopes.split(",") if s.strip()],
|
|
save_path=str(file) if file else None,
|
|
)
|
|
print(json.dumps(created, ensure_ascii=False, indent=2))
|
|
print("[!] 키는 이번 한 번만 표시됩니다. 다시 조회할 수 없습니다.", file=sys.stderr)
|
|
raise typer.Exit(EXIT_OK)
|
|
print(json.dumps({"keys": sorted(keystore._keys.keys())}, ensure_ascii=False))
|
|
raise typer.Exit(EXIT_OK)
|
|
|
|
|
|
def _setup_logging(settings: Settings, log_level: str) -> None:
|
|
from .observability.logging import setup_logging
|
|
|
|
setup_logging(log_level, json_lines=settings.log_json)
|
|
|
|
|
|
def _fail(code: int, message: str) -> None:
|
|
console.print(f"[red]오류:[/] {message}")
|
|
raise typer.Exit(code)
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
app()
|
|
except typer.Exit:
|
|
raise
|
|
except KeyboardInterrupt:
|
|
raise typer.Exit(130) from None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|