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
24 changed files with 2488 additions and 27 deletions
+11
View File
@@ -108,6 +108,17 @@ docker compose --profile gpu up -d
API + Redis + 워커, 공유 스토어 볼륨. `LUKESCRIBE_API_KEYS` 필수. 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) ## 알려진 제한 (v0.1)
- 실시간 decode는 `EngineOwner` 스텁 (실제 WS decode는 GPU 환경에서 활성화). - 실시간 decode는 `EngineOwner` 스텁 (실제 WS decode는 GPU 환경에서 활성화).
File diff suppressed because one or more lines are too long
+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()
+25 -1
View File
@@ -9,6 +9,7 @@ Lifespan (plan §3.10a/§3.5):
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -27,7 +28,7 @@ from ..errors import (
) )
from ..results.store import ResultStore from ..results.store import ResultStore
from .deps import KeyStore from .deps import KeyStore
from .routes import admin, jobs, stream from .routes import admin, dashboard, jobs, keys, stream
logger = logging.getLogger("luke_scribe.api") logger = logging.getLogger("luke_scribe.api")
@@ -63,6 +64,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.session_guard = _SessionGuard(settings) 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 model_cache = settings.model_cache_dir
if model_cache: if model_cache:
@@ -99,6 +117,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
tunnel.stop() tunnel.stop()
except Exception: except Exception:
pass pass
if inproc_worker is not None:
# stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후
# 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전)
inproc_worker.stop()
owner.unload_all() owner.unload_all()
app = FastAPI( app = FastAPI(
@@ -108,9 +130,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
lifespan=lifespan, lifespan=lifespan,
) )
app.include_router(dashboard.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(jobs.router) app.include_router(jobs.router)
app.include_router(stream.router) app.include_router(stream.router)
app.include_router(keys.router)
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ── # ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
@app.exception_handler(AuthError) @app.exception_handler(AuthError)
+7
View File
@@ -73,6 +73,13 @@ class KeyStore:
return Principal(key_id=key_id, scopes=entry["scopes"]) return Principal(key_id=key_id, scopes=entry["scopes"])
raise AuthError("유효하지 않은 API 키") 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: def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict:
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장.""" """새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
raw = f"luke-{secrets.token_urlsafe(32)}" raw = f"luke-{secrets.token_urlsafe(32)}"
+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")
+1
View File
@@ -168,6 +168,7 @@ def _to_status(job: Job) -> JobStatusResponse:
return JobStatusResponse( return JobStatusResponse(
job_id=job.id, job_id=job.id,
status=job.status.value, status=job.status.value,
source_name=job.source_name,
queue_position=job.queue_position, queue_position=job.queue_position,
progress=job.progress, progress=job.progress,
processed_sec=job.processed_sec, processed_sec=job.processed_sec,
+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()}
+6
View File
@@ -23,10 +23,15 @@ class TranscribeOptions(BaseModel):
hotwords: list[str] = Field(default_factory=list) hotwords: list[str] = Field(default_factory=list)
vad: bool = True vad: bool = True
glossary_id: str | None = None glossary_id: str | None = None
glossary: dict[str, str] | None = None # {오인식 패턴: 표준 표기} — 후처리 glossary
post_correction: dict[str, Any] | None = None post_correction: dict[str, Any] | None = None
diarize: bool = False diarize: bool = False
class KeyCreateRequest(BaseModel):
scopes: list[str] = Field(default_factory=lambda: ["transcribe"])
class JobCreateResponse(BaseModel): class JobCreateResponse(BaseModel):
job_id: str job_id: str
status: str status: str
@@ -36,6 +41,7 @@ class JobCreateResponse(BaseModel):
class JobStatusResponse(BaseModel): class JobStatusResponse(BaseModel):
job_id: str job_id: str
status: str status: str
source_name: str | None = None
queue_position: int | None = None queue_position: int | None = None
jobs_ahead: int | None = None jobs_ahead: int | None = None
progress: float | None = None progress: float | None = None
+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>
+35 -10
View File
@@ -56,6 +56,8 @@ def run_benchmark(
from ..errors import InvalidInput from ..errors import InvalidInput
raise InvalidInput("manifest에 clips가 없습니다") raise InvalidInput("manifest에 clips가 없습니다")
# manifest 최상위 glossary: {오인식 패턴: 표준 표기} — 벤치도 후처리를 적용한다
glossary = data.get("glossary") or None
report: dict = { report: dict = {
"report_version": REPORT_VERSION, "report_version": REPORT_VERSION,
@@ -71,6 +73,8 @@ def run_benchmark(
"beam_size": 5, "beam_size": 5,
"temperature": 0.0, "temperature": 0.0,
"vad_filter": True, "vad_filter": True,
"post_mode": settings.post_mode if settings.post_enabled else "none",
"glossary": glossary or {},
}, },
"models": [], "models": [],
"decision": {"status": "pending", "default_model": None, "reasons": []}, "decision": {"status": "pending", "default_model": None, "reasons": []},
@@ -81,7 +85,9 @@ def run_benchmark(
owner = EngineOwner.get(settings) owner = EngineOwner.get(settings)
model_results = {} model_results = {}
for model in models: for model in models:
agg = _run_model(owner, clips, model, device, compute_type, repeats, hotwords, report) agg = _run_model(
owner, clips, model, device, compute_type, repeats, hotwords, report, settings, glossary
)
model_results[model] = agg model_results[model] = agg
report["models"].append(agg["summary"]) report["models"].append(agg["summary"])
@@ -102,7 +108,9 @@ def run_benchmark(
return report return report
def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, report) -> dict: def _run_model(
owner, clips, model, device, compute_type, repeats, hotwords, report, settings, glossary
) -> dict:
from ..engine.base import TranscriptionOptions from ..engine.base import TranscriptionOptions
options = TranscriptionOptions( options = TranscriptionOptions(
@@ -114,7 +122,7 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
) )
# warm-up (비평가 클립 1회) # warm-up (비평가 클립 1회)
clip0 = clips[0] clip0 = clips[0]
_transcribe_clip(owner, options, clip0) _transcribe_clip(owner, options, clip0, settings, glossary)
rtf_samples: list[float] = [] rtf_samples: list[float] = []
rss_samples: list[float] = [] rss_samples: list[float] = []
@@ -141,7 +149,7 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
clip_success = False clip_success = False
for _ in range(repeats): for _ in range(repeats):
try: try:
result = _transcribe_clip(owner, options, clip) result = _transcribe_clip(owner, options, clip, settings, glossary)
m = clip_metrics(ref_text, result["text"], entities) m = clip_metrics(ref_text, result["text"], entities)
clip_rtfs.append(result["rtf"]) clip_rtfs.append(result["rtf"])
rss_samples.append(peak_process_rss_mb()) rss_samples.append(peak_process_rss_mb())
@@ -185,9 +193,15 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
return {"summary": summary} return {"summary": summary}
def _transcribe_clip(owner, options, clip) -> dict: def _transcribe_clip(owner, options, clip, settings, glossary) -> dict:
"""클립 전사 — 세그먼트 소비 + text/rtf 반환.""" """클립 전사 — 후처리(glossary/rules) 적용 + text/rtf 반환.
벤치 지표는 원시 전사가 아니라 사용자가 실제로 받는 후처리 결과를 측정해야
한다 (vLLM→BLM 같은 오인식은 rules/glossary에서 복원된다).
"""
from ..engine.owner import InferenceRequest from ..engine.owner import InferenceRequest
from ..postprocess.pipeline import run_postprocess
from ..results.models import Segment
audio_path = clip.get("audio_path") audio_path = clip.get("audio_path")
if not audio_path: if not audio_path:
@@ -196,11 +210,22 @@ def _transcribe_clip(owner, options, clip) -> dict:
t0 = time.time() t0 = time.time()
req = InferenceRequest(audio_path=audio_path, options=options, lane="batch") req = InferenceRequest(audio_path=audio_path, options=options, lane="batch")
outcome = owner.transcribe(req) outcome = owner.transcribe(req)
texts = [] segments: list[Segment] = []
for seg in outcome["segments"]: for idx, seg in enumerate(outcome["segments"]):
texts.append(seg.get("text", "")) 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 elapsed = time.time() - t0
return {"text": " ".join(t for t in texts if t), "rtf": elapsed / duration} return {"text": text, "rtf": elapsed / duration}
def _decide(model_results: dict) -> dict: def _decide(model_results: dict) -> dict:
+16 -1
View File
@@ -86,6 +86,9 @@ def transcribe(
), ),
vad: bool = typer.Option(True, "--vad/--no-vad"), vad: bool = typer.Option(True, "--vad/--no-vad"),
hotword: list[str] = typer.Option([], "--hotword", help="반복 가능"), 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)"), output: Path | None = typer.Option(None, "--output", "-", help="결과 파일 (기본 stdout)"),
force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"), force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"),
word_timestamps: bool = typer.Option(False, "--word-timestamps"), word_timestamps: bool = typer.Option(False, "--word-timestamps"),
@@ -113,9 +116,21 @@ def transcribe(
hotwords=hotword, hotwords=hotword,
word_timestamps=word_timestamps, 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: try:
pipeline = BatchPipeline(settings=settings, token=token) pipeline = BatchPipeline(settings=settings, token=token)
result = pipeline.run(source, options, source_name=source.name) result = pipeline.run(
source, options, source_name=source.name, glossary=glossary_dict or None
)
except LukeScribeError as exc: except LukeScribeError as exc:
if output is not None: if output is not None:
failed = TranscriptResult( failed = TranscriptResult(
+3
View File
@@ -58,6 +58,9 @@ class Settings(BaseSettings):
job_timeout_hours: float = 4.0 job_timeout_hours: float = 4.0
job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진 job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진
# ── 워커 ──
auto_worker: bool = False # in-proc 백엔드: 서버 프로세스가 큐를 소비 (dev/Colab)
# ── 입력 상한 ── # ── 입력 상한 ──
max_duration_sec: int = 14400 # 4h max_duration_sec: int = 14400 # 4h
max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
@@ -86,10 +86,15 @@ class FasterWhisperEngine(TranscriptionEngine):
with self._lock: with self._lock:
model = self._models.get(model_key) model = self._models.get(model_key)
if model is None: 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: try:
model = WhisperModel( model = WhisperModel(
options.model, options.model,
device=options.device, device=device_name,
device_index=device_index,
compute_type=options.compute_type, compute_type=options.compute_type,
download_root=self._download_root(), download_root=self._download_root(),
) )
@@ -125,8 +130,84 @@ class FasterWhisperEngine(TranscriptionEngine):
except Exception as exc: except Exception as exc:
raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc
wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False)) wrapped = _CancellableSegmentIterator(
return TranscriptionOutcome(wrapped, info=info) 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: def _download_root(self) -> str | None:
from ..config import get_settings from ..config import get_settings
+98 -8
View File
@@ -18,6 +18,9 @@ EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커
from __future__ import annotations from __future__ import annotations
import os
import struct
import tempfile
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -31,6 +34,49 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
MAX_DOWNGRADES = 2 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 @dataclass
class InferenceRequest: class InferenceRequest:
audio_path: str audio_path: str
@@ -149,16 +195,60 @@ class EngineOwner:
return profiles return profiles
def emit_hypothesis(self, pcm_chunk: bytes) -> dict: def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
"""실시간 레인 가설 생성 (v0.1 스텁). """실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode.
실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()`` 청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트
호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며, 반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락). 실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
무음 청크(RMS < 임계)는 decode 없이 빈 가설을 반환해 Whisper의
무음 할루시네이션이 LocalAgreement를 통해 확정되는 것을 막는다.
""" """
if self._stats.get("realtime_decode_ready"): from ..results.models import Segment
# 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments
pass if _rms16(pcm_chunk) < SILENCE_RMS_THRESHOLD:
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000} 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: def unload_all(self) -> None:
self._engine.unload_all() self._engine.unload_all()
+17 -2
View File
@@ -100,8 +100,23 @@ class Worker:
self._emit_progress(job, processed_sec, total_sec) self._emit_progress(job, processed_sec, total_sec)
try: try:
options = TranscriptionOptions(**job.options) # API의 TranscribeOptions에는 엔진과 무관한 키가 포함될 수 있다
result = pipeline.run(job, options, progress_cb=progress_cb) # (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로 전이 가능하게) # 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
self.store.write_result(job.id, result) self.store.write_result(job.id, result)
current = self._transition(job, JobStatus.COMPLETED) current = self._transition(job, JobStatus.COMPLETED)
+5 -1
View File
@@ -54,9 +54,13 @@ class BatchPipeline:
*, *,
progress_cb: Callable[[float, float], None] | None = None, progress_cb: Callable[[float, float], None] | None = None,
source_name: str | None = None, source_name: str | None = None,
glossary: dict[str, str] | None = None,
) -> TranscriptResult: ) -> TranscriptResult:
"""job(Job) 또는 source(Path)를 받아 전사. """job(Job) 또는 source(Path)를 받아 전사.
Args:
glossary: {오인식 패턴: 표준 표기} run_postprocess에 전달.
Returns: Returns:
completed TranscriptResult (후처리 포함). completed TranscriptResult (후처리 포함).
""" """
@@ -116,7 +120,7 @@ class BatchPipeline:
# 4) 후처리 (glossary/rules/LLM/confidence) # 4) 후처리 (glossary/rules/LLM/confidence)
t2 = time.time() t2 = time.time()
post_result = run_postprocess(segments, options, self.settings) post_result = run_postprocess(segments, options, self.settings, glossary=glossary)
postprocess_sec = time.time() - t2 postprocess_sec = time.time() - t2
text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip()) text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip())
+4
View File
@@ -13,6 +13,10 @@ from ..results.models import Segment
# 흔한 오인식 패턴 → 표준 표기 (정규식) # 흔한 오인식 패턴 → 표준 표기 (정규식)
DEFAULT_RULES: list[tuple[re.Pattern, str]] = [ DEFAULT_RULES: list[tuple[re.Pattern, str]] = [
(re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"), (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"\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"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"),
(re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"), (re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"),
+69 -1
View File
@@ -44,6 +44,72 @@ class TestHealth:
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류 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: class TestAuth:
def test_missing_key_rejected(self, client: TestClient): def test_missing_key_rejected(self, client: TestClient):
r = client.get("/v1/jobs") r = client.get("/v1/jobs")
@@ -81,7 +147,9 @@ class TestJobs:
# 조회 # 조회
r = client.get(f"/v1/jobs/{job_id}", headers=headers) r = client.get(f"/v1/jobs/{job_id}", headers=headers)
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["status"] == "queued" body = r.json()
assert body["status"] == "queued"
assert body["source_name"] == "meeting.mp3" # 대시보드 파일 컬럼용
# 결과는 아직 없음 # 결과는 아직 없음
r = client.get(f"/v1/jobs/{job_id}/result", headers=headers) r = client.get(f"/v1/jobs/{job_id}/result", headers=headers)
+36
View File
@@ -30,6 +30,42 @@ def _job(**kw) -> Job:
class TestWorkerLifecycle: 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): def test_complete_flow(self, settings, tmp_path):
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백.""" """enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
broker = InProcBroker(settings) broker = InProcBroker(settings)
+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
+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"}
+80
View File
@@ -34,6 +34,86 @@ def _opts(**kw) -> TranscriptionOptions:
return TranscriptionOptions(**kw) 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(): def test_no_downgrade_on_success():
owner = EngineOwner.__new__(EngineOwner) owner = EngineOwner.__new__(EngineOwner)
owner.settings = None owner.settings = None
+19
View File
@@ -43,6 +43,25 @@ class TestRules:
out = apply_rules(segs) out = apply_rules(segs)
assert "vLLM" in out["segments"][0].text 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): def test_whitespace_collapse(self):
segs = _segments(["오늘 API 서버"]) segs = _segments(["오늘 API 서버"])
out = apply_rules(segs) out = apply_rules(segs)