Files
luke_scribe/src/luke_scribe/cli.py
T
lukehemmin 7327145d7a 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.
2026-08-12 16:01:21 +09:00

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()