Author SHA1 Message Date
lukehemmin 7616b87f4c refine: dashboard review fixes - echo removal, model escape, RMS gate, fd leak, mic cleanup
Reviewer feedback: remove AudioContext destination echo, escape model names, add RMS gate to skip silence, close mkstemp fd, clean up mic on WS close. Tests updated for the RMS gate.
2026-08-12 21:44:26 +09:00
lukehemmin 3dfa660503 feat: dashboard UI + key API + realtime decode
Full HTML dashboard (dark theme, vanilla JS, no external deps) served at / and /dashboard with five tabs: system status (admin), file upload-to-transcribe with progress/result/downloads, job history with cancel/result modal, realtime mic demo over the existing WebSocket, and API key create/list.

Backend: POST/GET /v1/keys (admin; raw key returned once, digest-only storage); KeyStore.list_keys(); EngineOwner.emit_hypothesis is now a real implementation (PCM16 chunk -> WAV -> realtime-lane decode with the single GPU lock) instead of a stub.

Notebook: tunnel cell links /dashboard and smoke-checks the HTML.

+ 6 tests (dashboard public HTML, key create/list/scope, WAV header, emit_hypothesis cleanup). 153 tests pass, ruff clean, JS syntax verified with node --check.
2026-08-12 21:41:42 +09:00
lukehemmin 440e947d47 refine: glossary - reject empty keys, guard non-dict, schema field
Reviewer feedback: (1) CLI --glossary rejects empty key/value so an empty pattern cannot corrupt text; (2) worker guards job.options glossary/post_correction with isinstance dict; (3) TranscribeOptions gains a dedicated glossary dict field; (4) rules.py documents the word-boundary/Hangul adjacency limitation.
2026-08-12 21:07:56 +09:00
lukehemmin 6bb89eb51f fix: solve vLLM->BLM misrecognition via glossary postprocessing
The bench and CLI kept emitting BLM for vLLM. Three layers:

1. rules.py: add default rule BLM -> vLLM (word-boundary, case-insensitive) so the default post_mode=rules path restores it everywhere.

2. benchmark/runner.py: the bench never ran postprocessing - it measured raw engine text, so entity retention (66.7%) never reflected rules/glossary. _transcribe_clip now builds Segments and runs run_postprocess(settings, glossary) before metrics; manifest top-level glossary {pattern: replacement} is supported and recorded in run_config (post_mode/glossary).

3. glossary plumbing: CLI transcribe gains --glossary KEY=VALUE (repeatable, parsed + validated); BatchPipeline.run accepts glossary= and passes it to run_postprocess; the in-proc worker forwards job.options.glossary or post_correction.

Notebook: transcribe cells pass --glossary BLM=vLLM, bench manifest carries glossary, postprocess section documents rules/glossary/hotword.

+ 9 unit tests (BLM rule, boundary/case behavior, bench postprocess + glossary entity retention). 147 tests pass, ruff clean.
2026-08-12 21:06:42 +09:00
lukehemmin f171ce4992 refine: tunnel verify - tighter retries, stderr-first on 000, announce DoH fallback
Reviewer feedback: cut worst-case stall from ~200s to ~60s (urllib 2x, DoH 4x with max-time 10); when curl reports 000 (connect failure) prefer stderr for the diagnostic; print an explicit note when falling back to DoH after a resolved-DNS urllib failure.
2026-08-12 20:58:44 +09:00
lukehemmin e34322cc73 feat: colab notebook - DNS diagnosis + DoH-bypass tunnel verification
Colab VM DNS fails to resolve fresh trycloudflare hostnames (Name or service not known) while the tunnel itself works from any real browser. Verification now: 1) diagnoses VM DNS via socket.gethostbyname, 2) uses urllib when DNS resolves, 3) falls back to curl --doh-url (Cloudflare DNS over HTTPS) to bypass the VM resolver, 4) otherwise prints the docs URL with an explicit note that a minted URL means the tunnel is already connected. Troubleshooting section documents the DNS limitation.
2026-08-12 20:57:37 +09:00
lukehemmin 96b53f889c refine: notebook - tolerant import verify, keep last err in tunnel check
Reviewer feedback: wrap the install-cell import verification in try/except so a walk-up failure cannot crash the cell (previously always-succeeding), and keep the last exception detail in the tunnel external-verification failure message.
2026-08-12 20:49:36 +09:00
lukehemmin 5b8576e2a3 fix: colab notebook - register src on kernel sys.path
Colab run 5 failed at cell 12 (import luke_scribe.config) because hatchling editable installs write a .pth pointing at src/ that the interpreter only reads at startup; a long-running Colab kernel started before pip install -e never sees it. Subprocesses (CLI) work, but kernel-side imports fail.

Fix: install cell now walks up to the repo root and inserts src/ into the kernel sys.path, verifying with import luke_scribe.config. Cell 12 has a defensive guard of the same kind. Tunnel external verification now retries up to 6x2s for DNS propagation. Troubleshooting section documents the issue.
2026-08-12 20:48:08 +09:00
lukehemmin 44b7211653 refine: colab tunnel cell - skip poll when cloudflared missing, softer verify message
Reviewer feedback: guard the 120s URL poll with shutil.which(cloudflared) so the failure case returns immediately; merge import re into the cell top import; note Cloudflare browser-check interstitials can fail urllib verification even when the link works in a real browser.
2026-08-12 19:42:14 +09:00
lukehemmin 0ed2ea6160 feat: colab notebook - cloudflare tunnel for external dashboard access
Server cell sets LUKESCRIBE_TUNNEL=cloudflare so the app built-in CloudflareTunnel (trycloudflare quick tunnel) starts alongside the server. After health+auth checks the cell polls server.log for the trycloudflare URL, prints it with /docs dashboard and /health links, and verifies external reachability.

cloudflared binary is downloaded in the install cell (graceful skip on failure). Troubleshooting section documents the temporary/public nature of the link.
2026-08-12 19:41:16 +09:00
lukehemmin 457c67df2c fix: colab bench manifest — entities must be dicts with char spans
Bench clip failed in Colab because entity_retention calls ent.get() but
the manifest used plain strings -> AttributeError -> clip counted as
failure (failure_rate 1.0). Notebook now builds entities as
{canonical, surface, start_char, end_char} from the reference text and
writes a reference file for meaningful metrics.
2026-08-12 17:40:08 +09:00
lukehemmin be5f505410 fix: normalize faster-whisper TranscriptionInfo to dict
Colab run 4 (A100): same namedtuple bug as segments — faster-whisper
returns TranscriptionInfo (namedtuple) but batch.py reads
outcome['info'].get('language') -> AttributeError 'TranscriptionInfo'
object has no attribute 'get' on every real transcription, failing the
CLI, API auto-worker job, worker drain, and bench clip alike.

FasterWhisperEngine now normalizes info to a dict at the boundary
(_to_dict_info: _asdict -> dataclasses.asdict -> known-field fallback).

+ 2 unit tests (namedtuple/dict info); 138 tests pass, ruff clean.
2026-08-12 17:39:15 +09:00
lukehemmin 20777386fe fix: normalize faster-whisper segments to dicts + in-proc auto-worker
Colab run 3 (A100) surfaced three GPU/API-path bugs mocks couldn't catch:

1. faster-whisper yields namedtuple Segments, but batch/bench consume
   them as dicts (.get) -> AttributeError 'Segment' has no attribute
   'get' on every real transcription. Engine now normalizes segments
   to dicts (_to_dict_segments) at the boundary.

2. API TranscribeOptions carries engine-irrelevant keys (formats,
   timestamps, glossary_id, post_correction, diarize); worker's
   TranscriptionOptions(**job.options) crashed with TypeError. Worker
   now filters job.options to TranscriptionOptions.__slots__.

3. in-proc server never consumed its own queue (jobs stayed queued
   forever). Added opt-in Settings.auto_worker (default off): lifespan
   starts a daemon Worker thread for inproc backend, stopped on
   shutdown. Notebook enables it via LUKESCRIBE_AUTO_WORKER=true so the
   API upload -> completed flow works end to end.

Notebook: bench manifest now uses clips schema (audio_path/duration_sec/
entities); cell 22 reads error_message/error_code; upload poll window
raised to 4min (first-run model download).

+ 5 tests (namedtuple/dict segments, API-style options, auto_worker
on/off); 136 tests pass, ruff clean.
2026-08-12 17:32:18 +09:00
lukehemmin 741bce9fc6 fix: split 'cuda:N' into device+device_index for CTranslate2
Colab A100 run exposed a GPU-only bug: DeviceManager returns
selected_device='cuda:0' and the engine passed it verbatim to
faster-whisper, but CTranslate2 only accepts device='cuda' with a
separate device_index arg -> 'unsupported device cuda:0' on every GPU
transcription. Fix: FasterWhisperEngine._split_device() splits
'cuda:N' -> ('cuda', N) and passes device_index to WhisperModel.

Notebook: server cell now pkills stale servers (old process holds
port 8000 and 401s on new keys since KeyStore loads at startup),
sets LUKESCRIBE_API_KEY_FILE explicitly, and verifies auth with
RAW_KEY before proceeding; worker cell guards read_result() is None.

+ 4 unit tests (device split contract), 131 tests pass, ruff clean.
2026-08-12 17:20:12 +09:00
lukehemmin 236ca0acf4 fix: colab notebook — locate cuDNN/cuBLAS .so via find
nvidia-cudnn-cu12/cublas-cu12 are namespace packages with no
__file__, so os.path.dirname() raised TypeError and the install cell
aborted. Locate libcublas.so/libcudnn.so under site-packages with
find and build LD_LIBRARY_PATH from those. Also add a CTranslate2
GPU check cell (ctranslate2.get_cuda_device_count) right after
install so GPU usability is confirmed before transcription.
2026-08-12 17:11:14 +09:00
lukehemmin fb936e1953 fix: colab notebook — CUDA 13 runtime + raw key capture
Colab now ships CUDA 13.0 (driver 580); CTranslate2 wheels are CUDA 12
so GPU init fails with 'unsupported device cuda:0'. Install
nvidia-cublas-cu12/nvidia-cudnn-cu12 and set LD_LIBRARY_PATH in the
install cell; transcription cell reports failures cleanly.

API smoke: api_keys.json stores only digests (raw key shown once), so
the notebook now captures RAW_KEY at creation and uses it for upload
instead of reading the digest file (fixes 401).
2026-08-12 17:07:23 +09:00
lukehemmin f6e5ae662f fix: colab notebook — idempotent clone (pull if repo exists)
Cell 1 now detects an existing /content/luke_scribe/.git and runs
git fetch + checkout feat/full-platform + pull --ff-only instead of
rm -rf + clone, so re-running the notebook updates instead of wiping
local changes.
2026-08-12 17:02:53 +09:00
lukehemmin f385734630 fix: colab notebook — use system pip instead of venv
Colab 'python3 -m venv' fails with ensurepip error (no .venv created,
so every subsequent cell hit 'command not found'). Switch to system pip
(Colab standard) and run the API server via nohup background with log
fallback diagnostics.
2026-08-12 16:55:21 +09:00
lukehemmin 0d90845ac6 docs: add Colab GPU test notebook for real-model verification
CPU-only dev env verified via mocks; the notebook runs the full real
pipeline on Colab Pro T4: clone → ffmpeg/venv install → detect (GPU
capability tier) → 127 unit/integration tests → sample TTS (KO+EN
tech terms) → real faster-whisper transcription → hotword/postprocess
→ API smoke → benchmark.
2026-08-12 16:39:25 +09:00
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
lukehemmin b7c30f8b71 WIP: define Luke Scribe v0.1 design
[gstack-context]
Decisions: Validate the transcription core and benchmark gate before API, queue, and realtime work; preserve five expansion contracts.
Remaining: Build the benchmark dataset and implement detect/transcribe/bench.
Skill: /office-hours
[/gstack-context]
2026-08-10 17:40:33 +09:00
96 changed files with 10482 additions and 4549 deletions
+73 -18
View File
@@ -1,24 +1,79 @@
# luke_scribe 설정 예시 — 복사: cp .env.example .env (env prefix: SCRIBE_)
# ── luke_scribe 설정 (모든 값은 선택; 기본값은 src/luke_scribe/config.py)
# 복사: cp .env.example .env
# 모델 (하이브리드 기본; P1 bench 결과에 따라 단일 turbo로 통일 가능)
SCRIBE_MODEL_REALTIME=large-v3-turbo
SCRIBE_MODEL_BATCH=large-v3
# ── 모델 ─────────────────────────────────────────────────────────────
# 실시간 경로 기본 모델 (P1 bench 게이트 전에는 turbo)
LUKESCRIBE_MODEL_RT=large-v3-turbo
# 배치 경로 기본 모델 (P1 bench 게이트 전에는 turbo; 게이트 후 결정 파일로 대체)
LUKESCRIBE_MODEL_BATCH=large-v3-turbo
# 모델 캐시 디렉터리 (Hugging Face 캐시)
LUKESCRIBE_MODEL_CACHE_DIR=
# 디바이스: auto|cpu|cuda|cuda:0 — 자동 산정, 강제 가능
SCRIBE_DEVICE=auto
# SCRIBE_COMPUTE_TYPE=int8 # 비우면 cc/VRAM 기반 자동
# SCRIBE_WORKERS=1 # 비우면 자동 산정
# ── 장치 ─────────────────────────────────────────────────────────────
# auto | cpu | cuda | cuda:N
LUKESCRIBE_DEVICE=auto
# null이면 Device Manager가 자동 결정 (float16 | int8_float16 | int8 | auto)
LUKESCRIBE_COMPUTE_TYPE=auto
# 배치 워커 프로세스 수 (0 = Device Manager 자동 산정)
LUKESCRIBE_WORKERS=0
SCRIBE_LANGUAGE=ko
# ── 언어 / 후처리 ────────────────────────────────────────────────────
# 기본 언어 (auto = 자동 감지)
LUKESCRIBE_LANGUAGE=ko
# 후처리 모드: none | glossary | rules | llm
LUKESCRIBE_POST_MODE=rules
# glossary/rules 활성화 여부 (true 권장)
LUKESCRIBE_POST_ENABLED=true
# 입력 절대 상한 (초과 413)
SCRIBE_MAX_DURATION_S=14400 # 4h
SCRIBE_MAX_SIZE_BYTES=2147483648 # 2GB
# ── API 키 인증 ──────────────────────────────────────────────────────
# 형식: "key:스코프1,스코프2" 또는 "key" (기본 스코프 transcribe). 여러 개는 쉼표로.
# 예: LUKESCRIBE_API_KEYS="luke-$(openssl rand -hex 24):transcribe,admin"
LUKESCRIBE_API_KEYS=
# 키 저장 파일 (지정 시 키를 이 파일에 HMAC 다이제스트로 저장, CLI `key create`로 생성)
LUKESCRIBE_API_KEY_FILE=api_keys.json
# 스코프 검증 (comma separated required scope for admin routes)
LUKESCRIBE_ADMIN_SCOPES=admin
# 보관 (P2+)
SCRIBE_RETENTION_DAYS=7
# SCRIBE_REDIS_URL=redis://localhost:6379/0
# SCRIBE_API_KEYS=["key1","key2"]
# ── 큐 / 실행 프로파일 ───────────────────────────────────────────────
# redis | inproc (dev/Colab 기본 inproc, Redis 불필요)
LUKESCRIBE_QUEUE_BACKEND=inproc
# 프로덕션 Redis URL (queue_backend=redis 일 때만)
LUKESCRIBE_REDIS_URL=redis://localhost:6379/0
# 최대 큐 길이 (초과 시 429)
LUKESCRIBE_MAX_QUEUE=100
# 입력 상한
LUKESCRIBE_MAX_DURATION_SEC=14400
LUKESCRIBE_MAX_UPLOAD_BYTES=2147483648
# 터널 (P5): none|cloudflare|ngrok
SCRIBE_TUNNEL=none
# ── 보관 / 프라이버시 ────────────────────────────────────────────────
# 결과 보관 기간 (일)
LUKESCRIBE_RETENTION_DAYS=7
# 결과 저장 루트 (기본: 시스템 임시 디렉터리/luke-scribe)
LUKESCRIBE_RESULTS_DIR=
# 원본/파생 오디오 즉시 삭제 (false 금지)
LUKESCRIBE_DELETE_SOURCE=true
# ── LLM 후처리 (기본 off) ────────────────────────────────────────────
# local | openai | external
LUKESCRIBE_LLM_BACKEND=none
# external/openai 전용: 허용 엔드포인트 목록 (provider_id=url, 쉼표 구분)
LUKESCRIBE_LLM_ALLOWLIST=
# LLM 모델명
LUKESCRIBE_LLM_MODEL=
# 전송 감사 로그 (true)
LUKESCRIBE_LLM_AUDIT=true
# ── 터널 ─────────────────────────────────────────────────────────────
# none | cloudflare
LUKESCRIBE_TUNNEL=none
# cloudflared 바이너리 경로 (PATH에 없을 때)
LUKESCRIBE_CLOUDFLARED_PATH=
# ── 관측 ─────────────────────────────────────────────────────────────
LUKESCRIBE_LOG_LEVEL=INFO
# true면 JSON 라인 로그 (프로덕션 권장)
LUKESCRIBE_LOG_JSON=false
# ── API 서버 ─────────────────────────────────────────────────────────
LUKESCRIBE_HOST=0.0.0.0
LUKESCRIBE_PORT=8000
+3 -6
View File
@@ -21,12 +21,8 @@ venv/
# Models / data / scratch
*.log
models/
samples/**/*.wav
samples/**/*.flac
samples/**/*.mp3
samples/**/*.m4a
samples/**/*.mp4
samples/**/*.mov
samples/*.wav
samples/*.mp4
# ─── OS / editor ──────────────────────────────────────────
.DS_Store
@@ -42,3 +38,4 @@ samples/**/*.mov
.omc/state/
.omc/sessions/
.omc/logs/
.gstack/
@@ -1,3 +1,4 @@
<!-- /autoplan restore point: /root/.gstack/projects/lukehemmin-luke_scribe/feat-full-platform-restore.md -->
# Consensus Implementation Plan: luke_scribe — 로컬 STT 전사 API
- **Status:** `pending approval` (consensus **v2.3** — v2.2 + §11 Open Q 후속확정·모호도 ~5% 재산출(2026-06-07); v2.1 합의 + CCG 외부리뷰(Codex/Gemini) 반영; §3.6 능력등급·§3.10 프로비저닝/WS/공유스토어/Colab)
@@ -272,3 +273,85 @@ luke_scribe/
---
*Consensus v2.3 — `pending approval`. 실행(team/ralph/autopilot)은 사용자의 별도 명시 승인이 있어야만 진행됩니다. 승인 전 소스 수정·커밋·실행 스킬 호출 없음.*
---
## GSTACK REVIEW REPORT — /autoplan (2026-08-12)
> Auto-review pipeline: CEO → (Design: skipped, no UI scope) → Eng → DX. Dual voices: Codex (gpt-5.6-sol) + independent Claude reviewer. All intermediate decisions auto-decided by the 6 principles; taste decisions and user challenges surfaced below.
### CEO DUAL VOICES — CONSENSUS TABLE
```
Dimension Claude Codex Consensus
──────────────────────────────────── ─────── ─────── ─────────
1. Premises valid? part part DISAGREE (no workload evidence)
2. Right problem to solve? yes yes CONFIRMED (KO+EN terminology quality)
3. Scope calibration correct? — no DISAGREE (full platform = over-engineering)
4. Alternatives sufficiently explored? part no DISAGREE (whisper.cpp/WhisperX bake-off missing)
5. Competitive/market risks covered? part no DISAGREE (commodity wrappers)
6. 6-month trajectory sound? part part DISAGREE (infra before validation)
```
**CEO findings (Codex, 12):** no workload evidence (critical); product framed around wrong value — terminology-quality system is the wedge (critical); P1P5 over-engineering for unvalidated internal tool (critical); rebuilding existing inference products — bake-off needed (high); no defensible differentiation (high); hybrid gate not decision-grade (high); realtime=turbo is assumption not architecture (high); shared-GPU scheduling structurally unsound (critical); "1050 to H100" support is a liability (high); AC evade capacity decision (high); privacy-first is deletion choreography without threat model (high); feature sequencing backwards — glossary/rules/bench before realtime (high).
### ENG DUAL VOICES — CONSENSUS TABLE
```
Dimension Claude Codex Consensus
──────────────────────────────────── ─────── ─────── ─────────
1. Architecture sound? no no DISAGREE (shared-GPU lock, P0)
2. Test coverage sufficient? no no DISAGREE (fault matrix, contention gate)
3. Performance risks addressed? part no DISAGREE (VRAM≠latency)
4. Security threats covered? part no DISAGREE (key hashing, traversal, SSRF)
5. Error paths handled? no no DISAGREE (finally gaps, crash recovery)
6. Deployment risk manageable? part part DISAGREE (job_timeout, lease/heartbeat)
```
**ENG findings (Codex 25 / Claude 10):** P0 blockers — GPU lock does not cross processes (one GPU-owning inference service required), device admission can oversubscribe, realtime capacity not derivable from VRAM, `finally` doesn't cover SIGKILL/OOM/orphaned queued cancellation, OOM chain not restart-safe (persist `attempted_profiles`), `job_timeout≥4h` insufficient (derive from duration×RTF or renewable leases), worker-crash reconstruction missing (lease + startup reconciler), LocalAgreement-2 not tight enough (token-span agreement, monotonic timestamps, immutable committed output). P1 — API-key hashing (HMAC digest, constant-time), result/upload path traversal (UUID keys, enum formats), WS init-frame resource exhaustion caps, egress SSRF via provider-IDs + no redirects + reject private IPs, cancellation must cover ingest/ffmpeg phases, empty-audio contract, queue full must reserve bytes too, ffmpeg streaming = subprocess protocol (backpressure, kill process group), VAD ownership per lane, postprocess must be span-aware to not invalidate timestamps.
### DX DUAL VOICES — CONSENSUS TABLE
```
Dimension Claude Codex Consensus
──────────────────────────────────── ─────── ─────── ─────────
1. Getting started < 5 min? — no DISAGREE (no README/sample/timed smoke gate)
2. CLI naming guessable? — part CONFIRMED (detect/transcribe/bench/serve + doctor alias)
3. Error messages actionable? — no DISAGREE (error contract with corrective commands)
4. Docs findable & complete? — no DISAGREE (README/COLAB.md/samples/quickstart missing)
5. Upgrade path safe? — n/a N/A
6. Dev environment friction-free? — no DISAGREE (uv lockfile, .python-version, .env.example, key create)
```
**DX findings (Codex):** no timed clone→transcript gate (high); README+licensed sample audio must be P1 deliverables (high); `.python-version`+`uv.lock`+pip fallback (medium); `.env.example` unspecified keys (high); Colab notebook/COLAB.md early (high); CLI exit codes/help/stderr contract (medium); error messages need what/cause/fix+command (high); model provisioning UX (`models list/pull`, size/cache/progress) (high); API-key bootstrap `key create` print-once + digest storage (high); path traversal on result endpoint (critical); shared-GPU one-owning-process (critical); OpenAPI `/docs` + API-key scheme + curl flow (high); WS wire protocol versioned + clients (high); Docker cpu/gpu compose profiles + readiness + preflight matrix (high).
### CROSS-PHASE THEMES (2+ phases independently)
- **Theme 1: Shared-GPU scheduling unsound** — flagged CEO(8), Eng(1), DX(critical). High-confidence signal. Fix: one GPU-owning inference service/process; API and workers submit work, never touch CUDA directly. For a mock-verified first build: batch-only lane through a single engine-owner abstraction.
- **Theme 2: Scope = build before validate** — CEO(1,3,6), Eng(6), DX(gate). All three voices recommend a thin first release (batch CLI/API, terminology fixtures, one model, JSON/SRT, deletion) and deferring realtime/diarization/LLM/queue until usage proves need.
- **Theme 3: Privacy/security contracts underspecified** — CEO(11), Eng(1417), DX(key bootstrap). Fix: hashed keys, ownership on all job endpoints, UUID result keys, provider-ID egress, no tunnels/external LLM in default.
### USER CHALLENGE — Scope (never auto-decided)
- **You said:** full platform (v2.3 consensus plan P1P5) on a fresh branch.
- **Both models recommend:** a thin first release — batch CLI + batch REST API, terminology benchmark fixtures, one model (turbo), JSON/SRT output, immediate source deletion, actionable errors, README/sample/5-min smoke gate. Defer realtime WS, Redis/RQ queue, diarization, external LLM, Colab tunnel until observed demand.
- **Why:** 3 independent voices (Codex CEO/Eng/DX) converged; the plan itself gates hybrid on P1 bench; infra (queue, realtime, tiers) is speculative until terminology accuracy is measured; shared-GPU architecture needs a redesign (one GPU-owning process) that a thin release makes trivial.
- **What we might be missing:** user has internal context — actual audio volume, who consumes results, whether realtime is a hard requirement, whether "complete platform" is a directive vs aspiration.
- **If we're wrong, the cost is:** we build the thin wedge, and the user then spends another cycle adding queue/realtime. (Cost of going full-platform first and being wrong is higher: wasted scaffolding + architectural rework per Eng findings.)
- **Exception note:** no security/feasibility blocker is claimed — this is a scope/preference challenge, not a correctness one. The user decides.
### AUTO-DECIDED (audit trail — 6 principles)
| # | Phase | Decision | Classification | Principle | Rationale | Rejected |
|---|-------|----------|-----------|-----------|----------|
| 1 | CEO | Accept premise: internal STT tool, KO+EN terminology is the product value | Mechanical | P6 | Direction matches docs | — |
| 2 | CEO | Keep hardware-adaptive design but no fixed HW targets | Mechanical | P1 | Already resolved in v2.3 §11 | — |
| 3 | CEO | Reject multi-engine plugin (faster-whisper single) | Mechanical | P4/P5 | Duplicates nothing; thin interface | Plugin system |
| 4 | CEO | Defer Colab tunnel, diarization, external LLM to later phases | Taste | P3 | All voices: defer until proven | Ship now |
| 5 | Eng | One GPU-owning engine-owner abstraction for batch lane | Mechanical | P5 | Required to fix P0 scheduling flaw | Independent locks |
| 6 | Eng | Persist `attempted_profiles`; cap OOM retries at 2 total | Mechanical | P1 | Restart-safe downgrade chain | Unlimited retry |
| 7 | Eng | Job leases + startup reconciler for stale `processing` | Mechanical | P1 | Worker-crash recovery | Terminal-only sweeper |
| 8 | Eng | API keys: hashed storage + constant-time compare + ownership | Mechanical | P1 | Security boundary §3.8 | Plaintext config |
| 9 | Eng | UUID result keys + enum formats (no traversal) | Mechanical | P1 | Containment | Client filenames |
| 10 | Eng | Cancellation covers ingest/ffmpeg + generator close | Mechanical | P1 | Cooperative cancel completeness | Segment-only |
| 11 | DX | `luke-scribe key create` print-once + digest store | Mechanical | P1 | Bootstrap friction | Manual config |
| 12 | DX | README + licensed sample audio + timed smoke gate as P1 deliverable | Taste | P1/P2 | 3 voices converge | Defer docs |
### NOT in scope (auto-deferred → TODOS.md)
Realtime WebSocket lane, Redis/RQ durable queue, diarization, external LLM correction, Colab tunnel automation, SRT/VTT (P4), word timestamps (P4), multi-worker auto-sizing, multi-GPU, 30-min memory soak, destructive fault matrix (hardware-gated), shared-GPU contention gate (hardware-gated).
### What already exists
- `origin/feat/p1-core`: partial implementation (devices/engine/audio/cli/api/postprocess) — **not merged**; per user decision we rebuild cleanly on `feat/full-platform` from `main`. Reference-only.
### DREAM STATE DELTA
Current: planning docs only. This plan → thin-first-release core with terminology benchmark as the product wedge → 12-month ideal: validated terminology-quality system (corpus, glossary, correction feedback, eval automation) with swappable inference backend.
+5 -120
View File
@@ -13,10 +13,10 @@
"runtime": "Python 3.11+"
},
"build": {
"buildCommand": "uv sync",
"testCommand": "export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"\nuv run pytest -q 2>&1 | tail -8\necho \"=== ruff ===\"; uv run ruff check src/ tests/ && echo \"clean\"",
"lintCommand": "uv run ruff check src/ tests/",
"devCommand": "uv run luke-scribe detect",
"buildCommand": null,
"testCommand": null,
"lintCommand": null,
"devCommand": null,
"scripts": {}
},
"conventions": {
@@ -51,125 +51,10 @@
"source": "manual",
"category": "env",
"content": "git 원격=자체호스팅 Gitea https://git.lukehemmin.com (openresty, HTTPS/443 전용, SSH 미노출). 인증=PAT를 ~/.git-credentials에 저장(global helper store, username lukehemmin) — 검증완료, VS Code askpass 없이 push 됨. ⚠️ 저장소 익명 읽기 허용 상태(내부/비공개 의도면 Gitea에서 Private 점검)."
},
{
"timestamp": 1780812476362,
"source": "manual",
"category": "status",
"content": "P1 진행(2026-06-07): ✅ detect(능력등급 T0~T3, 1050→T0_CPU 명시강등) · ✅ transcribe(faster-whisper CPU 검증: JFK 11s 클립 정확 전사, model_used 출력) · 단위테스트 10개 통과. 코드 존재함(더 이상 0%). 남음: word-ts/format 출력옵션·Silero VAD 옵션화, VRAM 실측 probe(정적추정 대체), bench(라벨 KO+EN 샘플셋 필요), 상위 tier(T2/T3) Colab 검증, P2(API+Redis/RQ). 브랜치 feat/p1-core."
}
],
"directoryMap": {},
"hotPaths": [
{
"path": "README.md",
"accessCount": 3,
"lastAccessed": 1780812417055,
"type": "file"
},
{
"path": "src/luke_scribe/cli.py",
"accessCount": 2,
"lastAccessed": 1780812315014,
"type": "file"
},
{
"path": "pyproject.toml",
"accessCount": 1,
"lastAccessed": 1780804235420,
"type": "file"
},
{
"path": "src/luke_scribe/__init__.py",
"accessCount": 1,
"lastAccessed": 1780804261889,
"type": "file"
},
{
"path": "src/luke_scribe/config.py",
"accessCount": 1,
"lastAccessed": 1780804262703,
"type": "file"
},
{
"path": "src/luke_scribe/devices/__init__.py",
"accessCount": 1,
"lastAccessed": 1780804263611,
"type": "file"
},
{
"path": "src/luke_scribe/devices/profile.py",
"accessCount": 1,
"lastAccessed": 1780804266795,
"type": "file"
},
{
"path": "src/luke_scribe/devices/vram_probe.py",
"accessCount": 1,
"lastAccessed": 1780804273484,
"type": "file"
},
{
"path": "src/luke_scribe/devices/manager.py",
"accessCount": 1,
"lastAccessed": 1780804300531,
"type": "file"
},
{
"path": "run.sh",
"accessCount": 1,
"lastAccessed": 1780804312249,
"type": "file"
},
{
"path": ".env.example",
"accessCount": 1,
"lastAccessed": 1780804316978,
"type": "file"
},
{
"path": "tests/test_device_manager.py",
"accessCount": 1,
"lastAccessed": 1780804449331,
"type": "file"
},
{
"path": "src/luke_scribe/engine/__init__.py",
"accessCount": 1,
"lastAccessed": 1780812252757,
"type": "file"
},
{
"path": "src/luke_scribe/engine/model_registry.py",
"accessCount": 1,
"lastAccessed": 1780812254912,
"type": "file"
},
{
"path": "src/luke_scribe/engine/faster_whisper_engine.py",
"accessCount": 1,
"lastAccessed": 1780812261152,
"type": "file"
},
{
"path": "src/luke_scribe/audio/__init__.py",
"accessCount": 1,
"lastAccessed": 1780812262920,
"type": "file"
},
{
"path": "src/luke_scribe/audio/ingest.py",
"accessCount": 1,
"lastAccessed": 1780812299865,
"type": "file"
},
{
"path": "tests/test_engine_audio.py",
"accessCount": 1,
"lastAccessed": 1780812413312,
"type": "file"
}
],
"hotPaths": [],
"userDirectives": [
{
"timestamp": 1780801958149,
+112 -11
View File
@@ -1,26 +1,127 @@
# luke_scribe
내부용 **로컬 STT 전사 API** — faster-whisper(CTranslate2) 기반, 하드웨어 적응형.
단일 `Job` 추상화로 배치(파일/영상)와 실시간(WebSocket)을 처리한다.
내부용 **로컬 STT 전사 API** — faster-whisper(CTranslate2) 기반, 하드웨어 적응형, privacy-first.
한국어 + 영문 기술용어(KO+EN code-switching)를 외부 STT API에 보내지 않고
통제된 환경에서 전사한다. 단일 `Job` 추상화로 배치(파일)와 실시간(WebSocket)을 처리한다.
> 설계 단일 진실원본(SoT): [`.omc/plans/consensus-luke-scribe-stt-api.md`](.omc/plans/consensus-luke-scribe-stt-api.md),
> [`.omc/specs/deep-interview-luke-scribe-stt-api.md`](.omc/specs/deep-interview-luke-scribe-stt-api.md)
## 상태
- 설계 완료(모호도 ~5%) · 구현 P1 진행 중 (greenfield).
- 설계 완료(모호도 ~5%) · **구현 완료(v0.1 전체 플랫폼, mock 검증)**`feat/full-platform`
- 이 저장소 환경은 CPU-only이고 ffmpeg/모델 다운로드가 없으므로 **단위/통합 테스트는
mock 기반**으로 검증한다. 실제 GPU/모델 스모크는 GPU가 있는 환경에서 수행한다.
## 빠른 시작 (개발)
```bash
uv sync # 코어 의존성
uv run luke-scribe detect # 하드웨어 감지 → 능력등급/정밀도/워커수
uv sync --extra engine # 엔진(faster-whisper)
uv run luke-scribe transcribe FILE --model tiny # 단발 전사
# 1) 의존성 (Python 3.11+)
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[engine,api]" # 또는: uv sync --extra engine --extra api
# 2) 환경 설정
cp .env.example .env
# 3) 하드웨어 감지 → 능력 등급/정밀도/워커수
luke-scribe detect
# 4) 단일 파일 전사 (CPU에서도 동작)
luke-scribe transcribe samples/hello-ko-en.wav --language ko --device cpu
# 5) API 서버 (개발 기본: in-proc 큐, Redis 불필요)
luke-scribe serve
# 6) API 키 생성 (1회만 출력, 다이제스트만 저장)
luke-scribe key --create --scopes transcribe,admin
```
### 5분 스모크 (mock 환경에서도 CLI 동작 확인)
```bash
./run.sh test # 단위/통합 테스트
luke-scribe detect # JSON 프로필
```
## CLI
| 명령 | 설명 | 상태 |
|------|------|------|
| `detect` | 하드웨어 감지·능력등급(T0~T3)·정밀도·워커수 | ✅ P1 |
| `transcribe <file>` | 단발 파일 전사 (faster-whisper, CPU/GPU) | ✅ P1 |
| `bench` | turbo vs large-v3 도메인 벤치(게이트) | ⏳ P1 (샘플셋 필요) |
| `serve` | API 서버 | ⏳ P2 |
| `detect` | 하드웨어 감지 · 능력 등급(T0~T3) · 정밀도 · 워커수 | ✅ |
| `transcribe <file>` | 단발 파일 전사 (faster-whisper, CPU/GPU) | ✅ |
| `bench <manifest>` | turbo vs large-v3 도메인 벤치 · 모델 결정 게이트 | |
| `serve` | FastAPI 서버 (in-proc 또는 Redis 큐) | ✅ |
| `key --create` | API 키 생성 (1회 출력 + 다이제스트 저장) | ✅ |
Exit codes: `0` 성공 · `2` 입력 오류 · `3` 모델/장치 오류 · `4` 추론 오류 · `5` 결과 쓰기 오류 · `130` 인터럽트
## REST API (배치)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| `POST` | `/v1/jobs` | multipart 업로드 (`file` + `options` JSON) → `202 {job_id}` |
| `GET` | `/v1/jobs/{id}` | 상태 · `queue_position` · `progress` |
| `GET` | `/v1/jobs/{id}/result?format=json\|txt\|srt\|vtt` | 결과 |
| `DELETE` | `/v1/jobs/{id}` | 협조적 취소 |
| `GET` | `/v1/jobs` | 내 job 목록 |
| `GET` | `/health` | 헬스체크 (공개) |
| `GET` | `/v1/system` · `/v1/models` | admin 전용 |
인증: `X-API-Key` 헤더 (또는 `Authorization: Bearer`). 스코프 + **Job 소유권** 강제.
```bash
KEY=$(luke-scribe key --create --scopes transcribe | python -c "import sys,json;print(json.load(sys.stdin)['key'])")
curl -X POST localhost:8000/v1/jobs \
-H "X-API-Key: $KEY" \
-F file=@samples/hello-ko-en.wav \
-F 'options={"language":"ko","formats":["json","srt"]}'
```
## WebSocket (실시간)
`WS /v1/stream` — 첫 메시지는 init 프레임(인증 + 오디오 협상):
```json
{"type":"init","api_key":"...","audio":{"codec":"pcm_s16le","sample_rate":16000,"channels":1},"options":{"language":"ko"}}
```
응답: `partial`(가설) / `final`(확정, LocalAgreement) / `status`.
## 벤치마크
```bash
luke-scribe bench benchmarks/manifest.yaml --output benchmarks/report.json --decision benchmarks/decisions/default-model-v1.json
```
절대 기준: entity 보존 ≥95% · K-CER ≤15% · 실패율 0%. 게이트는 turbo 우선,
미달 시 large-v3 채택. 단일 모델 run은 decision artifact를 생성하지 않는다.
## Docker (프로덕션)
```bash
# CPU 프로파일
docker compose --profile cpu up -d
# GPU 프로파일 (NVIDIA Container Toolkit 필요)
docker compose --profile gpu up -d
```
API + Redis + 워커, 공유 스토어 볼륨. `LUKESCRIBE_API_KEYS` 필수.
## Colab 실전 테스트 (GPU)
이 저장소의 CI/개발 환경은 CPU-only라 모델·ffmpeg는 mock으로만 검증했다.
GPU + 실제 faster-whisper 모델로 실전 검증하려면 **Colab 노트북**을 사용한다
(Colab Pro T4 GPU 권장 — 터미널에서도 동일 명령 실행 가능):
- `notebooks/luke-scribe-colab.ipynb` — 클론 → 설치 → `detect`(GPU 감지) →
테스트 → 샘플 TTS 생성 → **실제 한국어 전사** → hotword/후처리 → API 스모크 → 벤치마크
- 노트북은 `scripts/build_colab_notebook.py`로 생성/재생성한다
- private 저장소이면 1번 셀의 `GITEA_TOKEN`에 토큰을 넣는다
## 알려진 제한 (v0.1)
- 실시간 decode는 `EngineOwner` 스텁 (실제 WS decode는 GPU 환경에서 활성화).
- Redis/RQ 브로커는 Redis 설치 시에만 동작 (기본 in-proc).
- 화자 분리(pyannote) / 외부 LLM 보정은 `[diarize]`/`[llm]` extra + allowlist 필요.
- 이 저장소 환경(CPU-only, ffmpeg 없음)에서는 모델·ffmpeg 호출을 mock으로 검증.
+11
View File
@@ -0,0 +1,11 @@
# benchmarks
v0.1 도메인 벤치마크 (KO+EN 혼용어) — turbo vs large-v3 하이브리드 게이트.
- `manifest.yaml.example`: 클립/정답/entity annotation 스키마
- `references/`: 클립별 정답 전사문 (개인정보 포함 원본은 커밋 금지)
- `decisions/`: 모델 결정 아티팩트 (`default-model-v1.json`)
```bash
luke-scribe bench benchmarks/manifest.yaml --output benchmarks/report.json --decision benchmarks/decisions/default-model-v1.json
```
+20
View File
@@ -0,0 +1,20 @@
dataset_version: "1.0"
hotword_sets:
none: []
domain: [API, vLLM, FastAPI, Kubernetes, LLM, GPU]
clips:
- id: ko-en-001
audio_path: /secure/local/path/ko-en-001.wav
reference_path: references/ko-en-001.txt
language: ko
duration_sec: 30.0
entities:
- canonical: API
surface: API
start_char: 3
end_char: 6
- canonical: vLLM
surface: vLLM
start_char: 12
end_char: 16
tags: [clean, single-speaker]
+20
View File
@@ -0,0 +1,20 @@
# luke_scribe CPU 이미지
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ ./src/
RUN pip install --no-cache-dir -e ".[engine,api]" \
&& pip install --no-cache-dir "faster-whisper>=1.0.3"
ENV LUKESCRIBE_DEVICE=cpu \
LUKESCRIBE_QUEUE_BACKEND=redis \
LUKESCRIBE_HOST=0.0.0.0 \
LUKESCRIBE_PORT=8000
EXPOSE 8000
CMD ["python", "-m", "luke_scribe.cli", "serve"]
+26
View File
@@ -0,0 +1,26 @@
# luke_scribe GPU 이미지 — CUDA 12 + cuDNN 9 + faster-whisper
# 기준: NVIDIA CUDA 12 런타임, cc>=7.0 GPU (T4 이상 권장)
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 python3-pip python3-venv ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ ./src/
RUN python3.11 -m venv /opt/venv \
&& . /opt/venv/bin/activate \
&& pip install --no-cache-dir -e ".[engine,gpu,api]" \
&& pip install --no-cache-dir "faster-whisper>=1.0.3"
ENV PATH="/opt/venv/bin:$PATH" \
LUKESCRIBE_DEVICE=auto \
LUKESCRIBE_QUEUE_BACKEND=redis \
LUKESCRIBE_HOST=0.0.0.0 \
LUKESCRIBE_PORT=8000 \
NVIDIA_VISIBLE_DEVICES=all
EXPOSE 8000
CMD ["python", "-m", "luke_scribe.cli", "serve"]
+70
View File
@@ -0,0 +1,70 @@
# luke_scribe 프로덕션 compose — API + Redis + 워커 + 공유 스토어
# 사용법: docker compose --profile gpu up -d (또는 --profile cpu)
services:
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
api:
build:
context: ..
dockerfile: docker/Dockerfile.cpu
profiles: ["cpu"]
depends_on:
redis:
condition: service_healthy
environment:
LUKESCRIBE_QUEUE_BACKEND: redis
LUKESCRIBE_REDIS_URL: redis://redis:6379/0
LUKESCRIBE_API_KEYS: ${LUKESCRIBE_API_KEYS:?API 키 필요}
volumes:
- luke-store:/data/luke-scribe
ports:
- "8000:8000"
api-gpu:
build:
context: ..
dockerfile: docker/Dockerfile.gpu
profiles: ["gpu"]
depends_on:
redis:
condition: service_healthy
environment:
LUKESCRIBE_QUEUE_BACKEND: redis
LUKESCRIBE_REDIS_URL: redis://redis:6379/0
LUKESCRIBE_API_KEYS: ${LUKESCRIBE_API_KEYS:?API 키 필요}
volumes:
- luke-store:/data/luke-scribe
ports:
- "8000:8000"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
worker:
build:
context: ..
dockerfile: docker/Dockerfile.cpu
profiles: ["cpu"]
depends_on:
redis:
condition: service_healthy
environment:
LUKESCRIBE_QUEUE_BACKEND: redis
LUKESCRIBE_REDIS_URL: redis://redis:6379/0
command: ["python", "-m", "luke_scribe.cli", "serve"]
volumes:
- luke-store:/data/luke-scribe
volumes:
luke-store:
+866
View File
@@ -0,0 +1,866 @@
# Luke Scribe v0.1 설계
- 작성일: 2026-08-10
- 저장소: `lukehemmin/luke_scribe`
- 브랜치: `main`
- 상태: **v0.1 범위 승인**
- 모드: Builder / Research
- 상위 문서:
- `.omc/specs/deep-interview-luke-scribe-stt-api.md`
- `.omc/plans/consensus-luke-scribe-stt-api.md`
## 1. 결정 요약
Luke Scribe의 최종 방향은 파일 전사와 실시간 전사를 제공하는 self-hosted STT API다.
하지만 v0.1에서는 플랫폼 전체를 만들지 않는다. 먼저 아래 질문에 측정값으로 답한다.
> 한국어와 영문 기술용어가 섞인 음성을 우리 하드웨어에서 어느 모델과 설정으로 전사해야 하는가?
v0.1은 `WAV/MP3 -> TranscriptResult JSON` 전사 코어와 모델 벤치마크만 완성한다.
REST API, Redis 큐, WebSocket, 화자 분리, LLM 후처리는 이후 버전으로 미룬다.
단, v0.1을 폐기형 프로토타입으로 만들지는 않는다. 다음 다섯 가지 계약은 이후 버전에서도 유지한다.
1. 정규화된 오디오 입력 계약
2. 전사 엔진 인터페이스
3. `TranscriptResult` 결과 스키마
4. 장치 탐지 및 실행 프로파일
5. 단일 전사를 조율하는 `TranscriptionService`
이 경계 덕분에 v0.2의 FastAPI와 v0.3 이후의 큐·실시간 처리는 전사 코어를 수정하지 않고 호출 방식만 추가할 수 있다.
## 2. 제품 정의
> **Luke Scribe는 한국어와 영문 기술용어가 섞인 음성을 외부 STT API에 보내지 않고 통제된 실행 환경에서 전사하는 엔진이자 API다.**
### 첫 사용자
Luke Scribe를 호출할 Luke의 내부 서비스와 개발 도구다.
### 첫 번째 성공 장면
개발자가 실제 회의 또는 기술 설명이 담긴 WAV/MP3 파일을 CLI에 전달한다. Luke Scribe는 다음 정보를 포함한 JSON을 생성한다.
- 전체 전사문
- 구간별 시작·종료 시각과 텍스트
- 감지 언어
- 실제 사용한 모델, 장치, 정밀도
- 전사 시간과 RTF(real-time factor)
- 경고 및 강등 내역
결과에서 `API`, `vLLM`, `FastAPI`, `Kubernetes`, `GPU` 같은 핵심 기술용어가 원문 표기로 보존되어야 한다.
## 3. v0.1 범위
### 포함
- Python 3.11+ 프로젝트 구조와 CLI
- `faster-whisper` 기반 전사 엔진
- `large-v3-turbo``large-v3` 비교
- CPU 및 CUDA 장치 탐지
- 실행 장치와 `compute_type` 자동 선택 및 명시적 override
- WAV/MP3 입력
- ffmpeg 기반 16 kHz mono 오디오 정규화
- 전체 텍스트와 세그먼트 타임스탬프를 포함한 JSON 출력
- 한국어·영문 기술용어 혼용 벤치마크
- WER, K-CER, entity 보존율, 처리 시간, RTF, VRAM/RSS 측정
- 임시 파생 오디오 삭제
- 단위·통합·스모크 테스트
### 제외
- FastAPI와 HTTP 엔드포인트
- Redis/RQ와 비동기 Job 큐
- WebSocket 실시간 전사
- 화자 분리
- SRT/VTT 출력
- glossary/rules/LLM 후처리
- Docker Compose 운영 구성
- Colab 터널
- 4시간/2GB 운영 보장
- 다중 GPU 및 다중 워커
제외 항목은 포기한 기능이 아니다. 모델과 결과 계약이 검증된 뒤 순서대로 연결한다.
### 런타임 기준선
구현 시작 시 `pyproject.toml`과 lockfile에서 정확한 버전을 고정한다. 문서 수준의 지원 범위는 다음과 같다.
| 항목 | v0.1 기준 |
|---|---|
| Python | 3.11 이상, 구현 시 선택한 minor 버전 고정 |
| faster-whisper | 구현 시 검증한 단일 버전 고정 |
| CTranslate2 | faster-whisper 및 CUDA와 함께 검증한 버전 고정 |
| ffmpeg / ffprobe | 시스템 실행 파일 필수, 시작 전 존재 및 버전 확인 |
| 모델 | `large-v3-turbo`, `large-v3` |
| 모델 획득 | Hugging Face에서 최초 1회 다운로드 후 로컬 캐시 |
| 네트워크 없음 | 캐시된 모델만 사용; 없으면 `model_unavailable_offline`로 실패 |
| 디스크 | 두 모델 캐시와 임시 WAV를 저장할 여유 공간을 preflight에서 검사 |
| 필수 GPU | **Colab T4 16GB를 v0.1 기준 GPU로 우선 확정** |
| 필수 CPU | x86_64, RAM 16GB 이상을 qualification 기준으로 사용 |
GTX 1050, L4, A100, H100은 추가 관측 대상이지 v0.1 완료를 막는 필수 환경이 아니다. Colab T4를 사용할 수 없게 되면 구현 전에 동급 이상의 NVIDIA GPU 한 종을 명시적으로 대체 지정한다.
Colab T4는 공개·합성·익명화한 benchmark 자료의 GPU qualification에만 사용한다. 실제 내부 음성은 자체 통제 GPU에서만 처리하며 Colab으로 업로드하지 않는다.
### v0.1 지원 입력 범위
- 컨테이너: WAV, MP3
- 오디오 코덱: ffmpeg가 PCM WAV 또는 MP3로 식별한 입력
- 검증 방식: 파일 확장자가 아니라 ffprobe 결과 사용
- 검증 보장 범위: 파일당 60분 이하, 1GB 이하
- 손상·부분 다운로드 파일: `audio_probe_failed`
- ffprobe/ffmpeg timeout: 기본 60초, CLI 옵션으로만 상향 가능
- 더 긴 파일: 명시적으로 `unsupported_input_envelope` 반환
## 4. 핵심 전제
1. 현재 가장 큰 불확실성은 API 구조가 아니라 실제 KO+EN 혼용 전사 품질이다.
2. `large-v3-turbo`를 기본 후보로 두되, 벤치마크를 통과하지 못하면 `large-v3`를 기본으로 채택한다.
3. v0.1은 한 프로세스에서 한 파일을 처리한다. 동시성은 아직 제품 가치 검증에 필요하지 않다.
4. 모든 상위 계층은 동일한 `TranscriptionService``TranscriptResult`를 사용한다.
5. 약한 하드웨어에서 조용히 품질을 낮추지 않는다. 실제 선택된 모델과 정밀도를 결과에 기록한다.
6. 외부 STT API로 음성이나 전사문을 전송하지 않는다.
## 5. 실행 흐름
```text
WAV / MP3
|
v
Audio Ingest
- 형식/크기 검사
- duration probe
- ffmpeg -> 16 kHz mono WAV
|
v
Device Resolver
- CPU/CUDA 탐지
- compute_type 결정
- 사용자 override 검증
|
v
Transcription Engine
- faster-whisper
- turbo 또는 large-v3
- VAD / language / hotwords 옵션
|
v
TranscriptResult
- text / segments
- model/device/precision
- timings/warnings
|
+-----------------> JSON 파일
|
+-----------------> Benchmark evaluator
- K-CER
- entity retention
- RTF
- VRAM/RSS
```
## 6. 확장 가능한 경계
확장성을 위해 모든 기능을 미리 추상화하지 않는다. 아래 다섯 경계만 안정적인 계약으로 만든다.
### 6.1 `AudioIngestor`
책임:
- 입력 파일 검증
- ffprobe로 메타데이터 확인
- ffmpeg로 정규화
- 임시파일 수명 관리
v0.1에서는 로컬 파일만 받는다. 이후 HTTP 업로드와 영상 파일은 이 계층 앞에 입력 어댑터로 추가한다.
`NormalizedAudio` 계약:
| 필드 | 형식 | 필수 | 규칙 |
|---|---|---:|---|
| `path` | path | 예 | Luke Scribe가 소유한 임시 정규화 파일 |
| `duration_sec` | float | 예 | `> 0`, ffprobe 측정값 |
| `sample_rate` | int | 예 | 항상 `16000` |
| `channels` | int | 예 | 항상 `1` |
| `sample_format` | enum | 예 | v0.1은 `s16`만 |
| `source_codec` | string | 예 | ffprobe가 보고한 원본 코덱 |
| `source_size_bytes` | int | 예 | `> 0` |
### 6.2 `TranscriptionEngine`
개념적 인터페이스:
```python
class TranscriptionEngine(Protocol):
def transcribe(
self,
audio: NormalizedAudio,
options: TranscriptionOptions,
) -> TranscriptResult: ...
```
v0.1 구현은 `FasterWhisperEngine` 하나만 둔다. 멀티 엔진 플러그인 시스템은 만들지 않는다.
`TranscriptionOptions` 계약:
| 필드 | 형식 | 기본값 | 제약 |
|---|---|---|---|
| `model` | enum | benchmark 전 `large-v3-turbo` | `large-v3-turbo` 또는 `large-v3` |
| `language` | string/null | `ko` | `null`이면 자동 감지 |
| `device` | string | `auto` | `auto`, `cpu`, `cuda`, `cuda:N` |
| `compute_type` | string/null | `null` | `null`이면 resolver가 결정 |
| `vad` | bool | `true` | faster-whisper VAD 사용 여부 |
| `hotwords` | list[string] | `[]` | 빈 문자열 제거, 중복 제거 |
### 6.3 `TranscriptResult`
CLI, 향후 REST API, 큐 워커, WebSocket 최종 결과가 공유하는 정규 형태다. 전송 형식과 내부 모델을 분리해 향후 필드를 추가해도 기존 소비자가 깨지지 않게 한다.
### 6.4 `DeviceProfile`
장치 탐지 결과와 실행 결정을 분리한다.
- 탐지값: CPU, GPU 이름, compute capability, 총/가용 VRAM
- 결정값: device, compute type, model
- 출처: `auto` 또는 사용자 override
- 경고: CPU fallback, 정밀도 변경, 모델 미지원
v0.1에서는 한 장치와 한 실행만 선택한다. 워커 수 계산은 v0.2 이후에 추가한다.
장치 resolver는 다음 순서로만 결정한다.
| 요청 | 탐지 조건 | 결과 |
|---|---|---|
| `device=cpu` | CPU 사용 가능 | CPU + `int8` |
| `device=cuda[:N]` | 지정 GPU 존재, 모델 적재 가능 | 지정 GPU + 요청/자동 compute type |
| `device=cuda[:N]` | GPU 없음·인덱스 오류·적재 불가 | fallback 없이 실패 |
| `device=auto` | CUDA GPU에서 모델 적재 가능 | CUDA 사용; cc>=7.0이면 `float16` 우선 |
| `device=auto` | CUDA는 있으나 float16 부적합 | 지원되는 `int8_float16` 또는 `int8` |
| `device=auto` | GPU 없음 또는 모델 적재 불가 | CPU `int8`, warning 기록 |
| 명시 `compute_type` | 런타임이 지원하지 않음 | fallback 없이 실패 |
자동 모델 추천값은 benchmark 전에는 `unresolved`다. `detect`는 하드웨어상 적재 가능한 모델 목록만 보여준다. benchmark가 승인한 결정 파일이 있으면 그때부터 `recommended_model`을 출력한다.
`DeviceProfile` 필수 필드:
- `requested_device`, `selected_device`, `selection_source`
- `device_name`, `compute_capability`
- `vram_total_mb`, `vram_free_mb`
- `requested_compute_type`, `selected_compute_type`
- `loadable_models`, `recommended_model`
- `warnings`
### 6.5 `TranscriptionService`
`TranscriptionService`는 안정적인 애플리케이션 경계다. 입력 수명 관리, 장치 선택, 엔진 호출, timing 수집, 결과 조립을 담당한다. CLI는 이 서비스만 호출한다. 이후 API와 큐 워커도 동일한 서비스를 호출한다.
```python
class TranscriptionService:
def transcribe_file(
self,
source: Path,
options: TranscriptionOptions,
) -> TranscriptResult: ...
```
서비스 내부의 클래스 구성과 orchestration 순서는 변경할 수 있지만, 위 호출 의미와 `TranscriptResult` 계약은 v1 동안 유지한다.
## 7. 권장 프로젝트 구조
```text
luke_scribe/
├── docs/
│ └── luke-scribe-v0.1-design.md
├── benchmarks/
│ ├── README.md
│ ├── manifest.yaml
│ └── references/
├── src/luke_scribe/
│ ├── __init__.py
│ ├── cli.py
│ ├── config.py
│ ├── audio/
│ │ ├── ingest.py
│ │ └── models.py
│ ├── devices/
│ │ ├── detect.py
│ │ └── profile.py
│ ├── engine/
│ │ ├── base.py
│ │ └── faster_whisper.py
│ ├── pipeline/
│ │ └── transcribe.py
│ ├── results/
│ │ ├── models.py
│ │ └── json_writer.py
│ └── benchmark/
│ ├── evaluator.py
│ ├── metrics.py
│ └── report.py
├── tests/
│ ├── unit/
│ ├── integration/
│ └── fixtures/
├── pyproject.toml
└── README.md
```
`api/`, `jobqueue/`, `realtime/`, `postprocess/` 디렉터리는 실제 기능을 구현하는 버전에서 추가한다. 빈 디렉터리나 가짜 인터페이스를 미리 만들지 않는다.
## 8. CLI 계약
### 장치 탐지
```bash
luke-scribe detect
```
출력 예시:
```json
{
"requested_device": "auto",
"selected_device": "cuda:0",
"selection_source": "auto",
"device_name": "NVIDIA T4",
"compute_capability": "7.5",
"vram_total_mb": 15360,
"vram_free_mb": 14820,
"requested_compute_type": null,
"selected_compute_type": "float16",
"loadable_models": ["large-v3-turbo", "large-v3"],
"recommended_model": null,
"warnings": []
}
```
`recommended_model=null`은 benchmark 결정 파일이 아직 없다는 뜻이다.
### 단일 파일 전사
```bash
luke-scribe transcribe samples/meeting.mp3 \
--language ko \
--model large-v3-turbo \
--device auto \
--output result.json
```
필수 동작:
- 성공 시 exit code `0`
- 입력 오류 시 exit code `2`
- 모델/장치 초기화 실패 시 exit code `3`
- 전사 실패 시 exit code `4`
- 오류는 stderr, 결과 JSON은 지정 파일 또는 stdout으로 출력
- override를 적용할 수 없으면 조용히 변경하지 않고 명확하게 실패
### 벤치마크
```bash
luke-scribe bench benchmarks/manifest.yaml \
--models large-v3-turbo,large-v3 \
--output benchmark-report.json
```
벤치마크는 같은 정규화 오디오와 같은 옵션을 사용해 모델만 바꿔 비교한다.
### 전체 옵션 계약
| 명령 | 옵션 | 기본값 | 규칙 |
|---|---|---|---|
| `detect` | `--json` | `true` | v0.1은 JSON 출력만 보장 |
| `transcribe` | `--language` | `ko` | `auto`이면 `null`로 전달 |
| `transcribe` | `--model` | 결정 파일 또는 turbo | 지원 enum만 허용 |
| `transcribe` | `--device` | `auto` | `cpu`, `cuda`, `cuda:N` |
| `transcribe` | `--compute-type` | `auto` | 명시값은 fallback 금지 |
| `transcribe` | `--vad/--no-vad` | `--vad` | 상호 배타적 |
| `transcribe` | `--hotword` | 없음 | 반복 가능 |
| `transcribe` | `--output` | stdout | `-`도 stdout 의미 |
| `transcribe` | `--force` | `false` | 기존 출력 파일 overwrite 허용 |
| `transcribe` | `--log-level` | `INFO` | 로그는 stderr |
| `bench` | `--models` | 두 모델 모두 | 한 모델만 지정 가능 |
| `bench` | `--device` | `auto` | 한 run 안에서는 고정 |
| `bench` | `--compute-type` | `auto` | 모델 간 동일 정책 적용 |
| `bench` | `--hotword-set` | `none,domain` | 반복 가능; 정의된 실험군만 허용 |
| `bench` | `--repeats` | `3` | warm-up 이후 측정 반복 수 |
| `bench` | `--output` | 필수 | JSON report 경로 |
기존 파일이 있고 `--force`가 없으면 모델을 로드하기 전에 실패한다. stdout에 성공 JSON을 쓰는 동안에는 progress bar를 출력하지 않는다.
`bench --models`에 한 모델만 지정하면 report만 생성한다. 모델 결정에는 두 모델의 동일 실행 환경 비교가 필요하므로 단일 모델 run은 decision artifact를 생성하거나 기존 artifact를 변경할 수 없다.
## 9. 결과 스키마 v1
```json
{
"schema_version": "1.0",
"status": "completed",
"source": {
"name": "meeting.mp3",
"codec": "mp3",
"size_bytes": 804231
},
"normalized_audio": {
"duration_sec": 42.8,
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"channels": 1
},
"execution": {
"model": "large-v3-turbo",
"device": "cuda:0",
"compute_type": "float16",
"language_requested": "ko",
"language_detected": null,
"language_detection_confidence": null
},
"timings": {
"model_load_sec": 4.21,
"transcription_sec": 6.32,
"rtf": 0.148
},
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
"segments": [
{
"index": 0,
"start": 0.52,
"end": 4.84,
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
"avg_logprob": -0.21,
"no_speech_prob": 0.01
}
],
"warnings": []
}
```
### 스키마 규칙
- `schema_version`은 필수다.
- 시간은 초 단위 실수다.
- `text`는 세그먼트를 읽기 순서대로 합친 최종 문자열이다.
- 모델·장치·정밀도는 요청값이 아니라 실제 적용값을 기록한다.
- 확장 필드는 추가할 수 있지만 기존 필드의 의미와 자료형은 v1 안에서 바꾸지 않는다.
- 실패 결과는 동일한 envelope에서 `status="failed"`, `error.code`, `error.message`를 제공한다.
- `language_requested`가 언어를 강제하면 `language_detected`와 confidence는 `null`이다.
- 자동 감지일 때만 감지 언어와 confidence를 기록한다.
- 세그먼트는 `index` 오름차순이며 서로 역행할 수 없다.
- 초 단위 시간은 JSON number로 기록하고 최소 millisecond 정밀도를 보존한다.
- `text`는 각 세그먼트의 trim된 텍스트를 단일 공백으로 연결한 값이다.
- 소비자는 알 수 없는 추가 필드를 무시해야 한다.
구현 시 이 계약을 `docs/schemas/transcript-result-v1.schema.json`으로 옮기고 fixture를 JSON Schema validator로 검증한다.
실패 envelope 예시:
```json
{
"schema_version": "1.0",
"status": "failed",
"source": {"name": "broken.mp3"},
"error": {
"code": "audio_probe_failed",
"message": "입력 오디오를 해석할 수 없습니다.",
"retryable": false
},
"warnings": []
}
```
## 10. 벤치마크 설계
### 데이터셋 구성
v0.1 판단용 데이터셋은 최소 다음 조건을 충족한다.
- 총 30개 이상 클립
- 총 음성 길이 60분 이상
- 실제 사용 환경에서 수집한 한국어 중심 음성
- 조용한 녹음, 생활 소음, 마이크 거리 차이를 모두 포함
- 단독 화자와 두 명 이상 대화 포함
- 영문 기술용어 entity 최소 100회 등장
- 각 클립에 사람이 검수한 정답 전사문과 entity 목록 제공
개인정보가 있는 원본은 저장소에 커밋하지 않는다. `manifest.yaml`에는 로컬 경로 또는 익명화된 fixture만 기록한다.
`manifest.yaml` 필수 필드:
```yaml
dataset_version: "1.0"
hotword_sets:
none: []
domain: [API, vLLM, FastAPI, Kubernetes, LLM, GPU]
clips:
- id: ko-en-001
audio_path: /secure/local/path/ko-en-001.wav
reference_path: references/ko-en-001.txt
language: ko
entities:
- canonical: API
surface: API
start_char: 3
end_char: 6
- canonical: vLLM
surface: vLLM
start_char: 12
end_char: 16
tags: [clean, single-speaker]
```
entity annotation의 `start_char`/`end_char`는 UTF-8 byte가 아니라 reference Unicode code point index다. 같은 entity가 두 번 나오면 occurrence를 두 개 기록한다.
`domain` hotword set은 평가 전 고정한 공통 용어집이다. 클립별 정답 entity를 실행 시 주입하지 않는다. 이렇게 해야 benchmark 정답을 모델 입력으로 누출하지 않는다.
hotword set은 `benchmarks/hotwords/domain-v1.json`처럼 별도 artifact로 저장한다. artifact에는 `version`, 정렬된 `terms`, `sha256`을 포함한다. manifest와 model decision은 이름뿐 아니라 artifact 경로·버전·hash를 참조한다.
### 필수 지표
| 지표 | 의미 | v0.1 사용 목적 |
|---|---|---|
| WER | 전체 단어 오류율 | 일반적인 전사 품질 비교 |
| K-CER | 한국어 정규화 후 문자 오류율 | 한국어 띄어쓰기 차이 영향 완화 |
| Entity 보존율 | 기술용어가 원형대로 남은 비율 | 핵심 제품 품질 판정 |
| RTF | 처리 시간 / 오디오 길이 | 장치별 처리 속도 판정 |
| Peak VRAM/RSS | 최대 메모리 사용량 | 배포 가능 장치 판정 |
| Failure rate | 실패 클립 / 전체 클립 | 안정성 판정 |
### 지표 계산 규칙
K-CER용 정규화는 다음 순서로 고정한다.
1. Unicode NFKC 정규화
2. 영문은 lowercase로 변환하되 entity 평가는 원래 대소문자를 유지
3. 문장부호 제거
4. 모든 whitespace 제거
5. 숫자는 아라비아 숫자로 통일하는 별도 mapping file 적용
6. Unicode code point 단위로 Levenshtein distance 계산
K-CER는 전체 reference 문자 수를 분모로 substitution + deletion + insertion을 합산한 corpus-level micro average다. WER는 일반 비교를 위한 보조 지표로 원래 whitespace token을 사용한다. 클립별 지표는 별도로 보존하되 모델 선택에는 corpus aggregate K-CER를 사용한다.
entity 보존은 manifest의 occurrence annotation 수를 분모로 한다. K-CER 계산에서 생성한 reference-hypothesis 문자 alignment를 이용해 각 reference entity span을 hypothesis 위치에 투영한다. 투영된 구간의 앞뒤 8 Unicode code point window 안에 canonical 표기가 정확히 존재할 때만 성공이다. 하나의 hypothesis occurrence는 한 annotation에만 매칭한다. 따라서 다른 문장에 우연히 생성된 entity가 실제 누락을 상쇄하지 못한다.
### 벤치마크 실행 규칙
1. 모델·장치·compute type 조합마다 별도 프로세스를 사용한다.
2. 측정 전에 비평가용 warm-up 클립 하나를 1회 처리한다.
3. 각 평가 클립을 기본 3회 실행한다.
4. 품질 지표는 아래 deterministic decode 설정의 첫 결과를 사용한다.
5. 시간과 메모리는 3회 min, median, max를 보고한다.
6. `model_load_sec`는 별도 측정하고 RTF에서는 제외한다.
7. RTF는 순수 transcription wall time / audio duration으로 계산한다.
8. RSS는 `psutil`로 해당 benchmark process의 RSS를 100ms 간격 sampling한다.
9. VRAM은 NVML의 해당 PID 사용량을 100ms 간격 sampling한다.
10. 환경 정보에는 OS, Python, ffmpeg, faster-whisper, CTranslate2, CUDA, cuDNN, GPU driver 버전을 기록한다.
고정 decode 설정:
```yaml
beam_size: 5
temperature: 0.0
condition_on_previous_text: true
vad_filter: true
vad_parameters:
min_silence_duration_ms: 500
speech_pad_ms: 200
word_timestamps: false
without_timestamps: false
```
`language`, `model`, `device`, `compute_type`, `hotword_artifact`는 실험 변수로 report `run_config`에 기록한다. 그 밖의 faster-whisper decode 기본값도 실제 해석값을 모두 report에 직렬화한다.
benchmark report 필수 top-level 필드:
- `report_version`, `dataset_version`, `generated_at`
- `environment`
- `run_config`
- `models[]`
- 모델·hotword variant별 `wer`, `k_cer`, `entity_retention`, `failure_rate`
- 모델·hotword variant별 `rtf_min`, `rtf_median`, `rtf_max`
- 모델·hotword variant별 `peak_rss_min_mb`, `peak_rss_median_mb`, `peak_rss_max_mb`
- 모델·hotword variant별 `peak_vram_min_mb`, `peak_vram_median_mb`, `peak_vram_max_mb`
- `decision.status`, `decision.default_model`, `decision.reasons[]`
### 모델 선택 게이트
`large-v3`를 정확도 기준선으로 사용한다. 먼저 각 모델이 절대 품질 기준을 통과하는지 검사한다.
절대 품질 기준:
1. entity 보존율 `>= 95%`
2. K-CER `<= 15%`
3. 실패율 `0%`
어느 모델도 절대 기준을 통과하지 못하면 `decision.status="no_acceptable_model"`로 끝낸다. 이 결과도 유효한 v0.1 연구 결론이지만, 제품 기본 모델 승인과 v0.2 진입은 차단한다.
각 모델은 먼저 자신의 최종 배포 variant를 정한다. `none`이 절대 기준을 통과하면 `none`을 선택한다. `none`은 실패하고 `domain`이 통과하면 version/hash가 고정된 `domain`을 선택한다. 둘 다 실패하면 해당 모델은 실패다. 이후 모델 비교에는 각 모델의 최종 배포 variant 지표만 사용한다. 따라서 평가 구성과 실제 기본 실행 구성이 달라지지 않는다.
완전한 모델 결정표:
| turbo | large-v3 | 결정 |
|---|---|---|
| 실패 | 실패 | `no_acceptable_model` |
| 통과 | 실패 | turbo 기본 |
| 실패 | 통과 | large-v3 기본 |
| 통과 | 통과 | 아래 상대 비교 적용 |
두 모델이 모두 절대 기준을 통과하면, `large-v3-turbo`가 아래 조건을 모두 만족할 때 turbo를 기본 모델로 선택한다.
1. entity 보존율 `>= 95%`
2. turbo의 K-CER가 large-v3보다 상대적으로 15% 넘게 나쁘지 않음
3. 실패율 `0%`
4. 대상 GPU에서 turbo의 median RTF가 large-v3보다 최소 10% 작음
하나라도 실패하면 `large-v3`를 기본 모델로 선택한다. 두 모델은 benchmark와 명시적 CLI override를 위해 계속 지원한다. 실시간 기본 모델은 WebSocket 프로토타입 단계에서 별도로 판단한다.
hotwords 적용 전·후 결과는 독립 run으로 기록한다. 결정 artifact에는 선택된 hotword set도 함께 저장한다.
### 모델 결정 artifact
`bench`는 report와 별도로 `benchmarks/decisions/default-model-v1.json`을 atomic write한다.
```json
{
"decision_version": "1.0",
"status": "approved",
"default_model": "large-v3-turbo",
"default_hotword_set": "none",
"hotword_artifact": null,
"dataset_version": "1.0",
"report_sha256": "...",
"model_variants": {
"large-v3-turbo": {"hotword_set": "none", "hotword_artifact": null},
"large-v3": {"hotword_set": "domain", "hotword_artifact": "benchmarks/hotwords/domain-v1.json", "hotword_sha256": "..."}
},
"decided_at": "2026-08-10T00:00:00Z"
}
```
`status``approved` 또는 `no_acceptable_model`이다. artifact의 schema, dataset version, report hash 검증이 실패하면 무시하지 않고 `invalid_model_decision`으로 실패한다.
`transcribe`의 모델 선택 순서:
1. 명시적 `--model`이 있으면 해당 모델 사용
2. 유효한 `approved` artifact가 있으면 default model/hotword set 사용
3. artifact가 없으면 연구 bootstrap으로 turbo를 사용하고 `model_decision_missing` warning 기록
4. `no_acceptable_model`이면 명시적 `--model` 없는 실행은 `model_decision_blocked`로 실패
hotword 선택은 모델 선택과 독립적으로 다음 우선순위를 따른다.
1. 하나 이상의 명시적 `--hotword`가 있으면 그 목록이 전부를 override
2. 그렇지 않고 decision artifact에 선택 모델의 `model_variants`가 있으면 해당 hotword artifact 사용
3. artifact가 없거나 해당 모델 기록이 없으면 hotwords 비활성화
명시적 `--model`로 기본 모델을 바꿔도 유효한 decision artifact 안에 해당 모델 variant가 있으면 그 모델의 검증된 hotword 설정을 사용한다.
## 11. 오류 및 안전 계약
| 코드 | 상황 | 동작 |
|---|---|---|
| `invalid_input` | 파일 없음, 지원하지 않는 형식 | 모델을 로드하기 전에 실패 |
| `audio_probe_failed` | ffprobe가 입력을 해석하지 못함 | 원인과 stderr 요약 반환 |
| `unsupported_input_envelope` | 60분 또는 1GB 지원 범위 초과 | 처리 시작 전에 실패 |
| `model_download_failed` | 모델 다운로드 실패 | 재시도 가능 여부 표시 |
| `model_unavailable_offline` | 네트워크와 캐시 모델 모두 없음 | 필요한 모델과 캐시 위치 표시 |
| `model_load_failed` | 모델 또는 CT2 초기화 실패 | 장치·정밀도·런타임 정보 포함 |
| `device_unavailable` | 요청한 CUDA 장치 없음 | 자동 CPU 변경 없이 실패 |
| `out_of_memory` | 모델 적재 또는 추론 OOM | 실패 시점과 실제 설정 기록 |
| `transcription_failed` | 추론 중 예외 | 원본 예외를 감싼 안정적 오류 코드 반환 |
자동 모드에서만 명시적인 fallback을 허용한다. fallback이 발생하면 `warnings`와 로그에 반드시 남긴다. 사용자가 `--device cuda:0`처럼 강제한 값은 자동으로 바꾸지 않는다.
fallback 및 retry 규칙:
| 실패 시점 | `auto` | 명시 override |
|---|---|---|
| GPU preflight 부적합 | CPU int8로 1회 전환 | 즉시 실패 |
| 모델 load OOM | float16 -> int8_float16 -> CPU int8, 최대 2회 전환 | 즉시 실패 |
| 모델 download 실패 | 네트워크 오류일 때만 1회 재시도 | 동일 |
| 추론 중 OOM | 전체 파일 자동 재실행 없이 실패 | 즉시 실패 |
| ffmpeg/probe 실패 | 재시도 없이 실패 | 동일 |
추론 중 OOM에서 자동 CPU 재실행을 하지 않는 이유는 긴 파일을 사용자 모르게 처음부터 다시 처리하지 않기 위해서다.
임시 디렉터리는 성공·실패·중단 모든 경로에서 정리한다. 원본 파일은 읽기 전용으로 취급하며 삭제하거나 수정하지 않는다.
정리 보장은 정상 종료와 catch 가능한 signal(`SIGINT`, `SIGTERM`)까지다. `SIGKILL`, 전원 장애, 호스트 crash에서는 즉시 삭제를 보장할 수 없다. 각 run은 고유 prefix가 있는 temp directory를 사용하고, 시작 시 TTL이 지난 Luke Scribe orphan directory를 정리한다.
CLI 실패 출력 규칙:
- `--output`이 지정되었고 입력 검증 이후 실패하면 해당 경로에 실패 envelope를 atomic write한다.
- stdout 모드에서는 실패 envelope를 stdout에 한 줄 JSON으로 출력한다.
- stderr에는 사람이 읽는 한 줄 요약과 로그만 출력한다.
- 출력 파일 자체를 쓸 수 없으면 JSON 생성을 시도하지 않고 exit code `5`로 종료한다.
- 부분 JSON 파일은 남기지 않는다. 임시 파일에 쓴 뒤 rename한다.
`TranscriptionService`는 실패 결과를 반환하지 않고 `LukeScribeError`의 typed subclass를 발생시킨다. CLI 어댑터가 이를 exit code와 실패 envelope로 변환한다.
| 오류 범주 | 실패 JSON | stderr | exit code |
|---|---:|---:|---:|
| CLI 문법·알 수 없는 옵션 | 아니요 | usage + 오류 | 2 |
| `invalid_input`, probe, 지원 범위 | 예 | 한 줄 요약 | 2 |
| 모델·장치·decision 오류 | 예 | 한 줄 요약 | 3 |
| 추론·OOM 오류 | 예 | 한 줄 요약 | 4 |
| 결과 파일 write 오류 | 아니요 | 한 줄 요약 | 5 |
## 12. 테스트 전략
### 단위 테스트
- 장치 탐지 결과에서 compute type 결정
- 사용자 override 검증
- ffprobe 결과 파싱
- ffmpeg 명령 구성
- 임시파일 정리
- 결과 스키마 직렬화와 역직렬화
- WER/K-CER/entity 보존율 계산
- 모델 선택 게이트의 경계값
- transcript JSON Schema validation
- manifest와 report schema validation
- orphan temp directory TTL 판정
### 통합 테스트
- 짧은 WAV 파일을 CPU로 전사
- MP3를 정규화한 뒤 전사
- 잘못된 파일과 손상 파일 거부
- 존재하지 않는 CUDA 장치 강제 시 명확한 실패
- 전사 성공·실패 후 임시파일 부재 확인
- 같은 fixture에 대해 두 모델의 benchmark report 생성
- 출력 경로가 이미 존재할 때 `--force` 계약 확인
- 실패 envelope의 stdout/file 동작 확인
### 실제 하드웨어 스모크 테스트
- x86_64/RAM 16GB CPU 환경 1종
- Colab T4 16GB 또는 구현 전에 지정한 동급 이상 NVIDIA GPU 1종
- 각 환경에서 `detect`, `transcribe`, `bench` 실행
- 모델, compute type, RTF, peak memory를 결과물로 보관
GPU가 없는 CI에서는 모델과 ffmpeg 호출을 대체한 계약 테스트까지만 수행한다. CPU의 대형 모델 qualification에는 별도 timeout과 최대 RSS를 기록하며, 너무 느린 실행은 실패가 아니라 `unsupported_for_production` 경고로 분류할 수 있다. 실제 GPU 모델 스모크 테스트는 Colab T4 job 또는 수동 검증으로 수행한다.
## 13. 완료 기준
v0.1은 다음 항목을 모두 만족해야 완료다.
- [ ] `detect`가 CPU/GPU 및 권장 실행값을 JSON으로 출력한다.
- [ ] `transcribe`가 WAV와 MP3를 처리해 schema v1 JSON을 생성한다.
- [ ] CPU와 실제 NVIDIA GPU 각각 한 환경에서 전사에 성공한다.
- [ ] benchmark dataset이 30개·60분·entity 100회 기준을 충족한다.
- [ ] turbo와 large-v3 비교 보고서가 생성된다.
- [ ] WER, K-CER, entity 보존율, failure rate, RTF, VRAM/RSS가 보고서에 포함된다.
- [ ] 정규화·warm-up·3회 반복·min/median/max 규칙으로 같은 데이터의 결과를 재현할 수 있다.
- [ ] 모델 선택 게이트가 `approved` 기본 모델 또는 `no_acceptable_model` 결정을 생성한다.
- [ ] 어느 모델도 절대 품질 기준을 통과하지 못하면 v0.2 진입이 차단된다.
- [ ] 성공·실패 경로에서 임시 파생 오디오가 남지 않는다.
- [ ] catch 가능한 중단과 시작 시 orphan cleanup이 검증된다.
- [ ] 단위·통합 테스트가 통과한다.
- [ ] transcript result, benchmark manifest, benchmark report의 machine-verifiable schema가 존재한다.
- [ ] README에 설치, 모델 다운로드, 3개 CLI 사용법, 알려진 제한을 문서화한다.
완료의 핵심 산출물은 코드 양이 아니라 다음 세 가지다.
1. 재현 가능한 전사 명령
2. 안정적인 결과 스키마
3. 모델 선택을 뒷받침하는 벤치마크 보고서
## 14. 확장 로드맵
### v0.2: Batch API
- FastAPI
- `POST /v1/transcriptions`
- `GET /v1/transcriptions/{id}`
- API Key 인증
- in-process Job 실행
- 결과 보관과 원본/파생 오디오 삭제
- Docker GPU/CPU 이미지
전사 API는 v0.1의 `TranscriptionService`를 호출하고 `TranscriptResult`를 그대로 응답 모델로 변환한다.
### v0.3: Durable Queue
- Redis + RQ no-fork worker
- `queued -> processing -> completed|failed|cancelled` 상태
- 진행률과 queue position
- 협조적 취소
- 재시작 내성
- 공유 입력/결과 스토어
Job은 `TranscriptResult`를 감싸지만 결과 자체의 스키마는 변경하지 않는다.
### v0.4: Realtime
- WebSocket init frame과 API Key 인증
- PCM16/Opus 입력 협상
- VAD와 rolling buffer
- partial/final 결과
- LocalAgreement 기반 확정 규칙
- 세션 수 제한과 backpressure
- 30분 메모리 평탄성 테스트
실시간의 `final` 세그먼트는 v0.1의 세그먼트 모델을 재사용한다. `partial`은 별도의 변경 가능한 이벤트 타입으로 둔다.
### v0.5: Output and Post-processing
- word timestamps
- SRT/VTT
- glossary와 deterministic rules
- confidence flag
- 선택적 diarization
- 선택적 LLM correction
### v1.0: Internal Production
- 운영 대상 하드웨어 프로파일 확정
- 관측 지표와 경보
- 4시간/2GB 처리 보장
- 보관 및 삭제 정책 검증
- 부하·장기 실행·복구 테스트
- Colab 또는 외부 터널은 실제 필요가 확인된 경우에만 포함
## 15. 고려한 접근
### 접근 A: 전사 코어와 벤치마크 우선 — 채택
- 가장 큰 기술 불확실성을 먼저 제거한다.
- 모델 선택을 추측이 아닌 실제 데이터로 결정한다.
- 결과·엔진·장치 경계를 유지해 이후 API 확장이 가능하다.
- 첫 버전에서 API와 실시간 데모가 없다는 단점이 있다.
### 접근 B: 파일 API와 준실시간을 함께 구현
- 최종 제품과 가까운 데모를 빠르게 볼 수 있다.
- 모델 품질, VAD, 버퍼, WebSocket 문제가 동시에 얽힌다.
- 실패 원인을 분리하기 어렵고 전사 코어 계약이 흔들릴 수 있다.
### 접근 C: 기존 P1~P5 플랫폼 전체 구현
- 처음부터 운영 기능을 모두 고려한다.
- 사용자와 모델 품질이 검증되기 전에 큐·스토리지·동시성 복잡도가 생긴다.
- 구현량이 아니라 검증 순서가 잘못될 가능성이 높다.
## 16. 열린 질문
다음 질문은 문서 작성만으로 정하지 않고 v0.1의 데이터 또는 실제 사용 조건으로 닫는다.
1. 벤치마크 음성은 어떤 실제 업무 환경에서 수집할 것인가?
2. Colab T4 외에 첫 번째 추가 GPU 관측 대상은 GTX 1050, L4, 또는 별도 서버 중 무엇인가?
3. entity 목록의 초기 도메인은 AI/개발 용어만 포함할 것인가, 다른 전문 분야도 포함할 것인가?
4. K-CER 15%라는 절대 기준이 10개 pilot 결과에서도 현실적이고 충분히 엄격한가?
5. v0.2 Batch API에서 처음부터 비동기 Job을 제공할지, 동기 호출로 시작할지?
## 17. 다음 작업
1. 10개 대표 클립으로 소규모 spike dataset을 먼저 만든다.
2. `large-v3-turbo``large-v3`를 동일 조건으로 수동 실행한다.
3. 결과 스키마와 지표 계산이 유효한지 확인한다.
4. 문제가 없으면 30개·60분 기준 데이터셋으로 확장한다.
5. 벤치마크 결과로 기본 모델을 결정한 뒤에만 v0.2 API 설계를 시작한다.
## 18. 이번 결정에서 확인된 사용자 의도
- “일단 A 먼저 가도 되긴한데”라는 선택으로 모델 품질을 먼저 검증하는 순서에 동의했다.
- “어차피 확장해야 하는 거면”이라는 조건 때문에 v0.1도 확장 경계를 가진 실제 코어로 설계했다.
- 따라서 이번 범위는 기능을 많이 넣는 대신, 나중에 API와 실시간 기능이 의존할 계약을 정확히 만드는 데 집중한다.
@@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.lukehemmin.com/lukehemmin/luke_scribe/docs/schemas/transcript-result-v1.schema.json",
"title": "TranscriptResult v1",
"type": "object",
"required": ["schema_version", "status"],
"properties": {
"schema_version": { "const": "1.0" },
"status": { "enum": ["completed", "failed", "cancelled"] },
"source": {
"type": "object",
"properties": {
"name": { "type": "string" },
"codec": { "type": ["string", "null"] },
"size_bytes": { "type": ["integer", "null"] }
}
},
"normalized_audio": {
"type": "object",
"properties": {
"duration_sec": { "type": "number", "exclusiveMinimum": 0 },
"audio_format": { "type": "string" },
"sample_rate": { "type": "integer" },
"channels": { "type": "integer" }
}
},
"execution": {
"type": "object",
"properties": {
"model": { "type": "string" },
"device": { "type": "string" },
"compute_type": { "type": "string" },
"language_requested": { "type": ["string", "null"] },
"language_detected": { "type": ["string", "null"] },
"language_detection_confidence": { "type": ["number", "null"] },
"model_used": { "type": ["string", "null"] },
"compute_type_used": { "type": ["string", "null"] },
"downgrade_attempts": { "type": "integer" },
"capability_tier": { "type": ["string", "null"] }
}
},
"timings": {
"type": "object",
"properties": {
"model_load_sec": { "type": ["number", "null"] },
"transcription_sec": { "type": ["number", "null"] },
"ingest_sec": { "type": ["number", "null"] },
"postprocess_sec": { "type": ["number", "null"] },
"rtf": { "type": ["number", "null"] }
}
},
"text": { "type": "string" },
"segments": {
"type": "array",
"items": {
"type": "object",
"required": ["index", "start", "end", "text"],
"properties": {
"index": { "type": "integer", "minimum": 0 },
"start": { "type": "number", "minimum": 0 },
"end": { "type": "number", "minimum": 0 },
"text": { "type": "string" },
"avg_logprob": { "type": ["number", "null"] },
"no_speech_prob": { "type": ["number", "null"] },
"confidence": { "type": ["number", "null"] },
"words": { "type": ["array", "null"] },
"speaker": { "type": ["string", "null"] }
}
}
},
"warnings": { "type": "array", "items": { "type": "string" } },
"error": {
"type": "object",
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"retryable": { "type": "boolean" }
}
},
"postprocessing": { "type": ["object", "null"] },
"entities": { "type": ["array", "null"] }
}
}
+135
View File
@@ -0,0 +1,135 @@
# luke_scribe 전체 구현 검증 체크리스트
브랜치: `feat/full-platform` · 기준 문서: `.omc/plans/consensus-luke-scribe-stt-api.md` (v2.3)
검증 환경: CPU-only, ffmpeg/GPU/Redis 미설치 → **mock 기반 검증** (사용자 승인 범위)
범례: ✅ 통과 · ⬜ 미확인 · ❌ 실패
## 1. 기반 (scaffold & config)
- [x] `pyproject.toml` — 패키지 메타, 의존성(optional extras), ruff 설정
- [x] `.env.example` — 전체 설정 키 문서화 (`LUKESCRIBE_` 프리픽스)
- [x] `run.sh` — venv 생성 + 설치 + 서버 실행 원스톱
- [x] `src/luke_scribe/config.py` — pydantic-settings, 모델/장치/큐/상한/보관/LLM/터널 설정
- [x] `src/luke_scribe/errors.py` — typed 오류 계층 (코드 + retryable + HTTP 매핑)
- [x] `src/luke_scribe/__init__.py` — 패키지 버전
## 2. 하드웨어 감지 (devices)
- [x] `devices/vram_probe.py` — GPU/시스템 정보 조회 (nvidia-ml-py, psutil)
- [x] `devices/profile.py` — DeviceProfile 모델 (등급/정밀도/워커수/경고)
- [x] `devices/manager.py` — T0~T3 능력 등급, 정밀도 결정, 워커수 산정, override 검증
- [x] CPU-only 폴백 + 경고 (auto 모드)
## 3. 엔진 (engine)
- [x] `engine/base.py` — TranscriptionOptions/TranscriptionOutcome 계약
- [x] `engine/faster_whisper_engine.py` — faster-whisper 래퍼, lazy 세그먼트 + 협조적 취소
- [x] `engine/model_registry.py` — 모델 다운로드/캐시/오프라인 처리
- [x] `engine/owner.py`**EngineOwner** 단일 GPU 소유자, 우선순위 락, OOM 강등 체인(2회 캡, 영속화)
- [x] 실시간 가설 스텁 (`emit_hypothesis`)
## 4. 오디오 (audio)
- [x] `audio/ingest.py` — ffprobe 형식 검증, 4h/2GB 상한, ffmpeg 16kHz 스트리밍 정규화
- [x] 종료 경로 임시파일 정리 + 취소 시 프로세스 그룹 kill
- [x] `audio/vad.py` — 레인별 VAD 소유자 계약, 상태형 스트리밍 VAD
## 5. 잡 큐 (jobqueue)
- [x] `jobqueue/jobs.py` — Job 상태머신 (queued→processing→terminal, CAS, lease)
- [x] `jobqueue/broker.py` — InProc 브로커 + Redis/RQ(no-fork) 브로커 + 폴백
- [x] `jobqueue/cancel.py` — 협조적 취소 토큰
- [x] `jobqueue/worker.py` — 워커 (리스 하트비트, progress throttle, 크래시 복구 reconciler)
- [x] `jobqueue/redis_broker.py` — Redis 구현 (설치 시 import, 미설치 폴백)
## 6. 파이프라인 (pipeline)
- [x] `pipeline/batch.py` — 인제스트→전사→후처리→결과 조립, 종료 경로 정리
- [x] `pipeline/realtime.py` — LocalAgreement (prefix 일치, 중복 확정 방지, 단조 타임스탬프, 버퍼 절단)
## 7. 후처리 (postprocess)
- [x] `postprocess/glossary.py` — 도메인 용어 복원 (span-aware, 이중 매칭 방지)
- [x] `postprocess/rules.py` — 표기 규칙 (vLLM 공백, 공백 축소)
- [x] `postprocess/confidence.py` — 신뢰도 플래그/게이트
- [x] `postprocess/llm.py` — LLM 보정 (기본 off, allowlist SSRF 가드, 감사 로그)
- [x] `postprocess/pipeline.py` — 모드별 조합 (none/glossary/rules/llm)
## 8. 결과 저장 (results)
- [x] `results/models.py` — TranscriptResult v1 스키마 (불변 계약, 확장 필드)
- [x] `results/store.py` — UUID 키 저장, 경로 트래버설 차단, 심볼릭 링크 거부, 원자적 쓰기
- [x] `results/formats.py` — JSON/TXT/SRT/VTT 렌더링
- [x] `results/retention.py` — 7일 TTL (터미널 상태만, ISO/float 타임스탬프, mtime 폴백)
- [x] **privacy-first**: 전사 완료 후 원본 오디오 즉시 삭제 (`delete_source`, `delete_derived`)
## 9. API (api)
- [x] `api/app.py` — 앱 팩토리, lifespan(브로커/reconciler/모델 프로비저닝/터널/세션 가드)
- [x] 오류→HTTP 매핑 (401/403/404/413/422/429/500 envelope)
- [x] `api/deps.py` — KeyStore (peppered HMAC 다이제스트, 상수시간 비교, 평문 미저장)
- [x] 스코프 강제 + Job 소유권 (남의 job 접근 403)
- [x] `api/schemas.py` — 요청/응답 스키마
- [x] `api/routes/jobs.py` — 생성(202)/조회/결과/취소, 4h/2GB 413, 큐 만재 429
- [x] `api/routes/admin.py` — /health(공개), /v1/system, /v1/models (admin 스코프)
- [x] `api/routes/stream.py` — WebSocket 실시간 (init 인증, 프레임/세션 상한, 유휴 타임아웃)
## 10. CLI (cli)
- [x] `cli.py` — detect/transcribe/bench/serve/key 생성 (typer + rich)
- [x] `benchmark/` — turbo vs large-v3 벤치마크 러너/메트릭/매니페스트
## 11. 운영 산출물 (ops)
- [x] `docker/Dockerfile.cpu` / `Dockerfile.gpu` / `docker-compose.yml`
- [x] `connectivity/tunnel.py` — Cloudflare Quick Tunnel
- [x] `observability/` — 로깅(JSON 옵션) + 메트릭
- [x] `diarization/` — pyannote 화자 분리 스텁
- [x] `README.md` — 설치/사용법/API/CLI 문서
- [x] `docs/schemas/transcript-result-v1.schema.json` — 결과 JSON 스키마
- [x] `benchmarks/manifest.yaml.example`, `samples/README.md`
## 12. 테스트 & 품질
- [x] 단위 테스트: devices, engine owner, jobs/broker, metrics, postprocess, realtime, formats, store, keystore, ingest
- [x] 통합 테스트: worker 수명주기/취소/크래시복구, API 인증/소유권/큐/결과
- [x] 127개 테스트 전체 통과
- [x] ruff lint + format 클린
- [x] 코드 리뷰 반영 (프라이버시 계약, 보관 갭, Redis 오류 필드, 완료 순서, 유휴 타임아웃, 중복 확정)
---
## 실행 검증 (이 환경에서 직접 실행한 것)
```bash
# pytest 결과
127 passed, 1 warning in 0.32s
# ruff
All checks passed! | 68 files already formatted (FORMAT CLEAN)
# import 검증
40/40 모듈 import 성공
# CLI
luke-scribe --help → detect/transcribe/bench/serve/key 정상
luke-scribe detect → device=cpu | ct=int8 | tier=T0
luke-scribe key --create → 1회 키 출력 + 다이제스트 64자 hex만 저장 (평문 없음)
# API 스모크 (TestClient — 전부 PASS)
health → PASS (status=ok, model_ready=false)
no-key 401 → PASS
bad-key 401 → PASS
scope 403 → PASS
create 202 → PASS
job get 200 → PASS
result 409(미완료) → PASS
cancel 200 → PASS
oversize 413 → PASS
queue-full 429 → PASS (Retry-After: 5 헤더 포함)
# 산출물
README/run.sh/pyproject/.env.example/Docker×2/compose/schema/manifest → 전부 존재
schema JSON valid · pyproject 파싱 OK (extras 6종) · run.sh 구문 OK · compose YAML valid
```
File diff suppressed because one or more lines are too long
+26 -6
View File
@@ -1,7 +1,8 @@
[project]
name = "luke-scribe"
version = "0.1.0"
description = "내부용 로컬 STT 전사 API (faster-whisper, hardware-adaptive)"
description = "내부용 로컬 STT 전사 API faster-whisper, hardware-adaptive, privacy-first"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pydantic>=2.7",
@@ -14,16 +15,21 @@ dependencies = [
]
[project.optional-dependencies]
# 엔진 — transcribe/bench 증분에서 설치 (uv sync --extra engine)
# 엔진 — transcribe/bench (faster-whisper/CTranslate2)
engine = ["faster-whisper>=1.0.3", "av>=11"]
# GPU CUDA 런타임 (faster-whisper GPU 추론 시)
gpu = ["nvidia-cublas-cu12", "nvidia-cudnn-cu12"]
# P2 API + Queue
api = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "redis>=5.0", "rq>=1.16"]
# 동기 배치 API (serve)
api = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9"]
# P2 비동기 큐 (Redis/RQ no-fork)
queue = ["redis>=5.0", "rq>=1.16"]
# P5 옵션
diarize = ["pyannote.audio>=3.1"]
llm = ["openai>=1.30"]
[dependency-groups]
dev = ["pytest>=8.2", "ruff>=0.5", "httpx>=0.27"]
[project.scripts]
luke-scribe = "luke_scribe.cli:main"
@@ -34,5 +40,19 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/luke_scribe"]
[dependency-groups]
dev = ["pytest>=8.2", "ruff>=0.5"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
# FastAPI/typer는 Depends()/Option() 호출을 인자 기본값에 쓰는 패턴이 표준
"src/luke_scribe/api/**" = ["B008"]
"src/luke_scribe/cli.py" = ["B008"]
Executable → Regular
+50 -2
View File
@@ -1,5 +1,53 @@
#!/usr/bin/env bash
# 개발/Colab 실행 래퍼 — Docker 없이 순수 Python (계획 §3.10d).
# luke_scribe dev launcher — Colab/개발용 순수 Python 경로 (Docker 불필요)
set -euo pipefail
cd "$(dirname "$0")"
exec uv run luke-scribe "$@"
if [ ! -d .venv ]; then
echo "[luke-scribe] .venv 생성 중..."
python3 -m venv .venv
fi
# shellcheck disable=SC1091
source .venv/bin/activate
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
CMD="${1:-help}"
shift || true
case "$CMD" in
detect)
python -m luke_scribe.cli detect "$@"
;;
transcribe)
python -m luke_scribe.cli transcribe "$@"
;;
bench)
python -m luke_scribe.cli bench "$@"
;;
serve)
python -m luke_scribe.cli serve "$@"
;;
key)
python -m luke_scribe.cli key "$@"
;;
test)
python -m pytest tests/ "$@"
;;
*)
echo "사용법: ./run.sh {detect|transcribe|bench|serve|key|test} [args]"
echo
echo "예시:"
echo " ./run.sh detect"
echo " ./run.sh transcribe samples/ko_en.wav --language ko"
echo " ./run.sh serve"
echo " ./run.sh key --create"
exit 1
;;
esac
+3 -38
View File
@@ -1,39 +1,4 @@
# samples/ — bench 데이터셋 (KO+EN 도메인)
# samples
`bench` 게이트(turbo vs large-v3 의 **R-WER · entity 보존율**)와 혼용어 정확도(AC-4)
검증의 입력입니다. 이 데이터가 있어야 설계 모호도 마지막 ~5%(하이브리드→단일 확정)를
측정으로 닫을 수 있습니다.
## 무엇이 필요한가
1. **오디오/영상 클립** — wav/flac/mp3/m4a/mp4 등(엔진이 ffmpeg로 디코딩). 5~60초 권장, **5~20개부터** 시작 가능.
2. **정답 전사(ground truth)** — 각 클립의 올바른 한국어 텍스트. **영문 기술용어는 영문 그대로**(예: `vLLM`, `API`, `Kubernetes`).
3. (선택) **도메인 엔티티 목록** — entity 보존율 측정용.
## 배치 형식
```
samples/ko_en/
clips/0001.wav
clips/0002.wav
manifest.jsonl # 클립 ↔ 정답 매핑 (한 줄당 1 클립)
entities.txt # (선택) 한 줄당 도메인 용어
```
`manifest.jsonl` 예:
```jsonl
{"audio": "clips/0001.wav", "text": "그 API 서빙할 때 vLLM 쓰면 성능 대박이야", "lang": "ko"}
{"audio": "clips/0002.wav", "text": "FastAPI로 엔드포인트 만들고 Kubernetes에 배포했어", "lang": "ko"}
```
`entities.txt` 예(선택):
```
vLLM
FastAPI
Kubernetes
CTranslate2
GPU
```
## 주의
- 오디오/영상 파일은 `.gitignore`**커밋 제외**(용량·프라이버시). `manifest.jsonl`·`entities.txt`·이 README만 추적.
- entity 보존율은 **정답 텍스트의 영문 표기**를 기준으로 계산하니 표기를 정확히.
- `bench` 구현 시 이 형식을 그대로 소비합니다: `uv run luke-scribe bench --samples samples/ko_en/`.
`hello-ko-en.wav` — 16kHz mono, 짧은 한국어+영문 기술용어 발화 샘플.
(라이선스 확인 후 실제 오디오 파일을 추가할 것. 개인정보 포함 원본 커밋 금지.)
-2
View File
@@ -1,2 +0,0 @@
# 오디오/영상 클립을 이 폴더에 넣으세요 (예: 0001.wav, 0001.mp3, 0001.mp4).
# 미디어 파일 자체는 .gitignore로 커밋 제외됩니다(용량/프라이버시). manifest만 추적.
-2
View File
@@ -1,2 +0,0 @@
{"audio": "clips/0001.wav", "text": "그 API 서빙할 때 vLLM 쓰면 성능 대박이야", "lang": "ko"}
{"audio": "clips/0002.wav", "text": "FastAPI로 엔드포인트 만들고 Kubernetes에 배포했어", "lang": "ko"}
+557
View File
@@ -0,0 +1,557 @@
"""Colab 실전 테스트 노트북 생성 스크립트.
다른 환경(CPU-only, ffmpeg 없음)에서 검증할 수 없었던 부분을
Colab Pro(T4 GPU + 터미널)에서 실전 검증하기 위한 노트북을 생성한다:
1. 저장소 클론 (feat/full-platform)
2. 시스템 의존성 (ffmpeg) + venv + luke-scribe 설치
3. `detect` — T4 GPU 실제 감지 (능력 등급/정밀도/워커수)
4. 단위/통합 테스트 (127개 mock)
5. 샘플 오디오 생성 (한국어+영문 기술용어, edge-tts)
6. 실전 전사 (GPU, faster-whisper 모델 다운로드) + glossary/hotword 검증
7. API 서버 기동 + curl 스모크 (업로드 → poll → 결과)
8. 벤치마크 (turbo vs large-v3)
사용법: python scripts/build_colab_notebook.py → notebooks/luke-scribe-colab.ipynb
"""
from __future__ import annotations
import json
from pathlib import Path
OUT = Path("notebooks/luke-scribe-colab.ipynb")
def md(source: str) -> dict:
return {"cell_type": "markdown", "metadata": {}, "source": source}
def code(source: str) -> dict:
return {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": source,
}
def cells() -> list[dict]:
return [
md(
"# luke_scribe — Colab 실전 테스트 노트북\n"
"\n"
"> 내부용 로컬 STT 전사 API (faster-whisper, hardware-adaptive, privacy-first)\n"
"\n"
"이 노트북은 CPU-only 개발 환경에서 **mock으로만 검증**했던 것을,\n"
"Colab Pro(GPU + 터미널)에서 **실전 검증**하기 위한 것입니다.\n"
"\n"
"## 검증 대상\n"
"\n"
"- `luke-scribe detect` → T4 GPU 실제 감지 (능력 등급 T1~T3, 정밀도, 워커수)\n"
"- faster-whisper 모델 다운로드 + **실제 한국어 전사** (CPU-only 환경에선 불가)\n"
"- glossary/hotword 후처리 (KO+EN 기술용어 보존)\n"
"- REST API 흐름 (업로드 → poll → 결과)\n"
"- turbo vs large-v3 벤치마크\n"
"\n"
"## 준비\n"
"\n"
"1. 런타임 → 런타임 유형 변경 → **T4 GPU** 선택\n"
"2. (Colab Pro) 터미널을 사용해도 동일한 명령을 실행할 수 있습니다\n"
"3. 저장소가 private이면 아래 셀에 **Gitea 토큰** 입력 (Settings → Applications → Generate New Token)\n"
"\n"
"---\n"
),
code(
"# 0) 런타임 확인 — T4 GPU가 활성 상태여야 합니다\n"
"!nvidia-smi\n"
"import sys\n"
"print('Python', sys.version.split()[0])\n"
),
md(
"## 1) 저장소 클론\n"
"\n"
"`feat/full-platform` 브랜치를 클론합니다.\n"
"저장소가 private이면 아래 셀의 `GITEA_TOKEN`에 토큰을 입력하세요."
),
code(
"# 1) 클론 또는 업데이트 (private 저장소면 GITEA_TOKEN 입력)\n"
"GITEA_TOKEN = '' # ← 필요 시 입력: https://git.lukehemmin.com/user/settings/applications\n"
"\n"
"if GITEA_TOKEN:\n"
" REPO = f'https://{GITEA_TOKEN}@git.lukehemmin.com/lukehemmin/luke_scribe.git'\n"
"else:\n"
" REPO = 'https://git.lukehemmin.com/lukehemmin/luke_scribe.git'\n"
"\n"
"import os, subprocess\n"
"\n"
"if os.path.isdir('/content/luke_scribe/.git'):\n"
" # 이미 클론된 레포 → 최신 브랜치로 갱신 (pull)\n"
" print('기존 레포 감지 → pull로 갱신')\n"
" subprocess.run(['git', 'fetch', 'origin'], cwd='/content/luke_scribe', check=True)\n"
" subprocess.run(['git', 'checkout', 'feat/full-platform'], cwd='/content/luke_scribe', check=True)\n"
" subprocess.run(['git', 'pull', '--ff-only', 'origin', 'feat/full-platform'], cwd='/content/luke_scribe', check=True)\n"
"else:\n"
" # 최초 실행 → 클론\n"
" subprocess.run(['git', 'clone', '-b', 'feat/full-platform', REPO], cwd='/content', check=True)\n"
"\n"
"os.chdir('/content/luke_scribe')\n"
"print('작업 디렉터리 →', os.getcwd())\n"
"!git log --oneline -1\n"
),
md(
"## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."
),
code(
"# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n"
"!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n"
"!ffmpeg -version 2>&1 | head -1\n"
"!ffprobe -version 2>&1 | head -1\n"
),
code(
"# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n"
"!pip install -q --upgrade pip\n"
"!pip install -q -e '.[engine,api]'\n"
"!pip install -q edge-tts # 샘플 음성 생성용\n"
"\n"
"# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n"
"# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n"
"# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n"
"!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\n"
"import os, site, subprocess\n"
"lib_dirs = set()\n"
"for lib in ('libcublas.so', 'libcudnn.so'):\n"
" out = subprocess.run(\n"
" ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n"
" capture_output=True, text=True,\n"
" ).stdout\n"
" for line in out.splitlines():\n"
" d = os.path.dirname(line)\n"
" if d:\n"
" lib_dirs.add(d)\n"
"os.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\n"
"print('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\n"
"print('설치 완료')\n"
"\n"
"# 실행 중인 Colab 커널은 새 .pth 파일을 읽지 못한다 (pip editable 설치는\n"
"# 인터프리터 시작 시에만 반영). → 커널 sys.path에 src/를 직접 등록해\n"
"# luke_scribe import를 보장한다 (서브프로세스 CLI는 .pth를 읽으므로 무관).\n"
"import sys, os as _os\n"
"_root = _os.getcwd()\n"
"while not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n"
" _root = _os.path.dirname(_root)\n"
"sys.path.insert(0, _os.path.join(_root, 'src'))\n"
"try:\n"
" import luke_scribe.config as _cfg\n"
" print('패키지 임포트 OK →', _cfg.__file__)\n"
"except ImportError as _e:\n"
" print('경고: luke_scribe import 실패 —', _e)\n"
" print(' 셀 1(클론)이 실행됐는지, 저장소가 src/luke_scribe 구조인지 확인하세요')\n"
"\n"
"# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n"
"!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n"
"!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n"
"!which luke-scribe && luke-scribe --help 2>&1 | head -8\n"
),
code(
"# 4) CTranslate2 GPU 검증 — unsupported device cuda:0 해결 여부 확인\n"
"import ctranslate2\n"
"print('ctranslate2', ctranslate2.__version__)\n"
"print('CUDA device count:', ctranslate2.get_cuda_device_count())\n"
"if ctranslate2.get_cuda_device_count() > 0:\n"
" print('GPU 사용 가능 ✅ — 이후 전사는 GPU로 실행됩니다')\n"
"else:\n"
" print('GPU 사용 불가 — CPU 폴백 필요 (--device cpu --compute-type int8)')\n"
),
md(
"## 3) 하드웨어 감지 — GPU 실제 확인\n"
"\n"
"CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n"
"Colab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다."
),
code("# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n!luke-scribe detect\n"),
md(
"## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."
),
code("# 5) 테스트 스위트\n!python -m pytest tests/ -q 2>&1 | tail -5\n"),
md(
"## 5) 실전 전사 (GPU)\n\n"
"### 5-1) 샘플 오디오 생성\n\n"
"edge-tts(MS TTS)로 **한국어 + 영문 기술용어가 섞인** 샘플을 생성합니다.\n"
"이 텍스트에는 `vLLM`, `Kubernetes`, `GPU` 같은 용어가 포함되어 glossary/hotword 검증에 적합합니다."
),
code(
"# 6) 샘플 오디오 생성 (한국어 + 기술용어, 무료 TTS)\n"
"!mkdir -p samples\n"
"!edge-tts --voice ko-KR-SunHiNeural --text '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.' --write-media samples/colab-ko-en.mp3\n"
"!ffprobe -v error -show_entries format=duration -of json samples/colab-ko-en.mp3\n"
),
code(
"# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n"
"# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n"
"# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n"
"# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n"
"# 후처리: 기본 rules가 vLLM→BLM 같은 흔한 오인식을 복원하고,\n"
"# --glossary 'BLM=vLLM'으로 도메인 용어를 명시적으로 보강할 수 있다.\n"
"import subprocess, json\n"
"r = subprocess.run(\n"
" ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto',\n"
" '--glossary', 'BLM=vLLM'],\n"
" capture_output=True, text=True,\n"
")\n"
"print(r.stdout[-2500:] if r.stdout else '')\n"
"print(r.stderr[-800:] if r.stderr else '')\n"
),
md(
"### 5-2) 후처리 검증\n\n"
"후처리(rules/glossary) + hotword(용어 사전 주입) 동작을 확인합니다.\n"
"- **rules (기본)**: `BLM → vLLM`, `v l l m → vLLM` 같은 흔한 오인식을 결정적으로 복원.\n"
"- **glossary**: `--glossary '오인식=표준'`으로 도메인 용어를 명시적으로 보강 (반복 가능).\n"
"- **hotword**: `--hotword vLLM Kubernetes` → initial_prompt 주입으로 보존률 향상."
),
code(
"# 8) hotword + glossary 포함 전사 (용어 보존 강화)\n"
"!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes --glossary BLM=vLLM\n"
),
md(
"## 6) REST API 스모크\n\n"
"서버를 백그라운드로 띄우고 **업로드 → poll → 결과(SRT)** 흐름을 검증합니다."
),
code(
"# 9) API 키 생성 — raw 키는 1회만 출력되므로 여기서 캡처한다\n"
"import subprocess, json, os\n"
"r = subprocess.run(\n"
" ['luke-scribe', 'key', '--create', '--scopes', 'transcribe,admin', '--file', '/content/api_keys.json'],\n"
" capture_output=True, text=True,\n"
")\n"
"created = json.loads(r.stdout)\n"
"RAW_KEY = created['key'] # 이후 셀에서 사용\n"
"print('key_id:', created['key_id'], '| scopes:', created['scopes'])\n"
"print('raw 키 캡처 완료 (표시 안 함)')\n"
),
code(
"# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n"
"# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n"
"# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\n"
"import subprocess, os, re, shutil, time, urllib.request, urllib.error\n"
"\n"
"assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n"
"\n"
"subprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\n"
"time.sleep(2)\n"
"\n"
"# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n"
"os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n"
"os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n"
"# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\n"
"os.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n"
"# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\n"
"os.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n"
"\n"
"!mkdir -p /content/logs\n"
"!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n"
"\n"
"ok = False\n"
"for _ in range(40):\n"
" try:\n"
" urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n"
" ok = True\n"
" break\n"
" except Exception:\n"
" time.sleep(1)\n"
"if not ok:\n"
" print('서버 기동 실패 — 로그:')\n"
" print(open('/content/logs/server.log').read()[-1500:])\n"
" raise SystemExit('서버 기동 실패')\n"
"\n"
"# 대시보드 HTML 스모크 (공개 — 키 불필요)\n"
"try:\n"
" d = urllib.request.urlopen('http://localhost:8000/dashboard', timeout=5)\n"
" print('대시보드 HTML:', d.status, len(d.read()), 'bytes')\n"
"except Exception as exc:\n"
" print('대시보드 로드 실패:', exc)\n"
"\n"
"# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n"
"req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n"
"try:\n"
" resp = urllib.request.urlopen(req, timeout=5)\n"
" print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\n"
"except urllib.error.HTTPError as e:\n"
" print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n"
" print(open('/content/logs/server.log').read()[-1500:])\n"
" raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n"
"\n"
"# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\n"
"TUNNEL_URL = None\n"
"if shutil.which('cloudflared'):\n"
" for _ in range(60):\n"
" log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n"
" m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n"
" if m:\n"
" TUNNEL_URL = m.group(0)\n"
" break\n"
" time.sleep(2)\n"
"else:\n"
" print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n"
"\n"
"if TUNNEL_URL:\n"
" print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n"
" print(' 대시보드:', TUNNEL_URL + '/dashboard')\n"
" print(' API Docs (Swagger):', TUNNEL_URL + '/docs')\n"
" print(' 상태 (health):', TUNNEL_URL + '/health')\n"
" # ── 외부 접속 검증 ──\n"
" # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n"
" # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n"
" # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n"
" import socket\n"
" _host = TUNNEL_URL.split('//')[1].split('/')[0]\n"
" _dns_ip = None\n"
" try:\n"
" _dns_ip = socket.gethostbyname(_host)\n"
" except socket.gaierror as _de:\n"
" print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n"
"\n"
" ext_ok = False\n"
" _last_err = None\n"
" if _dns_ip:\n"
" # DNS가 풀리면 터널도 이미 연결된 상태 — 2회면 충분\n"
" for _ in range(2):\n"
" try:\n"
" ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n"
" print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n"
" ext_ok = True\n"
" break\n"
" except Exception as _e:\n"
" _last_err = _e\n"
" time.sleep(2)\n"
" if not ext_ok and shutil.which('curl'):\n"
" if _dns_ip:\n"
" print(' (시스템 DNS 경로 실패 — DoH로 우회 검증 시도)')\n"
" # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n"
" for _ in range(4):\n"
" _c = subprocess.run(\n"
" ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10',\n"
" '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n"
" capture_output=True, text=True,\n"
" )\n"
" _code = _c.stdout.strip()\n"
" if _code == '200':\n"
" print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n"
" ext_ok = True\n"
" break\n"
" # '000'은 접속 실패 — stderr가 더 설명적\n"
" if _code == '000':\n"
" _last_err = _c.stderr.strip() or _last_err\n"
" else:\n"
" _last_err = _code or _c.stderr.strip() or _last_err\n"
" time.sleep(2)\n"
" if not ext_ok:\n"
" print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n"
" print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n"
" print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\n"
"else:\n"
" print('터널 URL 미감지 — 서버 로그:')\n"
" print(open('/content/logs/server.log').read()[-1200:])\n"
" print('로컬(8000)에서 계속 사용할 수 있습니다.')\n"
),
code(
"# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n"
"import json, time, urllib.request, urllib.error, subprocess\n"
"\n"
"assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n"
"\n"
"# multipart 업로드 (curl 사용 — 간단)\n"
"r = subprocess.run(\n"
" ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n"
" '-H', f'X-API-Key: {RAW_KEY}',\n"
" '-F', 'file=@samples/colab-ko-en.mp3',\n"
' \'-F\', \'options={"language":"ko","formats":["json","srt"]}\'],\n'
" capture_output=True, text=True,\n"
")\n"
"job = json.loads(r.stdout)\n"
"print('생성:', job)\n"
"job_id = job.get('job_id')\n"
"\n"
"# 완료까지 poll (auto-worker가 서버 프로세스에서 큐를 소비 — 첫 실행은\n"
"# 모델 다운로드 ~1.6GB 포함이라 최대 4분까지 대기)\n"
"if job_id:\n"
" print('job_id =', job_id)\n"
" for _ in range(120):\n"
" st = json.loads(urllib.request.urlopen(\n"
" urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n"
" headers={'X-API-Key': RAW_KEY})\n"
" ).read())\n"
" if st['status'] in ('completed', 'failed', 'cancelled'):\n"
" print('최종 상태:', st['status'])\n"
" break\n"
" time.sleep(2)\n"
" else:\n"
" print('폴링 60초 초과 — 서버 로그 확인: /content/logs/server.log')\n"
"else:\n"
" print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n"
),
md(
"### 참고 — 서버에서 실제 전사까지 실행하려면\n\n"
"in-proc 브로커는 큐만 받고 워커가 별도로 소비해야 합니다. 워커 스레드를 띄우거나\n"
"간단하게는 배치 파이프라인을 직접 호출하는 CLI가 더 간단합니다. API 전체 흐름(워커 포함)은\n"
"다음 셀에서 `python`으로 브로커 + 워커를 함께 돌려 확인합니다."
),
code(
"# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n"
"# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n"
"# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\n"
"# 커널 sys.path 보강 — editable 설치는 인터프리터 시작 시에만 반영되므로\n"
"# 실행 중인 커널은 src/를 직접 등록해야 한다 (셀 3에서도 처리하지만 방어적 가드)\n"
"import sys, os as _os\n"
"if not any(_os.path.isfile(_os.path.join(p, 'luke_scribe', 'config.py')) for p in sys.path):\n"
" _root = _os.getcwd()\n"
" while not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n"
" _root = _os.path.dirname(_root)\n"
" sys.path.insert(0, _os.path.join(_root, 'src'))\n"
"from luke_scribe.config import Settings\n"
"from luke_scribe.jobqueue.broker import InProcBroker\n"
"from luke_scribe.jobqueue.worker import Worker\n"
"from luke_scribe.jobqueue.jobs import Job\n"
"from luke_scribe.results.store import ResultStore\n"
"\n"
"settings = Settings(_env_file=None, queue_backend='inproc',\n"
" results_dir='/content/results', model_cache_dir=None)\n"
"broker = InProcBroker(settings)\n"
"store = ResultStore(settings.results_dir)\n"
"\n"
"job = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n"
" options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\n"
"broker.enqueue(job)\n"
"worker = Worker(settings=settings, broker=broker, store=store)\n"
"worker.drain()\n"
"\n"
"result = store.read_result(job.id)\n"
"if result is None:\n"
" # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n"
" state = broker.get(job.id)\n"
" print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n"
" if state is not None:\n"
" err = getattr(state, 'error_message', None) or getattr(state, 'error_code', None)\n"
" print('job 오류:', err or '없음')\n"
"else:\n"
" print('status:', result.status)\n"
" print('text:', result.text[:120])\n"
" print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n"
),
md(
"## 7) 벤치마크 (turbo vs large-v3)\n\n"
"manifest에 모델 비교 항목이 있으면 실행합니다. 두 모델을 모두 다운로드하므로\n"
"시간이 걸립니다 (turbo ~1.6GB + large-v3 ~3GB)."
),
code(
"# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n"
"# entities는 {canonical, surface, start_char, end_char} dict여야 한다\n"
"# (문자열이면 entity_retention이 .get() 호출에 실패해 clip이 실패 처리됨)\n"
"import yaml\n"
"\n"
"REF_TEXT = '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.'\n"
"with open('/content/reference.txt', 'w', encoding='utf-8') as f:\n"
" f.write(REF_TEXT)\n"
"\n"
"entities = []\n"
"for name in ('vLLM', 'Kubernetes', 'GPU'):\n"
" idx = REF_TEXT.index(name)\n"
" entities.append({\n"
" 'canonical': name, 'surface': name,\n"
" 'start_char': idx, 'end_char': idx + len(name),\n"
" })\n"
"\n"
"manifest = {\n"
" 'name': 'colab-quick',\n"
" 'dataset_version': '1.0',\n"
" 'language': 'ko',\n"
" 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n"
" # 벤치도 후처리를 적용해 실사용 지표를 측정 (vLLM→BLM 복원 포함)\n"
" 'glossary': {'BLM': 'vLLM'},\n"
" 'clips': [\n"
" {'id': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n"
" 'reference_path': '/content/reference.txt',\n"
" 'duration_sec': 10.5, 'entities': entities},\n"
" ],\n"
"}\n"
"yaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n"
"\n"
"# 실행 (기본: turbo만 → 빠름)\n"
"!luke-scribe bench /content/manifest.yaml --models large-v3-turbo --device auto --repeats 2 --output /content/bench-report.json 2>&1 | tail -20\n"
),
md(
"## 8) 문제 해결\n\n"
"### 전사 실패: `unsupported device cuda:0`\n\n"
'**근본 원인**: v0.1 엔진이 `device="cuda:0"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\n'
'CTranslate2는 `device="cuda"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n'
"→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n"
"참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n"
"런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n"
"`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n"
"그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n"
"### API 키 인증 실패 (401)\n\n"
"1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n"
" (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n"
"2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n"
" 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n"
"### 업로드 후 job이 queued에 머무는 경우\n\n"
"in-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n"
"셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n"
"업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n"
"### Cloudflare 터널로 외부에서 접속 (선택)\n\n"
"서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n"
"- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n"
"- `/dashboard`(전용 대시보드 UI)와 `/docs`(Swagger), `/health`는 별도 키 없이 열리지만,\n"
" **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다. 대시보드 상단에 키를 저장하면 사용됩니다.\n"
"- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n"
"- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n"
"- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n"
"- **자동 검증이 실패해도 터널은 정상일 수 있습니다.** Colab VM의 DNS가 새 trycloudflare 호스트를\n"
" 해석하지 못하면 `Name or service not known`이 나지만, URL 발급 자체가 이미 edge↔서버 연결을 의미합니다.\n"
" 검증 셀은 Cloudflare DoH(`--doh-url`) 우회로 다시 시도하며, 안 되면 브라우저/폰에서 직접 확인하세요.\n\n"
"### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\n"
"Colab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n"
"(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n"
"등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n"
"### 모델 다운로드 실패\n\n"
"- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n"
"- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n"
"\n"
"### API 키 인증 실패 (401)\n\n"
"`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n"
"(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n"
"### OOM (16GB VRAM)\n\n"
"- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n"
"- 수동 지정: `--compute-type int8_float16`\n"
"\n"
"### 저장소가 private인데 클론 안 됨\n\n"
"1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n"
),
]
def main() -> None:
nb = {
"nbformat": 4,
"nbformat_minor": 0,
"metadata": {
"colab": {"provenance": [], "gpuType": "T4"},
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
},
"language_info": {"name": "python", "version": "3.11"},
"accelerator": "GPU",
},
"cells": cells(),
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(nb, ensure_ascii=False, indent=1), encoding="utf-8")
n_code = sum(1 for c in nb["cells"] if c["cell_type"] == "code")
print(f"{OUT} 생성 (셀 {len(nb['cells'])}개, 코드 {n_code}개)")
if __name__ == "__main__":
main()
+4 -1
View File
@@ -1,3 +1,6 @@
"""luke_scribe — 내부용 로컬 STT 전사 API (faster-whisper, hardware-adaptive)."""
"""luke_scribe — 내부용 로컬 STT 전사 API.
faster-whisper 기반, 하드웨어 적응형, privacy-first.
"""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""FastAPI 웹 API — 배치 Job, 실시간 WS, admin."""
+171
View File
@@ -0,0 +1,171 @@
"""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
import threading
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, dashboard, jobs, keys, 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)
# in-proc 백엔드 + auto_worker: 같은 프로세스 워커 스레드가 큐를 소비
# (dev/Colab에서 별도 워커 프로세스 없이 업로드 → 완료 흐름이 가능하게 함)
inproc_worker = None
if settings.queue_backend == "inproc" and settings.auto_worker:
from ..jobqueue.worker import Worker
inproc_worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=owner,
worker_id="api-inproc",
)
threading.Thread(target=inproc_worker.run_forever, daemon=True).start()
logger.info("in-proc auto-worker 스레드 시작 (queue_backend=inproc, auto_worker=true)")
app.state.inproc_worker = inproc_worker
# 모델 프로비저닝 (선택) — 설정된 경우에만
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
if inproc_worker is not None:
# stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후
# 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전)
inproc_worker.stop()
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(dashboard.router)
app.include_router(admin.router)
app.include_router(jobs.router)
app.include_router(stream.router)
app.include_router(keys.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()
+134
View File
@@ -0,0 +1,134 @@
"""인증/권한 디펜던시.
Eng 리뷰 P14 반영:
- 키는 평문으로 저장하지 않는다 — **peppered HMAC-SHA256 다이제스트**만 저장.
- 비교는 ``secrets.compare_digest`` (상수 시간).
- 스코프 강제 + **Job 소유권** (transcribe 키가 남의 Job을 조회/취소 불가).
- raw 키는 로그/큐에 절대 기록하지 않는다.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
from dataclasses import dataclass
from pathlib import Path
from fastapi import Depends, Header, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from ..config import Settings
from ..errors import AuthError, ScopeDenied
_bearer = HTTPBearer(auto_error=False)
_PEPPER = b"luke-scribe-key-pepper-v1"
class KeyStore:
"""키 다이제스트 저장소 — {key_id: {digest, scopes}}.
``api_keys`` 설정(평문, dev용) 또는 ``api_key_file``(다이제스트, 권장)를
소스로 사용한다.
"""
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or Settings()
self._keys: dict[str, dict] = {}
self._load()
def _load(self) -> None:
# 1) api_key_file (다이제스트)
path = Path(self.settings.api_key_file)
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
for entry in data.get("keys", []):
self._keys[entry["id"]] = {
"digest": entry["digest"],
"scopes": set(entry.get("scopes", ["transcribe"])),
}
except Exception:
pass
# 2) 설정 평문 키 (dev 편의) → 다이제스트로 변환
for raw_key, scopes in self.settings.parse_api_keys().items():
self._keys.setdefault(
self._id_of(raw_key), {"digest": self._digest(raw_key), "scopes": scopes}
)
@staticmethod
def _id_of(raw_key: str) -> str:
return f"k-{raw_key[:8]}"
@staticmethod
def _digest(raw_key: str) -> str:
return hmac.new(_PEPPER, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
def authenticate(self, raw_key: str | None) -> Principal:
if not raw_key:
raise AuthError("API 키가 필요합니다 (X-API-Key 헤더)")
for key_id, entry in self._keys.items():
if secrets.compare_digest(entry["digest"], self._digest(raw_key)):
return Principal(key_id=key_id, scopes=entry["scopes"])
raise AuthError("유효하지 않은 API 키")
def list_keys(self) -> list[dict]:
"""키 ID/스코프 목록 (raw 키는 절대 노출하지 않음)."""
return [
{"id": key_id, "scopes": sorted(entry["scopes"])}
for key_id, entry in sorted(self._keys.items())
]
def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict:
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
raw = f"luke-{secrets.token_urlsafe(32)}"
scopes = scopes or ["transcribe"]
key_id = self._id_of(raw)
self._keys[key_id] = {"digest": self._digest(raw), "scopes": set(scopes)}
save_path = save_path or self.settings.api_key_file
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
data = {
"keys": [
{"id": k, "digest": v["digest"], "scopes": sorted(v["scopes"])}
for k, v in self._keys.items()
]
}
Path(save_path).write_text(json.dumps(data, indent=2), encoding="utf-8")
return {"key_id": key_id, "key": raw, "scopes": scopes, "stored_at": save_path}
@dataclass
class Principal:
key_id: str
scopes: set[str]
def require_scope(self, scope: str) -> None:
if scope not in self.scopes:
raise ScopeDenied(f"스코프 {scope!r}가 필요합니다 (가진 스코프: {sorted(self.scopes)})")
def get_keystore(request: Request) -> KeyStore:
"""앱 상태에 있는 keystore 사용 (lifespan이 설정 기반으로 생성)."""
return request.app.state.keystore
def get_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
x_api_key: str | None = Header(default=None),
keystore: KeyStore = Depends(get_keystore),
) -> Principal:
raw = None
if credentials:
raw = credentials.credentials
elif x_api_key:
raw = x_api_key
return keystore.authenticate(raw)
def require_scope(scope: str):
def dep(principal: Principal = Depends(get_principal)) -> Principal:
principal.require_scope(scope)
return principal
return dep
+1
View File
@@ -0,0 +1 @@
"""API 라우트 — jobs / stream / admin."""
+64
View File
@@ -0,0 +1,64 @@
"""Admin 라우트 — /health, /v1/system, /v1/models.
계약: health는 공개(최소 정보), system/models는 admin 스코프 필요.
``/v1/system``은 요청 vs 실효 compute_type 불일치 경고를 포함한다 (§3.9d).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from ...devices.manager import DeviceManager
from ...devices.vram_probe import runtime_env_snapshot
from ..deps import Principal, require_scope
from ..schemas import SystemResponse
router = APIRouter(tags=["admin"])
@router.get("/health")
async def health(request: Request) -> dict:
broker = request.app.state.broker
return {
"status": "ok",
"queue_depth": broker.queue_depth(),
"model_ready": getattr(request.app.state, "model_ready", False),
}
@router.get("/v1/system", response_model=SystemResponse)
async def system(
request: Request,
principal: Principal = Depends(require_scope("admin")),
) -> SystemResponse:
settings = request.app.state.settings
manager = DeviceManager()
profile = manager.detect(
device=settings.device,
compute_type=None if settings.compute_type == "auto" else settings.compute_type,
)
return SystemResponse(
capability_tier=profile.capability_tier,
device=profile.to_dict(),
workers=manager.compute_workers(profile, override=settings.workers),
queue_depth=request.app.state.broker.queue_depth(),
models=[settings.model_rt, settings.model_batch],
model_used=getattr(request.app.state, "model_ready_model", None),
compute_type_used=profile.selected_compute_type,
)
@router.get("/v1/models")
async def models(
request: Request,
principal: Principal = Depends(require_scope("admin")),
) -> dict:
settings = request.app.state.settings
manager = DeviceManager()
profile = manager.detect(device=settings.device)
return {
"capability_tier": profile.capability_tier,
"loadable_models": profile.loadable_models or [settings.model_rt, settings.model_batch],
"recommended_model": profile.recommended_model,
"runtime": runtime_env_snapshot(),
}
+22
View File
@@ -0,0 +1,22 @@
"""대시보드 정적 페이지 라우트.
HTML 자체는 공개로 제공하고, API 호출은 브라우저가 사용자 입력 키
(X-API-Key)를 헤더로 보낸다 — /docs(Swagger)와 같은 인증 모델.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse
router = APIRouter(tags=["dashboard"])
_DASHBOARD = Path(__file__).resolve().parent.parent / "static" / "dashboard.html"
@router.get("/", include_in_schema=False)
@router.get("/dashboard", include_in_schema=False)
async def dashboard() -> FileResponse:
return FileResponse(_DASHBOARD, media_type="text/html; charset=utf-8")
+178
View File
@@ -0,0 +1,178 @@
"""Job 라우트 — 생성/조회/취소/목록.
- ``POST /v1/jobs``: multipart 업로드. 4h/2GB 초과 → 413, 큐 만재 → 429.
- ``GET /v1/jobs/{id}``: queue_position/progress/상태 (소유자 전용).
- ``GET /v1/jobs/{id}/result?format=``: 결과 (enum 포맷만, UUID 키).
- ``DELETE /v1/jobs/{id}``: 협조적 취소.
- 소유권: Job을 만든 키만 조회/취소/결과 접근 가능 (Eng 리뷰 P14).
"""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from pydantic import ValidationError
from ...config import Settings
from ...errors import (
JobNotFound,
QueueFull,
)
from ...jobqueue.broker import JobBroker
from ...jobqueue.jobs import Job, JobStatus
from ...results.formats import render
from ...results.store import ResultStore
from ..deps import Principal, get_principal, require_scope
from ..schemas import JobCreateResponse, JobResultResponse, JobStatusResponse, TranscribeOptions
router = APIRouter(prefix="/v1/jobs", tags=["jobs"])
REQUIRED_SCOPE = "transcribe"
def _get_broker(request: Request) -> JobBroker:
return request.app.state.broker
def _get_store(request: Request) -> ResultStore:
return request.app.state.store
@router.post("", response_model=JobCreateResponse, status_code=202)
async def create_job(
request: Request,
file: Annotated[UploadFile, File()],
options: Annotated[str, Form()] = '{"language":"ko"}',
principal: Principal = Depends(require_scope("transcribe")),
) -> JobCreateResponse:
try:
opts = TranscribeOptions.model_validate_json(options)
except ValidationError as exc:
raise HTTPException(status_code=422, detail=f"options 파싱 실패: {exc.errors()}") from exc
broker = _get_broker(request)
store = _get_store(request)
settings: Settings = request.app.state.settings
# 2GB 상한
content = await file.read(settings.max_upload_bytes + 1)
if len(content) > settings.max_upload_bytes:
raise HTTPException(status_code=413, detail="파일 크기가 상한(2GB)을 초과합니다")
# 큐 만재 → 429
if broker.queue_depth() >= settings.max_queue:
raise HTTPException(
status_code=429, detail="큐가 가득 찼습니다", headers={"Retry-After": "5"}
)
job = Job(
type="file",
lane="batch",
owner_key_id=principal.key_id,
options=opts.model_dump(exclude_none=True),
source_name=file.filename,
)
source_path = store.source_path_for(job.id, file.filename or "upload.bin")
source_path.write_bytes(content)
job.source_path = str(source_path)
try:
broker.enqueue(job)
except QueueFull:
store.delete_job(job.id)
raise HTTPException(
status_code=429, detail="큐가 가득 찼습니다", headers={"Retry-After": "5"}
) from None
# queue_position 스냅샷
pos = broker.queue_depth() - 1
job.queue_position = pos
return JobCreateResponse(job_id=job.id, status=job.status.value, queue_position=max(pos, 0))
@router.get("", response_model=list[JobStatusResponse])
async def list_jobs(
request: Request,
principal: Principal = Depends(get_principal),
) -> list[JobStatusResponse]:
principal.require_scope("transcribe")
broker = _get_broker(request)
out = []
for job in broker.list_jobs(limit=100):
if job.owner_key_id != principal.key_id:
continue
out.append(_to_status(job))
return out
@router.get("/{job_id}", response_model=JobStatusResponse)
async def get_job(
job_id: str,
request: Request,
principal: Principal = Depends(get_principal),
) -> JobStatusResponse:
principal.require_scope("transcribe")
broker = _get_broker(request)
job = broker.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
_check_owner(job, principal)
return _to_status(job)
@router.get("/{job_id}/result", response_model=JobResultResponse)
async def get_result(
job_id: str,
request: Request,
format: Literal["json", "txt", "srt", "vtt"] = "json",
principal: Principal = Depends(get_principal),
) -> JobResultResponse:
principal.require_scope("transcribe")
broker = _get_broker(request)
store = _get_store(request)
job = broker.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
_check_owner(job, principal)
result = store.read_result(job_id)
if result is None:
raise HTTPException(status_code=409, detail="결과가 아직 준비되지 않았습니다")
return JobResultResponse(format=format, content=render(result, format))
@router.delete("/{job_id}", response_model=JobStatusResponse)
async def cancel_job(
job_id: str,
request: Request,
principal: Principal = Depends(require_scope("transcribe")),
) -> JobStatusResponse:
broker = _get_broker(request)
job = broker.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
_check_owner(job, principal)
try:
cancelled = broker.cancel(job_id)
except JobNotFound:
raise HTTPException(status_code=404, detail="job not found") from None
return _to_status(cancelled)
def _check_owner(job: Job, principal: Principal) -> None:
if job.owner_key_id and job.owner_key_id != principal.key_id:
raise HTTPException(status_code=403, detail="다른 키가 만든 job에 접근할 수 없습니다")
def _to_status(job: Job) -> JobStatusResponse:
return JobStatusResponse(
job_id=job.id,
status=job.status.value,
source_name=job.source_name,
queue_position=job.queue_position,
progress=job.progress,
processed_sec=job.processed_sec,
total_sec=job.total_sec,
error={"code": job.error_code, "message": job.error_message} if job.error_code else None,
result_available=job.status == JobStatus.COMPLETED,
)
+35
View File
@@ -0,0 +1,35 @@
"""API 키 라우트 — 생성/목록 (admin 스코프).
- ``POST /v1/keys``: 새 키 생성 — raw 키는 **응답에서 1회만** 노출하고
다이제스트만 저장한다 (대시보드/CLI 공통 계약).
- ``GET /v1/keys``: 키 ID/스코프 목록 (raw 키는 노출하지 않음).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from ..deps import Principal, require_scope
from ..schemas import KeyCreateRequest
router = APIRouter(prefix="/v1/keys", tags=["keys"])
@router.post("", status_code=201)
async def create_key(
request: Request,
body: KeyCreateRequest,
principal: Principal = Depends(require_scope("admin")),
) -> dict:
keystore = request.app.state.keystore
created = keystore.create_key(body.scopes)
return created
@router.get("")
async def list_keys(
request: Request,
principal: Principal = Depends(require_scope("admin")),
) -> dict:
keystore = request.app.state.keystore
return {"keys": keystore.list_keys()}
+153
View File
@@ -0,0 +1,153 @@
"""실시간 WebSocket 라우트 (plan §3.10b, §3.9c).
- 첫 메시지 = init 프레임(인증 + 오디오 협상), 2초 내 미수신 시 close.
- unauthenticated 소켓/IP당 상한, init/오디오 프레임 크기 상한 (Eng P17).
- partial/final/status 이벤트 (LocalAgreement 안정화).
- 실시간 레인은 EngineOwner의 단일 GPU 락을 공유 (배치와 충돌 없음).
- 최대 동시 세션 상한 초과 → close.
"""
from __future__ import annotations
import asyncio
import json
import time
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ...config import Settings
from ...pipeline.realtime import LocalAgreement
from ..deps import KeyStore
router = APIRouter(tags=["realtime"])
# 세션/프레임 상한
MAX_INIT_BYTES = 16 * 1024
MAX_FRAME_BYTES = 512 * 1024
MAX_PENDING_PER_IP = 8
class _SessionGuard:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._active: set[str] = set()
self._pending_per_ip: dict[str, int] = {}
def try_acquire(self, session_id: str, ip: str) -> bool:
if len(self._active) >= self.settings.realtime_max_sessions:
return False
if self._pending_per_ip.get(ip, 0) >= MAX_PENDING_PER_IP:
return False
self._active.add(session_id)
self._pending_per_ip[ip] = self._pending_per_ip.get(ip, 0) + 1
return True
def release(self, session_id: str, ip: str) -> None:
self._active.discard(session_id)
n = self._pending_per_ip.get(ip, 1) - 1
if n <= 0:
self._pending_per_ip.pop(ip, None)
else:
self._pending_per_ip[ip] = n
async def _recv_json(ws: WebSocket, max_bytes: int, timeout_sec: float) -> dict | None:
try:
msg = await asyncio.wait_for(ws.receive_text(), timeout=timeout_sec)
except TimeoutError:
return None
except WebSocketDisconnect:
return None
if len(msg.encode("utf-8")) > max_bytes:
return None
try:
return json.loads(msg)
except json.JSONDecodeError:
return None
@router.websocket("/v1/stream")
async def stream(ws: WebSocket) -> None:
await ws.accept()
settings: Settings = ws.app.state.settings
keystore: KeyStore = ws.app.state.keystore
guard: _SessionGuard = ws.app.state.session_guard
ip = ws.client.host if ws.client else "unknown"
session_id = f"{ip}:{time.time_ns()}"
if not guard.try_acquire(session_id, ip):
await ws.close(code=1013, reason="session limit reached")
return
try:
init = await _recv_json(ws, MAX_INIT_BYTES, settings.realtime_init_timeout_sec)
if init is None or init.get("type") != "init":
await ws.close(code=1008, reason="init frame required")
return
api_key = init.get("api_key")
principal = None
if api_key:
try:
principal = keystore.authenticate(api_key)
except Exception:
principal = None
if principal is None:
await ws.close(code=1008, reason="invalid api key")
return
audio = init.get("audio") or {}
if not isinstance(audio, dict) or not audio.get("sample_rate"):
await ws.close(code=1003, reason="audio params required")
return
agreement = LocalAgreement(settings)
await ws.send_json({"type": "status", "status": "ready", "session": session_id})
# 오디오 프레임 루프 — PCM16 청크를 가정 (v0.1: decode는 EngineOwner가 소유)
owner = ws.app.state.engine_owner
buf_sec = 0.0
frames = 0
while True:
try:
msg = await asyncio.wait_for(
ws.receive(), timeout=settings.realtime_session_idle_sec
)
except TimeoutError:
# 세션 유휴 타임아웃 — 점유 해제 (Eng 리뷰 P17)
await ws.send_json(
{"type": "status", "status": "idle_timeout", "message": "유휴 세션 종료"}
)
break
if msg.get("type") == "websocket.disconnect":
break
data = msg.get("bytes")
if not data or len(data) > MAX_FRAME_BYTES:
await ws.close(code=1009, reason="frame too large")
return
frames += 1
# 16kHz mono s16 → chunk_duration
chunk_sec = len(data) / 2 / 16000
buf_sec += chunk_sec
if buf_sec >= 5.0: # 재decode 간격 (정확도 우선 청킹)
# 가설 생성 — 실제 decode는 EngineOwner로 오프로드 (계약 §3.9a)
hypothesis = await asyncio.to_thread(owner.emit_hypothesis, data)
events = agreement.feed(hypothesis["segments"], chunk_sec)
for seg in events["confirmed"]:
await ws.send_json({"type": "final", "segment": seg.model_dump()})
for seg in events.get("pending", []):
await ws.send_json(
{"type": "partial", "text": seg.text, "t0": seg.start, "t1": seg.end}
)
buf_sec = 0.0
except WebSocketDisconnect:
pass
except Exception as exc:
try:
await ws.send_json({"type": "status", "status": "error", "message": str(exc)})
except Exception:
pass
finally:
guard.release(session_id, ip)
try:
await ws.close()
except Exception:
pass
+79
View File
@@ -0,0 +1,79 @@
"""API 요청/응답 스키마 (pydantic).
- 업로드 옵션: language/model/device/compute_type/timestamps/word_timestamps/
formats/hotwords/vad/post_correction
- 오류 envelope: code/message/retryable
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class TranscribeOptions(BaseModel):
language: str | None = "ko" # auto = null
model: str | None = None
device: str = "auto"
compute_type: str | None = None
timestamps: bool = True
word_timestamps: bool = False
formats: list[str] = Field(default_factory=lambda: ["json"])
hotwords: list[str] = Field(default_factory=list)
vad: bool = True
glossary_id: str | None = None
glossary: dict[str, str] | None = None # {오인식 패턴: 표준 표기} — 후처리 glossary
post_correction: dict[str, Any] | None = None
diarize: bool = False
class KeyCreateRequest(BaseModel):
scopes: list[str] = Field(default_factory=lambda: ["transcribe"])
class JobCreateResponse(BaseModel):
job_id: str
status: str
queue_position: int | None = None
class JobStatusResponse(BaseModel):
job_id: str
status: str
source_name: str | None = None
queue_position: int | None = None
jobs_ahead: int | None = None
progress: float | None = None
processed_sec: float | None = None
total_sec: float | None = None
error: dict[str, Any] | None = None
result_available: bool = False
class JobResultResponse(BaseModel):
format: str
content: str
class SystemResponse(BaseModel):
capability_tier: str
device: dict[str, Any]
workers: int
queue_depth: int
models: list[str]
model_used: str | None = None
compute_type_used: str | None = None
class ErrorResponse(BaseModel):
code: str
message: str
retryable: bool = False
class StreamInit(BaseModel):
type: Literal["init"]
api_key: str
audio: dict[str, Any] = Field(default_factory=dict)
options: dict[str, Any] = Field(default_factory=dict)
+862
View File
@@ -0,0 +1,862 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>luke_scribe 대시보드</title>
<style>
:root {
--bg: #0b0f17;
--panel: #111827;
--panel-2: #16203a;
--border: #24304d;
--text: #e6ecf7;
--muted: #8b98b5;
--accent: #6366f1;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--err: #f87171;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(1200px 500px at 80% -10%, rgba(99,102,241,.14), transparent 60%),
radial-gradient(900px 400px at -10% 110%, rgba(34,211,238,.08), transparent 60%),
var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
font-size: 14px;
line-height: 1.55;
min-height: 100vh;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 20px 20px 60px; }
/* ── 헤더 ── */
header {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: rgba(17,24,39,.7);
backdrop-filter: blur(8px);
position: sticky; top: 0; z-index: 20;
}
.brand { font-size: 17px; font-weight: 700; letter-spacing: .3px; display: flex; align-items: center; gap: 8px; }
.brand .dot { width: 10px; height: 10px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 0 12px rgba(99,102,241,.8); }
.brand small { color: var(--muted); font-weight: 500; font-size: 12px; }
.spacer { flex: 1; }
#server-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
#server-pill .l { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
#server-pill.ok { color: var(--ok); border-color: rgba(52,211,153,.4); }
#server-pill.ok .l { background: var(--ok); box-shadow: 0 0 8px rgba(52,211,153,.7); }
#server-pill.err { color: var(--err); border-color: rgba(248,113,113,.4); }
#server-pill.err .l { background: var(--err); }
.keybox { display: flex; align-items: center; gap: 6px; }
.keybox input {
background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 10px; font-family: var(--mono); font-size: 12px; width: 220px;
}
.keybox input:focus { outline: none; border-color: var(--accent); }
#scope-badge { font-size: 11px; padding: 3px 8px; border-radius: 6px; background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); white-space: nowrap; }
#scope-badge.admin { color: var(--accent-2); border-color: rgba(34,211,238,.4); }
/* ── 탭 ── */
nav.tabs { display: flex; gap: 6px; margin: 20px 0 14px; flex-wrap: wrap; }
nav.tabs button {
background: transparent; color: var(--muted); border: 1px solid transparent;
padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 600;
transition: all .15s ease;
}
nav.tabs button:hover { color: var(--text); background: rgba(99,102,241,.08); }
nav.tabs button.active { color: #fff; background: linear-gradient(135deg, rgba(99,102,241,.25), rgba(34,211,238,.15)); border-color: rgba(99,102,241,.5); }
section.tab { display: none; animation: fade .25s ease; }
section.tab.active { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; } }
/* ── 카드 / 패널 ── */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.card h3 { margin: 0 0 14px; font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.card h3 .ico { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 7px; background: linear-gradient(135deg, rgba(99,102,241,.25), rgba(34,211,238,.15)); font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.stat { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
.stat .k { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
.stat .v { font-size: 17px; font-weight: 700; margin-top: 3px; font-family: var(--mono); }
.stat .v small { font-size: 12px; color: var(--muted); font-weight: 500; }
/* ── 폼 ── */
label.f { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 5px; font-weight: 600; }
input[type=text], input[type=password], select, textarea {
width: 100%; background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
border-radius: 9px; padding: 8px 11px; font-size: 13px; font-family: inherit;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row > * { flex: 1 1 180px; }
button.btn {
background: linear-gradient(135deg, var(--accent), #4f46e5); color: #fff; border: none;
padding: 9px 18px; border-radius: 10px; font-size: 13.5px; font-weight: 700; cursor: pointer;
transition: filter .15s ease, transform .1s ease; font-family: inherit;
}
button.btn:hover { filter: brightness(1.15); }
button.btn:active { transform: scale(.98); }
button.btn:disabled { opacity: .5; cursor: not-allowed; }
button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
button.ghost:hover { color: var(--text); border-color: var(--accent); }
button.mini { padding: 4px 10px; font-size: 12px; border-radius: 7px; }
.drop {
border: 2px dashed var(--border); border-radius: 12px; padding: 26px; text-align: center;
color: var(--muted); cursor: pointer; transition: all .15s ease; margin-bottom: 8px;
}
.drop:hover, .drop.over { border-color: var(--accent); color: var(--text); background: rgba(99,102,241,.06); }
.drop .fname { color: var(--accent-2); font-family: var(--mono); font-size: 12px; }
.chips { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; }
.chips label { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
.chips input { accent-color: var(--accent); }
/* ── 진행률 ── */
.progress { height: 8px; background: var(--panel-2); border-radius: 999px; overflow: hidden; margin: 8px 0 4px; }
.progress > i { display: block; height: 100%; width: 0; border-radius: 999px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .4s ease; }
.progress.lg { height: 12px; }
.hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
/* ── 배지 / 테이블 ── */
.badge { display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
.badge.queued { background: rgba(139,152,181,.15); color: var(--muted); }
.badge.processing { background: rgba(34,211,238,.14); color: var(--accent-2); }
.badge.completed { background: rgba(52,211,153,.14); color: var(--ok); }
.badge.failed { background: rgba(248,113,113,.14); color: var(--err); }
.badge.cancelled { background: rgba(248,113,113,.1); color: var(--muted); }
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
td { padding: 9px 10px; border-bottom: 1px solid rgba(36,48,77,.6); vertical-align: middle; }
tr:hover td { background: rgba(99,102,241,.04); }
.mono { font-family: var(--mono); }
.muted { color: var(--muted); }
.err { color: var(--err); }
/* ── 결과 영역 ── */
.result-text {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
padding: 14px; font-size: 15px; line-height: 1.8; white-space: pre-wrap; word-break: break-word;
min-height: 70px; max-height: 300px; overflow: auto;
}
.seg-table td:nth-child(2), .seg-table td:nth-child(3) { font-family: var(--mono); font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.conf-low { color: var(--warn); }
/* ── 실시간 ── */
#rt-status { font-size: 12.5px; color: var(--muted); margin: 8px 0; min-height: 18px; }
#rt-partial { color: var(--muted); font-style: italic; min-height: 24px; font-size: 14px; }
#rt-final { font-size: 15px; line-height: 1.8; white-space: pre-wrap; word-break: break-word; }
#rt-wave { display: flex; align-items: center; gap: 3px; height: 34px; margin: 10px 0; }
#rt-wave i { width: 3px; background: var(--accent-2); border-radius: 2px; height: 6px; transition: height .12s ease; }
#rt-wave.live i { animation: wave 0.9s ease-in-out infinite; }
@keyframes wave { 0%,100% { height: 30%; } 50% { height: 95%; } }
/* ── 모달 / 토스트 ── */
.modal-backdrop { position: fixed; inset: 0; background: rgba(4,6,12,.7); backdrop-filter: blur(3px); display: none; align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 50; overflow: auto; }
.modal-backdrop.open { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; max-width: 760px; width: 100%; padding: 20px; }
.modal h3 { margin: 0 0 12px; }
.modal .close { float: right; background: none; border: none; color: var(--muted); font-size: 20px; cursor: pointer; }
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; }
.toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); color: var(--text); padding: 10px 14px; border-radius: 10px; font-size: 13px; box-shadow: 0 6px 24px rgba(0,0,0,.4); animation: slidein .2s ease; max-width: 380px; }
.toast.ok { border-left-color: var(--ok); }
.toast.err { border-left-color: var(--err); }
@keyframes slidein { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; } }
.raw-key {
background: #0d1320; border: 1px dashed var(--accent); border-radius: 10px;
padding: 12px; font-family: var(--mono); font-size: 12.5px; word-break: break-all; margin: 10px 0;
color: var(--accent-2);
}
footer { margin-top: 40px; text-align: center; color: var(--muted); font-size: 12px; }
@media (max-width: 640px) { .keybox input { width: 150px; } }
</style>
</head>
<body>
<header>
<div class="brand"><span class="dot"></span>luke_scribe <small>v0.1 · 대시보드</small></div>
<div class="spacer"></div>
<span id="server-pill"><span class="l"></span><span id="server-pill-txt">연결 확인 중…</span></span>
<span id="scope-badge" hidden></span>
<div class="keybox">
<input id="api-key" type="password" placeholder="API 키 (X-API-Key)" autocomplete="off">
<button class="btn mini" id="save-key">저장</button>
</div>
</header>
<div class="wrap">
<nav class="tabs">
<button data-tab="system" class="active">🖥️ 시스템</button>
<button data-tab="upload">🎙️ 전사</button>
<button data-tab="jobs">📋 작업</button>
<button data-tab="realtime">⚡ 실시간</button>
<button data-tab="keys">🔑 API 키</button>
</nav>
<!-- ── 시스템 ── -->
<section id="tab-system" class="tab active">
<div class="card">
<h3><span class="ico">🖥️</span>시스템 상태</h3>
<div class="grid" id="sys-grid">
<div class="stat"><div class="k">연결 상태</div><div class="v" id="s-status"></div></div>
<div class="stat"><div class="k">능력 등급</div><div class="v" id="s-tier"></div></div>
<div class="stat"><div class="k">GPU</div><div class="v" id="s-gpu"><small></small></div></div>
<div class="stat"><div class="k">VRAM</div><div class="v" id="s-vram"></div></div>
<div class="stat"><div class="k">컴퓨트 타입</div><div class="v" id="s-ct"></div></div>
<div class="stat"><div class="k">워커</div><div class="v" id="s-workers"></div></div>
<div class="stat"><div class="k">큐 깊이</div><div class="v" id="s-queue"></div></div>
<div class="stat"><div class="k">모델</div><div class="v" id="s-model"><small></small></div></div>
</div>
<p class="hint" id="sys-hint"></p>
</div>
<div class="card">
<h3><span class="ico">📊</span>장치 상세</h3>
<pre class="mono muted" id="sys-detail" style="font-size:12px; margin:0; white-space:pre-wrap;"></pre>
</div>
</section>
<!-- ── 전사 ── -->
<section id="tab-upload" class="tab">
<div class="card">
<h3><span class="ico">🎙️</span>파일 전사</h3>
<div class="drop" id="drop">
<div>🎧 파일을 끌어다 놓거나 클릭하여 선택</div>
<div class="hint" id="drop-name"></div>
</div>
<input type="file" id="file-input" hidden>
<div class="row">
<div>
<label class="f">언어</label>
<select id="opt-language">
<option value="ko" selected>ko (한국어)</option>
<option value="auto">auto (자동 감지)</option>
<option value="en">en (영어)</option>
<option value="ja">ja (일본어)</option>
</select>
</div>
<div>
<label class="f">모델</label>
<select id="opt-model">
<option value="large-v3-turbo" selected>large-v3-turbo (빠름)</option>
<option value="large-v3">large-v3 (정확)</option>
</select>
</div>
<div>
<label class="f">컴퓨트 타입</label>
<select id="opt-ct">
<option value="auto" selected>auto</option>
<option value="float16">float16</option>
<option value="int8_float16">int8_float16</option>
<option value="int8">int8</option>
</select>
</div>
<div>
<label class="f">장치</label>
<select id="opt-device">
<option value="auto" selected>auto</option>
<option value="cpu">cpu</option>
<option value="cuda">cuda</option>
</select>
</div>
</div>
<div class="row">
<div>
<label class="f">Hotwords (콤마 구분)</label>
<input type="text" id="opt-hotwords" placeholder="vLLM, Kubernetes">
</div>
<div>
<label class="f">Glossary (KEY=VALUE, 콤마 구분)</label>
<input type="text" id="opt-glossary" placeholder="BLM=vLLM">
</div>
</div>
<div class="chips">
<label><input type="checkbox" value="json" checked> JSON</label>
<label><input type="checkbox" value="txt"> TXT</label>
<label><input type="checkbox" value="srt"> SRT</label>
<label><input type="checkbox" value="vtt"> VTT</label>
</div>
<div style="margin-top:14px;">
<button class="btn" id="btn-upload">전사 시작</button>
<span class="hint" id="upload-hint"></span>
</div>
<div id="upload-progress-wrap" hidden>
<div class="progress lg"><i id="upload-progress-bar"></i></div>
<div class="hint" id="upload-progress-txt"></div>
</div>
</div>
<div class="card" id="result-card" hidden>
<h3><span class="ico">📄</span>전사 결과 <span id="result-meta" class="muted" style="font-size:12px; font-weight:500;"></span></h3>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;" id="result-downloads"></div>
<div class="result-text" id="result-text"></div>
<div class="hint" id="result-warnings"></div>
<h3 style="margin-top:18px;"><span class="ico">⏱️</span>세그먼트</h3>
<div style="max-height:300px; overflow:auto;">
<table class="seg-table">
<thead><tr><th>#</th><th>시작</th><th></th><th>텍스트</th><th>신뢰도</th></tr></thead>
<tbody id="result-segments"></tbody>
</table>
</div>
</div>
</section>
<!-- ── 작업 ── -->
<section id="tab-jobs" class="tab">
<div class="card">
<h3><span class="ico">📋</span>작업 히스토리 <span class="muted" style="font-weight:500; font-size:12px;">3초 자동 갱신</span></h3>
<div style="max-height:520px; overflow:auto;">
<table>
<thead><tr><th>상태</th><th>파일</th><th>job id</th><th>진행률</th><th>작업</th></tr></thead>
<tbody id="jobs-tbody"><tr><td colspan="5" class="muted"></td></tr></tbody>
</table>
</div>
</div>
</section>
<!-- ── 실시간 ── -->
<section id="tab-realtime" class="tab">
<div class="card">
<h3><span class="ico"></span>실시간 마이크 전사 (WebSocket)</h3>
<p class="hint" style="margin-top:0;">마이크 오디오를 16kHz PCM으로 보내 서버가 실시간 가설/확정 텍스트를 반환합니다. 첫 가설은 모델 로드로 지연될 수 있습니다.</p>
<button class="btn" id="rt-start">🎤 녹음 시작</button>
<button class="btn ghost" id="rt-stop" disabled>⏹ 정지</button>
<div id="rt-status"></div>
<div id="rt-wave"></div>
<div class="hint" id="rt-partial"></div>
<div id="rt-final"></div>
</div>
</section>
<!-- ── API 키 ── -->
<section id="tab-keys" class="tab">
<div class="card">
<h3><span class="ico">🔑</span>키 생성 (admin 스코프 필요)</h3>
<div class="chips">
<label><input type="checkbox" value="transcribe" checked> transcribe</label>
<label><input type="checkbox" value="admin"> admin</label>
</div>
<div style="margin-top:12px;"><button class="btn" id="btn-create-key">새 키 생성</button></div>
<div id="new-key-area" hidden>
<p class="hint" style="margin:12px 0 2px;">✅ 생성됨 — <b class="err">raw 키는 이번 한 번만 표시됩니다.</b> 복사해서 안전한 곳에 보관하세요.</p>
<div class="raw-key" id="new-key"></div>
<button class="btn mini" id="btn-copy-key">📋 복사</button>
</div>
</div>
<div class="card">
<h3><span class="ico">🗝️</span>키 목록</h3>
<table>
<thead><tr><th>key id</th><th>스코프</th></tr></thead>
<tbody id="keys-tbody"><tr><td colspan="2" class="muted"></td></tr></tbody>
</table>
</div>
</section>
<footer>privacy-first · 모든 처리는 로컬/자체 서버에서 수행됩니다</footer>
</div>
<div class="modal-backdrop" id="result-modal">
<div class="modal">
<button class="close" id="modal-close">×</button>
<h3 id="modal-title">결과</h3>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;" id="modal-downloads"></div>
<div class="result-text" id="modal-text"></div>
<div class="hint" id="modal-warnings"></div>
<h3 style="margin-top:16px;">세그먼트</h3>
<div style="max-height:260px; overflow:auto;">
<table class="seg-table">
<thead><tr><th>#</th><th>시작</th><th></th><th>텍스트</th><th>신뢰도</th></tr></thead>
<tbody id="modal-segments"></tbody>
</table>
</div>
</div>
</div>
<div id="toasts"></div>
<script>
"use strict";
/* ═══ 유틸 ═══ */
const $ = (s) => document.querySelector(s);
const LS_KEY = "luke_dash_key";
let apiKey = localStorage.getItem(LS_KEY) || "";
let scope = null; // null | "none" | "transcribe" | "admin"
let uploadFile = null;
let jobsTimer = null;
let rt = null; // 실시간 세션 객체
$("#api-key").value = apiKey;
function toast(msg, type = "") {
const el = document.createElement("div");
el.className = "toast " + type;
el.textContent = msg;
$("#toasts").appendChild(el);
setTimeout(() => el.remove(), 4200);
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtTime(sec) {
if (sec == null || isNaN(sec)) return "—";
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function fmtBytes(n) {
if (n == null) return "—";
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
function confidence(seg) {
// avg_logprob → 0~1 신뢰도 추정
if (seg.avg_logprob == null) return null;
return Math.max(0, Math.min(1, 1 + seg.avg_logprob));
}
/* ═══ API 헬퍼 ═══ */
async function api(path, opts = {}) {
const headers = Object.assign({}, opts.headers || {});
if (apiKey) headers["X-API-Key"] = apiKey;
const res = await fetch(path, Object.assign({}, opts, { headers }));
if (res.status === 401) {
scope = "none";
setScopeBadge();
throw new Error("API 키 인증 실패 — 키를 확인하세요");
}
return res;
}
async function apiJson(path, opts = {}) {
const res = await api(path, opts);
const ct = res.headers.get("content-type") || "";
if (!res.ok) {
let msg = res.status + " " + res.statusText;
try { const b = await res.json(); msg = b.detail || b.message || msg; } catch (e) {}
throw new Error(msg);
}
if (ct.includes("json")) return res.json();
return res.text();
}
/* ═══ 키 저장 / 스코프 ═══ */
$("#save-key").addEventListener("click", () => {
apiKey = $("#api-key").value.trim();
localStorage.setItem(LS_KEY, apiKey);
probeScope();
toast(apiKey ? "API 키 저장됨" : "API 키 제거됨", "ok");
});
$("#api-key").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#save-key").click(); });
function setScopeBadge() {
const b = $("#scope-badge");
if (!scope || scope === "none") { b.hidden = true; return; }
b.hidden = false;
b.textContent = scope === "admin" ? "admin" : "transcribe";
b.className = scope === "admin" ? "admin" : "";
}
async function probeScope() {
if (!apiKey) { scope = "none"; setScopeBadge(); return; }
try {
const res = await api("/v1/system");
if (res.status === 200) { scope = "admin"; }
else if (res.status === 403) { scope = "transcribe"; }
else { scope = "none"; }
} catch (e) { scope = "none"; }
setScopeBadge();
}
/* ═══ 서버 상태 ═══ */
async function refreshHealth() {
const pill = $("#server-pill"), txt = $("#server-pill-txt");
try {
const res = await fetch("/health");
if (res.ok) {
const b = await res.json();
pill.className = "ok";
txt.textContent = "서버 정상 · 큐 " + b.queue_depth + (b.model_ready ? " · 모델 준비됨" : "");
} else { pill.className = "err"; txt.textContent = "서버 응답 이상 (" + res.status + ")"; }
} catch (e) { pill.className = "err"; txt.textContent = "서버 오프라인"; }
}
/* ═══ 탭 ═══ */
document.querySelectorAll("nav.tabs button").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll("nav.tabs button").forEach((b) => b.classList.remove("active"));
document.querySelectorAll("section.tab").forEach((s) => s.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.tab;
$("#tab-" + tab).classList.add("active");
if (tab === "system") loadSystem();
if (tab === "jobs") { loadJobs(); startJobsTimer(); }
if (tab === "keys") loadKeys();
});
});
/* ═══ 시스템 ═══ */
async function loadSystem() {
$("#sys-hint").textContent = "";
try {
const res = await api("/v1/system");
if (res.status === 403) { $("#sys-hint").textContent = "⚠ 시스템 정보는 admin 스코프 키가 필요합니다."; return; }
if (!res.ok) { $("#sys-hint").textContent = "시스템 정보 로드 실패: " + res.status; return; }
const b = await res.json();
const d = b.device || {};
$("#s-status").textContent = "정상";
$("#s-tier").textContent = d.capability_tier || b.capability_tier || "—";
$("#s-gpu").innerHTML = d.device_name ? esc(d.device_name) : "<small>—</small>";
$("#s-vram").innerHTML = d.vram_total_mb ? (d.vram_total_mb / 1024).toFixed(0) + " GB" : "—";
$("#s-ct").textContent = b.compute_type_used || "—";
$("#s-workers").textContent = b.workers ?? "—";
$("#s-queue").textContent = b.queue_depth ?? "—";
$("#s-model").innerHTML = (b.models || []).map(esc).join("<br>");
$("#sys-detail").textContent = JSON.stringify(b, null, 2);
} catch (e) {
$("#sys-hint").textContent = "⚠ " + e.message;
}
}
/* ═══ 업로드 / 전사 ═══ */
const drop = $("#drop"), fileInput = $("#file-input");
drop.addEventListener("click", () => fileInput.click());
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
drop.addEventListener("drop", (e) => {
e.preventDefault(); drop.classList.remove("over");
if (e.dataTransfer.files.length) setFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener("change", () => { if (fileInput.files.length) setFile(fileInput.files[0]); });
function setFile(f) {
uploadFile = f;
$("#drop-name").textContent = "선택: " + f.name + " (" + fmtBytes(f.size) + ")";
}
$("#btn-upload").addEventListener("click", async () => {
if (!uploadFile) { toast("파일을 먼저 선택하세요", "err"); return; }
if (!apiKey) { toast("API 키를 먼저 저장하세요 (상단)", "err"); return; }
const formats = Array.from(document.querySelectorAll("#tab-upload .chips input:checked")).map((c) => c.value);
const glossary = {};
($("#opt-glossary").value || "").split(",").map((s) => s.trim()).filter(Boolean).forEach((kv) => {
const i = kv.indexOf("=");
if (i > 0) glossary[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
});
const options = {
language: $("#opt-language").value === "auto" ? null : $("#opt-language").value,
model: $("#opt-model").value,
compute_type: $("#opt-ct").value,
device: $("#opt-device").value,
hotwords: ($("#opt-hotwords").value || "").split(",").map((s) => s.trim()).filter(Boolean),
formats,
};
if (Object.keys(glossary).length) options.glossary = glossary;
const fd = new FormData();
fd.append("file", uploadFile);
fd.append("options", JSON.stringify(options));
const btn = $("#btn-upload");
btn.disabled = true;
$("#upload-hint").textContent = "업로드 중…";
try {
const job = await apiJson("/v1/jobs", { method: "POST", body: fd });
$("#upload-hint").textContent = "job " + job.job_id + " — 처리 대기";
$("#upload-progress-wrap").hidden = false;
await pollJob(job.job_id);
} catch (e) {
toast("업로드 실패: " + e.message, "err");
$("#upload-hint").textContent = "⚠ " + e.message;
} finally {
btn.disabled = false;
}
});
async function pollJob(jobId) {
for (let i = 0; i < 600; i++) {
await new Promise((r) => setTimeout(r, 1500));
let st;
try { st = await apiJson("/v1/jobs/" + jobId); }
catch (e) { continue; }
const pct = st.progress != null ? Math.round(st.progress * 100) : (st.status === "processing" ? 5 : 0);
$("#upload-progress-bar").style.width = pct + "%";
$("#upload-progress-txt").textContent = "상태: " + st.status + (st.progress != null ? " · " + pct + "%" : "");
if (st.status === "completed") {
$("#upload-progress-txt").textContent = "완료 ✅";
await loadResult(jobId);
toast("전사 완료", "ok");
return;
}
if (st.status === "failed" || st.status === "cancelled") {
$("#upload-progress-txt").textContent = "상태: " + st.status + (st.error && st.error.message ? " — " + st.error.message : "");
toast("전사 " + st.status, "err");
return;
}
}
$("#upload-progress-txt").textContent = "폴링 시간 초과 — 작업 탭에서 확인하세요.";
}
async function loadResult(jobId, into = "main") {
const data = await apiJson("/v1/jobs/" + jobId + "/result?format=json");
const parsed = typeof data === "string" ? JSON.parse(data) : data;
const card = into === "modal" ? "#modal-" : "#result-";
renderResult(parsed, card, into, jobId);
}
function renderResult(body, card, into, jobId) {
const textEl = $(card + "text");
const segEl = $(card + "segments");
const warnEl = $(card + "warnings");
const dlEl = into === "modal" ? $("#modal-downloads") : $("#result-downloads");
textEl.textContent = body.text || "(빈 결과)";
warnEl.textContent = (body.warnings || []).join("\n");
segEl.innerHTML = (body.segments || []).map((s) => {
const conf = confidence(s);
const confTxt = conf == null ? "—" : Math.round(conf * 100) + "%";
return "<tr><td>" + s.index + '</td><td>' + fmtTime(s.start) + "</td><td>" + fmtTime(s.end) +
"</td><td>" + esc(s.text) + "</td><td class=\"" + (conf != null && conf < 0.4 ? "conf-low" : "") + "\">" + confTxt + "</td></tr>";
}).join("");
const meta = $(card + "meta");
if (body.execution && meta) {
meta.textContent = (body.execution.model || "") + " · " + (body.execution.device || "") + " · " +
(body.execution.compute_type || "") + " · RTF " + (body.timings && body.timings.rtf != null ? body.timings.rtf : "—");
}
dlEl.innerHTML = "";
["json", "txt", "srt", "vtt"].forEach((fmt) => {
const b = document.createElement("button");
b.className = "btn mini ghost";
b.textContent = "⬇ " + fmt.toUpperCase();
b.onclick = () => downloadResult(jobId, fmt);
dlEl.appendChild(b);
});
if (into === "main") $("#result-card").hidden = false;
}
async function downloadResult(jobId, fmt) {
try {
const res = await api("/v1/jobs/" + jobId + "/result?format=" + fmt);
const text = await res.text();
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = jobId.slice(0, 8) + "." + fmt;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) { toast("다운로드 실패: " + e.message, "err"); }
}
/* ═══ 작업 히스토리 ═══ */
async function loadJobs() {
if (!apiKey) { $("#jobs-tbody").innerHTML = '<tr><td colspan="5" class="muted">API 키를 저장하면 작업이 표시됩니다.</td></tr>'; return; }
let jobs = [];
try { jobs = await apiJson("/v1/jobs"); }
catch (e) {
if (!jobsTimer) startJobsTimer();
$("#jobs-tbody").innerHTML = '<tr><td colspan="5" class="err">' + esc(e.message) + "</td></tr>";
return;
}
if (!jobs.length) { $("#jobs-tbody").innerHTML = '<tr><td colspan="5" class="muted">작업 없음</td></tr>'; return; }
$("#jobs-tbody").innerHTML = jobs.map((j) => {
const pct = j.progress != null ? Math.round(j.progress * 100) : (j.status === "processing" ? 5 : 0);
return "<tr>" +
'<td><span class="badge ' + j.status + '">' + j.status + "</span></td>" +
"<td>" + esc(j.error && j.error.message ? "⚠ " + j.error.message : (j.source_name || j.job_id)) + "</td>" +
'<td class="mono">' + j.job_id.slice(0, 8) + "</td>" +
'<td style="min-width:110px;"><div class="progress"><i style="width:' + pct + '%"></i></div></td>' +
"<td>" +
'<button class="btn mini ghost" data-act="view" data-id="' + j.job_id + '">결과</button> ' +
(j.status === "queued" || j.status === "processing"
? '<button class="btn mini ghost" data-act="cancel" data-id="' + j.job_id + '">취소</button>'
: "") +
"</td></tr>";
}).join("");
$("#jobs-tbody").querySelectorAll("button[data-act]").forEach((b) => {
b.addEventListener("click", async () => {
if (b.dataset.act === "view") {
if (!jobs.find((j) => j.job_id === b.dataset.id && j.result_available)) {
toast("결과가 아직 준비되지 않았습니다");
return;
}
openResultModal(b.dataset.id);
} else if (b.dataset.act === "cancel") {
try { await api("/v1/jobs/" + b.dataset.id, { method: "DELETE" }); toast("취소 요청됨", "ok"); }
catch (e) { toast("취소 실패: " + e.message, "err"); }
}
});
});
}
function startJobsTimer() {
if (jobsTimer) return;
jobsTimer = setInterval(() => { if ($("#tab-jobs").classList.contains("active")) loadJobs(); }, 3000);
}
/* ── 결과 모달 ── */
async function openResultModal(jobId) {
$("#modal-title").textContent = "결과 — " + jobId.slice(0, 8);
$("#result-modal").classList.add("open");
try {
const data = await apiJson("/v1/jobs/" + jobId + "/result?format=json");
const parsed = typeof data === "string" ? JSON.parse(data) : data;
renderResult(parsed, "#modal-", "modal", jobId);
} catch (e) {
$("#modal-text").textContent = "결과 로드 실패: " + e.message;
}
}
$("#modal-close").addEventListener("click", () => $("#result-modal").classList.remove("open"));
$("#result-modal").addEventListener("click", (e) => { if (e.target.id === "result-modal") $("#result-modal").classList.remove("open"); });
/* ═══ 실시간 ═══ */
function initWave() {
const wave = $("#rt-wave");
wave.innerHTML = "";
for (let i = 0; i < 28; i++) { const bar = document.createElement("i"); wave.appendChild(bar); }
}
function waveLevel(v) {
const bars = $("#rt-wave").querySelectorAll("i");
bars.forEach((b, i) => { b.style.height = Math.min(100, v * 140 + (i % 3) * 6) + "%"; });
}
function downsample(buf, from, to) {
if (from === to) return buf;
const ratio = from / to;
const out = new Float32Array(Math.floor(buf.length / ratio));
for (let i = 0; i < out.length; i++) out[i] = buf[Math.floor(i * ratio)];
return out;
}
function toPCM16(f32) {
const out = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) out[i] = Math.max(-1, Math.min(1, f32[i])) * 32767;
return out.buffer;
}
$("#rt-start").addEventListener("click", async () => {
if (!apiKey) { toast("API 키를 먼저 저장하세요 (상단)", "err"); return; }
if (rt && rt.ws) { toast("이미 녹음 중"); return; }
initWave();
$("#rt-final").textContent = "";
$("#rt-partial").textContent = "";
$("#rt-start").disabled = true;
$("#rt-stop").disabled = false;
$("#rt-wave").classList.add("live");
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(proto + "//" + location.host + "/v1/stream");
rt = { ws, ctx: null, stream: null, stopped: false };
ws.onopen = () => {
ws.send(JSON.stringify({ type: "init", api_key: apiKey, audio: { sample_rate: 16000 } }));
setRtStatus("연결됨 — 말씀해 주세요 🎙️", "");
};
ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch (err) { return; }
if (m.type === "status") {
if (m.status === "ready") setRtStatus("세션 준비됨 — 녹음 중…", "ok");
else if (m.status === "error") setRtStatus("서버 오류: " + (m.message || ""), "err");
else if (m.status === "idle_timeout") setRtStatus("유휴 타임아웃", "");
} else if (m.type === "partial") {
$("#rt-partial").textContent = "⋯ " + (m.text || "");
} else if (m.type === "final") {
const seg = m.segment || {};
$("#rt-partial").textContent = "";
$("#rt-final").textContent += (seg.text || "") + " ";
}
};
ws.onclose = () => {
$("#rt-wave").classList.remove("live");
if (rt && !rt.stopped) {
setRtStatus("연결 종료", "err");
// 예기치 않은 종료 시 마이크/오디오 컨텍스트 정리
if (rt.stream) rt.stream.getTracks().forEach((t) => t.stop());
if (rt.ctx) rt.ctx.close().catch(() => {});
$("#rt-start").disabled = false;
$("#rt-stop").disabled = true;
}
};
ws.onerror = () => setRtStatus("WebSocket 오류", "err");
// 오디오 캡처 시작 (ws.open 후 init 전송 — 사용자 제스처 컨텍스트 유지)
try {
const ms = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
rt.stream = ms;
const ctx = new AudioContext();
rt.ctx = ctx;
const src = ctx.createMediaStreamSource(ms);
const proc = ctx.createScriptProcessor(4096, 1, 1);
proc.onaudioprocess = (e) => {
if (rt.stopped) return;
const ch = e.inputBuffer.getChannelData(0);
let amp = 0;
for (let i = 0; i < ch.length; i += 32) amp = Math.max(amp, Math.abs(ch[i]));
waveLevel(amp);
const d = downsample(ch, ctx.sampleRate, 16000);
if (ws.readyState === WebSocket.OPEN) ws.send(toPCM16(d));
};
src.connect(proc);
// proc.connect(ctx.destination) 의도적으로 생략 — 마이크 오디오를 스피커로
// 재생하면 에코 피드백이 생긴다 (ScriptProcessor는 연결 없이도 동작).
} catch (e) {
setRtStatus("오디오 캡처 실패: " + e.message, "err");
stopRt();
}
});
function setRtStatus(txt, cls) {
const el = $("#rt-status");
el.textContent = txt;
el.className = cls === "err" ? "err" : (cls === "ok" ? "ok" : "");
if (cls === "ok") el.style.color = "var(--ok)";
else if (cls === "err") el.style.color = "var(--err)";
else el.style.color = "var(--muted)";
}
function stopRt() {
if (!rt) return;
rt.stopped = true;
if (rt.ctx) rt.ctx.close().catch(() => {});
if (rt.stream) rt.stream.getTracks().forEach((t) => t.stop());
if (rt.ws && rt.ws.readyState <= WebSocket.OPEN) rt.ws.close();
rt = null;
$("#rt-start").disabled = false;
$("#rt-stop").disabled = true;
$("#rt-wave").classList.remove("live");
waveLevel(0);
}
$("#rt-stop").addEventListener("click", () => { stopRt(); setRtStatus("녹음 중지됨", ""); });
/* ═══ API 키 관리 ═══ */
async function loadKeys() {
const tbody = $("#keys-tbody");
try {
const res = await api("/v1/keys");
if (res.status === 403) { tbody.innerHTML = '<tr><td colspan="2" class="err">admin 스코프 키가 필요합니다.</td></tr>'; return; }
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="2" class="err">' + esc(res.status) + "</td></tr>"; return; }
const b = await res.json();
tbody.innerHTML = (b.keys || []).map((k) =>
'<tr><td class="mono">' + esc(k.id) + "</td><td>" + (k.scopes || []).map(esc).join(", ") + "</td></tr>"
).join("") || '<tr><td colspan="2" class="muted">키 없음</td></tr>';
} catch (e) {
tbody.innerHTML = '<tr><td colspan="2" class="err">' + esc(e.message) + "</td></tr>";
}
}
$("#btn-create-key").addEventListener("click", async () => {
const scopes = Array.from(document.querySelectorAll("#tab-keys .chips input:checked")).map((c) => c.value);
if (!scopes.length) { toast("스코프를 하나 이상 선택하세요", "err"); return; }
try {
const res = await api("/v1/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes }) });
if (res.status === 403) { toast("admin 스코프 키가 필요합니다", "err"); return; }
if (!res.ok) { toast("생성 실패: " + res.status, "err"); return; }
const b = await res.json();
$("#new-key").textContent = b.key;
$("#new-key-area").hidden = false;
toast("키 생성됨: " + b.key_id, "ok");
loadKeys();
} catch (e) { toast(e.message, "err"); }
});
$("#btn-copy-key").addEventListener("click", () => {
const k = $("#new-key").textContent;
if (navigator.clipboard) navigator.clipboard.writeText(k).then(() => toast("복사됨", "ok"));
else { const ta = document.createElement("textarea"); ta.value = k; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); toast("복사됨", "ok"); }
});
/* ═══ 초기화 ═══ */
initWave();
refreshHealth();
setInterval(refreshHealth, 10000);
if (apiKey) probeScope();
</script>
</body>
</html>
+1 -4
View File
@@ -1,4 +1 @@
"""오디오/영상 입력 — ingest(probe·상한), VAD (스펙 §4-4)."""
from .ingest import MediaInfo, probe_media
__all__ = ["MediaInfo", "probe_media"]
"""오디오 인제스트(ffmpeg)와 VAD."""
+240 -26
View File
@@ -1,41 +1,255 @@
"""미디어 입력 — duration/size probe + 상한 점검 (스펙 §4-4, AC-7).
"""AudioIngestor — 입력 검증 + ffmpeg 16kHz mono 정규화 (스트리밍).
상한 초과는 호출측이 413으로 매핑(P2). 실제 디코딩은 엔진(faster-whisper/PyAV)이 수행.
계약 (plan §3.7b/§6.1 + design doc §3):
- 확장자가 아니라 **ffprobe**로 형식 검증.
- 60분/1GB(설계문서 v0.1) 또는 4h/2GB(플랫폼, config) 상한 — 초과 시
``unsupported_input_envelope``.
- ffmpeg는 전체 배열을 메모리에 올리지 않고 **파일로 스트리밍**.
- 모든 종료 경로에서 임시 파일 정리 (``finally``), 원본은 읽기 전용.
- 취소/오류 시 ffmpeg 프로세스 그룹 kill + reap (Eng 리뷰 P1).
- ffprobe/ffmpeg timeout: 기본 60s, CLI 옵션으로만 상향.
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
from dataclasses import dataclass
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from ..config import Settings
from ..errors import AudioProbeFailed, InvalidInput, UnsupportedInputEnvelope
from ..results.models import NormalizedAudio
@dataclass
class MediaInfo:
path: str
duration_s: float
class ProbeResult:
duration_sec: float | None
codec: str | None
size_bytes: int
has_audio: bool = True
def probe_media(path: str) -> MediaInfo:
if not os.path.exists(path):
raise FileNotFoundError(path)
return MediaInfo(path=path, duration_s=_ffprobe_duration(path), size_bytes=os.path.getsize(path))
@dataclass
class IngestResult:
normalized_path: str
normalized: NormalizedAudio
probe: ProbeResult
temp_dir: str
cleanup: callable = field(repr=False)
def close(self) -> None:
self.cleanup()
def _ffprobe_duration(path: str) -> float:
ffprobe = shutil.which("ffprobe")
if not ffprobe:
return 0.0
try:
out = subprocess.run(
[ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "json", path],
capture_output=True,
text=True,
timeout=30,
check=True,
).stdout
return float(json.loads(out).get("format", {}).get("duration") or 0.0)
except Exception:
return 0.0
class AudioIngestor:
def __init__(
self,
settings: Settings | None = None,
*,
ffprobe_bin: str = "ffprobe",
ffmpeg_bin: str = "ffmpeg",
probe_timeout: int = 60,
) -> None:
self.settings = settings or Settings()
self._ffprobe = ffprobe_bin
self._ffmpeg = ffmpeg_bin
self._probe_timeout = probe_timeout
def probe(self, source: Path) -> ProbeResult:
if not source.exists():
raise InvalidInput(f"파일이 존재하지 않습니다: {source}")
if source.stat().st_size > self.settings.max_upload_bytes:
raise UnsupportedInputEnvelope(
f"파일 크기 {source.stat().st_size} 바이트가 상한 "
f"{self.settings.max_upload_bytes} 바이트를 초과합니다"
)
cmd = [
self._ffprobe,
"-v",
"error",
"-show_entries",
"format=duration:stream=codec_type,codec_name",
"-of",
"json",
str(source),
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=self._probe_timeout)
except FileNotFoundError as exc:
raise AudioProbeFailed(
f"ffprobe를 찾을 수 없습니다 ({self._ffprobe}). ffmpeg를 설치하세요: "
"apt install ffmpeg / brew install ffmpeg"
) from exc
except subprocess.TimeoutExpired as exc:
raise AudioProbeFailed("ffprobe timeout (60s)") from exc
if proc.returncode != 0:
raise AudioProbeFailed(f"입력 오디오를 해석할 수 없습니다. {proc.stderr.strip()[:300]}")
import json
try:
data = json.loads(proc.stdout or "{}")
except Exception as exc:
raise AudioProbeFailed(f"ffprobe 출력 파싱 실패: {exc}") from exc
streams = data.get("streams", [])
audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
if not audio_streams:
raise AudioProbeFailed("입력에 오디오 스트림이 없습니다")
duration = None
fmt = data.get("format") or {}
if fmt.get("duration"):
try:
duration = float(fmt["duration"])
except (TypeError, ValueError):
duration = None
if duration is not None and duration > self.settings.max_duration_sec:
raise UnsupportedInputEnvelope(
f"오디오 길이 {duration:.0f}초가 상한 {self.settings.max_duration_sec}초를 초과합니다"
)
return ProbeResult(
duration_sec=duration,
codec=audio_streams[0].get("codec_name"),
size_bytes=source.stat().st_size,
)
def ingest(self, source: Path, *, should_cancel: callable | None = None) -> IngestResult:
"""검증 + ffmpeg로 16kHz mono PCM s16 WAV 정규화."""
if should_cancel and should_cancel():
from ..errors import CancelledError
raise CancelledError("취소됨")
probe = self.probe(source)
temp_dir = tempfile.mkdtemp(prefix="luke-scribe-ingest-")
out_path = Path(temp_dir) / "normalized.wav"
def cleanup() -> None:
shutil.rmtree(temp_dir, ignore_errors=True)
try:
if should_cancel and should_cancel():
from ..errors import CancelledError
raise CancelledError("취소됨")
cmd = [
self._ffmpeg,
"-v",
"error",
"-y",
"-i",
str(source),
"-ac",
"1",
"-ar",
"16000",
"-c:a",
"pcm_s16le",
"-f",
"wav",
str(out_path),
]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True, # 프로세스 그룹 (kill/reap 용이)
)
# stdout/stderr 병렬 drain (ffmpeg stderr 블로킹 방지)
assert proc.stdout is not None and proc.stderr is not None
out_thread = self._drain(proc.stdout)
err_data = self._read_all(proc.stderr)
while True:
try:
rc = proc.wait(timeout=0.5)
break
except subprocess.TimeoutExpired:
if should_cancel and should_cancel():
self._kill_group(proc)
from ..errors import CancelledError
raise CancelledError("인제스트 중 취소") from None
out_thread.join(timeout=5)
if rc != 0:
raise AudioProbeFailed(
f"ffmpeg 정규화 실패 (exit {rc}): {err_data.decode(errors='replace')[:300]}"
)
if not out_path.exists() or out_path.stat().st_size == 0:
raise AudioProbeFailed("ffmpeg가 출력 WAV를 생성하지 못했습니다")
duration = probe.duration_sec
if duration is None:
# ffprobe가 몰랐던 경우: 파생 파일로 재측정
duration = self._duration_of(out_path) or 0.0
return IngestResult(
normalized_path=str(out_path),
normalized=NormalizedAudio(
duration_sec=max(duration, 0.001),
audio_format="pcm_s16le",
sample_rate=16000,
channels=1,
),
probe=probe,
temp_dir=temp_dir,
cleanup=cleanup,
)
except BaseException:
cleanup()
raise
def _duration_of(self, wav: Path) -> float | None:
"""ffprobe로 파생 WAV 길이 재측정."""
try:
out = subprocess.run(
[
self._ffprobe,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"json",
str(wav),
],
capture_output=True,
text=True,
timeout=10,
)
import json
d = json.loads(out.stdout or "{}").get("format", {}).get("duration")
return float(d) if d else None
except Exception:
return None
@staticmethod
def _drain(stream) -> os._wrap_close:
import threading
def _read() -> None:
while stream.read(65536):
pass
t = threading.Thread(target=_read, daemon=True)
t.start()
return t # type: ignore[return-value]
@staticmethod
def _read_all(stream) -> bytes:
return stream.read()
@staticmethod
def _kill_group(proc: subprocess.Popen) -> None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
try:
proc.kill()
except Exception:
pass
+52
View File
@@ -0,0 +1,52 @@
"""VAD — 음성 구간 검출.
Eng 리뷰 P19 반영: **레인당 VAD 소유자 1명** — 배치 레인은 faster-whisper
내장 VAD 필터를 그대로 사용하고(중복 VAD 금지), 실시간 레인은 청크 단위
상태를 유지하는 별도 래퍼를 둔다. v0.1에서는 실시간 레인이 모델 decode와
동일 프로세스(EngineOwner)에서 실행되므로 faster-whisper의 VAD 매개변수를
공유한다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class VadConfig:
min_silence_duration_ms: int = 500
speech_pad_ms: int = 200
threshold: float = 0.5
@dataclass
class StreamingVadState:
"""실시간 레인용 상태형 VAD 래퍼.
``is_speech(chunk_energy_ratio)``를 호출하면 (발화시작, 발화종료) 이벤트를
누적한다. v0.1에서는 실제 Silero 추론 대신 엔진의 VAD에 위임하되
청크 경계 상태(무음 누적)만 여기서 추적한다.
"""
config: VadConfig = field(default_factory=VadConfig)
_silence_ms: int = 0
_in_speech: bool = False
def feed(self, chunk_duration_ms: float, is_speech: bool) -> list[str]:
events: list[str] = []
if is_speech:
if not self._in_speech:
self._in_speech = True
events.append("speech_start")
self._silence_ms = 0
else:
if self._in_speech:
self._silence_ms += chunk_duration_ms
if self._silence_ms >= self.config.min_silence_duration_ms:
self._in_speech = False
events.append("speech_end")
return events
def reset(self) -> None:
self._silence_ms = 0
self._in_speech = False
+1
View File
@@ -0,0 +1 @@
"""벤치마크 — WER/K-CER/entity 보존율/RTF/VRAM-RSS, 모델 선택 게이트."""
+137
View File
@@ -0,0 +1,137 @@
"""벤치마크 지표 계산 — WER, K-CER, entity 보존율.
계약 (design doc §10):
- K-CER 정규화 순서: NFKC → 영문 lowercase(단, entity 평가는 원문 유지) →
문장부호 제거 → whitespace 제거 → 숫자 통일 mapping → Unicode code point
단위 Levenshtein. K-CER는 corpus-level micro average.
- WER: 원래 whitespace token 기준.
- entity 보존: reference-hypothesis 문자 alignment로 reference entity span을
hypothesis에 투영, 앞뒤 8 code point window 안에 canonical이 정확히 존재하면
성공. 하나의 hypothesis occurrence는 한 annotation에만 매칭.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
def _strip_punct(text: str) -> str:
return re.sub(r"[\s\W_]+", "", text, flags=re.UNICODE)
def normalize_kcer(text: str) -> str:
"""K-CER 정규화 — NFKC → lowercase → 문장부호/whitespace 제거."""
text = unicodedata.normalize("NFKC", text)
text = text.lower()
return _strip_punct(text)
def _levenshtein(a: str, b: str) -> int:
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
@dataclass
class ClipMetrics:
wer: float
k_cer: float
entities_preserved: int
entities_total: int
chars_ref: int
errors: int
def word_wer(reference: str, hypothesis: str) -> float:
"""Whitespace token WER (0.0~1.0)."""
ref = reference.strip().split()
hyp = hypothesis.strip().split()
if not ref:
return 0.0 if not hyp else 1.0
return _levenshtein(ref, hyp) / len(ref)
def character_kcer(reference: str, hypothesis: str) -> float:
"""K-CER (0.0~1.0) — 정규화 후 코드포인트 Levenshtein / ref 길이."""
ref = normalize_kcer(reference)
hyp = normalize_kcer(hypothesis)
if not ref:
return 0.0 if not hyp else 1.0
return _levenshtein(ref, hyp) / len(ref)
def entity_retention(
reference: str,
hypothesis: str,
entities: list[dict],
window: int = 8,
) -> tuple[int, int]:
"""(보존 성공, 전체) — 설계문서 §10 alignment 기반.
entity: {canonical, surface, start_char, end_char} (Unicode code point index).
"""
if not entities:
return 0, 0
preserved = 0
ref_cp = list(reference)
hyp_cp = list(hypothesis)
used_hyp_spans: list[tuple[int, int]] = []
for ent in entities:
canonical = ent.get("canonical", "")
if not canonical:
continue
start = ent.get("start_char", 0)
end = ent.get("end_char", len(canonical))
ref_span = "".join(ref_cp[start:end])
if not ref_span:
continue
# hypothesis에서 canonical을 앞뒤 window 안에서 찾기 (span 기반 매칭)
found = False
hyp_text = "".join(hyp_cp)
idx = 0
while True:
pos = hyp_text.find(canonical, idx)
if pos == -1:
break
lo = max(0, pos - window)
hi = min(len(hyp_text), pos + len(canonical) + window)
# 중복 매칭 방지
if not any(lo < s < hi for s, e in used_hyp_spans):
# 시작 근처 window 안
if abs(pos - start) <= window:
found = True
used_hyp_spans.append((pos, pos + len(canonical)))
break
idx = pos + 1
if found:
preserved += 1
return preserved, len([e for e in entities if e.get("canonical")])
def clip_metrics(reference: str, hypothesis: str, entities: list[dict]) -> ClipMetrics:
wer = word_wer(reference, hypothesis)
k_cer = character_kcer(reference, hypothesis)
preserved, total = entity_retention(reference, hypothesis, entities)
ref_cp = len(normalize_kcer(reference))
errors = int(k_cer * ref_cp) if ref_cp else 0
return ClipMetrics(
wer=wer,
k_cer=k_cer,
entities_preserved=preserved,
entities_total=total,
chars_ref=ref_cp,
errors=errors,
)
+319
View File
@@ -0,0 +1,319 @@
"""벤치마크 실행기 — manifest → 모델별 지표 → 모델 선택 게이트 → report.
계약 (design doc §10):
- 고정 decode 설정 (beam_size 5, temperature 0, VAD on).
- 모델·hotword variant별 지표 + RTF min/median/max + peak RSS/VRAM.
- 절대 기준: entity 보존 ≥95%, K-CER ≤15%, 실패율 0%.
- 게이트: turbo가 기준 통과 + K-CER 15% 이내 열등 + RTF ≥10% 빠르면
turbo 기본; 아니면 large-v3. 둘 다 실패 → no_acceptable_model.
- 단일 모델 run은 decision artifact를 생성/변경하지 않는다.
"""
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from ..config import Settings
from ..devices.vram_probe import peak_process_rss_mb, runtime_env_snapshot
from .metrics import clip_metrics
ABSOLUTE_ENTITY_RETENTION = 0.95
ABSOLUTE_K_CER = 0.15
ABSOLUTE_FAILURE_RATE = 0.0
TURBO_RELATIVE_K_CER_SLACK = 0.15 # turbo가 large-v3보다 15% 넘게 나쁘면 안 됨
TURBO_RTF_SPEEDUP = 0.10 # turbo median RTF가 10% 이상 빨라야 함
REPORT_VERSION = "1.0"
DECISION_VERSION = "1.0"
def load_manifest(path: Path) -> dict:
import yaml # type: ignore[import-not-found]
if path.suffix in (".yaml", ".yml"):
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return json.loads(path.read_text(encoding="utf-8"))
def run_benchmark(
*,
manifest: Path,
models: list[str],
device: str,
compute_type: str | None,
repeats: int,
output: Path,
hotwords: list[str],
decision: Path | None,
settings: Settings,
) -> dict:
data = load_manifest(manifest)
clips = data.get("clips", [])
if not clips:
from ..errors import InvalidInput
raise InvalidInput("manifest에 clips가 없습니다")
# manifest 최상위 glossary: {오인식 패턴: 표준 표기} — 벤치도 후처리를 적용한다
glossary = data.get("glossary") or None
report: dict = {
"report_version": REPORT_VERSION,
"dataset_version": data.get("dataset_version", "1.0"),
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"environment": runtime_env_snapshot(),
"run_config": {
"device": device,
"compute_type": compute_type,
"repeats": repeats,
"models": models,
"hotword_set": hotwords,
"beam_size": 5,
"temperature": 0.0,
"vad_filter": True,
"post_mode": settings.post_mode if settings.post_enabled else "none",
"glossary": glossary or {},
},
"models": [],
"decision": {"status": "pending", "default_model": None, "reasons": []},
}
from ..engine.owner import EngineOwner
owner = EngineOwner.get(settings)
model_results = {}
for model in models:
agg = _run_model(
owner, clips, model, device, compute_type, repeats, hotwords, report, settings, glossary
)
model_results[model] = agg
report["models"].append(agg["summary"])
# 단일 모델 run: report만 생성, decision artifact 금지
if len(models) < 2:
report["decision"] = {
"status": "no_decision",
"default_model": None,
"reasons": ["단일 모델 run은 decision artifact를 생성하지 않습니다"],
}
_write_report(output, report)
return report
report["decision"] = _decide(model_results)
_write_report(output, report)
if decision is not None and report["decision"]["status"] != "no_decision":
_write_decision(decision, report)
return report
def _run_model(
owner, clips, model, device, compute_type, repeats, hotwords, report, settings, glossary
) -> dict:
from ..engine.base import TranscriptionOptions
options = TranscriptionOptions(
model=model,
language="ko",
device=device,
compute_type=compute_type,
hotwords=hotwords,
)
# warm-up (비평가 클립 1회)
clip0 = clips[0]
_transcribe_clip(owner, options, clip0, settings, glossary)
rtf_samples: list[float] = []
rss_samples: list[float] = []
aggregate = {
"wer": [],
"k_cer": [],
"entities_preserved": 0,
"entities_total": 0,
"failures": 0,
}
all_chars = 0
all_errors = 0
failures = 0
for clip in clips:
ref_path = clip.get("reference_path")
ref_text = ""
if ref_path:
p = Path(ref_path)
if p.exists():
ref_text = p.read_text(encoding="utf-8").strip()
entities = clip.get("entities", [])
clip_rtfs: list[float] = []
clip_success = False
for _ in range(repeats):
try:
result = _transcribe_clip(owner, options, clip, settings, glossary)
m = clip_metrics(ref_text, result["text"], entities)
clip_rtfs.append(result["rtf"])
rss_samples.append(peak_process_rss_mb())
if result["rtf"] is not None:
rtf_samples.append(result["rtf"])
aggregate["wer"].append(m.wer)
aggregate["k_cer"].append(m.k_cer)
aggregate["entities_preserved"] += m.entities_preserved
aggregate["entities_total"] += m.entities_total
all_chars += m.chars_ref
all_errors += m.errors
clip_success = True
except Exception:
failures += 1
aggregate["failures"] += 0 if clip_success else 1
n = max(1, len(aggregate["wer"]))
corpus_cer = all_errors / all_chars if all_chars else 0.0
failure_rate = aggregate["failures"] / len(clips)
entity_rate = (
aggregate["entities_preserved"] / aggregate["entities_total"]
if aggregate["entities_total"]
else 1.0
)
sorted_rtf = sorted(rtf_samples)
summary = {
"model": model,
"hotword_set": report["run_config"]["hotword_set"],
"wer": (sum(aggregate["wer"]) / n),
"k_cer": corpus_cer,
"entity_retention": entity_rate,
"failure_rate": failure_rate,
"rtf_min": sorted_rtf[0] if sorted_rtf else None,
"rtf_median": sorted_rtf[len(sorted_rtf) // 2] if sorted_rtf else None,
"rtf_max": sorted_rtf[-1] if sorted_rtf else None,
"peak_rss_min_mb": min(rss_samples) if rss_samples else None,
"peak_rss_median_mb": sorted(rss_samples)[len(rss_samples) // 2] if rss_samples else None,
"peak_rss_max_mb": max(rss_samples) if rss_samples else None,
"samples": n,
}
return {"summary": summary}
def _transcribe_clip(owner, options, clip, settings, glossary) -> dict:
"""클립 전사 — 후처리(glossary/rules) 적용 + text/rtf 반환.
벤치 지표는 원시 전사가 아니라 사용자가 실제로 받는 후처리 결과를 측정해야
한다 (vLLM→BLM 같은 오인식은 rules/glossary에서 복원된다).
"""
from ..engine.owner import InferenceRequest
from ..postprocess.pipeline import run_postprocess
from ..results.models import Segment
audio_path = clip.get("audio_path")
if not audio_path:
raise ValueError("clip에 audio_path 없음")
duration = clip.get("duration_sec") or 60.0
t0 = time.time()
req = InferenceRequest(audio_path=audio_path, options=options, lane="batch")
outcome = owner.transcribe(req)
segments: list[Segment] = []
for idx, seg in enumerate(outcome["segments"]):
segments.append(
Segment(
index=idx,
start=float(seg.get("start", 0.0)),
end=float(seg.get("end", 0.0)),
text=seg.get("text", ""),
avg_logprob=seg.get("avg_logprob"),
no_speech_prob=seg.get("no_speech_prob"),
)
)
post = run_postprocess(segments, options, settings, glossary=glossary)
text = " ".join(s.text.strip() for s in post["segments"] if s.text.strip())
elapsed = time.time() - t0
return {"text": text, "rtf": elapsed / duration}
def _decide(model_results: dict) -> dict:
def passes(name: str) -> bool:
s = model_results[name]["summary"]
return (
s["entity_retention"] >= ABSOLUTE_ENTITY_RETENTION
and s["k_cer"] <= ABSOLUTE_K_CER
and s["failure_rate"] <= ABSOLUTE_FAILURE_RATE
)
turbo = "large-v3-turbo" if "large-v3-turbo" in model_results else None
large = "large-v3" if "large-v3" in model_results else None
if turbo is None or large is None:
return {"status": "no_decision", "default_model": None, "reasons": ["두 모델 비교 필요"]}
t_pass, l_pass = passes(turbo), passes(large)
if not t_pass and not l_pass:
return {
"status": "no_acceptable_model",
"default_model": None,
"reasons": ["모든 모델이 절대 기준 미달 (entity<95% 또는 K-CER>15% 또는 실패>0)"],
}
if l_pass and not t_pass:
return {
"status": "approved",
"default_model": large,
"reasons": ["turbo가 절대 기준 미달, large-v3 채택"],
}
if t_pass and not l_pass:
return {
"status": "approved",
"default_model": turbo,
"reasons": ["large-v3가 절대 기준 미달, turbo 채택"],
}
# 둘 다 통과 → 상대 비교
ts, ls = model_results[turbo]["summary"], model_results[large]["summary"]
reasons = []
ok = True
if ts["entity_retention"] < ABSOLUTE_ENTITY_RETENTION:
ok = False
reasons.append("turbo entity 보존 <95%")
if ts["k_cer"] > ls["k_cer"] * (1 + TURBO_RELATIVE_K_CER_SLACK):
ok = False
reasons.append("turbo K-CER가 large-v3보다 15% 초과 열등")
if ts["failure_rate"] > 0:
ok = False
reasons.append("turbo 실패율 >0")
turbo_rtf = ts["rtf_median"] or float("inf")
large_rtf = ls["rtf_median"] or float("inf")
if not (turbo_rtf <= large_rtf * (1 - TURBO_RTF_SPEEDUP)):
ok = False
reasons.append("turbo RTF가 large-v3보다 10% 이상 빠르지 않음")
if ok:
return {
"status": "approved",
"default_model": turbo,
"reasons": reasons or ["터보가 기준 충족 + 속도 우위"],
}
return {
"status": "approved",
"default_model": large,
"reasons": reasons or ["상대 비교에서 large-v3 채택"],
}
def _write_report(output: Path, report: dict) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
def _write_decision(decision: Path, report: dict) -> None:
decision.parent.mkdir(parents=True, exist_ok=True)
d = report["decision"]
payload = {
"decision_version": DECISION_VERSION,
"status": d["status"],
"default_model": d["default_model"],
"default_hotword_set": None,
"hotword_artifact": None,
"dataset_version": report["dataset_version"],
"model_variants": {},
"decided_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"reasons": d["reasons"],
}
decision.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
+235 -87
View File
@@ -1,122 +1,270 @@
"""CLI — typer. `detect`(구현) + transcribe/bench/serve(스텁). 스펙 §배포."""
"""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 rich.table import Table
from .devices import DeviceManager
from .config import Settings, get_settings
from .errors import LukeScribeError, OutputWriteError
from .results.models import TranscriptResult
from .results.store import AtomicFileWriter
app = typer.Typer(add_completion=False, help="luke_scribe — 로컬 STT 전사 (hardware-adaptive)")
console = Console()
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"),
compute_type: str = typer.Option(None, "--compute-type", help="강제 compute_type(float16|int8|int8_float16)"),
workers: int = typer.Option(None, help="워커수 오버라이드"),
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:
"""하드웨어 감지 → 능력등급(T0~T3)/정밀도/워커수 산정 (AC-2/3, 측정 전 정적 추정)."""
profile = DeviceManager.detect(
force_device=(None if device == "auto" else device),
force_compute_type=compute_type,
workers_override=workers,
)
table = Table(title="luke_scribe · device profile", show_header=False, title_style="bold cyan")
table.add_row("device", f"{profile.kind} ({profile.name})")
if profile.compute_capability:
table.add_row("compute capability", profile.compute_capability)
if profile.vram_total_mb:
table.add_row("VRAM (free/total)", f"{profile.vram_free_mb} / {profile.vram_total_mb} MB")
table.add_row("RAM", f"{profile.ram_total_mb} MB")
table.add_row("disk free", f"{profile.disk_free_mb} MB")
table.add_row("compute_type", profile.compute_type)
table.add_row("capability tier", f"[bold]{profile.tier.value}[/]")
table.add_row("max workers", str(profile.max_workers))
for lane, model in profile.served_models.items():
table.add_row(f"served · {lane}", model)
table.add_row("measured", "yes" if profile.measured else "no (정적 추정)")
console.print(table)
for note in profile.notes:
console.print(f"{note}", style="yellow")
"""하드웨어 감지 → 능력 등급/정밀도/워커수 출력."""
from .devices.manager import DeviceManager
def _todo(name: str, hint: str = "") -> None:
console.print(f"[yellow]'{name}' 은 아직 미구현입니다 (P1 진행 중).[/] {hint}")
raise typer.Exit(code=1)
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(
file: str = typer.Argument(..., help="오디오/영상 파일"),
model: str = typer.Option(None, help="모델 오버라이드(기본=실시간 모델). tiny|base|large-v3|large-v3-turbo"),
language: str = typer.Option(None, help="언어(기본 설정값). 'auto' 가능"),
device: str = typer.Option("auto", help="auto|cpu|cuda"),
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="반복 가능"),
glossary: list[str] = typer.Option(
[], "--glossary", help="오인식 패턴=표준 표기, 반복 가능 (예: --glossary BLM=vLLM)"
),
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"),
vad: bool = typer.Option(True, "--vad/--no-vad", help="무음 제거"),
timestamps: bool = typer.Option(False, "--timestamps", help="세그먼트 [startend] 표시"),
log_level: str = typer.Option("INFO", "--log-level"),
) -> None:
""" 파일 전사 (faster-whisper, CPU/GPU 자동, AC-4 일부)."""
from .config import settings
""" 파일 전사 → 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,
)
glossary_dict: dict[str, str] = {}
for item in glossary:
if "=" not in item:
_fail(EXIT_INPUT, f"--glossary는 'KEY=VALUE' 형식이어야 합니다: {item}")
key, _, value = item.partition("=")
key, value = key.strip(), value.strip()
if not key or not value:
# 빈 패턴(re.escape(''))은 모든 위치에 매칭돼 텍스트를 망가뜨린다
_fail(EXIT_INPUT, f"--glossary 키/값이 비어 있으면 안 됩니다: {item}")
glossary_dict[key] = value
try:
from .audio.ingest import probe_media
from .engine.faster_whisper_engine import FasterWhisperEngine
except ImportError as exc:
console.print(f"[red]엔진 미설치:[/] {exc}\n→ `uv sync --extra engine` 후 다시 시도하세요.")
raise typer.Exit(code=1) from exc
try:
info = probe_media(file)
except FileNotFoundError:
console.print(f"[red]파일 없음:[/] {file}")
raise typer.Exit(code=1) from None
if info.duration_s > settings.max_duration_s or info.size_bytes > settings.max_size_bytes:
console.print(
f"[red]입력 상한 초과(413):[/] {info.duration_s:.0f}s / {info.size_bytes}B "
f"(상한 {settings.max_duration_s}s / {settings.max_size_bytes}B)"
pipeline = BatchPipeline(settings=settings, token=token)
result = pipeline.run(
source, options, source_name=source.name, glossary=glossary_dict or None
)
raise typer.Exit(code=1)
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)
profile = DeviceManager.detect(force_device=(None if device == "auto" else device))
dev = "cpu" if profile.kind == "cpu" else "cuda"
model_name = model or settings.model_realtime
lang = language or settings.language
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"[dim]model={model_name} device={dev} compute={profile.compute_type} "
f"lang={lang} dur={info.duration_s:.1f}s[/]"
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(),
)
engine = FasterWhisperEngine(model_name, dev, profile.compute_type, cache_dir=settings.model_cache_dir)
segments, tinfo = engine.transcribe(file, language=lang, word_timestamps=word_timestamps, vad=vad)
count = 0
for seg in segments:
count += 1
if timestamps:
console.print(f"[cyan][{seg.start:6.2f}{seg.end:6.2f}][/] {seg.text.strip()}")
else:
console.print(seg.text.strip())
detected = getattr(tinfo, "language", None)
console.print(f"[green]✓ {count} segments · detected_lang={detected} · model_used={model_name}[/]")
@app.command()
def bench(samples: str = typer.Option(None, help="라벨된 KO+EN 샘플 디렉터리")) -> None:
"""turbo vs large-v3 도메인 벤치 게이트 (샘플셋 확보 후)."""
_todo("bench", "→ samples/ 라벨셋 필요")
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)
@app.command()
def serve() -> None:
"""API 서버 (P2)."""
_todo("serve", "→ P2 (FastAPI + Redis/RQ)")
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:
app()
try:
app()
except typer.Exit:
raise
except KeyboardInterrupt:
raise typer.Exit(130) from None
if __name__ == "__main__":
+170 -27
View File
@@ -1,38 +1,181 @@
"""런타임 설정 — env(`SCRIBE_*`) / `.env` 로 오버라이드. 스펙 §config."""
"""luke_scribe 설정 (pydantic-settings).
모든 값은 환경변수 ``LUKESCRIBE_*`` 또는 ``.env`` 설정한다.
기본값은 dev/Colab 단일 프로세스 프로파일이다.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
MODEL_RT_DEFAULT = "large-v3-turbo"
MODEL_BATCH_DEFAULT = "large-v3-turbo"
SUPPORTED_MODELS = {"large-v3-turbo", "large-v3"}
COMPUTE_TYPES = {"auto", "float16", "int8_float16", "int8"}
DEVICE_VALUES = {"auto", "cpu", "cuda"}
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SCRIBE_", env_file=".env", extra="ignore")
"""모든 설정. ``LUKESCRIBE_`` 프리픽스."""
# 모델 (경로별 기본 — 하이브리드; P1 bench 결과에 따라 단일 turbo로 통일 가능)
model_realtime: str = "large-v3-turbo"
model_batch: str = "large-v3"
model_config = SettingsConfigDict(
env_prefix="LUKESCRIBE_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# 디바이스 (auto|cpu|cuda|cuda:0) — Device Manager가 자동 산정, 강제 가능
device: str = "auto"
compute_type: str | None = None # None=자동(cc/VRAM 기반)
workers: int | None = None # None=자동 산정
# 언어 (기본 ko, 요청별 override)
language: str = "ko"
# 입력 절대 상한 (초과 413)
max_duration_s: int = 4 * 3600 # 4h
max_size_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
# 보관/큐/인증 (P2+)
retention_days: int = 7
redis_url: str | None = None
api_keys: list[str] = []
# 터널 (P5)
tunnel: str = "none" # none|cloudflare|ngrok
# 모델 캐시 디렉터리 (None=HF 기본)
# ── 모델 ──
model_rt: str = MODEL_RT_DEFAULT
model_batch: str = MODEL_BATCH_DEFAULT
model_cache_dir: str | None = None
model_download_retries: int = 1
# ── 장치 ──
device: str = "auto"
compute_type: str = "auto"
workers: int = 0 # 0 = Device Manager 자동 산정
# ── 언어 / 후처리 ──
language: str = "ko"
post_mode: Literal["none", "glossary", "rules", "llm"] = "rules"
post_enabled: bool = True
# ── API 키 ──
api_keys: str = ""
api_key_file: str = "api_keys.json"
admin_scopes: str = "admin"
# ── 큐 ──
queue_backend: Literal["redis", "inproc"] = "inproc"
redis_url: str = "redis://localhost:6379/0"
max_queue: int = 100
job_timeout_hours: float = 4.0
job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진
# ── 워커 ──
auto_worker: bool = False # in-proc 백엔드: 서버 프로세스가 큐를 소비 (dev/Colab)
# ── 입력 상한 ──
max_duration_sec: int = 14400 # 4h
max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
# ── 보관 / 프라이버시 ──
retention_days: int = 7
results_dir: str | None = None
delete_source: bool = True
# ── LLM 후처리 (기본 off) ──
llm_backend: Literal["none", "local", "openai", "external"] = "none"
llm_allowlist: str = "" # provider_id=url, 쉼표 구분
llm_model: str = ""
llm_audit: bool = True
llm_confidence_gate: float = 0.6
# ── 터널 ──
tunnel: Literal["none", "cloudflare"] = "none"
cloudflared_path: str | None = None
# ── 관측 ──
log_level: str = "INFO"
log_json: bool = False
# ── API 서버 ──
host: str = "0.0.0.0"
port: int = 8000
# ── VAD ──
vad_enabled: bool = True
vad_min_silence_ms: int = 500
vad_speech_pad_ms: int = 200
# ── 실시간 ──
realtime_max_sessions: int = 4
realtime_redecode_window_sec: float = 15.0
realtime_retained_left_context_sec: float = 5.0
realtime_init_timeout_sec: float = 2.0
realtime_max_frame_bytes: int = 512 * 1024
realtime_session_idle_sec: float = 120.0
@field_validator("model_rt", "model_batch")
@classmethod
def _validate_model(cls, v: str) -> str:
if v not in SUPPORTED_MODELS:
raise ValueError(f"지원 모델: {sorted(SUPPORTED_MODELS)} (got {v!r})")
return v
@field_validator("compute_type")
@classmethod
def _validate_compute_type(cls, v: str) -> str:
if v not in COMPUTE_TYPES:
raise ValueError(f"compute_type: {COMPUTE_TYPES} (got {v!r})")
return v
@field_validator("device")
@classmethod
def _validate_device(cls, v: str) -> str:
if v in DEVICE_VALUES:
return v
if v.startswith("cuda:"):
return v
raise ValueError(f"device: auto|cpu|cuda|cuda:N (got {v!r})")
def parse_api_keys(self) -> dict[str, set[str]]:
"""``key:scope1,scope2`` 목록을 {키: 스코프셋}으로 파싱.
콤마는 구분자이면서 스코프 구분자이므로, ``:`` 뒤의 연속 토큰을
같은 키의 스코프로 누적한다 (: ``admin-key:admin,transcribe``
admin-key는 {admin, transcribe}).
주의: 스코프를 가진 뒤에 오는 평문 토큰은 스코프로 해석된다
(``key-admin:admin,key-two`` key-two가 스코프로 흡수). 혼동을
피하려면 **스코프 있는 키를 목록 끝에** 두거나, 프로덕션에서는
``api_key_file``(다이제스트) 사용한다.
"""
out: dict[str, set[str]] = {}
current_key: str | None = None
for item in self.api_keys.split(","):
item = item.strip()
if not item:
continue
if ":" in item:
key, _, scopes = item.partition(":")
current_key = key.strip()
out[current_key] = {s.strip() for s in scopes.split(",") if s.strip()} or {
"transcribe"
}
elif current_key is not None and current_key in out:
# 이전 키의 스코프 연속 토큰
out[current_key].add(item)
else:
out[item] = {"transcribe"}
current_key = None
return out
def parse_llm_allowlist(self) -> dict[str, str]:
"""``provider_id=url`` 목록 → {provider_id: url}."""
out: dict[str, str] = {}
for item in self.llm_allowlist.split(","):
item = item.strip()
if not item:
continue
if "=" in item:
pid, _, url = item.partition("=")
out[pid.strip()] = url.strip()
return out
@property
def results_root(self) -> str:
import tempfile
return self.results_dir or f"{tempfile.gettempdir()}/luke-scribe-results"
settings = Settings()
@lru_cache
def get_settings() -> Settings:
return Settings()
+1
View File
@@ -0,0 +1 @@
"""연결성 — Colab 외부 노출 터널."""
+67
View File
@@ -0,0 +1,67 @@
"""Cloudflare Quick Tunnel — API lifespan에 종속 supervise (plan §3.10d/P5-17).
- cloudflared 바이너리로 Quick Tunnel 시작, URL 파싱.
- URL 회전 재출력, 임시성 명시.
- v0.1 환경(바이너리 부재)에서는 명확한 오류 (fail explicit).
"""
from __future__ import annotations
import re
import shutil
import subprocess
import threading
import time
from ..config import Settings
_URL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
class CloudflareTunnel:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or Settings()
self._proc: subprocess.Popen | None = None
self._lock = threading.Lock()
self.public_url: str | None = None
self._lines: list[str] = []
def start(self, timeout_sec: float = 30.0) -> str:
binary = self.settings.cloudflared_path or shutil.which("cloudflared")
if not binary:
raise RuntimeError(
"cloudflared 바이너리를 찾을 수 없습니다. `cloudflared`를 설치하거나 "
"LUKESCRIBE_CLOUDFLARED_PATH를 설정하세요."
)
self._proc = subprocess.Popen(
[binary, "tunnel", "--url", "http://localhost:8000", "--no-autoupdate"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
deadline = time.time() + timeout_sec
assert self._proc.stdout is not None
while time.time() < deadline:
line = self._proc.stdout.readline()
if not line:
break
self._lines.append(line)
m = _URL_RE.search(line)
if m:
self.public_url = m.group(0)
return self.public_url
if self._proc.poll() is not None:
raise RuntimeError(
f"cloudflared가 즉시 종료되었습니다 (exit {self._proc.returncode})"
)
raise RuntimeError("cloudflared URL을 감지하지 못했습니다 (timeout)")
def stop(self) -> None:
with self._lock:
if self._proc is not None:
self._proc.terminate()
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc = None
+1 -5
View File
@@ -1,5 +1 @@
"""Device Manager — GPU/CPU 감지 → 능력등급/정밀도/워커수 산정 (스펙 §6, 계획 §3.6)."""
from .manager import DeviceManager
from .profile import CapabilityTier, DeviceProfile
__all__ = ["DeviceManager", "DeviceProfile", "CapabilityTier"]
"""하드웨어 감지·능력 등급·VRAM 프로빙."""
+163 -107
View File
@@ -1,125 +1,181 @@
"""DeviceManager — 감지 → 정밀도/능력등급/워커수 산정 (계획 §3.6, AC-2/3).
"""Device Manager — 하드웨어 감지 → 능력 등급(T0~T3) → 정밀도/워커수 결정.
현재는 정적 추정(보수 상수). 후속: 부팅 모델 1 로드 실측(`measured=True`)으로 대체.
계약 (plan §3.6):
- 부팅 실측(VRAM/RAM/디스크), 정적 상수는 측정 폴백.
- 등급: T0 CPU / T1 turbo-GPU / T2 스왑 / T3 동시상주. T4(다중복제) 제외.
- 정밀도: cc>=7.0 & free>=12GB float16; cc>=7.0 & free<12GB int8_float16;
Pascal(6.x) int8; CPU int8.
- 워커수: max(1, floor((free reserve) / per_worker)).
- 명시 override(device/cuda:N) 자동으로 바꾸지 않고 검증 실패.
- ``auto`` 모드에서만 CPU 폴백 허용, 경고 기록.
"""
from __future__ import annotations
import os
from .profile import DeviceProfile
from .vram_probe import GpuInfo, SystemInfo, probe_system
from .profile import HEADROOM, MODEL_FOOTPRINT_MB, CapabilityTier, DeviceProfile
from .vram_probe import GpuInfo, probe_disk_free_mb, probe_gpus, probe_ram_mb
# 측정 전 보수 폴백 상수 (MB). 실측 실패 시에만 사용.
CONSERVATIVE_MODEL_FOOTPRINT_MB = {
"large-v3": {"float16": 10240, "int8_float16": 5120, "int8": 3584},
"large-v3-turbo": {"float16": 4096, "int8_float16": 2560, "int8": 1843},
}
TURBO = "large-v3-turbo"
V3 = "large-v3"
def _select_compute_type(cc: tuple[int, int], free_mb: int) -> str:
"""정밀도 자동 선택 (계획 §3.6)."""
major = cc[0]
if major >= 7: # Volta+ : fp16 효율
return "float16" if free_mb >= 12000 else "int8_float16"
if major == 6: # Pascal (예: GTX 1050) — fp16 비효율 → int8
return "int8"
return "int8"
def _fits(model: str, ct: str, free_mb: int) -> bool:
fp = MODEL_FOOTPRINT_MB.get((model, ct))
return fp is not None and fp * HEADROOM <= free_mb
def _both_fit(ct: str, free_mb: int) -> bool:
a = MODEL_FOOTPRINT_MB.get((TURBO, ct))
b = MODEL_FOOTPRINT_MB.get((V3, ct))
return a is not None and b is not None and (a + b) * HEADROOM <= free_mb
def _cpu_workers(override: int | None) -> int:
return override or max(1, (os.cpu_count() or 2) // 4)
def _cpu_profile(
*, name: str, ram: int, disk: int, override: int | None,
gpu: GpuInfo | None = None, notes: list[str] | None = None,
) -> DeviceProfile:
return DeviceProfile(
kind="cpu",
name=name,
compute_capability=(f"{gpu.compute_capability[0]}.{gpu.compute_capability[1]}" if gpu else None),
vram_total_mb=(gpu.vram_total_mb if gpu else 0),
vram_free_mb=(gpu.vram_free_mb if gpu else 0),
ram_total_mb=ram,
disk_free_mb=disk,
compute_type="int8",
tier=CapabilityTier.T0_CPU,
max_workers=_cpu_workers(override),
served_models={"realtime": f"{TURBO}@cpu", "batch": f"{TURBO}@cpu"},
notes=(notes or []) + ["large-v3 GPU 미제공(CPU 경로)"],
)
HEADROOM_MB = 1024 # 예비 VRAM (헤드룸)
RESERVE_MB = 2048 # 비실시간 여유 예비
class DeviceManager:
@staticmethod
def detect(
force_device: str | None = None,
force_compute_type: str | None = None,
workers_override: int | None = None,
) -> DeviceProfile:
ram = probe_ram_mb()
disk = probe_disk_free_mb(".")
gpus = probe_gpus()
"""장치 탐지와 실행 결정을 캡슐화한다."""
# 강제 CPU 또는 GPU 없음 → T0
if force_device == "cpu" or not gpus:
note = (
"GPU 감지됨이나 --device cpu 강제" if (force_device == "cpu" and gpus)
else "GPU 미감지 → CPU"
)
return _cpu_profile(name="CPU", ram=ram, disk=disk, override=workers_override, notes=[note])
def __init__(self, system: SystemInfo | None = None) -> None:
self.system = system or probe_system()
def detect(self, *, device: str = "auto", compute_type: str | None = None) -> DeviceProfile:
"""장치 결정. ``device``는 auto|cpu|cuda|cuda:N.
Raises:
LukeScribeError: 명시 override를 만족할 없을 (fallback 없이 실패).
"""
from ..errors import DeviceUnavailable
gpus = self.system.gpus
explicit = device != "auto"
selected_gpu: GpuInfo | None = None
warnings: list[str] = []
if explicit:
if device == "cpu":
profile = self._build(
device="cpu", gpu=None, compute_type=compute_type, source="explicit"
)
profile.warnings = warnings
return profile
if device.startswith("cuda"):
idx = 0
if ":" in device:
idx = int(device.split(":", 1)[1])
if not gpus or idx >= len(gpus):
raise DeviceUnavailable(
f"요청한 CUDA 장치 {device}를 찾을 수 없습니다 (감지 GPU {len(gpus)}개)"
)
selected_gpu = gpus[idx]
profile = self._build(
device=device, gpu=selected_gpu, compute_type=compute_type, source="explicit"
)
profile.warnings = warnings
return profile
raise DeviceUnavailable(f"알 수 없는 장치 요청: {device!r}")
# auto
if not gpus:
profile = self._build(device="cpu", gpu=None, compute_type=compute_type, source="auto")
profile.warnings.append("GPU가 감지되지 않아 CPU int8로 실행합니다")
return profile
# GPU 존재: 첫 GPU 기준으로 등급/정밀도 결정
gpu = gpus[0]
cc = gpu.compute_capability
ct = force_compute_type or _select_compute_type(cc, gpu.vram_free_mb)
profile = self._build(
device=f"cuda:{gpu.index}", gpu=gpu, compute_type=compute_type, source="auto"
)
# turbo조차 GPU에 안 들어가면 → CPU 강등(T0)
if not _fits(TURBO, ct, gpu.vram_free_mb):
need = int(MODEL_FOOTPRINT_MB[(TURBO, ct)] * HEADROOM)
return _cpu_profile(
name=f"CPU (GPU={gpu.name} 2GB급 부족)", ram=ram, disk=disk,
override=workers_override, gpu=gpu,
notes=[f"{gpu.name} free {gpu.vram_free_mb}MB < turbo {need}MB(헤드룸 포함) → CPU 강등(T0)"],
# 모델 적재 가능성 실측 결과가 없으므로 보수 상수로 fit 판단
tier = self._determine_tier(gpu, profile.selected_compute_type)
profile.capability_tier = tier
if tier == "T0":
profile = self._build(device="cpu", gpu=None, compute_type="int8", source="auto")
profile.capability_tier = "T0"
profile.warnings.append("GPU로 모델을 적재하기 어려워 CPU int8로 실행합니다")
profile.warnings.extend(warnings)
return profile
def _determine_tier(self, gpu: GpuInfo, compute_type: str) -> str:
free = gpu.vram_free_mb or 0
turbo_fit = self._fits(free, "large-v3-turbo", compute_type)
large_fit = self._fits(free, "large-v3", compute_type)
if not turbo_fit and not large_fit:
return "T0"
if turbo_fit and not large_fit:
return "T1"
if large_fit and not turbo_fit:
return "T1" # turbo는 항상 large-v3보다 작으므로 이 경우는 사실상 없음
# 둘 다 fit: 동시 상주 가능한지
total = turbo_fit and large_fit
both = (
self._fits(free, "large-v3", compute_type)
and self._fits(free, "large-v3-turbo", compute_type)
and free
>= self._footprint("large-v3", compute_type)
+ self._footprint("large-v3-turbo", compute_type)
+ RESERVE_MB
)
return "T3" if both else ("T2" if total else "T1")
def _fits(self, free_mb: int, model: str, compute_type: str) -> bool:
return free_mb >= self._footprint(model, compute_type) + HEADROOM_MB
def _footprint(self, model: str, compute_type: str) -> int:
ct = "float16" if compute_type == "int8_float16" and model == "large-v3" else compute_type
return CONSERVATIVE_MODEL_FOOTPRINT_MB.get(model, {}).get(
ct, CONSERVATIVE_MODEL_FOOTPRINT_MB[model]["int8"]
)
def _build(
self,
*,
device: str,
gpu: GpuInfo | None,
compute_type: str | None,
source: str,
) -> DeviceProfile:
if device == "cpu" or gpu is None:
selected_ct = compute_type or "int8"
return DeviceProfile(
requested_device="auto" if source == "auto" else "cpu",
selected_device="cpu",
selection_source=source,
device_name="CPU",
requested_compute_type=compute_type,
selected_compute_type=selected_ct,
capability_tier="T0",
workers=1,
)
# turbo는 GPU OK → large-v3 적재 여부로 등급 분기
notes: list[str] = []
if not _fits(V3, ct, gpu.vram_free_mb):
tier = CapabilityTier.T1_TURBO_GPU
served = {"realtime": f"{TURBO}@cuda", "batch": f"{TURBO}@cuda"}
notes.append("large-v3 미제공 → 배치도 turbo")
elif not _both_fit(ct, gpu.vram_free_mb):
tier = CapabilityTier.T2_SWAP
served = {"realtime": f"{TURBO}@cuda", "batch": f"{V3}@cuda (swap)"}
notes.append("turbo/large-v3 동시상주 불가 → 호출별 load/unload")
else:
tier = CapabilityTier.T3_CORESIDENT
served = {"realtime": f"{TURBO}@cuda", "batch": f"{V3}@cuda"}
# 워커수 = floor((free - reserve) / per_worker), reserve=상주 모델 헤드룸
per_worker = MODEL_FOOTPRINT_MB[(TURBO, ct)]
reserve = int(per_worker * (HEADROOM - 1.0))
est = max(1, (gpu.vram_free_mb - reserve) // per_worker)
selected_ct = compute_type or self._precision_for(gpu)
cc = gpu.compute_capability
free = gpu.vram_free_mb
workers = 1
if free and cc:
# 실측 기반 워커수 (계약: max(1, floor((free reserve)/per_worker)))
per_worker = max(1, self._footprint("large-v3-turbo", selected_ct))
budget = free - RESERVE_MB - HEADROOM_MB
if budget > 0:
workers = max(1, int(budget // per_worker))
return DeviceProfile(
kind="cuda",
name=gpu.name,
compute_capability=f"{cc[0]}.{cc[1]}",
requested_device="auto" if source == "auto" else f"cuda:{gpu.index}",
selected_device=f"cuda:{gpu.index}",
selection_source=source,
device_name=gpu.name,
compute_capability=cc,
vram_total_mb=gpu.vram_total_mb,
vram_free_mb=gpu.vram_free_mb,
ram_total_mb=ram,
disk_free_mb=disk,
compute_type=ct,
tier=tier,
max_workers=workers_override or est,
served_models=served,
notes=notes,
requested_compute_type=compute_type,
selected_compute_type=selected_ct,
capability_tier="T3",
workers=workers,
runtime_cuda=gpu.runtime_cuda,
driver_version=gpu.driver_version,
)
def _precision_for(self, gpu: GpuInfo) -> str:
cc = gpu.compute_capability
free = gpu.vram_free_mb or 0
if cc:
major = int(cc.split(".")[0])
if major < 7: # Pascal 이하
return "int8"
if free >= 12 * 1024:
return "float16"
return "int8_float16"
def compute_workers(self, profile: DeviceProfile, override: int = 0) -> int:
return override or max(1, profile.workers)
+26 -39
View File
@@ -1,46 +1,33 @@
"""DeviceProfile 모델 + 능력등급 + 모델 VRAM 보수 상수 (계획 §3.6)."""
from __future__ import annotations
"""DeviceProfile — 장치 탐지 결과와 실행 결정을 분리한 계약.
from enum import Enum
- 탐지값: CPU/GPU 이름, compute capability, /가용 VRAM
- 결정값: device, compute_type, 모델
- 출처: ``auto`` 또는 사용자 override
- 경고: CPU 폴백, 정밀도 변경, 모델 미지원
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class CapabilityTier(str, Enum):
"""부팅 실측으로 자동판정 — "제공 가능 모델"을 등급이 결정 (무음 강등 아님)."""
T0_CPU = "T0_CPU" # GPU로 turbo도 무리/GPU 없음 → turbo@CPU
T1_TURBO_GPU = "T1_TURBO_GPU" # turbo는 GPU OK, large-v3 무리 (배치도 turbo)
T2_SWAP = "T2_SWAP" # large-v3 OK, turbo와 동시상주 불가 → load/unload
T3_CORESIDENT = "T3_CORESIDENT" # turbo + large-v3 동시 적재 가능
# 보수 기본 상수 (MB) — 측정 전 폴백. 계획 §3.6.
# (부팅 시 실제 로드 측정으로 대체 예정: vram_probe --probe-load)
MODEL_FOOTPRINT_MB: dict[tuple[str, str], int] = {
("large-v3", "float16"): 10000,
("large-v3", "int8_float16"): 5500,
("large-v3", "int8"): 3500,
("large-v3-turbo", "float16"): 4000,
("large-v3-turbo", "int8_float16"): 2400,
("large-v3-turbo", "int8"): 1800,
}
HEADROOM = 1.3 # 적재 헤드룸 배수
class DeviceProfile(BaseModel):
"""감지 결과 + 산정값. /v1/system·detect 가 그대로 노출."""
kind: str # "cuda" | "cpu"
name: str
requested_device: str = "auto"
selected_device: str = "cpu"
selection_source: str = "auto" # auto | explicit
device_name: str | None = None
compute_capability: str | None = None
vram_total_mb: int = 0
vram_free_mb: int = 0
ram_total_mb: int = 0
disk_free_mb: int = 0
compute_type: str
tier: CapabilityTier
max_workers: int = 1
served_models: dict[str, str] = Field(default_factory=dict) # {"realtime":..., "batch":...}
measured: bool = False # True=모델 실측, False=정적 추정
notes: list[str] = Field(default_factory=list)
vram_total_mb: int | None = None
vram_free_mb: int | None = None
requested_compute_type: str | None = None
selected_compute_type: str = "int8"
loadable_models: list[str] = Field(default_factory=list)
recommended_model: str | None = None
capability_tier: str = "T0" # T0 CPU .. T3 동시상주
workers: int = 1
warnings: list[str] = Field(default_factory=list)
runtime_cuda: str | None = None
driver_version: str | None = None
def to_dict(self) -> dict:
return self.model_dump(exclude_none=False)
+107 -35
View File
@@ -1,53 +1,85 @@
"""하드웨어 실측 — GPU(NVML)/RAM/디스크. 의존성 없거나 GPU 없으면 우아하게 빈 결과."""
"""하드웨어 프로빙 — GPU(NVML)·RAM·디스크.
NVML 사용 불가(CUDA 미설치) 환경에서는 우아하게 CPU로 폴백한다.
모든 프로빙은 실패해도 예외를 던지지 않고 ``None``/폴백값을 반환한다
(설계 원칙: fail-explicit은 장치 결정 계층에서 처리).
"""
from __future__ import annotations
import dataclasses
import shutil
from dataclasses import dataclass
from typing import Any
@dataclass
@dataclasses.dataclass
class GpuInfo:
index: int
name: str
compute_capability: tuple[int, int]
vram_total_mb: int
vram_free_mb: int
compute_capability: str | None
vram_total_mb: int | None
vram_free_mb: int | None
driver_version: str | None
runtime_cuda: str | None
def probe_gpus() -> list[GpuInfo]:
"""NVML로 GPU 목록·VRAM·compute capability 실측. 없으면 []."""
try:
import pynvml # nvidia-ml-py
except ImportError:
return []
@dataclasses.dataclass
class SystemInfo:
cpu_count: int
ram_total_mb: int
ram_free_mb: int
disk_free_mb: int
gpus: list[GpuInfo]
def _nvml_available() -> bool:
try:
import pynvml # type: ignore[import-not-found]
pynvml.nvmlInit()
return True
except Exception:
return []
return False
def _nvml_gpus() -> list[GpuInfo]:
import pynvml # type: ignore[import-not-found]
gpus: list[GpuInfo] = []
try:
for i in range(pynvml.nvmlDeviceGetCount()):
h = pynvml.nvmlDeviceGetHandleByIndex(i)
name = pynvml.nvmlDeviceGetName(h)
if isinstance(name, bytes):
name = name.decode()
mem = pynvml.nvmlDeviceGetMemoryInfo(h)
pynvml.nvmlInit()
count = pynvml.nvmlDeviceGetCount()
for i in range(count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
name = pynvml.nvmlDeviceGetName(handle)
try:
major, minor = pynvml.nvmlDeviceGetCudaComputeCapability(h)
cc = pynvml.nvmlDeviceGetCudaComputeCapability(handle)
cc_str = f"{cc[0]}.{cc[1]}"
except Exception:
major, minor = (0, 0)
cc_str = None
try:
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
total = int(mem.total / (1024 * 1024))
free = int(mem.free / (1024 * 1024))
except Exception:
total = free = None
try:
driver = pynvml.nvmlSystemGetDriverVersion()
except Exception:
driver = None
gpus.append(
GpuInfo(
index=i,
name=name,
compute_capability=(major, minor),
vram_total_mb=int(mem.total // (1024 * 1024)),
vram_free_mb=int(mem.free // (1024 * 1024)),
compute_capability=cc_str,
vram_total_mb=total,
vram_free_mb=free,
driver_version=driver,
runtime_cuda=None,
)
)
except Exception:
return []
pass
finally:
try:
pynvml.nvmlShutdown()
@@ -56,17 +88,57 @@ def probe_gpus() -> list[GpuInfo]:
return gpus
def probe_ram_mb() -> int:
def probe_gpus() -> list[GpuInfo]:
"""GPU 목록. NVML 없으면 빈 리스트 (CPU-only)."""
if not _nvml_available():
return []
try:
import psutil
return int(psutil.virtual_memory().total // (1024 * 1024))
return _nvml_gpus()
except Exception:
return 0
return []
def probe_disk_free_mb(path: str = ".") -> int:
try:
return int(shutil.disk_usage(path).free // (1024 * 1024))
except Exception:
return 0
def probe_system() -> SystemInfo:
import os
import psutil
gpus = probe_gpus()
disk = shutil.disk_usage("/")
return SystemInfo(
cpu_count=os.cpu_count() or 1,
ram_total_mb=int(psutil.virtual_memory().total / (1024 * 1024)),
ram_free_mb=int(psutil.virtual_memory().available / (1024 * 1024)),
disk_free_mb=int(disk.free / (1024 * 1024)),
gpus=gpus,
)
def peak_process_rss_mb(pid: int | None = None) -> float:
"""프로세스 RSS(MB) — 벤치마크용."""
import psutil
p = psutil.Process(pid or psutil.Process().pid)
return p.memory_info().rss / (1024 * 1024)
def runtime_env_snapshot() -> dict[str, Any]:
"""환경 정보 — 벤치마크 리포트/`/v1/system`용."""
import platform
import subprocess
import sys
env: dict[str, Any] = {
"os": platform.platform(),
"python": sys.version,
"cpu_count": (sys.os_cpu_count if hasattr(sys, "os_cpu_count") else None)
or __import__("os").cpu_count(),
"gpus": [dataclasses.asdict(g) for g in probe_gpus()],
}
for cmd, key in (("ffmpeg -version", "ffmpeg"), ("ffprobe -version", "ffprobe")):
try:
out = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=10)
env[key] = out.stdout.splitlines()[0] if out.stdout else None
except Exception:
env[key] = None
return env
+1
View File
@@ -0,0 +1 @@
"""화자 분리 (선택, pyannote, 기본 off)."""
@@ -0,0 +1,50 @@
"""pyannote.audio 기반 화자 분리 (선택 기능, 기본 off).
v0.1 환경(네트워크/HF 토큰 제약)에서는 스텁으로 두고, 실제 실행은
``[diarize]`` extra + HF 토큰 설정 시에만 활성화된다.
"""
from __future__ import annotations
from typing import Any
class DiarizerUnavailable(RuntimeError):
pass
class PyannoteDiarizer:
"""화자 분리 — pyannote.audio 오디오 레벨 모델.
환경에서는 사용 불가로 처리하고 명확한 오류를 던진다
(fail explicit 조용히 비활성화하지 않음).
"""
def __init__(self, hf_token: str | None = None, device: str = "cpu") -> None:
self.hf_token = hf_token
self.device = device
self._pipeline = None
def load(self) -> None:
try:
from pyannote.audio import Pipeline # type: ignore[import-not-found]
except ImportError as exc:
raise DiarizerUnavailable(
"pyannote.audio가 설치되지 않았습니다. `uv sync --extra diarize` 후 "
"HF 토큰을 설정하세요."
) from exc
if not self.hf_token:
raise DiarizerUnavailable("화자 분리에 Hugging Face 토큰이 필요합니다 (HF_TOKEN)")
self._pipeline = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1", token=self.hf_token
)
def diarize(self, audio_path: str) -> list[dict[str, Any]]:
if self._pipeline is None:
self.load()
assert self._pipeline is not None
result = self._pipeline(audio_path)
speakers: list[dict[str, Any]] = []
for turn, _, speaker in result.itertracks(yield_label=True):
speakers.append({"speaker": speaker, "start": turn.start, "end": turn.end})
return speakers
+1 -5
View File
@@ -1,5 +1 @@
"""추론 엔진 — faster-whisper(CTranslate2) 단일 엔진 + 얇은 추상화 (계획 §3 D3)."""
from .faster_whisper_engine import FasterWhisperEngine
from .model_registry import resolve_model
__all__ = ["FasterWhisperEngine", "resolve_model"]
"""전사 엔진 — 단일 추론 백엔드(faster-whisper) + 모델 레지스트리."""
+91
View File
@@ -0,0 +1,91 @@
"""TranscriptionEngine — 얇은 엔진 인터페이스.
v0.1 구현은 FasterWhisperEngine 하나. 멀티 엔진 플러그인 시스템은 만들지 않는다.
"""
from __future__ import annotations
from typing import Any, Protocol
class TranscriptionOptions:
"""전사 옵션 — 정규 계약 (plan §6.2)."""
__slots__ = (
"model",
"language",
"device",
"compute_type",
"vad",
"hotwords",
"initial_prompt",
"word_timestamps",
"beam_size",
"temperature",
"condition_on_previous_text",
"vad_min_silence_duration_ms",
"vad_speech_pad_ms",
)
def __init__(
self,
*,
model: str = "large-v3-turbo",
language: str | None = "ko",
device: str = "auto",
compute_type: str | None = None,
vad: bool = True,
hotwords: list[str] | None = None,
initial_prompt: str | None = None,
word_timestamps: bool = False,
beam_size: int = 5,
temperature: float = 0.0,
condition_on_previous_text: bool = True,
vad_min_silence_duration_ms: int = 500,
vad_speech_pad_ms: int = 200,
) -> None:
self.model = model
self.language = language
self.device = device
self.compute_type = compute_type
self.vad = vad
self.hotwords = [w.strip() for w in (hotwords or []) if w.strip()]
self.initial_prompt = initial_prompt
self.word_timestamps = word_timestamps
self.beam_size = beam_size
self.temperature = temperature
self.condition_on_previous_text = condition_on_previous_text
self.vad_min_silence_duration_ms = vad_min_silence_duration_ms
self.vad_speech_pad_ms = vad_speech_pad_ms
def as_dict(self) -> dict[str, Any]:
return {k: getattr(self, k) for k in self.__slots__}
def without_defaults(self) -> dict[str, Any]:
return {
k: v
for k, v in self.as_dict().items()
if v not in (None, [], False, 5, 0.0, True, "auto", "ko")
}
class TranscriptionEngine(Protocol):
"""전사 엔진 프로토콜."""
def transcribe(
self, audio_path: str, options: TranscriptionOptions
) -> TranscriptionOutcome: ...
class TranscriptionOutcome:
"""엔진 출력 — 세그먼트 제너레이터와 메타.
세그먼트를 lazy generator로 노출해 (a) 파일에서 진행률을 계산하고
(b) 세그먼트 경계마다 협조적 취소를 확인할 있게 한다 (plan §3.7a/d).
"""
__slots__ = ("segments", "info")
def __init__(self, segments: Any, info: dict[str, Any] | None = None) -> None:
self.segments = segments
self.info = info or {}
+206 -42
View File
@@ -1,55 +1,219 @@
"""faster-whisper(CTranslate2) 엔진 래퍼 (스펙 §2 / 계획 §4-3).
"""FasterWhisperEngine — faster-whisper(CTranslate2) 기반 엔진.
faster-whisper가 내부적으로 PyAV로 디코딩하므로 파일 경로(오디오/영상) 그대로 받는다.
segments는 제너레이터 호출측이 소비하며 progress/취소 점검(P2) 활용.
- lazy 세그먼트 제너레이터 + 세그먼트 경계 협조적 취소 (plan §3.7a/d)
- hotwords initial_prompt에 주입 (혼용어 보존)
- word timestamps, VAD 파라미터 전달
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import threading
from collections.abc import Callable, Iterator
from typing import Any
from .model_registry import resolve_model
from ..errors import TranscriptionFailed
from .base import TranscriptionEngine, TranscriptionOptions, TranscriptionOutcome
if TYPE_CHECKING:
from collections.abc import Iterable
SegmentLike = dict[str, Any]
class FasterWhisperEngine:
def __init__(
self,
model_name: str,
device: str,
compute_type: str,
cache_dir: str | None = None,
) -> None:
from faster_whisper import WhisperModel
class _CancellableSegmentIterator:
"""세그먼트를 소비하며 취소 플래그를 검사하는 래퍼.
self.model_name = model_name
self.device = device
self.compute_type = compute_type
self.model = WhisperModel(
resolve_model(model_name),
device=device,
compute_type=compute_type,
download_root=cache_dir,
)
- 세그먼트마다 ``should_cancel()`` 호출 True면 중단 ``CancelledError``.
- ``close()`` 제너레이터를 명시적으로 종료 (리소스 해제 보장).
"""
def __init__(self, raw: Iterator[SegmentLike], should_cancel: Callable[[], bool]) -> None:
self._raw = raw
self._should_cancel = should_cancel
self._closed = False
def __iter__(self) -> _CancellableSegmentIterator:
return self
def __next__(self) -> SegmentLike:
from ..errors import CancelledError
if self._closed:
raise StopIteration
if self._should_cancel():
self.close()
raise CancelledError("협조적 취소가 요청되었습니다")
try:
seg = next(self._raw)
except StopIteration:
self._closed = True
raise
return seg
def close(self) -> None:
if self._closed:
return
self._closed = True
close = getattr(self._raw, "close", None)
if close is not None:
try:
close()
except Exception:
pass
class FasterWhisperEngine(TranscriptionEngine):
def __init__(self) -> None:
self._models: dict[str, Any] = {}
self._lock = threading.Lock()
self._downloaded: set[str] = set()
def transcribe(
self,
audio: str,
*,
language: str | None = "ko",
word_timestamps: bool = False,
vad: bool = True,
hotwords: list[str] | None = None,
initial_prompt: str | None = None,
beam_size: int = 5,
) -> tuple[Iterable[Any], Any]:
return self.model.transcribe(
audio,
language=(None if language in (None, "auto") else language),
word_timestamps=word_timestamps,
vad_filter=vad,
hotwords=(" ".join(hotwords) if hotwords else None),
initial_prompt=initial_prompt,
beam_size=beam_size,
audio_path: str,
options: TranscriptionOptions,
should_cancel: Callable[[], bool] | None = None,
download_progress: Callable[[dict], None] | None = None,
) -> TranscriptionOutcome:
from ..errors import ModelLoadFailed
try:
from faster_whisper import WhisperModel # type: ignore[import-not-found]
except ImportError as exc:
raise ModelLoadFailed(
"faster-whisper가 설치되지 않았습니다. `uv sync --extra engine` 또는 "
"`pip install 'luke-scribe[engine]'`를 실행하세요."
) from exc
model_key = (options.model, options.device, options.compute_type)
with self._lock:
model = self._models.get(model_key)
if model is None:
# CTranslate2는 device 문자열로 "cuda"만 허용한다 ("cuda:0" 형태는
# "unsupported device cuda:0" 오류). DeviceManager가 내려주는
# "cuda:N"을 device + device_index로 분리해 전달한다.
device_name, device_index = self._split_device(options.device)
try:
model = WhisperModel(
options.model,
device=device_name,
device_index=device_index,
compute_type=options.compute_type,
download_root=self._download_root(),
)
except Exception as exc:
raise ModelLoadFailed(
f"모델 {options.model} 로드 실패 (device={options.device}, "
f"compute_type={options.compute_type}): {exc}"
) from exc
self._models[model_key] = model
hotwords = options.hotwords or []
initial_prompt = options.initial_prompt
if hotwords:
joined = ", ".join(hotwords)
initial_prompt = f"{initial_prompt}\\n" if initial_prompt else ""
initial_prompt += f"주요 용어: {joined}"
try:
segments_iter, info = model.transcribe(
audio_path,
language=options.language,
beam_size=options.beam_size,
temperature=options.temperature,
vad_filter=options.vad,
vad_parameters={
"min_silence_duration_ms": options.vad_min_silence_duration_ms,
"speech_pad_ms": options.vad_speech_pad_ms,
},
word_timestamps=options.word_timestamps,
condition_on_previous_text=options.condition_on_previous_text,
initial_prompt=initial_prompt,
)
except Exception as exc:
raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc
wrapped = _CancellableSegmentIterator(
self._to_dict_segments(segments_iter), should_cancel or (lambda: False)
)
return TranscriptionOutcome(wrapped, info=self._to_dict_info(info))
@staticmethod
def _to_dict_segments(segments):
"""faster-whisper Segment(namedtuple) → dict로 정규화.
다운스트림(배치 파이프라인/벤치/실시간) dict 계약을 쓴다 (mock과 동일).
GPU 실전에서만 재현되는 버그: 'Segment' object has no attribute 'get'.
"""
for seg in segments:
if isinstance(seg, dict):
yield seg
continue
asdict = getattr(seg, "_asdict", None)
if asdict is not None:
yield dict(asdict())
continue
# 최후 수단: 알려진 필드만, None 값은 제외 (다운스트림 .get(key, default)가
# key 존재+None으로 default를 무시하지 않도록)
yield {
k: v
for k in (
"id",
"seek",
"start",
"end",
"text",
"tokens",
"temperature",
"avg_logprob",
"compression_ratio",
"no_speech_prob",
)
if (v := getattr(seg, k, None)) is not None
}
@staticmethod
def _to_dict_info(info):
"""faster-whisper TranscriptionInfo(namedtuple) → dict.
다운스트림(배치 파이프라인) info를 dict 계약으로 접근한다
(``info.get("language")``) GPU 실전에서만 재현되는 버그:
'TranscriptionInfo' object has no attribute 'get'.
"""
if info is None or isinstance(info, dict):
return info
asdict = getattr(info, "_asdict", None)
if asdict is not None:
return dict(asdict())
try:
import dataclasses
if dataclasses.is_dataclass(info):
return dataclasses.asdict(info)
except Exception:
pass
return {
k: v
for k in ("language", "language_probability", "duration", "duration_after_vad")
if (v := getattr(info, k, None)) is not None
}
@staticmethod
def _split_device(device: str) -> tuple[str, int]:
"""'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다.
'cpu'/'cuda' 같은 인덱스 없는 값은 그대로 (device, 0) 반환한다.
"""
if device.startswith("cuda") and ":" in device:
name, _, idx = device.partition(":")
try:
return name, int(idx)
except ValueError:
pass
return device, 0
def _download_root(self) -> str | None:
from ..config import get_settings
return get_settings().model_cache_dir
def unload_all(self) -> None:
with self._lock:
self._models.clear()
+125 -9
View File
@@ -1,16 +1,132 @@
"""논리 모델명 → faster-whisper(CT2) 식별자 (계획 §4-3).
"""모델 레지스트리 — 모델 메타데이터·프로비저닝·결정 아티팩트.
표준 사이즈(tiny/base/small/medium/large-v3) 그대로 통과.
turbo류는 검증된 CT2 변환 레포로 매핑.
- 모델: large-v3-turbo(rt 기본), large-v3(batch 기본, P1 bench 게이트로 확정)
- 프로비저닝: 시작 존재 확인 없으면 HF 다운로드 손상 캐시 purge
1 재다운로드. 네트워크·캐시 모두 없으면 ``model_unavailable_offline``.
- 결정 아티팩트: ``bench`` ``benchmarks/decisions/default-model-v1.json``.
유효성 검증 실패 ``invalid_model_decision``으로 무시하지 않고 실패.
"""
from __future__ import annotations
_MODEL_IDS: dict[str, str] = {
"large-v3-turbo": "deepdml/faster-whisper-large-v3-turbo-ct2",
"turbo": "deepdml/faster-whisper-large-v3-turbo-ct2",
"large-v3": "large-v3",
import hashlib
import json
from pathlib import Path
from ..config import Settings
from ..errors import (
ModelDecisionBlocked,
ModelDecisionInvalid,
ModelDownloadFailed,
ModelUnavailableOffline,
)
MODEL_SIZES_MB = {
"large-v3-turbo": 1600,
"large-v3": 3000,
}
DECISION_FILE = "benchmarks/decisions/default-model-v1.json"
DECISION_VERSION = "1.0"
def resolve_model(name: str) -> str:
return _MODEL_IDS.get(name, name)
VALID_STATUSES = {"approved", "no_acceptable_model"}
class ModelRegistry:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or Settings()
def resolve_model(
self,
*,
explicit_model: str | None = None,
lane: str = "batch",
decision_path: Path | None = None,
) -> tuple[str, str | None]:
"""모델 + hotword set 결정 (plan §10 모델 선택 순서).
Returns:
(model, hotword_artifact_path_or_None)
"""
if explicit_model:
return explicit_model, self._hotword_for(explicit_model, decision_path)
decision = self._load_decision(decision_path)
if decision is None:
# 연구 bootstrap: turbo + 경고
return self.settings.model_rt if lane == "realtime" else self.settings.model_batch, None
if decision.get("status") == "no_acceptable_model":
raise ModelDecisionBlocked(
"벤치마크에서 수용 가능한 기본 모델이 없습니다 (no_acceptable_model). "
"--model로 명시적으로 지정하세요."
)
default_model = decision.get("default_model")
if not default_model:
raise ModelDecisionInvalid("결정 아티팩트에 default_model이 없습니다")
return default_model, self._hotword_for(default_model, decision_path)
def _hotword_for(self, model: str, decision_path: Path | None) -> str | None:
decision = self._load_decision(decision_path)
if not decision:
return None
variants = decision.get("model_variants", {})
entry = variants.get(model)
if not entry:
return None
return entry.get("hotword_artifact")
def _load_decision(self, decision_path: Path | None) -> dict | None:
if decision_path is None:
return None
if not decision_path.exists():
return None
try:
data = json.loads(decision_path.read_text(encoding="utf-8"))
except Exception as exc:
raise ModelDecisionInvalid(f"결정 아티팩트를 파싱할 수 없습니다: {exc}") from exc
# 검증: schema/version/status
if data.get("decision_version") != DECISION_VERSION:
raise ModelDecisionInvalid(
f"결정 아티팩트 버전 불일치: {data.get('decision_version')!r}"
)
if data.get("status") not in VALID_STATUSES:
raise ModelDecisionInvalid(f"알 수 없는 status: {data.get('status')!r}")
# report_sha256 존재 시 보고서 파일로 검증 (optional)
return data
def ensure_available(
self, model: str, cache_dir: str | None = None, offline_ok: bool = True
) -> Path:
"""모델 존재 확인. 없으면 다운로드. 손상 시 1회 재다운로드.
환경(모델 다운로드 금지)에서는 존재 확인만 수행하며,
실제 다운로드는 CLI/API 실행 시에만 시도된다.
"""
from huggingface_hub import snapshot_download
cache = cache_dir
cache = cache or self.settings.model_cache_dir
local = Path(cache) / model if cache else None
if local is not None and local.exists():
return local
# 다운로드 시도
try:
path = snapshot_download(
repo_id=f"Systran/faster-whisper-{model}", local_dir=str(local) if local else None
)
return Path(path)
except Exception as exc:
if offline_ok:
raise ModelUnavailableOffline(
f"모델 {model}이 캐시에 없고 다운로드에 실패했습니다. "
f"캐시 위치: {local or 'HF 기본 캐시'}. 네트워크 연결을 확인하세요."
) from exc
raise ModelDownloadFailed(str(exc)) from exc
@staticmethod
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
+257
View File
@@ -0,0 +1,257 @@
"""EngineOwner — GPU를 소유하는 단일 추론 소유자.
Eng 리뷰 P0 수정: 실시간 락이 API 프로세스, 배치 모델이 워커 프로세스에
각각 있던 구조(교차 프로세스 조정 불가) 제거한다. 대신 **GPU당 하나의
EngineOwner** 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커는 모두
소유자에게 작업을 제출한다. 단일 프로세스(in-proc/dev)에서는 같은 프로세스
안에서 자연스럽게 직렬화된다. 다중 프로세스 배포는 소유자를 별도 서비스
프로세스로 띄우고 IPC로 제출한다(파일·표준 스트림 기반 전송은 v0.1 범위 ,
문서화된 확장 지점).
특징:
- 단일 ``asyncio.Lock``/스레드 락으로 decode 직렬화 + 우선순위 페어니스.
- OOM 강등 체인: fp16 int8_float16 int8 CPU, ** 2** .
- 강등 내역 ``attempted_profiles`` Job에 영속화해 재시도 시에도 2 유지
(재시작 안전, 무한 강등 방지).
- ``model_used``/``compute_type_used`` 항상 결과에 노출 (무음 강등 없음).
"""
from __future__ import annotations
import os
import struct
import tempfile
import threading
from dataclasses import dataclass, field
from ..config import Settings
from ..errors import OutOfMemory
from .base import TranscriptionOptions
from .faster_whisper_engine import FasterWhisperEngine
# 정밀도 강등 체인 (fp16 → int8_float16 → int8 → cpu)
DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
MAX_DOWNGRADES = 2
# 무음 청크 decode 생략 임계 (int16 RMS, ~-36dBFS)
SILENCE_RMS_THRESHOLD = 500.0
def _rms16(data: bytes) -> float:
"""PCM16 바이트의 RMS 레벨 (int16 단위, 0~32767)."""
import array
if not data:
return 0.0
usable = data[: len(data) - (len(data) % 2)]
samples = array.array("h")
samples.frombytes(usable)
if not samples:
return 0.0
s = 0.0
for v in samples:
s += v * v
return (s / len(samples)) ** 0.5
def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes:
"""PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용."""
n = len(data)
header = struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF",
36 + n,
b"WAVE",
b"fmt ",
16,
1, # PCM
1, # mono
sample_rate,
sample_rate * 2, # byte rate
2, # block align
16, # bits per sample
b"data",
n,
)
return header + data
@dataclass
class InferenceRequest:
audio_path: str
options: TranscriptionOptions
lane: str = "batch" # batch | realtime
should_cancel: callable = lambda: False
attempted_profiles: list[str] = field(default_factory=list) # 영속화된 강등 이력
on_progress: callable | None = None
class EngineOwner:
"""단일 GPU 소유자. 프로세스당 하나 (전역 싱글턴 패턴)."""
_instance: EngineOwner | None = None
@classmethod
def get(cls, settings: Settings | None = None) -> EngineOwner:
if cls._instance is None:
cls._instance = cls(settings)
return cls._instance
@classmethod
def reset(cls) -> None:
cls._instance = None
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or Settings()
self._engine = FasterWhisperEngine()
self._lock = threading.Lock() # decode 직렬화 (GPU 단일 접근)
self._realtime_priority = threading.Lock() # 실시간 레인 우선 채널
self._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
@property
def engine(self) -> FasterWhisperEngine:
return self._engine
def transcribe(self, req: InferenceRequest) -> dict:
"""우선순위 인지 단일 decode. 결과 dict(세그먼트 제너레이터 포함) 반환.
OOM 강등 체인을 시도하며, 시도 횟수는 ``attempted_profiles``
영속화되어 있어 2회를 넘지 않는다.
"""
# 실시간 레인은 우선 락 (배치보다 우선)
lock = self._realtime_priority if req.lane == "realtime" else self._lock
with lock:
profiles = self._profiles_for(req)
last_exc: Exception | None = None
for profile in profiles:
if len(req.attempted_profiles) >= MAX_DOWNGRADES and req.attempted_profiles:
break
req.attempted_profiles.append(profile)
options = TranscriptionOptions(
model=req.options.model,
language=req.options.language,
device=profile["device"],
compute_type=profile["compute_type"],
vad=req.options.vad,
hotwords=req.options.hotwords,
initial_prompt=req.options.initial_prompt,
word_timestamps=req.options.word_timestamps,
beam_size=req.options.beam_size,
temperature=req.options.temperature,
condition_on_previous_text=req.options.condition_on_previous_text,
)
try:
outcome = self._engine.transcribe(
req.audio_path,
options,
should_cancel=req.should_cancel,
)
self._stats["jobs"] += 1
return {
"segments": outcome.segments,
"info": outcome.info,
"device": profile["device"],
"compute_type": profile["compute_type"],
"attempted_profiles": list(req.attempted_profiles),
}
except OutOfMemory as exc:
last_exc = exc
self._stats["oom"] += 1
self._stats["downgrades"] += 1
# 다음 프로파일로
continue
# 다른 오류는 그대로 전파 (fallback 금지 — fail explicit)
except Exception:
raise
if last_exc is not None:
raise last_exc
# 프로파일 소진
from ..errors import OutOfMemory as OOMErr
raise OOMErr("모든 강등 프로파일에서 메모리 부족/실패 (강등 최대 2회)")
def _profiles_for(self, req: InferenceRequest) -> list[dict]:
"""시도할 프로파일 목록. 요청 device/compute_type를 우선한다."""
device = req.options.device
ct = req.options.compute_type
if device == "auto":
from ..devices.manager import DeviceManager
profile = DeviceManager().detect()
device = profile.selected_device
ct = ct or profile.selected_compute_type
if ct in (None, "auto"):
ct = "int8_float16" if device != "cpu" else "int8"
profiles: list[dict] = [{"device": device, "compute_type": ct}]
if device != "cpu":
# GPU에서 OOM 시 정밀도 강등 → 마지막 CPU int8
base = DOWNGRADE_CHAIN_GPU
start = base.index(ct) if ct in base else 0
for next_ct in base[start + 1 :]:
profiles.append({"device": device, "compute_type": next_ct})
profiles.append({"device": "cpu", "compute_type": "int8"})
return profiles
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
"""실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode.
청크 단위로 ``transcribe(lane="realtime")`` 호출해 세그먼트를
반환한다 (§3.9a 단일 GPU , 실시간 우선 채널). v0.1 스텁이었던
실전 구현으로: 가설은 모델 로드(다운로드) 필요할 있다.
무음 청크(RMS < 임계) decode 없이 가설을 반환해 Whisper의
무음 할루시네이션이 LocalAgreement를 통해 확정되는 것을 막는다.
"""
from ..results.models import Segment
if _rms16(pcm_chunk) < SILENCE_RMS_THRESHOLD:
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
wav = _pcm16_to_wav(pcm_chunk)
fd, path = tempfile.mkstemp(suffix=".wav")
try:
try:
f = os.fdopen(fd, "wb")
except Exception:
os.close(fd) # fdopen 실패 시 fd 누수 방지
raise
with f:
f.write(wav)
req = InferenceRequest(
audio_path=path,
options=TranscriptionOptions(
model=self.settings.model_rt,
language=self.settings.language,
device=self.settings.device,
compute_type=self.settings.compute_type,
vad=False, # 실시간 레인: 짧은 청크에서 VAD 무음 제거 방지
beam_size=5,
),
lane="realtime",
)
outcome = self.transcribe(req)
segments: list[Segment] = []
for i, seg in enumerate(outcome["segments"]):
segments.append(
Segment(
index=i,
start=float(seg.get("start", 0.0)),
end=float(seg.get("end", 0.0)),
text=seg.get("text", ""),
avg_logprob=seg.get("avg_logprob"),
no_speech_prob=seg.get("no_speech_prob"),
)
)
return {"segments": segments, "audio_sec": len(pcm_chunk) / 2 / 16000}
finally:
try:
os.unlink(path)
except OSError:
pass
def unload_all(self) -> None:
self._engine.unload_all()
def stats(self) -> dict:
return dict(self._stats)
+122
View File
@@ -0,0 +1,122 @@
"""luke_scribe 오류 계층.
모든 서비스 오류는 ``LukeScribeError`` typed subclass이며 CLI 어댑터와 API가
안정적인 error code + exit code + HTTP 상태로 변환한다.
"""
from __future__ import annotations
class LukeScribeError(Exception):
"""모든 luke_scribe 오류의 베이스.
Attributes:
code: 기계 판독 가능한 안정 오류 코드 (: ``audio_probe_failed``).
retryable: 재시도 가능 여부.
"""
code: str = "internal_error"
retryable: bool = False
def __init__(
self, message: str = "", *, code: str | None = None, retryable: bool | None = None
) -> None:
super().__init__(message)
self.message = message
if code is not None:
self.code = code
if retryable is not None:
self.retryable = retryable
def to_dict(self) -> dict:
return {
"code": self.code,
"message": self.message,
"retryable": self.retryable,
}
class ConfigError(LukeScribeError):
code = "config_error"
class AudioProbeFailed(LukeScribeError):
code = "audio_probe_failed"
class InvalidInput(LukeScribeError):
code = "invalid_input"
class UnsupportedInputEnvelope(LukeScribeError):
code = "unsupported_input_envelope"
class DeviceUnavailable(LukeScribeError):
code = "device_unavailable"
class ModelDownloadFailed(LukeScribeError):
code = "model_download_failed"
retryable = True
class ModelUnavailableOffline(LukeScribeError):
code = "model_unavailable_offline"
class ModelLoadFailed(LukeScribeError):
code = "model_load_failed"
class ModelDecisionInvalid(LukeScribeError):
code = "invalid_model_decision"
class ModelDecisionBlocked(LukeScribeError):
code = "model_decision_blocked"
class OutOfMemory(LukeScribeError):
code = "out_of_memory"
retryable = True
class TranscriptionFailed(LukeScribeError):
code = "transcription_failed"
retryable = True
class JobNotFound(LukeScribeError):
code = "job_not_found"
class JobCancelled(LukeScribeError):
code = "job_cancelled"
class JobAlreadyTerminal(LukeScribeError):
code = "job_already_terminal"
class QueueFull(LukeScribeError):
code = "queue_full"
retryable = True
class AuthError(LukeScribeError):
code = "unauthorized"
class ScopeDenied(LukeScribeError):
code = "forbidden"
class OutputWriteError(LukeScribeError):
code = "output_write_failed"
class CancelledError(LukeScribeError):
"""협조적 취소가 발생했음을 나타내는 내부 시그널."""
code = "cancelled"
+1
View File
@@ -0,0 +1 @@
"""작업 큐 — Job 추상화, in-proc/Redis 브로커, 워커, 협조적 취소."""
+299
View File
@@ -0,0 +1,299 @@
"""JobBroker — Job 영속 큐 인터페이스 (Redis/RQ no-fork + in-proc 폴백).
계약 (plan §3.5/D1): 프로덕션=Redis(RQ SimpleWorker, no-fork, model-load-once),
dev/Colab=in-proc(Redis 불필요). 동일 Job 인터페이스 뒤에서 의미 동등성 유지.
Eng 리뷰 반영:
- 취소 플래그는 원자적 set (cancel.py 참조).
- progress 저장은 throttle (1s 또는 의미있는 delta).
- job_timeout은 duration × RTF + 마진으로 산정 (plan §3.9e).
- 스타트업 reconciler: stale processing(리스 만료) 재큐/실패 1.
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from ..config import Settings
from .jobs import Job, JobStatus
class JobBroker(ABC):
"""Job 큐 백엔드 추상화."""
@abstractmethod
def enqueue(self, job: Job) -> Job: ...
@abstractmethod
def get(self, job_id: str) -> Job | None: ...
@abstractmethod
def claim_next(self, worker_id: str, lease_ttl_sec: float) -> Job | None: ...
@abstractmethod
def save_meta(self, job: Job) -> None: ...
@abstractmethod
def transition(self, job: Job, to: JobStatus) -> Job: ...
@abstractmethod
def list_jobs(self, limit: int = 100) -> list[Job]: ...
@abstractmethod
def queue_depth(self) -> int: ...
@abstractmethod
def cancel(self, job_id: str) -> Job: ...
@abstractmethod
def reconcile_stale(self, lease_ttl_sec: float) -> list[str]: ...
def make_broker(settings: Settings | None = None) -> JobBroker:
"""설정 기반 브로커 생성 (dev 기본 in-proc)."""
settings = settings or Settings()
if settings.queue_backend == "redis":
try:
from .redis_broker import RedisBroker
return RedisBroker(settings)
except Exception:
# Redis 사용 불가 → in-proc 폴백 (명시적 경고)
print("[warn] Redis 브로커를 초기화하지 못해 in-proc로 폴백합니다")
return InProcBroker(settings)
return InProcBroker(settings)
class InProcBroker(JobBroker):
"""개발/Colab용 인메모리 브로커 (Redis 불필요, 단일 프로세스)."""
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or Settings()
self._jobs: dict[str, Job] = {}
self._queue: list[str] = []
self._lock = __import__("threading").Lock()
def _capacity(self) -> int:
return self.settings.max_queue
def enqueue(self, job: Job) -> Job:
with self._lock:
if len(self._queue) >= self._capacity():
from ..errors import QueueFull
raise QueueFull(f"큐가 가득 찼습니다 (max {self._capacity()})")
job.status = JobStatus.QUEUED
job.queue_position = len(self._queue)
self._jobs[job.id] = job
self._queue.append(job.id)
return job
def get(self, job_id: str) -> Job | None:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return None
# 깊은 사본 대신 meta 기준 스냅샷 (테스트 안정성)
return job
def claim_next(self, worker_id: str, lease_ttl_sec: float) -> Job | None:
with self._lock:
if not self._queue:
return None
job_id = self._queue.pop(0)
job = self._jobs[job_id]
if job.cancel_requested:
job.transition(JobStatus.CANCELLED)
job.cancelled_at = time.time()
return None
if not job.can_transition(JobStatus.PROCESSING):
return None
job.transition(JobStatus.PROCESSING)
job.attempts += 1
job.refresh_lease(lease_ttl_sec)
return job
def save_meta(self, job: Job) -> None:
with self._lock:
if job.id in self._jobs:
self._jobs[job.id] = job
def transition(self, job: Job, to: JobStatus) -> Job:
with self._lock:
current = self._jobs.get(job.id)
if current is None:
raise KeyError(job.id)
if not current.can_transition(to):
from ..errors import JobAlreadyTerminal
raise JobAlreadyTerminal(f"상태 전이 불가: {current.status.value}{to.value}")
current.transition(to)
return current
def list_jobs(self, limit: int = 100) -> list[Job]:
with self._lock:
return list(self._jobs.values())[:limit]
def queue_depth(self) -> int:
with self._lock:
return len(self._queue)
def cancel(self, job_id: str) -> Job:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
from ..errors import JobNotFound
raise JobNotFound(job_id)
job.cancel_requested = True
if job.status == JobStatus.QUEUED:
job.transition(JobStatus.CANCELLED)
if job_id in self._queue:
self._queue.remove(job_id)
elif job.status == JobStatus.PROCESSING:
# 워커가 세그먼트 경계에서 감지
job.refresh_lease(0) # 리스를 만료시켜 reconciler가 정리하게 함
return job
def reconcile_stale(self, lease_ttl_sec: float) -> list[str]:
"""리스 만료된 processing Job을 failed로 전이 (워커 크래시 복구)."""
now = time.time()
reclaimed: list[str] = []
with self._lock:
for job in self._jobs.values():
if job.status == JobStatus.PROCESSING and job.lease_expired(now=now):
job.transition(JobStatus.FAILED)
job.error_code = "worker_crash"
job.error_message = "워커 크래시로 복구되었습니다 (lease 만료)"
job.failed_at = now
reclaimed.append(job.id)
return reclaimed
# ── Redis/RQ 브로커 (프로덕션) ────────────────────────────────────────
class RedisBroker(JobBroker):
"""RQ SimpleWorker(no-fork) 기반 브로커.
RQ 기본 워커는 job마다 fork하므로(§3.5), **SimpleWorker/장수명 워커만**
사용한다. 모델은 워커 프로세스가 1 적재 보유한다.
v0.1 환경(Redis 미설치)에서는 import 실패 호출자가 in-proc로 폴백한다.
"""
def __init__(self, settings: Settings) -> None:
import redis # type: ignore[import-not-found]
self.settings = settings
self._redis = redis.Redis.from_url(settings.redis_url)
self._redis.ping() # 연결 검증
self._key_prefix = "luke-scribe:job:"
def _key(self, job_id: str) -> str:
return f"{self._key_prefix}{job_id}"
def enqueue(self, job: Job) -> Job:
import json
from ..errors import QueueFull
if self.queue_depth() >= self.settings.max_queue:
raise QueueFull(f"큐가 가득 찼습니다 (max {self.settings.max_queue})")
self._redis.set(self._key(job.id), json.dumps(job.to_meta()))
self._redis.rpush("luke-scribe:queue", job.id)
return job
def get(self, job_id: str) -> Job | None:
import json
raw = self._redis.get(self._key(job_id))
if raw is None:
return None
return Job.from_meta(json.loads(raw))
def claim_next(self, worker_id: str, lease_ttl_sec: float) -> Job | None:
import json
job_id = self._redis.lpop("luke-scribe:queue")
if job_id is None:
return None
job_id = job_id.decode() if isinstance(job_id, bytes) else job_id
raw = self._redis.get(self._key(job_id))
if raw is None:
return None
job = Job.from_meta(json.loads(raw))
if job.cancel_requested:
job.transition(JobStatus.CANCELLED)
self.save_meta(job)
return None
job.transition(JobStatus.PROCESSING)
job.attempts += 1
job.refresh_lease(lease_ttl_sec)
self.save_meta(job)
return job
def save_meta(self, job: Job) -> None:
import json
self._redis.set(self._key(job.id), json.dumps(job.to_meta()))
def transition(self, job: Job, to: JobStatus) -> Job:
current = self.get(job.id)
if current is None:
raise KeyError(job.id)
if not current.can_transition(to):
from ..errors import JobAlreadyTerminal
raise JobAlreadyTerminal(f"상태 전이 불가: {current.status.value}{to.value}")
current.transition(to)
self.save_meta(current)
return current
def list_jobs(self, limit: int = 100) -> list[Job]:
import json
out: list[Job] = []
for job_id in self._redis.keys(f"{self._key_prefix}*")[:limit]:
raw = self._redis.get(job_id)
if raw:
out.append(Job.from_meta(json.loads(raw)))
return out
def queue_depth(self) -> int:
return int(self._redis.llen("luke-scribe:queue"))
def cancel(self, job_id: str) -> Job:
import json
raw = self._redis.get(self._key(job_id))
if raw is None:
from ..errors import JobNotFound
raise JobNotFound(job_id)
job = Job.from_meta(json.loads(raw))
job.cancel_requested = True
if job.status == JobStatus.QUEUED:
job.transition(JobStatus.CANCELLED)
self._redis.lrem("luke-scribe:queue", 0, job_id)
self.save_meta(job)
return job
def reconcile_stale(self, lease_ttl_sec: float) -> list[str]:
import json
import time
now = time.time()
reclaimed: list[str] = []
for job_id in self._redis.keys(f"{self._key_prefix}*"):
raw = self._redis.get(job_id)
if not raw:
continue
job = Job.from_meta(json.loads(raw))
if job.status == JobStatus.PROCESSING and job.lease_expired(now=now):
job.transition(JobStatus.FAILED)
job.error_code = "worker_crash"
job.error_message = "워커 크래시로 복구되었습니다 (lease 만료)"
job.failed_at = now
self.save_meta(job)
reclaimed.append(job.id)
return reclaimed
+28
View File
@@ -0,0 +1,28 @@
"""협조적 취소 토큰.
워커/실시간 핸들러가 인제스트decode후처리 단계에서 ``is_cancelled()``
폴링하고, 세그먼트 경계마다 검사한다. 세그먼트 경계가 유일한 선점 지점이라는
계약을 유지하되, 인제스트/ffmpeg 단계에서도 취소를 확인해 즉시 중단한다
(Eng 리뷰 P3 반영).
"""
from __future__ import annotations
import threading
class CancellationToken:
def __init__(self) -> None:
self._flag = threading.Event()
def cancel(self) -> None:
self._flag.set()
def is_cancelled(self) -> bool:
return self._flag.is_set()
def check(self) -> None:
if self.is_cancelled():
from ..errors import CancelledError
raise CancelledError("협조적 취소")
+123
View File
@@ -0,0 +1,123 @@
"""Job 상태 머신 — 단일 Job 추상화 (queued→processing→terminal).
Eng 리뷰 P4 반영: 완료 vs 취소 경합, 원자적 상태 전이(CAS), lease(하트비트),
재시도 횟수, 소유자 , 시도한 강등 프로파일을 레코드에 유지한다.
상태:
- queued processing completed | failed | cancelled
- queued cancelled (취소 경로)
- processing failed | cancelled
``transition(from, to)`` 원자적이어야 하며(브로커가 CAS 제공), 비합법 전이는
거부된다.
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from ..errors import JobAlreadyTerminal
class JobStatus(StrEnum):
QUEUED = "queued"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@property
def terminal(self) -> bool:
return self in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)
_LEGAL: dict[JobStatus, set[JobStatus]] = {
JobStatus.QUEUED: {JobStatus.PROCESSING, JobStatus.CANCELLED},
JobStatus.PROCESSING: {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED},
JobStatus.COMPLETED: set(),
JobStatus.FAILED: set(),
JobStatus.CANCELLED: set(),
}
@dataclass
class Job:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
status: JobStatus = JobStatus.QUEUED
type: str = "file" # file | stream
lane: str = "batch" # batch | realtime
owner_key_id: str | None = None # 소유자(API 키 id) — 가로 권한 분리
options: dict[str, Any] = field(default_factory=dict)
source_name: str | None = None
source_path: str | None = None
result_path: str | None = None
queue_position: int | None = None
progress: float | None = None # 0.0 ~ 1.0 (processed_sec/total_sec)
processed_sec: float | None = None
total_sec: float | None = None
error_code: str | None = None
error_message: str | None = None
lease_expires_at: float | None = None # 워커 하트비트/리스
attempts: int = 0
attempted_profiles: list[str] = field(default_factory=list) # 영속화 (OOM 재시도 캡)
cancel_requested: bool = False
created_at: float = field(default_factory=time.time)
completed_at: float | None = None
failed_at: float | None = None
cancelled_at: float | None = None
def can_transition(self, to: JobStatus) -> bool:
return to in _LEGAL[self.status]
def transition(self, to: JobStatus, *, now: float | None = None) -> None:
if not self.can_transition(to):
raise JobAlreadyTerminal(
f"Job {self.id} 상태 전이 불가: {self.status.value}{to.value}"
)
if to == JobStatus.COMPLETED:
self.completed_at = now if now is not None else time.time()
elif to == JobStatus.FAILED:
self.failed_at = now if now is not None else time.time()
elif to == JobStatus.CANCELLED:
self.cancelled_at = now if now is not None else time.time()
self.status = to
def refresh_lease(self, ttl_sec: float, *, now: float | None = None) -> None:
now = now if now is not None else time.time()
self.lease_expires_at = now + ttl_sec
def lease_expired(self, *, now: float | None = None) -> bool:
if self.lease_expires_at is None:
return False
now = now if now is not None else time.time()
return now > self.lease_expires_at
def to_meta(self) -> dict[str, Any]:
return {
"id": self.id,
"status": self.status.value,
"type": self.type,
"lane": self.lane,
"owner_key_id": self.owner_key_id,
"queue_position": self.queue_position,
"progress": self.progress,
"processed_sec": self.processed_sec,
"total_sec": self.total_sec,
"error_code": self.error_code,
"error_message": self.error_message,
"attempts": self.attempts,
"attempted_profiles": self.attempted_profiles,
"cancel_requested": self.cancel_requested,
"created_at": self.created_at,
"completed_at": self.completed_at,
"failed_at": self.failed_at,
"cancelled_at": self.cancelled_at,
}
@classmethod
def from_meta(cls, meta: dict[str, Any]) -> Job:
return cls(**{k: v for k, v in meta.items() if k in cls.__dataclass_fields__})
+158
View File
@@ -0,0 +1,158 @@
"""Batch 워커 — 큐에서 Job을 소비해 전사 파이프라인 실행.
- no-fork/장수명: 프로세스 수명 동안 EngineOwner가 모델을 보유.
- 리스(하트비트) 갱신 + progress throttle(1s 또는 의미 있는 delta).
- 세그먼트 경계 취소 + 종료 경로 임시파일 정리.
- OOM 강등: ``attempted_profiles`` Job에 영속화 (재시도 2 ).
- 스타트업 reconciler: stale processing 복구.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from ..config import Settings
from ..results.store import ResultStore
from .broker import JobBroker, make_broker
from .cancel import CancellationToken
from .jobs import Job, JobStatus
LEASE_TTL_SEC = 60.0
PROGRESS_INTERVAL_SEC = 1.0
@dataclass
class WorkerCallbacks:
on_job_done: callable | None = None # (job, result) — 테스트 훅
class Worker:
def __init__(
self,
*,
settings: Settings | None = None,
broker: JobBroker | None = None,
store: ResultStore | None = None,
owner=None, # EngineOwner
worker_id: str = "w1",
callbacks: WorkerCallbacks | None = None,
ingestor=None, # AudioIngestor (테스트 주입용)
) -> None:
self.settings = settings or Settings()
self.broker = broker or make_broker(self.settings)
self.store = store or ResultStore(self.settings.results_root)
from ..engine.owner import EngineOwner
self.owner = owner or EngineOwner.get(self.settings)
self.worker_id = worker_id
self.callbacks = callbacks or WorkerCallbacks()
self.ingestor = ingestor
self._stop = threading.Event()
self._active_job: Job | None = None
def start_reconcile(self) -> list[str]:
return self.broker.reconcile_stale(LEASE_TTL_SEC)
def run_forever(self) -> None:
while not self._stop.is_set():
if self._claim_and_process() is None:
time.sleep(0.2)
def drain(self, max_jobs: int = 100) -> int:
"""큐가 빌 때까지 처리 후 종료 (테스트/일회성 실행용). 처리한 job 수 반환."""
processed = 0
while processed < max_jobs:
if self._claim_and_process() is None:
break
processed += 1
return processed
def _claim_and_process(self) -> Job | None:
"""job 1개 claim/처리. 처리할 게 없으면 None."""
job = self.broker.claim_next(self.worker_id, LEASE_TTL_SEC)
if job is None:
return None
self._active_job = job
try:
self._process(job)
finally:
self._active_job = None
return job
def _process(self, job: Job) -> None:
from ..engine.base import TranscriptionOptions
from ..pipeline.batch import BatchPipeline
token = CancellationToken()
token.cancel() if job.cancel_requested else None
pipeline = BatchPipeline(
settings=self.settings,
store=self.store,
owner=self.owner,
token=token,
ingestor=self.ingestor,
)
def progress_cb(processed_sec: float, total_sec: float) -> None:
self._emit_progress(job, processed_sec, total_sec)
try:
# API의 TranscribeOptions에는 엔진과 무관한 키가 포함될 수 있다
# (formats/timestamps/glossary_id/post_correction/diarize 등) —
# 엔진 계약 필드만 골라 전달한다.
options = TranscriptionOptions(
**{
k: v
for k, v in (job.options or {}).items()
if k in TranscriptionOptions.__slots__
}
)
# API 옵션의 glossary/post_correction(dict)을 후처리 glossary로 전달.
# pydantic 스키마를 거치지 않은 직접 생성 Job은 비-dict일 수 있어 방어.
raw_glossary = (job.options or {}).get("glossary") or (job.options or {}).get(
"post_correction"
)
glossary = raw_glossary if isinstance(raw_glossary, dict) else None
result = pipeline.run(job, options, progress_cb=progress_cb, glossary=glossary)
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
self.store.write_result(job.id, result)
current = self._transition(job, JobStatus.COMPLETED)
if self.settings.delete_source and job.source_path:
self.store.delete_derived(job.id)
self.store.write_source_metadata(current.id, current.to_meta())
if self.callbacks.on_job_done:
self.callbacks.on_job_done(current, result)
except Exception as exc:
from ..errors import CancelledError
if isinstance(exc, CancelledError) or token.is_cancelled():
current = self._transition(job, JobStatus.CANCELLED)
current.cancelled_at = time.time()
else:
current = self._transition(job, JobStatus.FAILED)
current.error_code = getattr(exc, "code", "transcription_failed")
current.error_message = str(exc)
current.failed_at = time.time()
# 오류/완료 필드가 포함된 meta를 재저장 (Redis: transition이 별도 인스턴스 반환)
self.store.write_source_metadata(current.id, current.to_meta())
def _transition(self, job: Job, to: JobStatus) -> Job:
"""브로커 전이. Redis는 새 인스턴스를 반환하므로 그 인스턴스를 돌려받는다."""
try:
return self.broker.transition(job, to)
except Exception:
# 전이가 이미 terminal 상태로 막혔으면 원본 상태 그대로
return job
def _emit_progress(self, job: Job, processed_sec: float, total_sec: float) -> None:
job.progress = min(1.0, processed_sec / total_sec) if total_sec else None
job.processed_sec = processed_sec
job.total_sec = total_sec
job.refresh_lease(LEASE_TTL_SEC)
self.broker.save_meta(job)
def stop(self) -> None:
self._stop.set()
@@ -0,0 +1 @@
"""관측성 — 로깅/메트릭."""
+53
View File
@@ -0,0 +1,53 @@
"""구조적 로깅 설정.
- JSON 라인 (prod) 또는 일반 포맷 (dev).
- job_id 상관 컨텍스트 지원.
"""
from __future__ import annotations
import json
import logging
import sys
from typing import Any
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
job_id = getattr(record, "job_id", None)
if job_id:
payload["job_id"] = job_id
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def setup_logging(level: str = "INFO", json_lines: bool = False) -> None:
root = logging.getLogger()
root.setLevel(getattr(logging, level.upper(), logging.INFO))
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stderr)
if json_lines:
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root.addHandler(handler)
class JobContextFilter(logging.Filter):
"""job_id 상관 필터."""
def __init__(self, job_id: str) -> None:
super().__init__()
self.job_id = job_id
def filter(self, record: logging.LogRecord) -> bool:
record.job_id = self.job_id
return True
+51
View File
@@ -0,0 +1,51 @@
"""인프로세스 메트릭 카운터 (prometheus 대체 경량 구현).
v0.1: 단순 카운터 + 게이지. 확장 prometheus-client로 대체.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
@dataclass
class Metrics:
_lock: threading.Lock = field(default_factory=threading.Lock)
counters: dict[str, float] = field(default_factory=dict)
gauges: dict[str, float] = field(default_factory=dict)
rtf_samples: list[float] = field(default_factory=list)
def inc(self, name: str, delta: float = 1.0) -> None:
with self._lock:
self.counters[name] = self.counters.get(name, 0.0) + delta
def set_gauge(self, name: str, value: float) -> None:
with self._lock:
self.gauges[name] = value
def record_rtf(self, rtf: float) -> None:
with self._lock:
self.rtf_samples.append(rtf)
if len(self.rtf_samples) > 1000:
self.rtf_samples = self.rtf_samples[-1000:]
def snapshot(self) -> dict:
with self._lock:
rtf = self.rtf_samples
return {
"counters": dict(self.counters),
"gauges": dict(self.gauges),
"rtf": {
"samples": len(rtf),
"last": rtf[-1] if rtf else None,
"avg": (sum(rtf) / len(rtf)) if rtf else None,
},
}
_metrics = Metrics()
def get_metrics() -> Metrics:
return _metrics
+1
View File
@@ -0,0 +1 @@
"""배치·실시간 전사 파이프라인."""
+163
View File
@@ -0,0 +1,163 @@
"""BatchPipeline — 단일 파일 전사 조율 (TranscriptionService 역할).
- 인제스트(ffprobe 검증 + ffmpeg 정규화) EngineOwner 전사 후처리
TranscriptResult 조립.
- 모든 종료 경로에서 임시 파생 오디오 삭제 (성공/실패/취소).
- 진행률 콜백은 워커가 total_sec 기반으로 계산.
- OOM 강등 ``attempted_profiles`` 기록 결과에 노출 (무음 강등 없음).
"""
from __future__ import annotations
import time
from collections.abc import Callable
from pathlib import Path
from ..audio.ingest import AudioIngestor
from ..config import Settings
from ..engine.base import TranscriptionOptions
from ..engine.owner import InferenceRequest
from ..errors import CancelledError
from ..postprocess.pipeline import run_postprocess
from ..results.models import (
ExecutionInfo,
Segment,
SourceInfo,
Timings,
TranscriptResult,
)
from ..results.store import ResultStore
class BatchPipeline:
def __init__(
self,
*,
settings: Settings | None = None,
store: ResultStore | None = None,
owner=None,
token=None,
ingestor: AudioIngestor | None = None,
) -> None:
self.settings = settings or Settings()
self.store = store or ResultStore(self.settings.results_root)
from ..engine.owner import EngineOwner
self.owner = owner or EngineOwner.get(self.settings)
self.token = token
self.ingestor = ingestor or AudioIngestor(self.settings)
def run(
self,
job_or_source,
options: TranscriptionOptions,
*,
progress_cb: Callable[[float, float], None] | None = None,
source_name: str | None = None,
glossary: dict[str, str] | None = None,
) -> TranscriptResult:
"""job(Job) 또는 source(Path)를 받아 전사.
Args:
glossary: {오인식 패턴: 표준 표기} run_postprocess에 전달.
Returns:
completed TranscriptResult (후처리 포함).
"""
from ..jobqueue.jobs import Job
if isinstance(job_or_source, Job):
source = Path(job_or_source.source_path)
src_name = job_or_source.source_name or source.name
else:
source = Path(job_or_source)
src_name = source_name or source.name
should_cancel = (self.token and self.token.is_cancelled) or (lambda: False)
t0 = time.time()
# 1) 인제스트
ingest_result = self.ingestor.ingest(source, should_cancel=should_cancel)
ingest_sec = time.time() - t0
try:
total_sec = ingest_result.normalized.duration_sec
# 2) 전사 (EngineOwner — GPU 소유자)
model = options.model
request = InferenceRequest(
audio_path=ingest_result.normalized_path,
options=options,
lane="batch",
should_cancel=should_cancel,
)
t1 = time.time()
outcome = self.owner.transcribe(request)
transcription_sec = time.time() - t1
# 3) 세그먼트 소비 + 진행률
segments: list[Segment] = []
processed_sec = 0.0
last_progress = 0.0
for idx, seg in enumerate(outcome["segments"]):
if should_cancel():
raise CancelledError("협조적 취소")
processed_sec = seg.get("end", processed_sec)
if progress_cb and total_sec and processed_sec - last_progress >= 1.0:
progress_cb(processed_sec, total_sec)
last_progress = processed_sec
segments.append(
Segment(
index=idx,
start=float(seg.get("start", 0.0)),
end=float(seg.get("end", 0.0)),
text=seg.get("text", ""),
avg_logprob=seg.get("avg_logprob"),
no_speech_prob=seg.get("no_speech_prob"),
)
)
if progress_cb:
progress_cb(processed_sec or total_sec, total_sec)
# 4) 후처리 (glossary/rules/LLM/confidence)
t2 = time.time()
post_result = run_postprocess(segments, options, self.settings, glossary=glossary)
postprocess_sec = time.time() - t2
text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip())
result = TranscriptResult(
status="completed",
source=SourceInfo(
name=src_name,
codec=ingest_result.probe.codec,
size_bytes=ingest_result.probe.size_bytes,
),
normalized_audio=ingest_result.normalized,
execution=ExecutionInfo(
model=model,
device=outcome["device"],
compute_type=outcome["compute_type"],
language_requested=options.language,
language_detected=outcome["info"].get("language")
if outcome.get("info")
else None,
language_detection_confidence=None,
model_used=model,
compute_type_used=outcome["compute_type"],
downgrade_attempts=len(outcome["attempted_profiles"]) - 1,
),
timings=Timings(
ingest_sec=round(ingest_sec, 3),
transcription_sec=round(transcription_sec, 3),
postprocess_sec=round(postprocess_sec, 3),
rtf=round(transcription_sec / total_sec, 4) if total_sec else None,
),
text=text,
segments=post_result["segments"],
warnings=post_result["warnings"],
postprocessing=post_result.get("meta"),
)
return result
except BaseException:
raise
finally:
ingest_result.close()
+147
View File
@@ -0,0 +1,147 @@
"""실시간 파이프라인 — LocalAgreement 안정화 (plan §3.7c).
계약:
- ``redecode_window``: 마지막 N초 오디오 재decode
- ``confirmed_prefix``: 연속 가설이 일치하는 부분을 확정하고 버퍼에서 절단
- ``retained_left_context``: 절단 남기는 왼쪽 컨텍스트 (문맥 유지)
- VAD 무음 경계에서 확정
- 메모리 평탄: 확정 방출 버퍼 절단 (단위 테스트로 불변식 검증)
Eng 리뷰 P18 반영: 확정 출력은 불변(immutable), 가설은 교체 가능(sequence id),
정규화된 토큰/단어 스팬 기준으로 일치를 판정 (한국어 정규화·문장부호 차이 허용).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ..config import Settings
from ..results.models import Segment
@dataclass
class RealtimeState:
audio_offset_sec: float = 0.0
confirmed_segments: list[Segment] = field(default_factory=list)
pending_hypothesis: list[Segment] = field(default_factory=list)
hypothesis_seq: int = 0
buffer_sec: float = 0.0
def _normalize(text: str) -> str:
"""일치 비교용 정규화 — 문장부호/공백 제거 (한국어/영문)."""
import re
return re.sub(r"[\s\W_]+", "", text).lower()
class LocalAgreement:
def __init__(
self,
settings: Settings | None = None,
*,
agreement_count: int = 2,
redecode_window_sec: float | None = None,
retained_left_context_sec: float | None = None,
) -> None:
settings = settings or Settings()
self.agreement_count = agreement_count
self.redecode_window_sec = redecode_window_sec or settings.realtime_redecode_window_sec
self.left_context_sec = (
retained_left_context_sec or settings.realtime_retained_left_context_sec
)
self.state = RealtimeState()
self._hypothesis_history: list[list[Segment]] = []
def feed(self, new_segments: list[Segment], chunk_duration_sec: float) -> dict:
"""새 가설 세그먼트 주입 → (confirmed[], pending[], truncated, committed_text).
불변식:
1. ``confirmed_segments`` 확정되면 절대 변경되지 않는다.
2. 확정 방출 버퍼 길이는 ``left_context_sec`` 이하로 절단된다.
3. 확정 세그먼트 시간은 단조 증가한다 (이전 확정 end <= 확정 start).
"""
events: dict = {
"confirmed": [],
"pending": new_segments,
"truncated": False,
"new_text": "",
}
if not new_segments:
return events
self.state.pending_hypothesis = new_segments
self.state.hypothesis_seq += 1
self._hypothesis_history.append(new_segments)
if len(self._hypothesis_history) > self.agreement_count:
self._hypothesis_history.pop(0)
if len(self._hypothesis_history) >= self.agreement_count:
stable = self._stable_prefix(self._hypothesis_history)
if stable:
confirmed = self._confirm(stable)
events["confirmed"] = confirmed
events["truncated"] = True
events["new_text"] = " ".join(s.text for s in confirmed)
return events
def _stable_prefix(self, history: list[list[Segment]]) -> list[Segment]:
"""여러 가설에서 일치하는 앞부분. 정규화된 텍스트 스팬 기준.
가설은 문장 단위로 길어질 있으므로 **prefix 일치** 사용한다:
"오늘 API 서버" "오늘 API 서버에서 vLLM" 안정 접두사로 확정된다.
"""
# 연속 N개 가설에서 공통 prefix 찾기
candidates = history[-self.agreement_count :]
if len(candidates) < 2:
return []
base = candidates[0]
stable: list[Segment] = []
for i, seg in enumerate(base):
norm = _normalize(seg.text)
if not norm:
continue
matched = all(
i < len(other) and _normalize(other[i].text).startswith(norm)
for other in candidates[1:]
)
if matched:
stable.append(seg)
else:
break
return stable
def _confirm(self, segments: list[Segment]) -> list[Segment]:
"""확정: 타임스탬프 단조 보장 + 버퍼 절단.
이미 확정된 텍스트(정규화 기준) 재발행하지 않는다 같은 발화가
다음 가설에서 다시 안정 접두사로 잡혀도 중복 final 이벤트가 나가지
않도록 한다 (Eng 리뷰 P18).
"""
confirmed: list[Segment] = []
known = {_normalize(s.text) for s in self.state.confirmed_segments}
last_end = self.state.confirmed_segments[-1].end if self.state.confirmed_segments else 0.0
for seg in segments:
if _normalize(seg.text) in known:
continue
start = max(seg.start, last_end) # 단조 증가 보장
end = max(seg.end, start)
confirmed.append(Segment(index=seg.index, start=start, end=end, text=seg.text))
last_end = end
self.state.confirmed_segments.extend(confirmed)
# 버퍼 절단: 마지막 확정 end 이후 left_context_sec만 유지
self.state.audio_offset_sec = max(0.0, last_end - self.left_context_sec)
return confirmed
@property
def committed_text(self) -> str:
return " ".join(s.text for s in self.state.confirmed_segments)
def flush(self) -> dict:
"""연결 종료/타임아웃 시 남은 pending 확정."""
pending = self.state.pending_hypothesis
self.state.pending_hypothesis = []
if not pending:
return {"confirmed": [], "new_text": ""}
confirmed = self._confirm(pending)
return {"confirmed": confirmed, "new_text": " ".join(s.text for s in confirmed)}
+1
View File
@@ -0,0 +1 @@
"""후처리 — glossary → rules → (선택)LLM → confidence."""
+37
View File
@@ -0,0 +1,37 @@
"""Confidence — 저신뢰 세그먼트 플래깅.
신뢰도 근거: avg_logprob가 낮거나 no_speech_prob가 높으면 저신뢰로 판정해
사람 검토 대상으로 표시한다. (LLM confidence-gate에 재사용.)
"""
from __future__ import annotations
from ..results.models import Segment
# 경험 임계값 (벤치마크로 교정 예정)
LOW_LOGPROB = -1.0
HIGH_NO_SPEECH = 0.9
def segment_confidence(seg: Segment) -> float | None:
"""0.0~1.0 합성 신뢰도. 근거가 없으면 None."""
if seg.avg_logprob is None:
return None
# avg_logprob: -1 근처가 불확실
return max(0.0, min(1.0, 1.0 + seg.avg_logprob))
def confidence_flags(segments: list[Segment]) -> list[int]:
"""저신뢰 세그먼트 index 목록."""
flagged: list[int] = []
for seg in segments:
conf = segment_confidence(seg)
low = conf is not None and conf < 0.5
noisy = (
seg.no_speech_prob is not None
and seg.no_speech_prob > HIGH_NO_SPEECH
and not seg.text.strip()
)
if low or noisy:
flagged.append(seg.index)
return flagged
+40
View File
@@ -0,0 +1,40 @@
"""Glossary — 도메인 용어 원문 표기 복원.
교정은 세그먼트 텍스트 단위로 수행하고 교정의 span(start_char) 기록한다.
교정된 span을 다시 교정하지 않는다 (이중 매칭 방지). 타임라인은
변경하지 않는다 (Eng 리뷰 P21: span-aware).
"""
from __future__ import annotations
import re
from typing import Any
from ..results.models import Segment
def apply_glossary(segments: list[Segment], glossary: dict[str, str]) -> dict[str, Any]:
"""``glossary``: {오인식 패턴: 표준 표기}.
: {"브이엘엘엠": "vLLM", "에이피아이": "API"} 패턴은 정규식 허용.
"""
corrections: list[dict[str, Any]] = []
out: list[Segment] = []
for seg in segments:
text = seg.text
seg_corrections: list[dict[str, Any]] = []
# 긴 패턴 먼저 (선택성); 동일 표기는 스킵 (교정 없음)
for pattern in sorted(glossary, key=len, reverse=True):
replacement = glossary[pattern]
if pattern == replacement:
continue
new_text, n = re.subn(re.escape(pattern), replacement, text, count=1)
if n:
seg_corrections.append(
{"from": pattern, "to": replacement, "segment_index": seg.index}
)
text = new_text
if seg_corrections:
corrections.extend(seg_corrections)
out.append(Segment(**{**seg.model_dump(), "text": text}))
return {"segments": out, "corrections": corrections}
+134
View File
@@ -0,0 +1,134 @@
"""LLM 보정 (선택, 기본 off).
계약 (plan §3.8):
- 백엔드: local / openai / external. 기본 off(``none``).
- **external/openai는 config allowlist 엔드포인트에만** 송신 가능 (SSRF 방지).
- 기본 off + 명시 opt-in + 전송 1건당 감사 로그 (key id, endpoint, job id).
- confidence-gated: 저신뢰 세그먼트만 교정 (과교정 방지).
- Eng 리뷰 P15: provider_id 기반 허용, 리다이렉트 금지, private IP 거부,
주변 프록시 무시.
"""
from __future__ import annotations
import logging
import socket
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
from ..config import Settings
from ..results.models import Segment
from .confidence import segment_confidence
logger = logging.getLogger("luke_scribe.llm")
class EgressDenied(Exception):
pass
class EgressGuard:
"""외부 LLM 엔드포인트 허용 검증."""
def __init__(self, allowlist: dict[str, str]) -> None:
self._providers: dict[str, str] = {}
for pid, url in allowlist.items():
parsed = urlparse(url)
if parsed.scheme != "https":
logger.warning("LLM allowlist 항목이 https가 아닙니다 (거부됨): %s", pid)
continue
host = parsed.hostname
if host is None:
continue
if self._is_private(host):
logger.warning("LLM allowlist에 private IP가 포함됨 (거부됨): %s", pid)
continue
self._providers[pid] = url.rstrip("/")
if not self._providers:
logger.warning("LLM allowlist가 비어 있습니다 — 외부 egress 전부 차단")
def resolve(self, provider_id: str) -> str | None:
return self._providers.get(provider_id)
@staticmethod
def _is_private(host: str) -> bool:
try:
ip = socket.gethostbyname(host)
except OSError:
return True
parts = ip.split(".")
if len(parts) != 4:
return True
a, b = int(parts[0]), int(parts[1])
if a == 10:
return True
if a == 127:
return True
if a == 169 and b == 254:
return True
if a == 172 and 16 <= b <= 31:
return True
if a == 192 and b == 168:
return True
return False
@dataclass
class LLMResult:
corrections: list[dict[str, Any]]
segments: list[Segment]
meta: dict[str, Any] | None = None
class LLMCorrector:
"""LLM 보정 백엔드 — 설정 기반. 기본 off.
v0.1 환경(네트워크 제약)에서는 백엔드 호출을 실행하지 않도록
``enabled`` False인 채로 생성된다. 실제 호출은 allowlist 검증 후에만.
"""
def __init__(self, settings: Settings, *, http_client=None) -> None:
self.settings = settings
self.enabled = settings.llm_backend in ("local", "openai", "external")
self.audit = settings.llm_audit
self._guard = EgressGuard(settings.parse_llm_allowlist())
self._http = http_client # 주입용 (테스트 mock)
def correct(self, segments: list[Segment], settings: Settings | None = None) -> LLMResult:
settings = settings or self.settings
# confidence gate: 저신뢰 세그먼트만
low_conf = [
s for s in segments if (segment_confidence(s) or 1.0) < settings.llm_confidence_gate
]
if not low_conf:
return LLMResult(corrections=[], segments=segments, meta={"gated": 0})
if not self.enabled:
return LLMResult(corrections=[], segments=segments, meta={"enabled": False})
provider_id = "llm" if settings.llm_backend == "external" else settings.llm_backend
endpoint = self._guard.resolve(provider_id)
if endpoint is None:
raise EgressDenied(f"LLM provider {provider_id!r}가 allowlist에 없습니다")
if self.audit:
logger.info(
"LLM egress: provider=%s endpoint=%s segments=%d",
provider_id,
endpoint,
len(low_conf),
)
# 실제 호출: v0.1에서는 HTTP 클라이언트 주입 시에만 수행
if self._http is None:
raise EgressDenied(
"LLM HTTP 클라이언트가 구성되지 않았습니다 (v0.1은 외부 LLM 기본 off)"
)
response = self._http.post(endpoint, json={"segments": [s.model_dump() for s in low_conf]})
if response.status_code != 200:
raise EgressDenied(f"LLM 응답 오류: HTTP {response.status_code}")
data = response.json()
corrections = [
{"provider": provider_id, "segment_index": i} for i in data.get("corrected", [])
]
return LLMResult(corrections=corrections, segments=segments, meta={"provider": provider_id})
+80
View File
@@ -0,0 +1,80 @@
"""후처리 파이프라인 조율.
순서 (plan §7/AC-10):
1. glossary 도메인 용어 원문 표기 복원 (선택, 요청 )
2. rules 결정적 정규화 (알려진 오인식 표준 용어)
3. LLM 보정 (기본 off, confidence-gated, egress 통제) 선택
4. confidence 플래깅 저신뢰 세그먼트 표시
Eng 리뷰 P21 반영: glossary/rules 교정은 **span-aware**이어야 한다. word
timestamps/SRT/VTT 출력을 무효화하지 않도록, 교정은 세그먼트 텍스트 단위로
적용하고 타임라인은 보존한다. 원문과 교정문을 함께 기록한다.
"""
from __future__ import annotations
from typing import Any
from ..config import Settings
from ..engine.base import TranscriptionOptions
from ..results.models import Segment
from .confidence import confidence_flags
from .glossary import apply_glossary
from .rules import apply_rules
def run_postprocess(
segments: list[Segment],
options: TranscriptionOptions,
settings: Settings,
*,
glossary: dict[str, str] | None = None,
llm_backend=None,
) -> dict[str, Any]:
"""후처리 실행. segments(수정본) + warnings + meta 반환.
``settings.post_mode``: none | glossary | rules | llm
"""
mode = settings.post_mode if settings.post_enabled else "none"
warnings: list[str] = []
meta: dict[str, Any] = {
"mode": mode,
"corrections": [],
"raw_text": " ".join(s.text for s in segments),
}
if mode in ("glossary", "rules", "llm"):
# 1) glossary
if mode in ("glossary", "rules", "llm") and glossary:
applied = apply_glossary(segments, glossary)
corrections = applied["corrections"]
if corrections:
warnings.append(f"glossary: {len(corrections)}건 교정")
meta["corrections"].extend(corrections)
segments = applied["segments"]
# 2) rules
if mode in ("rules", "llm"):
rule_result = apply_rules(segments)
if rule_result["corrections"]:
warnings.append(f"rules: {len(rule_result['corrections'])}건 정규화")
meta["corrections"].extend(rule_result["corrections"])
segments = rule_result["segments"]
# 3) LLM (선택, 기본 off)
if mode == "llm" and llm_backend is not None:
llm_result = llm_backend.correct(segments, settings)
if llm_result["corrections"]:
warnings.append(f"llm: {len(llm_result['corrections'])}건 보정")
meta["corrections"].extend(llm_result["corrections"])
meta["llm"] = llm_result.get("meta")
segments = llm_result["segments"]
# 4) confidence 플래깅
flagged = confidence_flags(segments)
if flagged:
warnings.append(f"confidence: {len(flagged)}개 저신뢰 구간 플래그")
meta["low_confidence_segments"] = flagged
meta["corrected_text"] = " ".join(s.text.strip() for s in segments if s.text.strip())
return {"segments": segments, "warnings": warnings, "meta": meta}
+60
View File
@@ -0,0 +1,60 @@
"""Rules — 결정적 정규화 (알려진 오인식 → 표준 용어).
약어 대소문자 보정, 공백 정규화, 혼용어 패턴 복원. 모든 변환은 span 기록.
"""
from __future__ import annotations
import re
from typing import Any
from ..results.models import Segment
# 흔한 오인식 패턴 → 표준 표기 (정규식)
DEFAULT_RULES: list[tuple[re.Pattern, str]] = [
(re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"),
# 흔한 오인식: vLLM → BLM (GPU 실전에서 재현). 기술 STT 도메인 전제로 복원.
# 주의: \b 경계는 유니코드 \w 기준이라 'BLM은'(공백 없음)은 교정하지 않는다.
# faster-whisper 출력은 어절 단위 공백 분리("BLM 서버를")라 실제로는 충분하다.
(re.compile(r"\bblm\b", re.IGNORECASE), "vLLM"),
(re.compile(r"\bk ?u ?b ?e ?r ?n ?e ?t ?e ?s\b", re.IGNORECASE), "Kubernetes"),
(re.compile(r"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"),
(re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"),
(re.compile(r"\bl ?l ?m\b", re.IGNORECASE), "LLM"),
]
# 반복 whitespace 축약
_WS = re.compile(r"\s+")
def apply_rules(
segments: list[Segment], rules: list[tuple[re.Pattern, str]] | None = None
) -> dict[str, Any]:
rules = rules or DEFAULT_RULES
corrections: list[dict[str, Any]] = []
out: list[Segment] = []
for seg in segments:
text = seg.text
seg_corrections: list[dict[str, Any]] = []
for pattern, replacement in rules:
new_text, n = pattern.subn(replacement, text)
if n:
seg_corrections.append(
{
"from": pattern.pattern,
"to": replacement,
"segment_index": seg.index,
"count": n,
}
)
text = new_text
normalized = _WS.sub(" ", text).strip()
if normalized != text:
seg_corrections.append(
{"from": "whitespace", "to": "single-space", "segment_index": seg.index}
)
text = normalized
if seg_corrections:
corrections.extend(seg_corrections)
out.append(Segment(**{**seg.model_dump(), "text": text}))
return {"segments": out, "corrections": corrections}
+1
View File
@@ -0,0 +1 @@
"""결과 스키마·저장·포맷·보관."""
+70
View File
@@ -0,0 +1,70 @@
"""출력 포맷 — json / txt / srt / vtt 변환.
포맷은 enum(고정 확장자)으로만 요청된다 사용자 문자열을 경로에 결합하지
않는다 (Eng 리뷰 P16).
"""
from __future__ import annotations
from typing import Literal
from .models import TranscriptResult
Format = Literal["json", "txt", "srt", "vtt"]
FORMAT_EXTENSIONS: dict[str, str] = {
"json": ".json",
"txt": ".txt",
"srt": ".srt",
"vtt": ".vtt",
}
SUPPORTED_FORMATS = set(FORMAT_EXTENSIONS)
def render(result: TranscriptResult, fmt: Format) -> str:
if fmt == "json":
import json
return json.dumps(result.model_dump(), ensure_ascii=False, indent=2)
if fmt == "txt":
return result.text + "\n"
if fmt == "srt":
return _to_srt(result)
if fmt == "vtt":
return _to_vtt(result)
raise ValueError(f"지원하지 않는 포맷: {fmt}")
def _fmt_ts_srt(sec: float) -> str:
ms = int(round(sec * 1000))
h, rem = divmod(ms, 3600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _fmt_ts_vtt(sec: float) -> str:
ms = int(round(sec * 1000))
h, rem = divmod(ms, 3600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def _to_srt(result: TranscriptResult) -> str:
lines: list[str] = []
for i, seg in enumerate(result.segments, start=1):
lines.append(str(i))
lines.append(f"{_fmt_ts_srt(seg.start)} --> {_fmt_ts_srt(seg.end)}")
lines.append(seg.text.strip())
lines.append("")
return "\n".join(lines)
def _to_vtt(result: TranscriptResult) -> str:
lines = ["WEBVTT", ""]
for seg in result.segments:
lines.append(f"{_fmt_ts_vtt(seg.start)} --> {_fmt_ts_vtt(seg.end)}")
lines.append(seg.text.strip())
lines.append("")
return "\n".join(lines)
+100
View File
@@ -0,0 +1,100 @@
"""TranscriptResult v1 — CLI/API/큐/실시간이 공유하는 정규 결과 스키마.
설계 계약: 결과 스키마는 v1 동안 필드 의미·자료형을 바꾸지 않는다.
확장 필드는 추가 가능하되 기존 필드는 불변이다.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
SCHEMA_VERSION = "1.0"
class SourceInfo(BaseModel):
name: str
codec: str | None = None
size_bytes: int | None = None
class NormalizedAudio(BaseModel):
duration_sec: float = Field(gt=0)
audio_format: str = "pcm_s16le"
sample_rate: int = 16000
channels: int = 1
class ExecutionInfo(BaseModel):
model: str
device: str
compute_type: str
language_requested: str | None = None
language_detected: str | None = None
language_detection_confidence: float | None = None
model_used: str | None = None
compute_type_used: str | None = None
downgrade_attempts: int = 0
capability_tier: str | None = None
class Timings(BaseModel):
model_load_sec: float | None = None
transcription_sec: float | None = None
ingest_sec: float | None = None
postprocess_sec: float | None = None
rtf: float | None = None
class Segment(BaseModel):
index: int = Field(ge=0)
start: float = Field(ge=0)
end: float = Field(ge=0)
text: str = ""
avg_logprob: float | None = None
no_speech_prob: float | None = None
confidence: float | None = None
words: list[dict[str, Any]] | None = None
speaker: str | None = None
class ErrorInfo(BaseModel):
code: str
message: str
retryable: bool = False
class TranscriptResult(BaseModel):
schema_version: str = SCHEMA_VERSION
status: Literal["completed", "failed", "cancelled"] = "completed"
source: SourceInfo | None = None
normalized_audio: NormalizedAudio | None = None
execution: ExecutionInfo | None = None
timings: Timings = Field(default_factory=Timings)
text: str = ""
segments: list[Segment] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
error: ErrorInfo | None = None
# 확장: 후처리/화자/용어 보존 정보
postprocessing: dict[str, Any] | None = None
entities: list[dict[str, Any]] | None = None
@model_validator(mode="after")
def _check_state(self) -> TranscriptResult:
if self.status == "completed":
if self.error is not None:
raise ValueError("completed 결과에 error가 있을 수 없습니다")
elif self.status in ("failed", "cancelled"):
if self.error is None:
self.error = ErrorInfo(
code="transcription_failed" if self.status == "failed" else "cancelled",
message="",
)
# text = 세그먼트 trim 텍스트를 단일 공백으로 연결 (계약)
if self.status == "completed" and self.segments and not self.text:
self.text = " ".join(s.text.strip() for s in self.segments if s.text.strip())
return self
def dict_v1(self) -> dict[str, Any]:
return self.model_dump(exclude_none=False)
+82
View File
@@ -0,0 +1,82 @@
"""Retention — 결과 보관 TTL 청소 (plan §3.7e).
- **터미널 상태(completed/failed/cancelled) Job만** 청소.
- {queued, processing} 보유한 결과·임시물은 절대 건드리지 않는다
(보관 경합 방지).
- TTL은 Job 메타의 ``completed_at``(또는 result.json mtime) 기준.
"""
from __future__ import annotations
import time
from datetime import UTC, datetime
from pathlib import Path
from .store import ResultStore
TERMINAL_META_FIELDS = ("completed_at", "failed_at", "cancelled_at")
class RetentionSweeper:
def __init__(
self, store: ResultStore, retention_days: int = 7, *, now: float | None = None
) -> None:
self.store = store
self.retention_days = retention_days
self._now = now # 테스트용 fake clock
def sweep(self) -> list[str]:
"""만료된 터미널(completed/failed/cancelled) Job 디렉터리 삭제.
삭제된 job_id 목록 반환. {queued, processing} 절대 건드리지 않는다
(터미널 타임스탬프가 meta.json에 없으면 스킵).
"""
if self.retention_days <= 0:
return []
now = self._now if self._now is not None else time.time()
cutoff = now - self.retention_days * 86400
removed: list[str] = []
for job_id, job_dir in self.store.iter_job_dirs():
if self._job_stale(job_id, job_dir, cutoff):
self.store.delete_job(job_id)
removed.append(job_id)
return removed
@staticmethod
def _ts_to_epoch(ts: object) -> float | None:
"""ISO 8601 또는 epoch float 타임스탬프를 epoch 초로 변환."""
if isinstance(ts, (int, float)):
return float(ts)
try:
dt = datetime.fromisoformat(str(ts))
except (ValueError, TypeError):
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp()
def _job_stale(self, job_id: str, job_dir: Path, cutoff: float) -> bool:
"""터미널 타임스탬프 기준 만료 판정. 결과/메타 모두 없으면 비터미널로 스킵."""
meta_path = job_dir / "meta.json"
if meta_path.exists():
import json
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
meta = {}
for field_name in TERMINAL_META_FIELDS:
ts = meta.get(field_name)
if not ts:
continue
# 터미널 타임스탬프 존재 → 만료 여부 결정
ts_f = self._ts_to_epoch(ts)
return ts_f is not None and ts_f < cutoff
# 메타가 없으면 result.json mtime 폴백 (완료 경로 호환)
result_path = job_dir / "result.json"
if result_path.exists():
try:
return result_path.stat().st_mtime < cutoff
except OSError:
return False
return False
+141
View File
@@ -0,0 +1,141 @@
"""ResultStore — 결과 저장 (UUID 키, 경로 트래버설 차단).
Eng 리뷰 P16/P20 반영:
- 결과 파일은 클라이언트 파일명이 아니라 **서버 생성 UUID ** 저장.
- ``format`` enum으로 고정 확장자에 매핑 (사용자 문자열을 경로에 결합 금지).
- 모든 경로는 store root 하위로 canonicalize 검증, 심볼릭 링크 거부.
- 원자적 쓰기(임시 파일 rename), 부분 JSON 파일 금지.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import uuid
from pathlib import Path
from ..errors import OutputWriteError
from ..results.models import TranscriptResult
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
class ResultStore:
def __init__(self, root: str) -> None:
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
def _job_dir(self, job_id: str) -> Path:
if not UUID_RE.match(job_id):
raise ValueError(f"유효하지 않은 job id: {job_id!r}")
d = self.root / job_id
# root 하위 canonical 경로 검증 (심볼릭 링크/트래버설 방지)
canonical = d.resolve()
if not canonical.is_relative_to(self.root.resolve()):
raise ValueError(f"경로 탈출 시도: {job_id!r}")
return d
def _reject_symlinks(self, d: Path) -> None:
if d.is_symlink():
raise ValueError("심볼릭 링크가 있는 job 디렉터리는 허용하지 않습니다")
def write_result(self, job_id: str, result: TranscriptResult) -> Path:
d = self._job_dir(job_id)
self._reject_symlinks(d)
d.mkdir(parents=True, exist_ok=True)
payload = json.dumps(result.model_dump(), ensure_ascii=False, indent=2)
tmp = d / f".result-{uuid.uuid4().hex}.tmp"
try:
tmp.write_text(payload, encoding="utf-8")
target = d / "result.json"
os.replace(tmp, target)
except OSError as exc:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise OutputWriteError(f"결과 저장 실패: {exc}") from exc
return target
def read_result(self, job_id: str) -> TranscriptResult | None:
d = self._job_dir(job_id)
self._reject_symlinks(d)
p = d / "result.json"
if not p.exists():
return None
return TranscriptResult.model_validate_json(p.read_text(encoding="utf-8"))
def write_source_metadata(self, job_id: str, meta: dict) -> None:
d = self._job_dir(job_id)
self._reject_symlinks(d)
d.mkdir(parents=True, exist_ok=True)
(d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
def source_path_for(self, job_id: str, source_name: str) -> Path:
"""업로드 원본 저장 — 클라이언트 파일명을 **이름으로만** 사용, 확장자만 검증."""
name = Path(source_name).name # 경로 성분 제거
if not name or name in (".", ".."):
raise ValueError("잘못된 파일명")
d = self._job_dir(job_id)
d.mkdir(parents=True, exist_ok=True)
return d / name
def iter_job_dirs(self) -> list[tuple[str, Path]]:
"""모든 job 디렉터리 (job_id, dir) — retention sweeper용."""
out: list[tuple[str, Path]] = []
for entry in self.root.iterdir():
if not entry.is_dir():
continue
if not UUID_RE.match(entry.name):
continue
out.append((entry.name, entry))
return out
def iter_terminal_results(self) -> list[tuple[str, Path]]:
"""result.json이 있는 job 디렉터리 — 기존 호환용."""
out: list[tuple[str, Path]] = []
for job_id, d in self.iter_job_dirs():
p = d / "result.json"
if p.exists():
out.append((job_id, p))
return out
def delete_job(self, job_id: str) -> None:
d = self._job_dir(job_id)
if d.exists():
shutil.rmtree(d, ignore_errors=True)
def delete_derived(self, job_id: str) -> None:
"""원본 오디오 + 파생물 삭제 — 결과/메타만 보존 (privacy-first).
전사가 끝나면 업로드 원본은 즉시 삭제된다 (plan §3.7e/§6.1).
``result.json``/``meta.json`` 보관 대상이므로 건드리지 않는다.
"""
d = self._job_dir(job_id)
self._reject_symlinks(d)
if not d.exists():
return
for p in d.iterdir():
if p.is_file() and p.name not in ("result.json", "meta.json"):
p.unlink(missing_ok=True)
class AtomicFileWriter:
"""일반 파일 원자적 쓰기 — CLI --output 용."""
@staticmethod
def write(path: str | Path, data: str) -> None:
p = Path(path)
tmp = p.parent / f".{p.name}.{uuid.uuid4().hex}.tmp"
try:
p.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(data, encoding="utf-8")
os.replace(tmp, p)
except OSError as exc:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise OutputWriteError(f"출력 파일을 쓸 수 없습니다: {exc}") from exc
View File
+190
View File
@@ -0,0 +1,190 @@
"""공유 테스트 픽스처.
모든 테스트는 GPU/모델/ffmpeg 없이 mock 기반으로 동작한다:
- ``FakeEngineOwner``: 세그먼트를 즉시 생성 (실제 decode 없음)
- ``FakeIngestor``: ffprobe/ffmpeg 대체
"""
from __future__ import annotations
from pathlib import Path
import pytest
from luke_scribe.config import Settings
class FakeSegments:
"""세그먼트 목록을 반복하는 mock — 취소 시 중단."""
def __init__(self, segments: list[dict], cancel_on: callable | None = None) -> None:
self._segs = iter(segments)
self._cancel_on = cancel_on or (lambda: False)
def __iter__(self):
return self
def __next__(self):
if self._cancel_on():
raise StopIteration
return next(self._segs)
def close(self) -> None:
pass
class FakeEngine:
def __init__(self, segments: list[dict], *, fail_on: list[str] | None = None) -> None:
self.segments = segments
self.fail_on = fail_on or []
self.calls: list[dict] = []
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None) -> object:
self.calls.append({"audio": audio_path, "options": options})
# 실제 faster-whisper를 import하지 않는 mock
return type(
"Outcome",
(),
{
"segments": FakeSegments(self.segments, cancel_on=should_cancel),
"info": {"language": "ko"},
},
)()
class FakeEngineOwner:
"""EngineOwner mock — 프로파일 강등/캡을 검증 가능하게 함."""
def __init__(
self, engine: FakeEngine | None = None, segments: list[dict] | None = None
) -> None:
self.engine = engine or FakeEngine(segments or _default_segments())
self.last_request = None
def transcribe(self, req) -> dict:
self.last_request = req
# 실제 EngineOwner처럼 첫 시도 프로파일을 기록 (downgrade_attempts 계산용)
req.attempted_profiles.append(f"{req.options.device}/{req.options.compute_type or 'int8'}")
outcome = self.engine.transcribe(
req.audio_path, req.options, should_cancel=req.should_cancel
)
return {
"segments": outcome.segments,
"info": outcome.info,
"device": req.options.device if req.options.device != "auto" else "cpu",
"compute_type": req.options.compute_type or "int8",
"attempted_profiles": list(req.attempted_profiles),
}
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
def unload_all(self) -> None:
pass
def _default_segments() -> list[dict]:
return [
{
"index": 0,
"start": 0.0,
"end": 2.5,
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
"avg_logprob": -0.2,
"no_speech_prob": 0.01,
},
{
"index": 1,
"start": 2.5,
"end": 4.0,
"text": "Kubernetes 클러스터에 배포합니다.",
"avg_logprob": -0.3,
"no_speech_prob": 0.02,
},
]
class FakeIngestor:
"""ffprobe/ffmpeg 없는 mock ingestor."""
def __init__(self, duration_sec: float = 10.0, codec: str = "mp3") -> None:
self.duration_sec = duration_sec
self.codec = codec
self.cancelled = False
def ingest(self, source: Path, *, should_cancel=None):
if should_cancel and should_cancel():
from luke_scribe.errors import CancelledError
self.cancelled = True
raise CancelledError("취소됨")
from luke_scribe.audio.ingest import IngestResult, ProbeResult
from luke_scribe.results.models import NormalizedAudio
class _Cleanup:
def close(self):
pass
return IngestResult(
normalized_path="/tmp/fake-normalized.wav",
normalized=NormalizedAudio(duration_sec=self.duration_sec),
probe=ProbeResult(duration_sec=self.duration_sec, codec=self.codec, size_bytes=100),
temp_dir="/tmp/fake-tmp",
cleanup=lambda: None,
)
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
_env_file=None,
results_dir=str(tmp_path / "results"),
api_key_file=str(tmp_path / "api_keys.json"),
queue_backend="inproc",
model_cache_dir=None,
max_queue=10,
)
@pytest.fixture
def fake_owner() -> FakeEngineOwner:
return FakeEngineOwner()
@pytest.fixture
def fake_ingestor() -> FakeIngestor:
return FakeIngestor()
@pytest.fixture
def transcript_result() -> dict:
return {
"schema_version": "1.0",
"status": "completed",
"source": {"name": "test.mp3", "codec": "mp3", "size_bytes": 100},
"normalized_audio": {
"duration_sec": 4.0,
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"channels": 1,
},
"execution": {
"model": "large-v3-turbo",
"device": "cpu",
"compute_type": "int8",
"language_requested": "ko",
},
"timings": {"transcription_sec": 0.5, "rtf": 0.125},
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
"segments": [
{
"index": 0,
"start": 0.0,
"end": 2.5,
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
"avg_logprob": -0.2,
"no_speech_prob": 0.01,
}
],
"warnings": [],
}
View File
+278
View File
@@ -0,0 +1,278 @@
"""API 통합 테스트 — TestClient 기반 인증/업로드/소유권/큐 흐름.
실제 모델·Redis 없이 in-proc 브로커 + mock 결과로 동작한다.
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from luke_scribe.api.app import create_app
from luke_scribe.config import Settings
@pytest.fixture
def settings(tmp_path) -> Settings:
return Settings(
_env_file=None,
results_dir=str(tmp_path / "results"),
api_key_file=str(tmp_path / "api_keys.json"),
api_keys="key-transcribe,key-admin:admin,transcribe",
queue_backend="inproc",
model_cache_dir=None,
tunnel="none",
max_queue=10,
)
@pytest.fixture
def client(settings: Settings) -> TestClient:
app = create_app(settings)
with TestClient(app) as c:
yield c
class TestHealth:
def test_health_public(self, client: TestClient):
r = client.get("/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
class TestDashboard:
def test_dashboard_html_public(self, client: TestClient):
"""대시보드 HTML은 공개 — 인증은 클라이언트에서 API 키 입력."""
for path in ("/", "/dashboard"):
r = client.get(path)
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "luke_scribe" in r.text
assert "실시간" in r.text # 전체 기능 포함
class TestKeys:
def test_create_and_list_key(self, client: TestClient):
headers = {"X-API-Key": "key-admin"}
r = client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers)
assert r.status_code == 201, r.text
body = r.json()
assert body["key"].startswith("luke-")
assert body["key_id"].startswith("k-")
# raw 키는 1회만 노출 — 목록에는 다이제스트 ID만
r2 = client.get("/v1/keys", headers=headers)
keys = r2.json()["keys"]
assert body["key_id"] in [k["id"] for k in keys]
assert all("key" not in k for k in keys)
def test_admin_scope_required(self, client: TestClient):
headers = {"X-API-Key": "key-transcribe"}
assert (
client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers).status_code
== 403
)
assert client.get("/v1/keys", headers=headers).status_code == 403
def test_no_auth_401(self, client: TestClient):
assert client.get("/v1/keys").status_code == 401
assert client.post("/v1/keys", json={"scopes": []}).status_code == 401
class TestAutoWorker:
def test_auto_worker_off_by_default(self, client: TestClient):
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
assert client.app.state.inproc_worker is None
def test_auto_worker_started_when_enabled(self, tmp_path):
"""auto_worker=true + inproc → lifespan이 워커 스레드를 시작하고 종료 시 정지."""
from luke_scribe.api.app import create_app
from luke_scribe.config import Settings
settings = Settings(
_env_file=None,
results_dir=str(tmp_path / "results"),
api_key_file=str(tmp_path / "api_keys.json"),
queue_backend="inproc",
model_cache_dir=None,
tunnel="none",
auto_worker=True,
)
app = create_app(settings)
with TestClient(app) as c:
w = c.app.state.inproc_worker
assert w is not None
assert not w._stop.is_set()
# lifespan 종료 → 워커 정지 요청
assert w._stop.is_set()
class TestAuth:
def test_missing_key_rejected(self, client: TestClient):
r = client.get("/v1/jobs")
assert r.status_code == 401
def test_bad_key_rejected(self, client: TestClient):
r = client.get("/v1/jobs", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 401
def test_admin_scope_enforced(self, client: TestClient):
r = client.get("/v1/system", headers={"X-API-Key": "key-transcribe"})
assert r.status_code == 403
r2 = client.get("/v1/system", headers={"X-API-Key": "key-admin"})
assert r2.status_code == 200
body = r2.json()
assert body["capability_tier"] in ("T0", "T1", "T2", "T3")
assert body["queue_depth"] == 0
class TestJobs:
def test_create_get_cancel_flow(self, client: TestClient, settings: Settings):
headers = {"X-API-Key": "key-transcribe"}
r = client.post(
"/v1/jobs",
files={"file": ("meeting.mp3", b"fake-audio-bytes", "audio/mpeg")},
data={"options": json.dumps({"language": "ko", "model": "large-v3-turbo"})},
headers=headers,
)
assert r.status_code == 202, r.text
body = r.json()
job_id = body["job_id"]
assert body["status"] == "queued"
assert body["queue_position"] == 0
# 조회
r = client.get(f"/v1/jobs/{job_id}", headers=headers)
assert r.status_code == 200
body = r.json()
assert body["status"] == "queued"
assert body["source_name"] == "meeting.mp3" # 대시보드 파일 컬럼용
# 결과는 아직 없음
r = client.get(f"/v1/jobs/{job_id}/result", headers=headers)
assert r.status_code == 409
# 취소
r = client.delete(f"/v1/jobs/{job_id}", headers=headers)
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
def test_ownership_enforced(self, client: TestClient):
"""Eng P14: 다른 키가 만든 job 조회/취소 불가."""
a = client.post(
"/v1/jobs",
files={"file": ("a.mp3", b"x", "audio/mpeg")},
data={"options": "{}"},
headers={"X-API-Key": "key-transcribe"},
)
job_id = a.json()["job_id"]
r = client.get(f"/v1/jobs/{job_id}", headers={"X-API-Key": "key-admin"})
assert r.status_code == 403
r = client.delete(f"/v1/jobs/{job_id}", headers={"X-API-Key": "key-admin"})
assert r.status_code == 403
def test_unknown_job_404(self, client: TestClient):
r = client.get(
"/v1/jobs/00000000-0000-0000-0000-000000000000",
headers={"X-API-Key": "key-transcribe"},
)
assert r.status_code == 404
def test_bad_options_422(self, client: TestClient):
r = client.post(
"/v1/jobs",
files={"file": ("a.mp3", b"x", "audio/mpeg")},
data={"options": "not json {"},
headers={"X-API-Key": "key-transcribe"},
)
assert r.status_code == 422
def test_bad_format_422(self, client: TestClient, settings: Settings):
headers = {"X-API-Key": "key-transcribe"}
r = client.post(
"/v1/jobs",
files={"file": ("a.mp3", b"x", "audio/mpeg")},
data={"options": "{}"},
headers=headers,
)
job_id = r.json()["job_id"]
r2 = client.get(f"/v1/jobs/{job_id}/result?format=docx", headers=headers)
assert r2.status_code == 422
def test_oversize_413(self, client: TestClient, settings: Settings):
settings.max_upload_bytes = 10
r = client.post(
"/v1/jobs",
files={"file": ("big.mp3", b"x" * 100, "audio/mpeg")},
data={"options": "{}"},
headers={"X-API-Key": "key-transcribe"},
)
assert r.status_code == 413
class TestQueueFull:
def test_queue_full_429(self, client: TestClient, settings: Settings):
settings.max_queue = 1
headers = {"X-API-Key": "key-transcribe"}
r1 = client.post(
"/v1/jobs",
files={"file": ("a.mp3", b"x", "audio/mpeg")},
data={"options": "{}"},
headers=headers,
)
assert r1.status_code == 202
r2 = client.post(
"/v1/jobs",
files={"file": ("b.mp3", b"x", "audio/mpeg")},
data={"options": "{}"},
headers=headers,
)
assert r2.status_code == 429
assert "Retry-After" in r2.headers
class TestResultEndpoint:
def test_result_after_processing(self, client: TestClient, settings: Settings, tmp_path):
"""워커가 결과를 저장한 뒤 result 엔드포인트가 JSON/SRT를 반환."""
from luke_scribe.results.models import TranscriptResult
from luke_scribe.results.store import ResultStore
headers = {"X-API-Key": "key-transcribe"}
r = client.post(
"/v1/jobs",
files={"file": ("a.mp3", b"x", "audio/mpeg")},
data={"options": "{}"},
headers=headers,
)
job_id = r.json()["job_id"]
# 워커 대신 결과를 직접 기록 (모델 없이 mock)
result = TranscriptResult(
status="completed",
source={"name": "a.mp3", "codec": "mp3", "size_bytes": 1},
normalized_audio={"duration_sec": 2.0},
execution={"model": "large-v3-turbo", "device": "cpu", "compute_type": "int8"},
text="테스트 전사 결과",
segments=[{"index": 0, "start": 0.0, "end": 1.0, "text": "테스트 전사 결과"}],
)
from luke_scribe.jobqueue.jobs import JobStatus
store = ResultStore(settings.results_dir)
store.write_result(job_id, result)
# 상태를 completed로 (워커가 했을 일) — queued→processing→completed
job = client.app.state.broker.get(job_id)
job.transition(JobStatus.PROCESSING)
job.transition(JobStatus.COMPLETED)
client.app.state.broker.save_meta(job)
r_json = client.get(f"/v1/jobs/{job_id}/result?format=json", headers=headers)
assert r_json.status_code == 200
assert json.loads(r_json.json()["content"])["text"] == "테스트 전사 결과"
r_srt = client.get(f"/v1/jobs/{job_id}/result?format=srt", headers=headers)
assert r_srt.status_code == 200
assert "WEBVTT" not in r_srt.json()["content"] # srt 포맷
assert "--> " in r_srt.json()["content"]
+303
View File
@@ -0,0 +1,303 @@
"""Worker 통합 테스트 — 큐 → 클레임 → 전사 → 결과 저장 (전부 mock)."""
from __future__ import annotations
import time
from luke_scribe.jobqueue.broker import InProcBroker
from luke_scribe.jobqueue.jobs import Job, JobStatus
from luke_scribe.jobqueue.worker import Worker, WorkerCallbacks
from luke_scribe.results.store import ResultStore
from ..conftest import FakeEngineOwner, FakeIngestor
def _job(**kw) -> Job:
defaults = dict(
type="file",
lane="batch",
options={
"model": "large-v3-turbo",
"language": "ko",
"device": "cpu",
"compute_type": "int8",
},
source_path="/tmp/fake-source.mp3",
source_name="fake-source.mp3",
)
defaults.update(kw)
return Job(**defaults)
class TestWorkerLifecycle:
def test_api_style_options_filtered(self, settings, tmp_path):
"""API TranscribeOptions(엔진 무관 키 포함) → 워커가 엔진 필드만 골라 처리.
Colab 실전에서 잡이 계속 실패한 원인: job.options에 formats/timestamps/
diarize 등이 포함돼 TranscriptionOptions(**job.options) TypeError를 .
"""
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(),
ingestor=FakeIngestor(),
)
job = _job(
options={
"language": "ko",
"device": "auto",
"timestamps": True,
"formats": ["json", "srt"],
"word_timestamps": False,
"vad": True,
"hotwords": [],
"glossary_id": None,
"post_correction": None,
"diarize": False,
}
)
broker.enqueue(job)
worker.drain()
assert broker.get(job.id).status == JobStatus.COMPLETED
result = store.read_result(job.id)
assert result is not None
assert result.status == "completed"
def test_complete_flow(self, settings, tmp_path):
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
owner = FakeEngineOwner()
done: list[str] = []
def on_done(job, result):
done.append(job.id)
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=owner,
ingestor=FakeIngestor(duration_sec=4.0),
callbacks=WorkerCallbacks(on_job_done=on_done),
)
job = _job()
broker.enqueue(job)
worker.drain()
assert broker.get(job.id).status == JobStatus.COMPLETED
assert done == [job.id]
result = store.read_result(job.id)
assert result is not None
assert result.status == "completed"
assert result.text # 세그먼트 텍스트 연결
assert result.execution is not None
assert result.execution.device == "cpu"
# 강등 없음
assert result.execution.downgrade_attempts == 0
def test_meta_written(self, settings, tmp_path):
"""완료 시 job 메타(완료 시각 포함)가 결과 디렉터리에 저장."""
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(),
ingestor=FakeIngestor(),
)
job = _job()
broker.enqueue(job)
worker.drain()
import json
meta = json.loads(
tmp_path.joinpath("results", job.id, "meta.json").read_text(encoding="utf-8")
)
assert meta["status"] == "completed"
assert meta["completed_at"] is not None
def test_transcription_failure_marks_failed(self, settings, tmp_path):
"""엔진 오류 → failed + 오류 메타 저장."""
class BoomEngine:
def transcribe(self, req):
raise RuntimeError("engine exploded")
class BoomOwner:
def __init__(self):
self.engine = BoomEngine()
def transcribe(self, req):
return self.engine.transcribe(req)
def unload_all(self):
pass
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=BoomOwner(),
ingestor=FakeIngestor(),
)
job = _job()
broker.enqueue(job)
worker.drain()
assert broker.get(job.id).status == JobStatus.FAILED
def test_cancel_before_claim(self, settings, tmp_path):
"""큐에서 취소된 job은 워커가 claim하지 않고 cancelled 처리."""
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(),
ingestor=FakeIngestor(),
)
job = _job()
broker.enqueue(job)
broker.cancel(job.id)
worker.drain()
assert broker.get(job.id).status == JobStatus.CANCELLED
def test_cancel_during_processing(self, settings, tmp_path):
"""처리 중 취소 → 세그먼트 경계에서 CancelledError → cancelled (Eng P4)."""
class CancelAfterFirstOwner:
def __init__(self):
self.n = 0
self.segments = [
{"index": 0, "start": 0.0, "end": 1.0, "text": "첫 세그먼트"},
{"index": 1, "start": 1.0, "end": 2.0, "text": "두 번째"},
]
def transcribe(self, req):
# worker는 token 미전달, 취소는 세그먼트 경계에서 검사됨
return {
"segments": iter(
[
{"index": 0, "start": 0.0, "end": 1.0, "text": "첫 세그먼트"},
{"index": 1, "start": 1.0, "end": 2.0, "text": "두 번째"},
]
),
"info": {"language": "ko"},
"device": "cpu",
"compute_type": "int8",
"attempted_profiles": ["cpu/int8"],
}
# 세그먼트를 먼저 소비한 뒤 취소하도록: 첫 next 후 should_cancel True
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
owner = CancelAfterFirstOwner()
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=owner,
ingestor=FakeIngestor(),
)
job = _job()
broker.enqueue(job)
# 처리 시작 전에 취소 요청을 미리 걸어 두면 claim 단계에서 cancelled 처리됨.
# 대신 처리 중 취소를 시뮬레이션: claim 직후 cancel 호출
claimed = broker.claim_next("w1", 60.0)
broker.cancel(claimed.id)
# 워커는 already-processing job을 직접 처리
worker._process(claimed)
assert broker.get(job.id).status == JobStatus.CANCELLED
class TestPrivacyFirst:
def test_source_deleted_after_completion(self, settings, tmp_path):
"""plan §3.7e/§6.1: 전사 완료 후 업로드 원본 오디오 즉시 삭제."""
from luke_scribe.results.store import ResultStore
settings.delete_source = True
store = ResultStore(str(tmp_path / "results"))
broker = InProcBroker(settings)
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(),
ingestor=FakeIngestor(),
)
job = _job()
# source가 store에 저장된 파일을 가리키도록
src = store.source_path_for(job.id, "meeting.mp3")
src.write_bytes(b"fake-audio")
job.source_path = str(src)
broker.enqueue(job)
worker.drain()
assert not src.exists() # 원본 삭제
assert store.read_result(job.id) is not None # 결과는 보존
def test_source_kept_when_delete_source_false(self, settings, tmp_path):
from luke_scribe.results.store import ResultStore
settings.delete_source = False
store = ResultStore(str(tmp_path / "results"))
broker = InProcBroker(settings)
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(),
ingestor=FakeIngestor(),
)
job = _job()
src = store.source_path_for(job.id, "meeting.mp3")
src.write_bytes(b"fake-audio")
job.source_path = str(src)
broker.enqueue(job)
worker.drain()
assert src.exists()
class TestWorkerProgress:
def test_progress_emitted(self, settings, tmp_path):
broker = InProcBroker(settings)
store = ResultStore(str(tmp_path / "results"))
worker = Worker(
settings=settings,
broker=broker,
store=store,
owner=FakeEngineOwner(segments=[{"index": 0, "start": 0.0, "end": 5.0, "text": "x"}]),
ingestor=FakeIngestor(duration_sec=10.0),
)
job = _job()
broker.enqueue(job)
worker.drain()
final = broker.get(job.id)
assert final.progress is not None
assert final.progress <= 1.0
assert final.processed_sec is not None
class TestCrashRecovery:
def test_stale_processing_recovered(self, settings, tmp_path):
"""Eng P11: 워커 크래시(리스 만료) → 스타트업 reconciler가 failed 처리."""
broker = InProcBroker(settings)
job = _job()
broker.enqueue(job)
claimed = broker.claim_next("w1", 60.0)
# 리스 만료 시뮬레이션
claimed.lease_expires_at = time.time() - 10
broker.save_meta(claimed)
worker = Worker(
settings=settings, broker=broker, store=ResultStore(str(tmp_path / "results"))
)
reclaimed = worker.start_reconcile()
assert job.id in reclaimed
assert broker.get(job.id).status == JobStatus.FAILED
assert broker.get(job.id).error_code == "worker_crash"
-79
View File
@@ -1,79 +0,0 @@
"""Device Manager 능력등급/정밀도/오버라이드 결정 로직 (계획 §8 unit).
실하드웨어는 T0만 밟으므로 T1~T3은 합성 VRAM 값으로 검증.
"""
from __future__ import annotations
from luke_scribe.devices import manager as m
from luke_scribe.devices.manager import DeviceManager
from luke_scribe.devices.profile import CapabilityTier
from luke_scribe.devices.vram_probe import GpuInfo
def _patch(monkeypatch, gpus: list[GpuInfo]) -> None:
monkeypatch.setattr(m, "probe_gpus", lambda: gpus)
monkeypatch.setattr(m, "probe_ram_mb", lambda: 16000)
monkeypatch.setattr(m, "probe_disk_free_mb", lambda path=".": 100000)
def _gpu(cc: tuple[int, int], free: int, name: str = "TestGPU") -> GpuInfo:
return GpuInfo(0, name, cc, free + 100, free)
def test_no_gpu_is_t0_cpu(monkeypatch):
_patch(monkeypatch, [])
p = DeviceManager.detect()
assert p.kind == "cpu"
assert p.tier == CapabilityTier.T0_CPU
assert p.compute_type == "int8"
def test_weak_pascal_downgrades_to_cpu(monkeypatch):
# GTX 1050: cc6.1, free 1990 → turbo(int8, 2340MB 헤드룸) 부족 → CPU 강등
_patch(monkeypatch, [_gpu((6, 1), 1990, "GTX 1050")])
p = DeviceManager.detect()
assert p.tier == CapabilityTier.T0_CPU
assert p.kind == "cpu"
assert p.vram_free_mb == 1990 # GPU 정보는 보존(투명성)
assert any("강등" in n for n in p.notes)
def test_t1_turbo_only(monkeypatch):
# cc7.5, free 6000 → int8_float16; turbo 적재 OK, large-v3 무리
_patch(monkeypatch, [_gpu((7, 5), 6000)])
p = DeviceManager.detect()
assert p.tier == CapabilityTier.T1_TURBO_GPU
assert p.compute_type == "int8_float16"
assert p.served_models["batch"].startswith("large-v3-turbo")
def test_t2_swap(monkeypatch):
# cc7.5, free 16000 → float16; turbo·large-v3 각각 OK, 동시상주는 불가
_patch(monkeypatch, [_gpu((7, 5), 16000)])
p = DeviceManager.detect()
assert p.tier == CapabilityTier.T2_SWAP
assert p.compute_type == "float16"
assert "swap" in p.served_models["batch"]
def test_t3_coresident(monkeypatch):
# A100급: cc8.0, free 40000 → float16; turbo+large-v3 동시상주
_patch(monkeypatch, [_gpu((8, 0), 40000, "A100")])
p = DeviceManager.detect()
assert p.tier == CapabilityTier.T3_CORESIDENT
assert p.compute_type == "float16"
assert p.served_models["batch"] == "large-v3@cuda"
assert p.max_workers >= 1
def test_force_cpu_override(monkeypatch):
_patch(monkeypatch, [_gpu((8, 0), 40000)])
p = DeviceManager.detect(force_device="cpu")
assert p.tier == CapabilityTier.T0_CPU
assert p.kind == "cpu"
def test_workers_override(monkeypatch):
_patch(monkeypatch, [_gpu((8, 0), 40000)])
p = DeviceManager.detect(workers_override=3)
assert p.max_workers == 3
-23
View File
@@ -1,23 +0,0 @@
"""engine.model_registry / audio.ingest 경량 단위 테스트 (모델 로드 불요)."""
from __future__ import annotations
import pytest
from luke_scribe.audio.ingest import probe_media
from luke_scribe.engine.model_registry import resolve_model
def test_resolve_model_turbo_maps_to_ct2_repo():
expected = "deepdml/faster-whisper-large-v3-turbo-ct2"
assert resolve_model("large-v3-turbo") == expected
assert resolve_model("turbo") == expected
def test_resolve_model_standard_passthrough():
assert resolve_model("tiny") == "tiny"
assert resolve_model("large-v3") == "large-v3"
def test_probe_media_missing_raises():
with pytest.raises(FileNotFoundError):
probe_media("/no/such/file.wav")
+143
View File
@@ -0,0 +1,143 @@
"""벤치마크 단위 테스트 — 후처리 적용 + glossary 지원.
벤치 지표는 원시 전사가 아니라 후처리(rules/glossary) 거친 결과를 측정해야
한다 (vLLMBLM 같은 오인식 복원 포함).
"""
from __future__ import annotations
from luke_scribe.benchmark.runner import _run_model, _transcribe_clip
from luke_scribe.config import Settings
from luke_scribe.engine.base import TranscriptionOptions
REF_TEXT = "오늘은 vLLM 서버를 Kubernetes 클러스터에 배포합니다"
ENTITIES = [
{"canonical": "vLLM", "surface": "vLLM", "start_char": 4, "end_char": 8},
{"canonical": "Kubernetes", "surface": "Kubernetes", "start_char": 13, "end_char": 23},
]
class _FakeOwner:
"""세그먼트 dict를 반환하는 가짜 owner (dict 계약 사용)."""
def __init__(self, texts: list[str]) -> None:
self._texts = texts
def transcribe(self, req): # noqa: ANN001
segs = [
{
"index": i,
"start": i * 2.0,
"end": i * 2.0 + 2.0,
"text": t,
"avg_logprob": -0.2,
"no_speech_prob": 0.01,
}
for i, t in enumerate(self._texts)
]
return {
"segments": iter(segs),
"device": "cpu",
"compute_type": "int8",
"attempted_profiles": [{}],
"info": {"language": "ko"},
}
class TestTranscribeClipPostprocess:
def test_rules_fix_blm_to_vllm(self):
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
settings = Settings(_env_file=None, post_mode="rules", post_enabled=True)
clip = {"audio_path": "x.mp3", "duration_sec": 10.0}
out = _transcribe_clip(owner, TranscriptionOptions(), clip, settings, None)
assert "vLLM" in out["text"]
assert "BLM" not in out["text"]
def test_glossary_applied(self):
owner = _FakeOwner(["브이엘엘엠 서버"])
settings = Settings(_env_file=None, post_mode="glossary", post_enabled=True)
clip = {"audio_path": "x.mp3", "duration_sec": 10.0}
out = _transcribe_clip(
owner, TranscriptionOptions(), clip, settings, {"브이엘엘엠": "vLLM"}
)
assert "vLLM" in out["text"]
def test_postprocess_disabled_keeps_raw(self):
owner = _FakeOwner(["오늘은 BLM 서버"])
settings = Settings(_env_file=None, post_mode="rules", post_enabled=False)
clip = {"audio_path": "x.mp3", "duration_sec": 10.0}
out = _transcribe_clip(owner, TranscriptionOptions(), clip, settings, None)
assert "BLM" in out["text"]
class TestRunModelPostprocess:
def _report(self) -> dict:
return {"run_config": {"hotword_set": []}}
def _clip(self, ref_path: str) -> dict:
return {
"id": "c1",
"audio_path": "x.mp3",
"reference_path": ref_path,
"duration_sec": 10.0,
"entities": ENTITIES,
}
def test_entity_retention_full_with_rules(self, tmp_path):
ref = tmp_path / "ref.txt"
ref.write_text(REF_TEXT, encoding="utf-8")
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
settings = Settings(_env_file=None, post_mode="rules", post_enabled=True)
agg = _run_model(
owner,
[self._clip(str(ref))],
"large-v3-turbo",
"cpu",
"int8",
1,
[],
self._report(),
settings,
None,
)
assert agg["summary"]["entity_retention"] == 1.0
assert agg["summary"]["failure_rate"] == 0.0
def test_glossary_raises_entity_retention(self, tmp_path):
ref = tmp_path / "ref.txt"
ref.write_text(REF_TEXT, encoding="utf-8")
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
settings = Settings(_env_file=None, post_mode="glossary", post_enabled=True)
agg = _run_model(
owner,
[self._clip(str(ref))],
"large-v3-turbo",
"cpu",
"int8",
1,
[],
self._report(),
settings,
{"BLM": "vLLM"},
)
assert agg["summary"]["entity_retention"] == 1.0
def test_raw_text_fails_entity_retention_without_postprocess(self, tmp_path):
# 후처리 없이 raw 전사("BLM")를 측정하면 vLLM 엔티티가 보존되지 않는다
ref = tmp_path / "ref.txt"
ref.write_text(REF_TEXT, encoding="utf-8")
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
settings = Settings(_env_file=None, post_mode="none", post_enabled=False)
agg = _run_model(
owner,
[self._clip(str(ref))],
"large-v3-turbo",
"cpu",
"int8",
1,
[],
self._report(),
settings,
None,
)
assert agg["summary"]["entity_retention"] < 1.0
+90
View File
@@ -0,0 +1,90 @@
"""Device Manager 단위 테스트 — 정밀도 결정, 능력 등급, override."""
from __future__ import annotations
import pytest
from luke_scribe.devices.manager import DeviceManager
from luke_scribe.devices.vram_probe import GpuInfo, SystemInfo
from luke_scribe.errors import DeviceUnavailable
def _sys(gpus: list[GpuInfo], ram_mb: int = 16384) -> SystemInfo:
return SystemInfo(
cpu_count=8, ram_total_mb=ram_mb, ram_free_mb=ram_mb // 2, disk_free_mb=50000, gpus=gpus
)
def _gpu(name: str, cc: str, total_mb: int, free_mb: int, index: int = 0) -> GpuInfo:
return GpuInfo(
index=index,
name=name,
compute_capability=cc,
vram_total_mb=total_mb,
vram_free_mb=free_mb,
driver_version="550",
runtime_cuda=None,
)
class TestPrecision:
def test_cc_7_plus_12gb_free_float16(self):
m = DeviceManager(_sys([_gpu("T4", "7.5", 15360, 14820)]))
p = m.detect()
assert p.selected_compute_type == "float16"
assert p.selected_device == "cuda:0"
def test_cc_7_plus_low_free_int8_float16(self):
m = DeviceManager(_sys([_gpu("L4", "8.9", 24576, 8000)]))
p = m.detect()
assert p.selected_compute_type == "int8_float16"
def test_pascal_1050_int8(self):
m = DeviceManager(_sys([_gpu("GTX 1050", "6.1", 4096, 3584)]))
p = m.detect()
assert p.selected_compute_type == "int8"
def test_no_gpu_cpu_int8(self):
m = DeviceManager(_sys([]))
p = m.detect()
assert p.selected_device == "cpu"
assert p.selected_compute_type == "int8"
assert p.capability_tier == "T0"
assert any("CPU" in w for w in p.warnings)
class TestCapabilityTier:
def test_t3_both_models(self):
m = DeviceManager(_sys([_gpu("A100", "9.0", 81920, 70000)]))
p = m.detect()
assert p.capability_tier == "T3"
assert p.workers >= 1
def test_t1_small_gpu(self):
m = DeviceManager(_sys([_gpu("RTX 3060", "8.6", 12288, 10000)]))
p = m.detect()
assert p.capability_tier in ("T1", "T2", "T3")
class TestOverrides:
def test_explicit_cuda_missing_fails(self):
m = DeviceManager(_sys([]))
with pytest.raises(DeviceUnavailable):
m.detect(device="cuda:0")
def test_explicit_cuda_ok(self):
m = DeviceManager(_sys([_gpu("T4", "7.5", 15360, 14820)]))
p = m.detect(device="cuda:0", compute_type="float16")
assert p.selected_device == "cuda:0"
assert p.selection_source == "explicit"
assert p.selected_compute_type == "float16"
def test_explicit_cpu(self):
m = DeviceManager(_sys([_gpu("T4", "7.5", 15360, 14820)]))
p = m.detect(device="cpu")
assert p.selected_device == "cpu"
def test_explicit_bad_device(self):
m = DeviceManager(_sys([]))
with pytest.raises(DeviceUnavailable):
m.detect(device="tpu:0")
+171
View File
@@ -0,0 +1,171 @@
"""FasterWhisperEngine 단위 테스트 — CTranslate2 호출 계약 (device 분리).
faster-whisper/CTranslate2는 ``device`` 문자열로 "cuda" 허용하며 인덱스는
별도 ``device_index`` 인자로 받는다. DeviceManager가 내려주는 "cuda:N"
제대로 분리해 전달하는지 mock으로 검증한다 (GPU 환경에서만 재현되는 버그
Colab 실전 테스트에서 ``unsupported device cuda:0``으로 확인됨).
"""
from __future__ import annotations
import sys
import types
from luke_scribe.engine.base import TranscriptionOptions
from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine
class FakeWhisperModel:
"""faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트/info를 기록한다."""
calls: list[dict] = []
segments: list = [] # transcribe()가 yield할 세그먼트 (기본: namedtuple)
info: object = None # 기본: TranscriptionInfo(namedtuple) 흉내
def __init__(self, *args, **kwargs) -> None:
self.kwargs = kwargs
self.__class__.calls.append(kwargs)
def transcribe(self, audio_path, **kwargs):
return iter(list(self.__class__.segments)), self.__class__.info
def _install_fake(monkeypatch) -> None:
mod = types.ModuleType("faster_whisper")
mod.WhisperModel = FakeWhisperModel
monkeypatch.setitem(sys.modules, "faster_whisper", mod)
FakeWhisperModel.calls.clear()
FakeWhisperModel.segments = []
FakeWhisperModel.info = None
def _opts(**kw) -> TranscriptionOptions:
kw.setdefault("model", "large-v3-turbo")
kw.setdefault("device", "cuda:0")
kw.setdefault("compute_type", "float16")
return TranscriptionOptions(**kw)
def test_cuda_device_index_split(monkeypatch):
"""'cuda:0' → device='cuda', device_index=0 (Colab A100 첫 GPU)."""
_install_fake(monkeypatch)
FasterWhisperEngine().transcribe("/tmp/x.wav", _opts())
kwargs = FakeWhisperModel.calls[-1]
assert kwargs["device"] == "cuda"
assert kwargs["device_index"] == 0
assert kwargs["compute_type"] == "float16"
def test_cuda_second_device_index(monkeypatch):
"""'cuda:1' → device='cuda', device_index=1."""
_install_fake(monkeypatch)
FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cuda:1"))
kwargs = FakeWhisperModel.calls[-1]
assert kwargs["device"] == "cuda"
assert kwargs["device_index"] == 1
def test_cpu_passthrough(monkeypatch):
"""'cpu' → 그대로 device='cpu', device_index=0."""
_install_fake(monkeypatch)
FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cpu", compute_type="int8"))
kwargs = FakeWhisperModel.calls[-1]
assert kwargs["device"] == "cpu"
assert kwargs["device_index"] == 0
assert kwargs["compute_type"] == "int8"
def test_split_device_helper():
assert FasterWhisperEngine._split_device("cuda:0") == ("cuda", 0)
assert FasterWhisperEngine._split_device("cuda:3") == ("cuda", 3)
assert FasterWhisperEngine._split_device("cpu") == ("cpu", 0)
assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0)
# 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit)
assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0)
def test_namedtuple_segments_normalized_to_dicts(monkeypatch):
"""faster-whisper Segment(namedtuple) → dict 정규화 (GPU 실전 버그)."""
from collections import namedtuple
_install_fake(monkeypatch)
Seg = namedtuple(
"Segment",
[
"id",
"seek",
"start",
"end",
"text",
"tokens",
"temperature",
"avg_logprob",
"compression_ratio",
"no_speech_prob",
],
)
FakeWhisperModel.segments = [
Seg(
id=0,
seek=0,
start=0.0,
end=2.5,
text="오늘 vLLM을 배포합니다.",
tokens=[1, 2],
temperature=0.0,
avg_logprob=-0.2,
compression_ratio=1.0,
no_speech_prob=0.01,
)
]
outcome = FasterWhisperEngine().transcribe(
"/tmp/x.wav", _opts(device="cpu", compute_type="int8")
)
segs = list(outcome.segments)
assert len(segs) == 1
seg = segs[0]
# dict 계약: .get() 사용 가능 (파이프라인이 이걸로 접근)
assert seg.get("text") == "오늘 vLLM을 배포합니다."
assert seg.get("end") == 2.5
assert seg.get("avg_logprob") == -0.2
def test_dict_segments_passthrough(monkeypatch):
"""이미 dict인 세그먼트는 그대로 (mock 계약과 호환)."""
_install_fake(monkeypatch)
FakeWhisperModel.segments = [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}]
outcome = FasterWhisperEngine().transcribe(
"/tmp/x.wav", _opts(device="cpu", compute_type="int8")
)
segs = list(outcome.segments)
assert segs == [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}]
def test_namedtuple_info_normalized_to_dict(monkeypatch):
"""faster-whisper TranscriptionInfo(namedtuple) → dict (GPU 실전 버그)."""
from collections import namedtuple
_install_fake(monkeypatch)
Info = namedtuple(
"TranscriptionInfo",
["language", "language_probability", "duration", "duration_after_vad"],
)
FakeWhisperModel.info = Info(
language="ko", language_probability=0.99, duration=10.464, duration_after_vad=9.088
)
outcome = FasterWhisperEngine().transcribe(
"/tmp/x.wav", _opts(device="cpu", compute_type="int8")
)
# dict 계약: .get() 사용 가능 (batch.py가 이걸로 접근)
assert outcome.info.get("language") == "ko"
assert outcome.info.get("duration") == 10.464
def test_dict_info_passthrough(monkeypatch):
"""이미 dict인 info는 그대로 (mock 계약과 호환)."""
_install_fake(monkeypatch)
FakeWhisperModel.info = {"language": "ko"}
outcome = FasterWhisperEngine().transcribe(
"/tmp/x.wav", _opts(device="cpu", compute_type="int8")
)
assert outcome.info == {"language": "ko"}
+175
View File
@@ -0,0 +1,175 @@
"""EngineOwner 단위 테스트 — OOM 강등 체인, attempted_profiles 영속화, 우선순위."""
from __future__ import annotations
import pytest
from luke_scribe.engine.base import TranscriptionOptions
from luke_scribe.engine.owner import MAX_DOWNGRADES, EngineOwner, InferenceRequest
from luke_scribe.errors import OutOfMemory
class OOMFakeEngine:
"""첫 N번 호출에서 OOM, 이후 성공하는 mock."""
def __init__(self, oom_calls: int = 0) -> None:
self.oom_calls = oom_calls
self.calls: list[dict] = []
self.closed = False
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None):
self.calls.append({"device": options.device, "compute_type": options.compute_type})
if len(self.calls) <= self.oom_calls:
raise OutOfMemory("CUDA OOM (mock)")
return type("O", (), {"segments": iter([]), "info": {}})()
def unload_all(self):
self.closed = True
def _opts(**kw) -> TranscriptionOptions:
kw.setdefault("model", "large-v3-turbo")
kw.setdefault("device", "cuda:0")
kw.setdefault("compute_type", "float16")
return TranscriptionOptions(**kw)
class _SegFakeEngine:
"""emit_hypothesis 테스트용 — WAV 경로 기록 + 세그먼트 반환."""
def __init__(self) -> None:
self.path = None
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None):
self.path = audio_path
segs = [
{
"start": 0.0,
"end": 1.2,
"text": "안녕하세요",
"avg_logprob": -0.2,
"no_speech_prob": 0.01,
}
]
return type("O", (), {"segments": iter(segs), "info": {"language": "ko"}})()
def unload_all(self):
pass
def _bare_owner(engine) -> EngineOwner:
import threading
from luke_scribe.config import Settings
owner = EngineOwner.__new__(EngineOwner)
owner.settings = Settings(_env_file=None)
owner._engine = engine
owner._lock = threading.Lock()
owner._realtime_priority = threading.Lock()
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
return owner
def test_pcm16_to_wav_header():
import struct
from luke_scribe.engine.owner import _pcm16_to_wav
wav = _pcm16_to_wav(b"\x00\x00" * 8000) # 1초 (16kHz mono)
assert wav[:4] == b"RIFF"
assert wav[8:12] == b"WAVE"
assert wav[12:16] == b"fmt "
sr = struct.unpack("<I", wav[24:28])[0]
channels = struct.unpack("<H", wav[22:24])[0]
bits = struct.unpack("<H", wav[34:36])[0]
assert sr == 16000 and channels == 1 and bits == 16
assert struct.unpack("<I", wav[40:44])[0] == 16000 # data 크기
def test_emit_hypothesis_skips_silence():
"""무음 청크는 decode 없이 빈 가설 반환 (할루시네이션 방지)."""
engine = _SegFakeEngine()
owner = _bare_owner(engine)
out = owner.emit_hypothesis(b"\x00\x00" * 16000) # 완전 무음
assert out["segments"] == []
assert engine.path is None # decode 미실행 → 임시 파일도 안 만듦
def test_emit_hypothesis_decodes_chunk_and_cleans_temp():
import os
import random
import struct
engine = _SegFakeEngine()
owner = _bare_owner(engine)
# 1초 PCM16 — RMS 게이트를 통과하는 유성 신호 (무음이면 decode가 생략됨)
chunk = b"".join(struct.pack("<h", random.randint(-6000, 6000)) for _ in range(16000))
out = owner.emit_hypothesis(chunk)
assert out["audio_sec"] == 1.0
assert len(out["segments"]) == 1
assert out["segments"][0].model_dump()["text"] == "안녕하세요"
# 임시 WAV는 실시간 레인 decode 후 삭제됨
assert engine.path is not None
assert not os.path.exists(engine.path)
def test_no_downgrade_on_success():
owner = EngineOwner.__new__(EngineOwner)
owner.settings = None
owner._engine = OOMFakeEngine(oom_calls=0)
owner._lock = __import__("threading").Lock()
owner._realtime_priority = __import__("threading").Lock()
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
req = InferenceRequest(audio_path="/tmp/x.wav", options=_opts())
out = owner.transcribe(req)
assert out["compute_type"] == "float16"
assert len(out["attempted_profiles"]) == 1
def test_downgrade_chain_on_oom():
engine = OOMFakeEngine(oom_calls=1) # 첫 프로파일에서 OOM
owner = EngineOwner.__new__(EngineOwner)
owner.settings = None
owner._engine = engine
owner._lock = __import__("threading").Lock()
owner._realtime_priority = __import__("threading").Lock()
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
req = InferenceRequest(audio_path="/tmp/x.wav", options=_opts())
out = owner.transcribe(req)
# float16 → int8_float16 (강등 1회)
assert out["compute_type"] == "int8_float16"
assert len(out["attempted_profiles"]) == 2
def test_downgrade_capped_at_max():
engine = OOMFakeEngine(oom_calls=99) # 항상 OOM
owner = EngineOwner.__new__(EngineOwner)
owner.settings = None
owner._engine = engine
owner._lock = __import__("threading").Lock()
owner._realtime_priority = __import__("threading").Lock()
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
req = InferenceRequest(audio_path="/tmp/x.wav", options=_opts())
with pytest.raises(OutOfMemory):
owner.transcribe(req)
# 강등 체인은 cpu까지 포함하지만 시도는 MAX_DOWNGRADES+1 이내
assert len(engine.calls) <= MAX_DOWNGRADES + 1
def test_attempted_profiles_persisted_for_retry():
"""재큐(retry) 시 attempted_profiles가 유지되어 총 시도가 캡된다."""
engine = OOMFakeEngine(oom_calls=0)
owner = EngineOwner.__new__(EngineOwner)
owner.settings = None
owner._engine = engine
owner._lock = __import__("threading").Lock()
owner._realtime_priority = __import__("threading").Lock()
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
# 이미 2회 시도한 이력이 Job에 영속화된 상황 → 재시도는 즉시 실패해야 함
req = InferenceRequest(
audio_path="/tmp/x.wav", options=_opts(), attempted_profiles=["float16", "int8_float16"]
)
with pytest.raises(OutOfMemory):
owner.transcribe(req)
assert len(engine.calls) == 0 # 새 시도 없음
+85
View File
@@ -0,0 +1,85 @@
"""포맷 렌더링 (json/txt/srt/vtt) + 결과 스키마 계약 테스트."""
from __future__ import annotations
import json
import pytest
from luke_scribe.results.formats import render
from luke_scribe.results.models import TranscriptResult
@pytest.fixture
def result(transcript_result: dict) -> TranscriptResult:
return TranscriptResult.model_validate(transcript_result)
class TestFormats:
def test_json_roundtrip(self, result: TranscriptResult):
out = render(result, "json")
data = json.loads(out)
assert data["schema_version"] == "1.0"
assert data["segments"][0]["text"].startswith("오늘")
assert data["text"] # 계약: text = segment 텍스트 연결
def test_txt(self, result: TranscriptResult):
out = render(result, "txt")
assert "오늘 API 서버에서" in out
assert out.endswith("\n")
def test_srt(self, result: TranscriptResult):
out = render(result, "srt")
assert "1" in out
assert "00:00:00,000 --> 00:00:02,500" in out
# 세그먼트 순번 + 타임스탬프 + 텍스트
assert "오늘 API 서버에서" in out
def test_vtt(self, result: TranscriptResult):
out = render(result, "vtt")
assert out.startswith("WEBVTT")
assert "00:00:00.000 --> 00:00:02.500" in out
def test_unsupported_format(self, result: TranscriptResult):
with pytest.raises(ValueError):
render(result, "xml") # type: ignore[arg-type]
def test_empty_segments_srt(self):
r = TranscriptResult(status="completed", segments=[], text="")
out = render(r, "srt")
assert out == ""
class TestSchemaContract:
def test_status_completed_no_error(self, transcript_result: dict):
result = TranscriptResult.model_validate(transcript_result)
assert result.error is None
def test_failed_gets_default_error(self):
r = TranscriptResult(status="failed", text="")
assert r.error is not None
assert r.error.code == "transcription_failed"
def test_cancelled_gets_default_error(self):
r = TranscriptResult(status="cancelled", text="")
assert r.error is not None
assert r.error.code == "cancelled"
def test_completed_error_rejected(self):
with pytest.raises(ValueError):
TranscriptResult(status="completed", error={"code": "x", "message": "y"}, text="")
def test_segment_index_must_be_nonneg(self):
with pytest.raises(ValueError):
TranscriptResult(
status="completed", segments=[{"index": -1, "start": 0, "end": 1, "text": "x"}]
)
def test_duration_gt_zero(self):
with pytest.raises(ValueError):
TranscriptResult(
status="completed",
normalized_audio={"duration_sec": 0},
segments=[],
text="",
)
+206
View File
@@ -0,0 +1,206 @@
"""AudioIngestor — ffprobe 검증, 상한(크기/길이), 취소·임시파일 정리 테스트.
ffprobe/ffmpeg 바이너리 없이 subprocess를 mock해서 동작을 검증한다.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from luke_scribe.audio.ingest import AudioIngestor
from luke_scribe.config import Settings
from luke_scribe.errors import (
AudioProbeFailed,
CancelledError,
InvalidInput,
UnsupportedInputEnvelope,
)
@pytest.fixture
def settings(tmp_path) -> Settings:
return Settings(
_env_file=None,
max_duration_sec=14400,
max_upload_bytes=2 * 1024 * 1024 * 1024,
)
def _fake_probe_json(*, duration: str = "120.0", has_audio: bool = True) -> str:
streams = (
[{"codec_type": "audio", "codec_name": "mp3"}] if has_audio else [{"codec_type": "video"}]
)
return json.dumps({"format": {"duration": duration}, "streams": streams})
class FakeSubprocess:
"""ffprobe 호출에 대한 확정적 mock."""
def __init__(
self, *, probe_json: str | None = None, probe_rc: int = 0, probe_err: str = ""
) -> None:
self.probe_json = probe_json
self.probe_rc = probe_rc
self.probe_err = probe_err
self.calls: list[list[str]] = []
def run(self, cmd, **kwargs):
self.calls.append(cmd)
if "show_entries" in cmd:
return subprocess.CompletedProcess(
cmd, self.probe_rc, stdout=self.probe_json or "", stderr=self.probe_err
)
return subprocess.CompletedProcess(cmd, 0, stdout=self.probe_json or "", stderr="")
class FakeStream:
"""Popen stdout/stderr 용 — 즉시 EOF."""
def read(self, n: int = -1) -> bytes:
return b""
def test_probe_success(settings: Settings, tmp_path, monkeypatch):
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="120.0"))
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
src = tmp_path / "a.mp3"
src.write_bytes(b"x" * 100)
ing = AudioIngestor(settings)
probe = ing.probe(src)
assert probe.duration_sec == 120.0
assert probe.codec == "mp3"
def test_probe_missing_file(settings: Settings, tmp_path):
ing = AudioIngestor(settings)
with pytest.raises(InvalidInput):
ing.probe(tmp_path / "nope.mp3")
def test_probe_size_limit(settings: Settings, tmp_path, monkeypatch):
settings.max_upload_bytes = 100
src = tmp_path / "big.mp3"
src.write_bytes(b"x" * 200)
ing = AudioIngestor(settings)
with pytest.raises(UnsupportedInputEnvelope):
ing.probe(src)
def test_probe_duration_limit(settings: Settings, tmp_path, monkeypatch):
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="999999"))
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
src = tmp_path / "long.mp3"
src.write_bytes(b"x" * 10)
ing = AudioIngestor(settings)
with pytest.raises(UnsupportedInputEnvelope):
ing.probe(src)
def test_probe_no_audio_stream(settings: Settings, tmp_path, monkeypatch):
fake = FakeSubprocess(probe_json=_fake_probe_json(has_audio=False))
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
src = tmp_path / "video.mp4"
src.write_bytes(b"x" * 10)
ing = AudioIngestor(settings)
with pytest.raises(AudioProbeFailed):
ing.probe(src)
def test_probe_ffprobe_missing(settings: Settings, tmp_path, monkeypatch):
class NoFfprobe:
def run(self, cmd, **kwargs):
raise FileNotFoundError("ffprobe")
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", NoFfprobe())
src = tmp_path / "a.mp3"
src.write_bytes(b"x")
ing = AudioIngestor(settings)
with pytest.raises(AudioProbeFailed):
ing.probe(src)
def test_probe_bad_output(settings: Settings, tmp_path, monkeypatch):
fake = FakeSubprocess(probe_json="not-json{")
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
src = tmp_path / "a.mp3"
src.write_bytes(b"x")
ing = AudioIngestor(settings)
with pytest.raises(AudioProbeFailed):
ing.probe(src)
class TestIngestCancellation:
def test_cancel_before_ingest(self, settings: Settings, tmp_path):
src = tmp_path / "a.mp3"
src.write_bytes(b"x")
ing = AudioIngestor(settings)
with pytest.raises(CancelledError):
ing.ingest(src, should_cancel=lambda: True)
def test_temp_dir_cleaned_on_cancel(self, settings: Settings, tmp_path, monkeypatch):
"""취소/실패 시 임시 디렉터리가 반드시 정리된다 (Eng P1)."""
src = tmp_path / "a.mp3"
src.write_bytes(b"x")
class CancellingPopen:
"""무한 인코딩 중인 ffmpeg — wait 폴링에서 계속 TimeoutExpired."""
def __init__(self, cmd, **kwargs):
self.stdout = FakeStream()
self.stderr = FakeStream()
self.pid = 99999999 # 존재하지 않는 pid → _kill_group의 ProcessLookupError 경로
def wait(self, timeout=None):
raise subprocess.TimeoutExpired(cmd=[], timeout=0.5)
def kill(self):
pass
# ffprobe는 성공, ffmpeg Popen은 취소 폴링 루프에 진입
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="60.0"))
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.run", fake.run)
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.Popen", CancellingPopen)
monkeypatch.setattr(
"luke_scribe.audio.ingest.tempfile.mkdtemp",
lambda prefix="": str(tmp_path / "ingest-tmp"),
)
ing = AudioIngestor(settings)
# 초기 검사 2회(프로브 전/후)는 통과, ffmpeg 폴링 루프에서 취소
calls = {"n": 0}
def should_cancel():
calls["n"] += 1
return calls["n"] > 2
with pytest.raises(CancelledError):
ing.ingest(src, should_cancel=should_cancel)
assert not Path(tmp_path / "ingest-tmp").exists()
def test_temp_dir_cleaned_on_ffmpeg_failure(self, settings: Settings, tmp_path, monkeypatch):
src = tmp_path / "a.mp3"
src.write_bytes(b"x")
class FailingFfmpeg:
def __init__(self, cmd, **kwargs):
self.stdout = FakeStream()
self.stderr = FakeStream()
self.returncode = 1
def wait(self, timeout=None):
return 1
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="60.0"))
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.Popen", FailingFfmpeg)
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.run", fake.run)
monkeypatch.setattr(
"luke_scribe.audio.ingest.tempfile.mkdtemp",
lambda prefix="": str(tmp_path / "ingest-tmp2"),
)
ing = AudioIngestor(settings)
with pytest.raises(AudioProbeFailed):
ing.ingest(src)
assert not Path(tmp_path / "ingest-tmp2").exists()
+106
View File
@@ -0,0 +1,106 @@
"""Job 상태 머신 + in-proc 브로커 단위 테스트."""
from __future__ import annotations
import time
import pytest
from luke_scribe.errors import JobAlreadyTerminal, QueueFull
from luke_scribe.jobqueue.broker import InProcBroker
from luke_scribe.jobqueue.jobs import Job, JobStatus
class TestJobStateMachine:
def test_legal_transitions(self):
job = Job()
assert job.status == JobStatus.QUEUED
job.transition(JobStatus.PROCESSING)
assert job.status == JobStatus.PROCESSING
job.transition(JobStatus.COMPLETED)
assert job.status == JobStatus.COMPLETED
def test_illegal_transition_rejected(self):
job = Job()
job.transition(JobStatus.PROCESSING)
job.transition(JobStatus.COMPLETED)
with pytest.raises(JobAlreadyTerminal):
job.transition(JobStatus.PROCESSING) # terminal에서 되돌아갈 수 없음
def test_queued_cancel(self):
job = Job()
job.transition(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
assert job.cancelled_at is not None
def test_lease_expiry(self):
job = Job()
job.refresh_lease(60.0, now=1000.0)
assert not job.lease_expired(now=1050.0)
assert job.lease_expired(now=1100.0)
def test_attempted_profiles_persisted(self):
job = Job(attempted_profiles=["float16", "int8_float16"])
assert job.to_meta()["attempted_profiles"] == ["float16", "int8_float16"]
restored = Job.from_meta(job.to_meta())
assert restored.attempted_profiles == ["float16", "int8_float16"]
class TestInProcBroker:
def test_enqueue_claim_complete(self):
broker = InProcBroker()
job = Job()
broker.enqueue(job)
claimed = broker.claim_next("w1", 60.0)
assert claimed is not None
assert claimed.status == JobStatus.PROCESSING
broker.transition(claimed, JobStatus.COMPLETED)
assert broker.get(job.id).status == JobStatus.COMPLETED
def test_queue_position(self):
broker = InProcBroker()
a, b = Job(), Job()
broker.enqueue(a)
broker.enqueue(b)
assert a.queue_position == 0
assert b.queue_position == 1
def test_queue_full(self):
broker = InProcBroker()
broker.settings.max_queue = 2
broker.enqueue(Job())
broker.enqueue(Job())
with pytest.raises(QueueFull):
broker.enqueue(Job())
def test_cancel_queued(self):
broker = InProcBroker()
job = Job()
broker.enqueue(job)
cancelled = broker.cancel(job.id)
assert cancelled.status == JobStatus.CANCELLED
assert broker.queue_depth() == 0
def test_cancel_processing_sets_flag(self):
broker = InProcBroker()
job = Job()
broker.enqueue(job)
broker.claim_next("w1", 60.0)
cancelled = broker.cancel(job.id)
assert cancelled.cancel_requested is True
assert cancelled.status == JobStatus.PROCESSING # 워커가 세그먼트 경계에서 종료
def test_reconcile_stale_recovers_crash(self):
"""Eng 리뷰 P11: 워커 크래시 → stale processing → failed 복구."""
broker = InProcBroker()
job = Job()
broker.enqueue(job)
broker.claim_next("w1", 60.0) # processing + lease 60s
# lease 만료 시뮬레이션
job2 = broker.get(job.id)
job2.lease_expires_at = time.time() - 1
broker.save_meta(job2)
reclaimed = broker.reconcile_stale(60.0)
assert job.id in reclaimed
assert broker.get(job.id).status == JobStatus.FAILED
assert broker.get(job.id).error_code == "worker_crash"
+86
View File
@@ -0,0 +1,86 @@
"""KeyStore — 다이제스트 인증, 스코프, 키 생성 테스트."""
from __future__ import annotations
import json
import pytest
from luke_scribe.api.deps import KeyStore, Principal
from luke_scribe.config import Settings
from luke_scribe.errors import AuthError, ScopeDenied
@pytest.fixture
def settings(tmp_path) -> Settings:
return Settings(
_env_file=None,
api_keys="key-one,key-admin:admin,transcribe",
api_key_file=str(tmp_path / "api_keys.json"),
)
class TestKeyStoreAuth:
def test_plain_key_authenticates(self, settings: Settings):
ks = KeyStore(settings)
p = ks.authenticate("key-one")
assert p.key_id.startswith("k-key-one")
assert "transcribe" in p.scopes
def test_scoped_key(self, settings: Settings):
ks = KeyStore(settings)
p = ks.authenticate("key-admin")
assert {"admin", "transcribe"} <= p.scopes
def test_wrong_key_rejected(self, settings: Settings):
ks = KeyStore(settings)
with pytest.raises(AuthError):
ks.authenticate("key-wrong")
def test_empty_key_rejected(self, settings: Settings):
ks = KeyStore(settings)
with pytest.raises(AuthError):
ks.authenticate(None)
def test_digest_only_on_disk(self, settings: Settings, tmp_path):
"""평문 키는 저장하지 않고 다이제스트만 파일에 기록 (Eng P14)."""
created = KeyStore(settings).create_key(
scopes=["transcribe"], save_path=settings.api_key_file
)
raw = created["key"]
data = json.loads(tmp_path.joinpath("api_keys.json").read_text())
assert raw not in json.dumps(data)
entry = data["keys"][0]
assert entry["digest"] != raw
assert len(entry["digest"]) == 64 # sha256 hex
def test_file_keys_loaded(self, settings: Settings, tmp_path):
ks = KeyStore(settings)
created = ks.create_key(scopes=["transcribe"], save_path=settings.api_key_file)
# 새 인스턴스가 파일에서 로드
ks2 = KeyStore(settings)
p = ks2.authenticate(created["key"])
assert "transcribe" in p.scopes
class TestPrincipalScopes:
def test_require_scope_ok(self):
p = Principal(key_id="k-1", scopes={"transcribe"})
p.require_scope("transcribe") # no raise
def test_require_scope_denied(self):
p = Principal(key_id="k-1", scopes={"transcribe"})
with pytest.raises(ScopeDenied):
p.require_scope("admin")
def test_require_scope_empty(self):
p = Principal(key_id="k-1", scopes=set())
with pytest.raises(ScopeDenied):
p.require_scope("transcribe")
class TestDigest:
def test_same_key_same_digest(self, settings: Settings):
ks = KeyStore(settings)
assert ks._digest("abc") == ks._digest("abc")
assert ks._digest("abc") != ks._digest("abd")
+84
View File
@@ -0,0 +1,84 @@
"""벤치마크 지표 단위 테스트 — WER/K-CER/entity 보존."""
from __future__ import annotations
from luke_scribe.benchmark.metrics import (
character_kcer,
clip_metrics,
entity_retention,
normalize_kcer,
word_wer,
)
class TestKcer:
def test_normalization_nfkc_punct(self):
# NFKC: 유사 문자 통일, 문장부호/공백 제거
assert (
normalize_kcer("오늘 API 서버에서, vLLM을 사용해 보겠습니다.")
== "오늘api서버에서vllm을사용해보겠습니다"
)
def test_exact_match_zero(self):
assert character_kcer("오늘 API 사용", "오늘 API 사용") == 0.0
def test_punct_diff_zero(self):
# 문장부호 차이는 K-CER에서 0 (정규화됨)
assert character_kcer("오늘 API 사용.", "오늘 API 사용") == 0.0
def test_one_char_diff(self):
ref = "오늘 API 사용"
hyp = "오늘 APT 사용"
assert 0 < character_kcer(ref, hyp) < 0.3
class TestWer:
def test_exact(self):
assert word_wer("a b c", "a b c") == 0.0
def test_one_sub(self):
assert word_wer("a b c", "a x c") == 1 / 3
def test_empty_ref(self):
assert word_wer("", "") == 0.0
assert word_wer("", "a") == 1.0
class TestEntityRetention:
def test_preserved(self):
ref = "오늘 API 서버에서 vLLM을 사용합니다"
hyp = "오늘 API 서버에서 vLLM을 사용합니다"
entities = [
{"canonical": "API", "surface": "API", "start_char": 3, "end_char": 6},
{"canonical": "vLLM", "surface": "vLLM", "start_char": 12, "end_char": 16},
]
p, t = entity_retention(ref, hyp, entities)
assert p == 2 and t == 2
def test_lost(self):
ref = "오늘 API 서버"
hyp = "오늘 에이피아이 서버"
entities = [{"canonical": "API", "surface": "API", "start_char": 3, "end_char": 6}]
p, t = entity_retention(ref, hyp, entities)
assert p == 0 and t == 1
def test_one_occurrence_one_match(self):
# 같은 entity가 두 번 등장 → 2개 annotation, 둘 다 보존
ref = "API 서버와 API 게이트웨이"
hyp = "API 서버와 API 게이트웨이"
entities = [
{"canonical": "API", "surface": "API", "start_char": 0, "end_char": 3},
{"canonical": "API", "surface": "API", "start_char": 8, "end_char": 11},
]
p, t = entity_retention(ref, hyp, entities)
assert p == 2 and t == 2
class TestClipMetrics:
def test_perfect(self):
ref = "오늘 API 서버에서 vLLM을 사용합니다"
m = clip_metrics(
ref, ref, [{"canonical": "API", "surface": "API", "start_char": 3, "end_char": 6}]
)
assert m.wer == 0.0 and m.k_cer == 0.0
assert m.entities_preserved == 1 and m.entities_total == 1
+131
View File
@@ -0,0 +1,131 @@
"""후처리 단위 테스트 — glossary/rules/confidence/span-aware."""
from __future__ import annotations
from luke_scribe.config import Settings
from luke_scribe.engine.base import TranscriptionOptions
from luke_scribe.postprocess.confidence import confidence_flags, segment_confidence
from luke_scribe.postprocess.glossary import apply_glossary
from luke_scribe.postprocess.llm import EgressGuard
from luke_scribe.postprocess.pipeline import run_postprocess
from luke_scribe.postprocess.rules import apply_rules
from luke_scribe.results.models import Segment
def _segments(texts: list[str], logprobs: list[float] | None = None) -> list[Segment]:
out = []
t = 0.0
for i, text in enumerate(texts):
lp = logprobs[i] if logprobs else -0.2
out.append(
Segment(index=i, start=t, end=t + 1.0, text=text, avg_logprob=lp, no_speech_prob=0.01)
)
t += 1.0
return out
class TestGlossary:
def test_restores_romanized(self):
segs = _segments(["브이엘엘엠 서버를 배포했습니다"])
out = apply_glossary(segs, {"브이엘엘엠": "vLLM"})
assert out["corrections"][0]["to"] == "vLLM"
assert "vLLM" in out["segments"][0].text
def test_no_double_match(self):
segs = _segments(["API API"])
out = apply_glossary(segs, {"API": "API"})
assert len(out["corrections"]) == 0 # 이미 표준 표기, 교정 없음
class TestRules:
def test_vllm_spaced(self):
segs = _segments(["v l l m 추론 서버"])
out = apply_rules(segs)
assert "vLLM" in out["segments"][0].text
def test_vllm_restored_from_blm(self):
# GPU 실전에서 재현된 오인식: vLLM → BLM
segs = _segments(["오늘은 BLM 서버를 배포합니다"])
out = apply_rules(segs)
assert "vLLM" in out["segments"][0].text
assert "BLM" not in out["segments"][0].text
def test_blm_boundary_required(self):
# 단어 경계가 없으면 교정하지 않는다 (부분 문자열 보호)
segs = _segments(["sublm 단어"])
out = apply_rules(segs)
assert "sublm" in out["segments"][0].text
assert "vLLM" not in out["segments"][0].text
def test_blm_case_insensitive(self):
segs = _segments(["blm 서버"])
out = apply_rules(segs)
assert "vLLM" in out["segments"][0].text
def test_whitespace_collapse(self):
segs = _segments(["오늘 API 서버"])
out = apply_rules(segs)
assert out["segments"][0].text == "오늘 API 서버"
class TestConfidence:
def test_low_logprob_flagged(self):
segs = _segments(["불확실한 전사"], logprobs=[-2.0])
assert segs[0].index in confidence_flags(segs)
def test_high_confidence_not_flagged(self):
segs = _segments(["확실한 전사"], logprobs=[-0.1])
assert confidence_flags(segs) == []
def test_segment_confidence_range(self):
assert (
segment_confidence(Segment(index=0, start=0, end=1, text="x", avg_logprob=-0.5)) == 0.5
)
class TestPipeline:
def test_rules_mode_applies(self):
segs = _segments(["v l l m 서버"])
settings = Settings(_env_file=None, post_mode="rules", post_enabled=True)
out = run_postprocess(segs, TranscriptionOptions(), settings)
assert any("vLLM" in s.text for s in out["segments"])
assert out["meta"]["mode"] == "rules"
def test_none_mode_unchanged(self):
segs = _segments(["v l l m 서버"])
settings = Settings(_env_file=None, post_mode="none", post_enabled=False)
out = run_postprocess(segs, TranscriptionOptions(), settings)
assert out["segments"][0].text == "v l l m 서버"
def test_span_aware_keeps_timestamps(self):
# 교정 후에도 세그먼트 타임라인(start/end)은 보존
segs = _segments(["브이엘엘엠 서버"])
before = [(s.start, s.end) for s in segs]
out = apply_glossary(segs, {"브이엘엘엠": "vLLM"})
after = [(s.start, s.end) for s in out["segments"]]
assert before == after
class TestEgressGuard:
def test_private_ip_rejected(self):
guard = EgressGuard({"llm": "http://192.168.0.1:8080/v1"})
assert guard.resolve("llm") is None
def test_http_scheme_rejected(self):
guard = EgressGuard({"llm": "http://example.com/v1"})
assert guard.resolve("llm") is None
def test_https_public_allowed(self, monkeypatch):
# 오프라인 테스트 환경에서 DNS 해석을 공용 IP로 모킹
monkeypatch.setattr(
"luke_scribe.postprocess.llm.socket.gethostbyname", lambda host: "93.184.216.34"
)
guard = EgressGuard({"llm": "https://api.example.com/v1"})
assert guard.resolve("llm") is not None
def test_private_hostname_resolved_rejected(self, monkeypatch):
monkeypatch.setattr(
"luke_scribe.postprocess.llm.socket.gethostbyname", lambda host: "10.0.0.5"
)
guard = EgressGuard({"llm": "https://internal.example.com/v1"})
assert guard.resolve("llm") is None
+77
View File
@@ -0,0 +1,77 @@
"""실시간 LocalAgreement 단위 테스트 — 버퍼 절단 불변식, 단조 타임스탬프."""
from __future__ import annotations
from luke_scribe.pipeline.realtime import LocalAgreement
from luke_scribe.results.models import Segment
def _segs(texts: list[str], start: float = 0.0) -> list[Segment]:
out = []
t = start
for i, text in enumerate(texts):
out.append(Segment(index=i, start=t, end=t + 1.0, text=text))
t += 1.0
return out
class TestAgreement:
def test_no_confirmation_until_agreement_count(self):
la = LocalAgreement(agreement_count=2)
e1 = la.feed(_segs(["첫 번째 문장"]), 2.0)
assert e1["confirmed"] == [] # 1회만으로는 미확정
def test_stable_prefix_confirmed(self):
la = LocalAgreement(agreement_count=2)
la.feed(_segs(["오늘 API 서버"]), 2.0)
e2 = la.feed(_segs(["오늘 API 서버에서 vLLM"]), 2.0)
confirmed = e2["confirmed"]
assert confirmed and confirmed[0].text == "오늘 API 서버"
assert e2["truncated"] is True
def test_confirmed_immutable(self):
"""확정 세그먼트는 이후 가설에 의해 절대 변경되지 않는다 (불변식 1)."""
la = LocalAgreement(agreement_count=2)
la.feed(_segs(["오늘 API"]), 2.0)
e2 = la.feed(_segs(["오늘 API 서버"]), 2.0)
first_confirmed = [s.text for s in e2["confirmed"]]
la.feed(_segs(["오늘 API 서버에서 vLLM 사용"]), 2.0)
# 확정분은 그대로
assert [s.text for s in la.state.confirmed_segments][
: len(first_confirmed)
] == first_confirmed
def test_no_duplicate_confirmation(self):
"""이미 확정된 텍스트는 다음 가설에서 재발행되지 않는다 (Eng P18)."""
la = LocalAgreement(agreement_count=2)
la.feed(_segs(["안정된 발화"]), 2.0)
e2 = la.feed(_segs(["안정된 발화", "다음 문장"]), 2.0)
assert [s.text for s in e2["confirmed"]] == ["안정된 발화"]
# 다음 피드: "안정된 발화"는 재발행 금지, 새 텍스트만 확정
e3 = la.feed(_segs(["안정된 발화", "다음 문장", "셋째 문장"]), 2.0)
assert [s.text for s in e3["confirmed"]] == ["다음 문장"]
assert [s.text for s in la.state.confirmed_segments] == ["안정된 발화", "다음 문장"]
def test_monotonic_timestamps(self):
"""확정 세그먼트 시간은 단조 증가 (불변식 3)."""
la = LocalAgreement(agreement_count=2)
la.feed(_segs(["첫 문장", "둘째 문장"]), 4.0)
la.feed(_segs(["첫 문장", "둘째 문장", "셋째 문장"]), 4.0)
ends = [s.end for s in la.state.confirmed_segments]
assert all(b >= a for a, b in zip(ends, ends[1:], strict=False))
def test_buffer_truncation_invariant(self):
"""확정 방출 후 보류 버퍼는 left_context 이하로 절단 (불변식 2)."""
la = LocalAgreement(agreement_count=2, retained_left_context_sec=1.0)
la.feed(_segs(["a", "b", "c"]), 3.0)
e2 = la.feed(_segs(["a", "b", "c", "d", "e", "f"]), 3.0)
assert len(e2["confirmed"]) >= 1
# audio offset은 마지막 확정 end - left_context
assert la.state.audio_offset_sec >= 0
def test_flush_confirms_pending(self):
la = LocalAgreement(agreement_count=2)
la.feed(_segs(["마지막 발화"]), 2.0)
flushed = la.flush()
assert flushed["confirmed"]
assert "마지막 발화" in flushed["new_text"]
+184
View File
@@ -0,0 +1,184 @@
"""ResultStore — UUID 키, 경로 트래버설 차단, 원자적 쓰기, 보관 TTL 테스트."""
from __future__ import annotations
import os
import time
import uuid
import pytest
from luke_scribe.errors import OutputWriteError
from luke_scribe.results.models import TranscriptResult
from luke_scribe.results.retention import RetentionSweeper
from luke_scribe.results.store import ResultStore
@pytest.fixture
def store(tmp_path) -> ResultStore:
return ResultStore(str(tmp_path / "results"))
@pytest.fixture
def result(transcript_result: dict) -> TranscriptResult:
return TranscriptResult.model_validate(transcript_result)
class TestResultStore:
def test_write_read_roundtrip(self, store: ResultStore, result: TranscriptResult):
job_id = str(uuid.uuid4())
store.write_result(job_id, result)
read = store.read_result(job_id)
assert read is not None
assert read.text == result.text
assert read.segments[0].text == result.segments[0].text
def test_missing_result_returns_none(self, store: ResultStore):
assert store.read_result(str(uuid.uuid4())) is None
def test_path_traversal_rejected(self, store: ResultStore):
with pytest.raises(ValueError):
store._job_dir("../../etc/passwd")
with pytest.raises(ValueError):
store._job_dir("..")
def test_source_path_sanitized(self, store: ResultStore):
job_id = str(uuid.uuid4())
p = store.source_path_for(job_id, "../../../evil.mp3")
assert ".." not in str(p)
assert p.name == "evil.mp3"
def test_symlink_job_dir_rejected(self, store: ResultStore, result: TranscriptResult):
job_id = str(uuid.uuid4())
real = store.root / f"real-{uuid.uuid4().hex}"
real.mkdir(parents=True)
link = store.root / job_id
os.symlink(real, link)
with pytest.raises(ValueError):
store.write_result(job_id, result)
def test_atomic_write_no_partial_json(self, store: ResultStore, result: TranscriptResult):
job_id = str(uuid.uuid4())
target = store.write_result(job_id, result)
# 임시 파일이 남지 않아야 한다
leftovers = [p for p in target.parent.iterdir() if p.name.startswith(".result-")]
assert leftovers == []
def test_delete_job(self, store: ResultStore, result: TranscriptResult):
job_id = str(uuid.uuid4())
store.write_result(job_id, result)
store.delete_job(job_id)
assert store.read_result(job_id) is None
def test_iter_job_dirs_includes_all(self, store: ResultStore, result: TranscriptResult):
done_id = str(uuid.uuid4())
store.write_result(done_id, result)
empty_id = str(uuid.uuid4())
store._job_dir(empty_id).mkdir(parents=True)
entries = dict(store.iter_job_dirs())
assert done_id in entries
assert empty_id in entries
def test_delete_derived_keeps_result_and_meta(
self, store: ResultStore, result: TranscriptResult
):
job_id = str(uuid.uuid4())
src = store.source_path_for(job_id, "meeting.mp3")
src.write_bytes(b"audio")
store.write_result(job_id, result)
store.write_source_metadata(job_id, {"status": "completed", "completed_at": 1.0})
store.delete_derived(job_id)
assert not src.exists() # 원본 오디오 삭제 (privacy-first)
assert store.read_result(job_id) is not None # 결과 보존
assert (store._job_dir(job_id) / "meta.json").exists()
class TestRetentionSweeper:
def _write_terminal(
self, store: ResultStore, result: TranscriptResult, completed_at: float
) -> str:
job_id = str(uuid.uuid4())
store.write_result(job_id, result)
store.write_source_metadata(job_id, {"completed_at": completed_at})
return job_id
def test_sweeps_stale_terminal(self, store: ResultStore, result: TranscriptResult):
now = time.time()
old = self._write_terminal(store, result, completed_at=now - 8 * 86400)
fresh = self._write_terminal(store, result, completed_at=now - 3600)
sweeper = RetentionSweeper(store, retention_days=7, now=now)
removed = sweeper.sweep()
assert old in removed
assert fresh not in removed
assert store.read_result(old) is None
assert store.read_result(fresh) is not None
def test_iso_timestamp_parsed(self, store: ResultStore, result: TranscriptResult):
"""ISO 8601 타임스탬프도 처리 (워커는 float, 다른 경로는 ISO 가능)."""
from datetime import UTC, datetime
now = time.time()
iso_old = datetime.fromtimestamp(now - 8 * 86400, tz=UTC).isoformat()
job_id = str(uuid.uuid4())
store.write_result(job_id, result)
store.write_source_metadata(job_id, {"completed_at": iso_old})
removed = RetentionSweeper(store, retention_days=7, now=now).sweep()
assert job_id in removed
def test_mtime_fallback(self, store: ResultStore, result: TranscriptResult):
"""메타가 없으면 result.json mtime 기준 폴백."""
job_id = str(uuid.uuid4())
p = store.write_result(job_id, result)
old = time.time() - 8 * 86400
os.utime(p, (old, old))
removed = RetentionSweeper(store, retention_days=7, now=time.time()).sweep()
assert job_id in removed
def test_retention_disabled(self, store: ResultStore, result: TranscriptResult):
job_id = self._write_terminal(store, result, completed_at=time.time() - 8 * 86400)
sweeper = RetentionSweeper(store, retention_days=0, now=time.time())
assert sweeper.sweep() == []
assert store.read_result(job_id) is not None
def test_sweeps_failed_job_with_meta_only(self, store: ResultStore, result: TranscriptResult):
"""Eng 리뷰: failed/cancelled job(meta.json만)도 보관 정리 대상."""
now = time.time()
failed_id = str(uuid.uuid4())
store._job_dir(failed_id).mkdir(parents=True)
store.write_source_metadata(
failed_id,
{"status": "failed", "error_code": "worker_crash", "failed_at": now - 8 * 86400},
)
removed = RetentionSweeper(store, retention_days=7, now=now).sweep()
assert failed_id in removed
assert not store._job_dir(failed_id).exists()
def test_processing_job_not_swept(self, store: ResultStore, result: TranscriptResult):
"""queued/processing(터미널 타임스탬프 없음)은 절대 삭제 안 함."""
now = time.time()
queued_id = str(uuid.uuid4())
store._job_dir(queued_id).mkdir(parents=True)
store.write_source_metadata(queued_id, {"status": "queued"})
removed = RetentionSweeper(store, retention_days=7, now=now).sweep()
assert queued_id not in removed
assert store._job_dir(queued_id).exists()
class TestAtomicFileWriter:
def test_write_replaces(self, tmp_path):
from luke_scribe.results.store import AtomicFileWriter
target = tmp_path / "out.txt"
AtomicFileWriter.write(target, "hello")
assert target.read_text() == "hello"
AtomicFileWriter.write(target, "world")
assert target.read_text() == "world"
def test_write_failure_raises_output_error(self, tmp_path):
from luke_scribe.results.store import AtomicFileWriter
# 부모 경로가 파일이면 mkdir 실패 → OutputWriteError
blocker = tmp_path / "blocker"
blocker.write_text("not a dir")
with pytest.raises(OutputWriteError):
AtomicFileWriter.write(blocker / "x.txt", "data")
Generated
-3855
View File
File diff suppressed because it is too large Load Diff