Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7616b87f4c | ||
|
|
3dfa660503 | ||
|
|
440e947d47 | ||
|
|
6bb89eb51f | ||
|
|
f171ce4992 | ||
|
|
e34322cc73 | ||
|
|
96b53f889c | ||
|
|
5b8576e2a3 | ||
|
|
44b7211653 | ||
|
|
0ed2ea6160 | ||
|
|
457c67df2c | ||
|
|
be5f505410 | ||
|
|
20777386fe | ||
|
|
741bce9fc6 | ||
|
|
236ca0acf4 | ||
|
|
fb936e1953 | ||
|
|
f6e5ae662f | ||
|
|
f385734630 | ||
|
|
0d90845ac6 |
@@ -108,6 +108,17 @@ docker compose --profile gpu up -d
|
||||
|
||||
API + Redis + 워커, 공유 스토어 볼륨. `LUKESCRIBE_API_KEYS` 필수.
|
||||
|
||||
## Colab 실전 테스트 (GPU)
|
||||
|
||||
이 저장소의 CI/개발 환경은 CPU-only라 모델·ffmpeg는 mock으로만 검증했다.
|
||||
GPU + 실제 faster-whisper 모델로 실전 검증하려면 **Colab 노트북**을 사용한다
|
||||
(Colab Pro T4 GPU 권장 — 터미널에서도 동일 명령 실행 가능):
|
||||
|
||||
- `notebooks/luke-scribe-colab.ipynb` — 클론 → 설치 → `detect`(GPU 감지) →
|
||||
테스트 → 샘플 TTS 생성 → **실제 한국어 전사** → hotword/후처리 → API 스모크 → 벤치마크
|
||||
- 노트북은 `scripts/build_colab_notebook.py`로 생성/재생성한다
|
||||
- private 저장소이면 1번 셀의 `GITEA_TOKEN`에 토큰을 넣는다
|
||||
|
||||
## 알려진 제한 (v0.1)
|
||||
|
||||
- 실시간 decode는 `EngineOwner` 스텁 (실제 WS decode는 GPU 환경에서 활성화).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
@@ -9,6 +9,7 @@ Lifespan (plan §3.10a/§3.5):
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -27,7 +28,7 @@ from ..errors import (
|
||||
)
|
||||
from ..results.store import ResultStore
|
||||
from .deps import KeyStore
|
||||
from .routes import admin, jobs, stream
|
||||
from .routes import admin, dashboard, jobs, keys, stream
|
||||
|
||||
logger = logging.getLogger("luke_scribe.api")
|
||||
|
||||
@@ -63,6 +64,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
app.state.session_guard = _SessionGuard(settings)
|
||||
|
||||
# in-proc 백엔드 + auto_worker: 같은 프로세스 워커 스레드가 큐를 소비
|
||||
# (dev/Colab에서 별도 워커 프로세스 없이 업로드 → 완료 흐름이 가능하게 함)
|
||||
inproc_worker = None
|
||||
if settings.queue_backend == "inproc" and settings.auto_worker:
|
||||
from ..jobqueue.worker import Worker
|
||||
|
||||
inproc_worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=owner,
|
||||
worker_id="api-inproc",
|
||||
)
|
||||
threading.Thread(target=inproc_worker.run_forever, daemon=True).start()
|
||||
logger.info("in-proc auto-worker 스레드 시작 (queue_backend=inproc, auto_worker=true)")
|
||||
app.state.inproc_worker = inproc_worker
|
||||
|
||||
# 모델 프로비저닝 (선택) — 설정된 경우에만
|
||||
model_cache = settings.model_cache_dir
|
||||
if model_cache:
|
||||
@@ -99,6 +117,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
tunnel.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if inproc_worker is not None:
|
||||
# stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후
|
||||
# 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전)
|
||||
inproc_worker.stop()
|
||||
owner.unload_all()
|
||||
|
||||
app = FastAPI(
|
||||
@@ -108,9 +130,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(stream.router)
|
||||
app.include_router(keys.router)
|
||||
|
||||
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
|
||||
@app.exception_handler(AuthError)
|
||||
|
||||
@@ -73,6 +73,13 @@ class KeyStore:
|
||||
return Principal(key_id=key_id, scopes=entry["scopes"])
|
||||
raise AuthError("유효하지 않은 API 키")
|
||||
|
||||
def list_keys(self) -> list[dict]:
|
||||
"""키 ID/스코프 목록 (raw 키는 절대 노출하지 않음)."""
|
||||
return [
|
||||
{"id": key_id, "scopes": sorted(entry["scopes"])}
|
||||
for key_id, entry in sorted(self._keys.items())
|
||||
]
|
||||
|
||||
def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict:
|
||||
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
|
||||
raw = f"luke-{secrets.token_urlsafe(32)}"
|
||||
|
||||
@@ -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")
|
||||
@@ -168,6 +168,7 @@ def _to_status(job: Job) -> JobStatusResponse:
|
||||
return JobStatusResponse(
|
||||
job_id=job.id,
|
||||
status=job.status.value,
|
||||
source_name=job.source_name,
|
||||
queue_position=job.queue_position,
|
||||
progress=job.progress,
|
||||
processed_sec=job.processed_sec,
|
||||
|
||||
@@ -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()}
|
||||
@@ -23,10 +23,15 @@ class TranscribeOptions(BaseModel):
|
||||
hotwords: list[str] = Field(default_factory=list)
|
||||
vad: bool = True
|
||||
glossary_id: str | None = None
|
||||
glossary: dict[str, str] | None = None # {오인식 패턴: 표준 표기} — 후처리 glossary
|
||||
post_correction: dict[str, Any] | None = None
|
||||
diarize: bool = False
|
||||
|
||||
|
||||
class KeyCreateRequest(BaseModel):
|
||||
scopes: list[str] = Field(default_factory=lambda: ["transcribe"])
|
||||
|
||||
|
||||
class JobCreateResponse(BaseModel):
|
||||
job_id: str
|
||||
status: str
|
||||
@@ -36,6 +41,7 @@ class JobCreateResponse(BaseModel):
|
||||
class JobStatusResponse(BaseModel):
|
||||
job_id: str
|
||||
status: str
|
||||
source_name: str | None = None
|
||||
queue_position: int | None = None
|
||||
jobs_ahead: int | None = None
|
||||
progress: float | None = None
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
||||
@@ -56,6 +56,8 @@ def run_benchmark(
|
||||
from ..errors import InvalidInput
|
||||
|
||||
raise InvalidInput("manifest에 clips가 없습니다")
|
||||
# manifest 최상위 glossary: {오인식 패턴: 표준 표기} — 벤치도 후처리를 적용한다
|
||||
glossary = data.get("glossary") or None
|
||||
|
||||
report: dict = {
|
||||
"report_version": REPORT_VERSION,
|
||||
@@ -71,6 +73,8 @@ def run_benchmark(
|
||||
"beam_size": 5,
|
||||
"temperature": 0.0,
|
||||
"vad_filter": True,
|
||||
"post_mode": settings.post_mode if settings.post_enabled else "none",
|
||||
"glossary": glossary or {},
|
||||
},
|
||||
"models": [],
|
||||
"decision": {"status": "pending", "default_model": None, "reasons": []},
|
||||
@@ -81,7 +85,9 @@ def run_benchmark(
|
||||
owner = EngineOwner.get(settings)
|
||||
model_results = {}
|
||||
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
|
||||
report["models"].append(agg["summary"])
|
||||
|
||||
@@ -102,7 +108,9 @@ def run_benchmark(
|
||||
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
|
||||
|
||||
options = TranscriptionOptions(
|
||||
@@ -114,7 +122,7 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
|
||||
)
|
||||
# warm-up (비평가 클립 1회)
|
||||
clip0 = clips[0]
|
||||
_transcribe_clip(owner, options, clip0)
|
||||
_transcribe_clip(owner, options, clip0, settings, glossary)
|
||||
|
||||
rtf_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
|
||||
for _ in range(repeats):
|
||||
try:
|
||||
result = _transcribe_clip(owner, options, clip)
|
||||
result = _transcribe_clip(owner, options, clip, settings, glossary)
|
||||
m = clip_metrics(ref_text, result["text"], entities)
|
||||
clip_rtfs.append(result["rtf"])
|
||||
rss_samples.append(peak_process_rss_mb())
|
||||
@@ -185,9 +193,15 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
|
||||
return {"summary": summary}
|
||||
|
||||
|
||||
def _transcribe_clip(owner, options, clip) -> dict:
|
||||
"""클립 전사 — 세그먼트 소비 + text/rtf 반환."""
|
||||
def _transcribe_clip(owner, options, clip, settings, glossary) -> dict:
|
||||
"""클립 전사 — 후처리(glossary/rules) 적용 + text/rtf 반환.
|
||||
|
||||
벤치 지표는 원시 전사가 아니라 사용자가 실제로 받는 후처리 결과를 측정해야
|
||||
한다 (vLLM→BLM 같은 오인식은 rules/glossary에서 복원된다).
|
||||
"""
|
||||
from ..engine.owner import InferenceRequest
|
||||
from ..postprocess.pipeline import run_postprocess
|
||||
from ..results.models import Segment
|
||||
|
||||
audio_path = clip.get("audio_path")
|
||||
if not audio_path:
|
||||
@@ -196,11 +210,22 @@ def _transcribe_clip(owner, options, clip) -> dict:
|
||||
t0 = time.time()
|
||||
req = InferenceRequest(audio_path=audio_path, options=options, lane="batch")
|
||||
outcome = owner.transcribe(req)
|
||||
texts = []
|
||||
for seg in outcome["segments"]:
|
||||
texts.append(seg.get("text", ""))
|
||||
segments: list[Segment] = []
|
||||
for idx, seg in enumerate(outcome["segments"]):
|
||||
segments.append(
|
||||
Segment(
|
||||
index=idx,
|
||||
start=float(seg.get("start", 0.0)),
|
||||
end=float(seg.get("end", 0.0)),
|
||||
text=seg.get("text", ""),
|
||||
avg_logprob=seg.get("avg_logprob"),
|
||||
no_speech_prob=seg.get("no_speech_prob"),
|
||||
)
|
||||
)
|
||||
post = run_postprocess(segments, options, settings, glossary=glossary)
|
||||
text = " ".join(s.text.strip() for s in post["segments"] if s.text.strip())
|
||||
elapsed = time.time() - t0
|
||||
return {"text": " ".join(t for t in texts if t), "rtf": elapsed / duration}
|
||||
return {"text": text, "rtf": elapsed / duration}
|
||||
|
||||
|
||||
def _decide(model_results: dict) -> dict:
|
||||
|
||||
+16
-1
@@ -86,6 +86,9 @@ def transcribe(
|
||||
),
|
||||
vad: bool = typer.Option(True, "--vad/--no-vad"),
|
||||
hotword: list[str] = typer.Option([], "--hotword", help="반복 가능"),
|
||||
glossary: list[str] = typer.Option(
|
||||
[], "--glossary", help="오인식 패턴=표준 표기, 반복 가능 (예: --glossary BLM=vLLM)"
|
||||
),
|
||||
output: Path | None = typer.Option(None, "--output", "-", help="결과 파일 (기본 stdout)"),
|
||||
force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"),
|
||||
word_timestamps: bool = typer.Option(False, "--word-timestamps"),
|
||||
@@ -113,9 +116,21 @@ def transcribe(
|
||||
hotwords=hotword,
|
||||
word_timestamps=word_timestamps,
|
||||
)
|
||||
glossary_dict: dict[str, str] = {}
|
||||
for item in glossary:
|
||||
if "=" not in item:
|
||||
_fail(EXIT_INPUT, f"--glossary는 'KEY=VALUE' 형식이어야 합니다: {item}")
|
||||
key, _, value = item.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if not key or not value:
|
||||
# 빈 패턴(re.escape(''))은 모든 위치에 매칭돼 텍스트를 망가뜨린다
|
||||
_fail(EXIT_INPUT, f"--glossary 키/값이 비어 있으면 안 됩니다: {item}")
|
||||
glossary_dict[key] = value
|
||||
try:
|
||||
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:
|
||||
if output is not None:
|
||||
failed = TranscriptResult(
|
||||
|
||||
@@ -58,6 +58,9 @@ class Settings(BaseSettings):
|
||||
job_timeout_hours: float = 4.0
|
||||
job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진
|
||||
|
||||
# ── 워커 ──
|
||||
auto_worker: bool = False # in-proc 백엔드: 서버 프로세스가 큐를 소비 (dev/Colab)
|
||||
|
||||
# ── 입력 상한 ──
|
||||
max_duration_sec: int = 14400 # 4h
|
||||
max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
|
||||
@@ -86,10 +86,15 @@ class FasterWhisperEngine(TranscriptionEngine):
|
||||
with self._lock:
|
||||
model = self._models.get(model_key)
|
||||
if model is None:
|
||||
# CTranslate2는 device 문자열로 "cuda"만 허용한다 ("cuda:0" 형태는
|
||||
# "unsupported device cuda:0" 오류). DeviceManager가 내려주는
|
||||
# "cuda:N"을 device + device_index로 분리해 전달한다.
|
||||
device_name, device_index = self._split_device(options.device)
|
||||
try:
|
||||
model = WhisperModel(
|
||||
options.model,
|
||||
device=options.device,
|
||||
device=device_name,
|
||||
device_index=device_index,
|
||||
compute_type=options.compute_type,
|
||||
download_root=self._download_root(),
|
||||
)
|
||||
@@ -125,8 +130,84 @@ class FasterWhisperEngine(TranscriptionEngine):
|
||||
except Exception as exc:
|
||||
raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc
|
||||
|
||||
wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False))
|
||||
return TranscriptionOutcome(wrapped, info=info)
|
||||
wrapped = _CancellableSegmentIterator(
|
||||
self._to_dict_segments(segments_iter), should_cancel or (lambda: False)
|
||||
)
|
||||
return TranscriptionOutcome(wrapped, info=self._to_dict_info(info))
|
||||
|
||||
@staticmethod
|
||||
def _to_dict_segments(segments):
|
||||
"""faster-whisper Segment(namedtuple) → dict로 정규화.
|
||||
|
||||
다운스트림(배치 파이프라인/벤치/실시간)은 dict 계약을 쓴다 (mock과 동일).
|
||||
GPU 실전에서만 재현되는 버그: 'Segment' object has no attribute 'get'.
|
||||
"""
|
||||
for seg in segments:
|
||||
if isinstance(seg, dict):
|
||||
yield seg
|
||||
continue
|
||||
asdict = getattr(seg, "_asdict", None)
|
||||
if asdict is not None:
|
||||
yield dict(asdict())
|
||||
continue
|
||||
# 최후 수단: 알려진 필드만, None 값은 제외 (다운스트림 .get(key, default)가
|
||||
# key 존재+None으로 default를 무시하지 않도록)
|
||||
yield {
|
||||
k: v
|
||||
for k in (
|
||||
"id",
|
||||
"seek",
|
||||
"start",
|
||||
"end",
|
||||
"text",
|
||||
"tokens",
|
||||
"temperature",
|
||||
"avg_logprob",
|
||||
"compression_ratio",
|
||||
"no_speech_prob",
|
||||
)
|
||||
if (v := getattr(seg, k, None)) is not None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_dict_info(info):
|
||||
"""faster-whisper TranscriptionInfo(namedtuple) → dict.
|
||||
|
||||
다운스트림(배치 파이프라인)은 info를 dict 계약으로 접근한다
|
||||
(``info.get("language")``) — GPU 실전에서만 재현되는 버그:
|
||||
'TranscriptionInfo' object has no attribute 'get'.
|
||||
"""
|
||||
if info is None or isinstance(info, dict):
|
||||
return info
|
||||
asdict = getattr(info, "_asdict", None)
|
||||
if asdict is not None:
|
||||
return dict(asdict())
|
||||
try:
|
||||
import dataclasses
|
||||
|
||||
if dataclasses.is_dataclass(info):
|
||||
return dataclasses.asdict(info)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
k: v
|
||||
for k in ("language", "language_probability", "duration", "duration_after_vad")
|
||||
if (v := getattr(info, k, None)) is not None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _split_device(device: str) -> tuple[str, int]:
|
||||
"""'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다.
|
||||
|
||||
'cpu'/'cuda' 같은 인덱스 없는 값은 그대로 (device, 0)을 반환한다.
|
||||
"""
|
||||
if device.startswith("cuda") and ":" in device:
|
||||
name, _, idx = device.partition(":")
|
||||
try:
|
||||
return name, int(idx)
|
||||
except ValueError:
|
||||
pass
|
||||
return device, 0
|
||||
|
||||
def _download_root(self) -> str | None:
|
||||
from ..config import get_settings
|
||||
|
||||
@@ -18,6 +18,9 @@ EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -31,6 +34,49 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
|
||||
MAX_DOWNGRADES = 2
|
||||
|
||||
|
||||
# 무음 청크 decode 생략 임계 (int16 RMS, ~-36dBFS)
|
||||
SILENCE_RMS_THRESHOLD = 500.0
|
||||
|
||||
|
||||
def _rms16(data: bytes) -> float:
|
||||
"""PCM16 바이트의 RMS 레벨 (int16 단위, 0~32767)."""
|
||||
import array
|
||||
|
||||
if not data:
|
||||
return 0.0
|
||||
usable = data[: len(data) - (len(data) % 2)]
|
||||
samples = array.array("h")
|
||||
samples.frombytes(usable)
|
||||
if not samples:
|
||||
return 0.0
|
||||
s = 0.0
|
||||
for v in samples:
|
||||
s += v * v
|
||||
return (s / len(samples)) ** 0.5
|
||||
|
||||
|
||||
def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes:
|
||||
"""PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용."""
|
||||
n = len(data)
|
||||
header = struct.pack(
|
||||
"<4sI4s4sIHHIIHH4sI",
|
||||
b"RIFF",
|
||||
36 + n,
|
||||
b"WAVE",
|
||||
b"fmt ",
|
||||
16,
|
||||
1, # PCM
|
||||
1, # mono
|
||||
sample_rate,
|
||||
sample_rate * 2, # byte rate
|
||||
2, # block align
|
||||
16, # bits per sample
|
||||
b"data",
|
||||
n,
|
||||
)
|
||||
return header + data
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceRequest:
|
||||
audio_path: str
|
||||
@@ -149,17 +195,61 @@ class EngineOwner:
|
||||
return profiles
|
||||
|
||||
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
|
||||
"""실시간 레인 가설 생성 (v0.1 스텁).
|
||||
"""실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode.
|
||||
|
||||
실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()``를
|
||||
호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며,
|
||||
GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락).
|
||||
청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를
|
||||
반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
|
||||
실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
|
||||
무음 청크(RMS < 임계)는 decode 없이 빈 가설을 반환해 Whisper의
|
||||
무음 할루시네이션이 LocalAgreement를 통해 확정되는 것을 막는다.
|
||||
"""
|
||||
if self._stats.get("realtime_decode_ready"):
|
||||
# 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments
|
||||
pass
|
||||
from ..results.models import Segment
|
||||
|
||||
if _rms16(pcm_chunk) < SILENCE_RMS_THRESHOLD:
|
||||
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
|
||||
|
||||
wav = _pcm16_to_wav(pcm_chunk)
|
||||
fd, path = tempfile.mkstemp(suffix=".wav")
|
||||
try:
|
||||
try:
|
||||
f = os.fdopen(fd, "wb")
|
||||
except Exception:
|
||||
os.close(fd) # fdopen 실패 시 fd 누수 방지
|
||||
raise
|
||||
with f:
|
||||
f.write(wav)
|
||||
req = InferenceRequest(
|
||||
audio_path=path,
|
||||
options=TranscriptionOptions(
|
||||
model=self.settings.model_rt,
|
||||
language=self.settings.language,
|
||||
device=self.settings.device,
|
||||
compute_type=self.settings.compute_type,
|
||||
vad=False, # 실시간 레인: 짧은 청크에서 VAD 무음 제거 방지
|
||||
beam_size=5,
|
||||
),
|
||||
lane="realtime",
|
||||
)
|
||||
outcome = self.transcribe(req)
|
||||
segments: list[Segment] = []
|
||||
for i, seg in enumerate(outcome["segments"]):
|
||||
segments.append(
|
||||
Segment(
|
||||
index=i,
|
||||
start=float(seg.get("start", 0.0)),
|
||||
end=float(seg.get("end", 0.0)),
|
||||
text=seg.get("text", ""),
|
||||
avg_logprob=seg.get("avg_logprob"),
|
||||
no_speech_prob=seg.get("no_speech_prob"),
|
||||
)
|
||||
)
|
||||
return {"segments": segments, "audio_sec": len(pcm_chunk) / 2 / 16000}
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def unload_all(self) -> None:
|
||||
self._engine.unload_all()
|
||||
|
||||
|
||||
@@ -100,8 +100,23 @@ class Worker:
|
||||
self._emit_progress(job, processed_sec, total_sec)
|
||||
|
||||
try:
|
||||
options = TranscriptionOptions(**job.options)
|
||||
result = pipeline.run(job, options, progress_cb=progress_cb)
|
||||
# API의 TranscribeOptions에는 엔진과 무관한 키가 포함될 수 있다
|
||||
# (formats/timestamps/glossary_id/post_correction/diarize 등) —
|
||||
# 엔진 계약 필드만 골라 전달한다.
|
||||
options = TranscriptionOptions(
|
||||
**{
|
||||
k: v
|
||||
for k, v in (job.options or {}).items()
|
||||
if k in TranscriptionOptions.__slots__
|
||||
}
|
||||
)
|
||||
# API 옵션의 glossary/post_correction(dict)을 후처리 glossary로 전달.
|
||||
# pydantic 스키마를 거치지 않은 직접 생성 Job은 비-dict일 수 있어 방어.
|
||||
raw_glossary = (job.options or {}).get("glossary") or (job.options or {}).get(
|
||||
"post_correction"
|
||||
)
|
||||
glossary = raw_glossary if isinstance(raw_glossary, dict) else None
|
||||
result = pipeline.run(job, options, progress_cb=progress_cb, glossary=glossary)
|
||||
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
|
||||
self.store.write_result(job.id, result)
|
||||
current = self._transition(job, JobStatus.COMPLETED)
|
||||
|
||||
@@ -54,9 +54,13 @@ class BatchPipeline:
|
||||
*,
|
||||
progress_cb: Callable[[float, float], None] | None = None,
|
||||
source_name: str | None = None,
|
||||
glossary: dict[str, str] | None = None,
|
||||
) -> TranscriptResult:
|
||||
"""job(Job) 또는 source(Path)를 받아 전사.
|
||||
|
||||
Args:
|
||||
glossary: {오인식 패턴: 표준 표기} — run_postprocess에 전달.
|
||||
|
||||
Returns:
|
||||
completed TranscriptResult (후처리 포함).
|
||||
"""
|
||||
@@ -116,7 +120,7 @@ class BatchPipeline:
|
||||
|
||||
# 4) 후처리 (glossary/rules/LLM/confidence)
|
||||
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
|
||||
|
||||
text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip())
|
||||
|
||||
@@ -13,6 +13,10 @@ from ..results.models import Segment
|
||||
# 흔한 오인식 패턴 → 표준 표기 (정규식)
|
||||
DEFAULT_RULES: list[tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"),
|
||||
# 흔한 오인식: vLLM → BLM (GPU 실전에서 재현). 기술 STT 도메인 전제로 복원.
|
||||
# 주의: \b 경계는 유니코드 \w 기준이라 'BLM은'(공백 없음)은 교정하지 않는다.
|
||||
# faster-whisper 출력은 어절 단위 공백 분리("BLM 서버를")라 실제로는 충분하다.
|
||||
(re.compile(r"\bblm\b", re.IGNORECASE), "vLLM"),
|
||||
(re.compile(r"\bk ?u ?b ?e ?r ?n ?e ?t ?e ?s\b", re.IGNORECASE), "Kubernetes"),
|
||||
(re.compile(r"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"),
|
||||
(re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"),
|
||||
|
||||
@@ -44,6 +44,72 @@ class TestHealth:
|
||||
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
def test_dashboard_html_public(self, client: TestClient):
|
||||
"""대시보드 HTML은 공개 — 인증은 클라이언트에서 API 키 입력."""
|
||||
for path in ("/", "/dashboard"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
assert "luke_scribe" in r.text
|
||||
assert "실시간" in r.text # 전체 기능 포함
|
||||
|
||||
|
||||
class TestKeys:
|
||||
def test_create_and_list_key(self, client: TestClient):
|
||||
headers = {"X-API-Key": "key-admin"}
|
||||
r = client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["key"].startswith("luke-")
|
||||
assert body["key_id"].startswith("k-")
|
||||
# raw 키는 1회만 노출 — 목록에는 다이제스트 ID만
|
||||
r2 = client.get("/v1/keys", headers=headers)
|
||||
keys = r2.json()["keys"]
|
||||
assert body["key_id"] in [k["id"] for k in keys]
|
||||
assert all("key" not in k for k in keys)
|
||||
|
||||
def test_admin_scope_required(self, client: TestClient):
|
||||
headers = {"X-API-Key": "key-transcribe"}
|
||||
assert (
|
||||
client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers).status_code
|
||||
== 403
|
||||
)
|
||||
assert client.get("/v1/keys", headers=headers).status_code == 403
|
||||
|
||||
def test_no_auth_401(self, client: TestClient):
|
||||
assert client.get("/v1/keys").status_code == 401
|
||||
assert client.post("/v1/keys", json={"scopes": []}).status_code == 401
|
||||
|
||||
|
||||
class TestAutoWorker:
|
||||
def test_auto_worker_off_by_default(self, client: TestClient):
|
||||
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
|
||||
assert client.app.state.inproc_worker is None
|
||||
|
||||
def test_auto_worker_started_when_enabled(self, tmp_path):
|
||||
"""auto_worker=true + inproc → lifespan이 워커 스레드를 시작하고 종료 시 정지."""
|
||||
from luke_scribe.api.app import create_app
|
||||
from luke_scribe.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
results_dir=str(tmp_path / "results"),
|
||||
api_key_file=str(tmp_path / "api_keys.json"),
|
||||
queue_backend="inproc",
|
||||
model_cache_dir=None,
|
||||
tunnel="none",
|
||||
auto_worker=True,
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as c:
|
||||
w = c.app.state.inproc_worker
|
||||
assert w is not None
|
||||
assert not w._stop.is_set()
|
||||
# lifespan 종료 → 워커 정지 요청
|
||||
assert w._stop.is_set()
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_missing_key_rejected(self, client: TestClient):
|
||||
r = client.get("/v1/jobs")
|
||||
@@ -81,7 +147,9 @@ class TestJobs:
|
||||
# 조회
|
||||
r = client.get(f"/v1/jobs/{job_id}", headers=headers)
|
||||
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)
|
||||
|
||||
@@ -30,6 +30,42 @@ def _job(**kw) -> Job:
|
||||
|
||||
|
||||
class TestWorkerLifecycle:
|
||||
def test_api_style_options_filtered(self, settings, tmp_path):
|
||||
"""API TranscribeOptions(엔진 무관 키 포함) → 워커가 엔진 필드만 골라 처리.
|
||||
|
||||
Colab 실전에서 잡이 계속 실패한 원인: job.options에 formats/timestamps/
|
||||
diarize 등이 포함돼 TranscriptionOptions(**job.options)가 TypeError를 냄.
|
||||
"""
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=FakeEngineOwner(),
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job(
|
||||
options={
|
||||
"language": "ko",
|
||||
"device": "auto",
|
||||
"timestamps": True,
|
||||
"formats": ["json", "srt"],
|
||||
"word_timestamps": False,
|
||||
"vad": True,
|
||||
"hotwords": [],
|
||||
"glossary_id": None,
|
||||
"post_correction": None,
|
||||
"diarize": False,
|
||||
}
|
||||
)
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
assert broker.get(job.id).status == JobStatus.COMPLETED
|
||||
result = store.read_result(job.id)
|
||||
assert result is not None
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_complete_flow(self, settings, tmp_path):
|
||||
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
|
||||
broker = InProcBroker(settings)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""벤치마크 단위 테스트 — 후처리 적용 + glossary 지원.
|
||||
|
||||
벤치 지표는 원시 전사가 아니라 후처리(rules/glossary)를 거친 결과를 측정해야
|
||||
한다 (vLLM→BLM 같은 오인식 복원 포함).
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -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"}
|
||||
@@ -34,6 +34,86 @@ def _opts(**kw) -> TranscriptionOptions:
|
||||
return TranscriptionOptions(**kw)
|
||||
|
||||
|
||||
class _SegFakeEngine:
|
||||
"""emit_hypothesis 테스트용 — WAV 경로 기록 + 세그먼트 반환."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.path = None
|
||||
|
||||
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None):
|
||||
self.path = audio_path
|
||||
segs = [
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.2,
|
||||
"text": "안녕하세요",
|
||||
"avg_logprob": -0.2,
|
||||
"no_speech_prob": 0.01,
|
||||
}
|
||||
]
|
||||
return type("O", (), {"segments": iter(segs), "info": {"language": "ko"}})()
|
||||
|
||||
def unload_all(self):
|
||||
pass
|
||||
|
||||
|
||||
def _bare_owner(engine) -> EngineOwner:
|
||||
import threading
|
||||
|
||||
from luke_scribe.config import Settings
|
||||
|
||||
owner = EngineOwner.__new__(EngineOwner)
|
||||
owner.settings = Settings(_env_file=None)
|
||||
owner._engine = engine
|
||||
owner._lock = threading.Lock()
|
||||
owner._realtime_priority = threading.Lock()
|
||||
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
|
||||
return owner
|
||||
|
||||
|
||||
def test_pcm16_to_wav_header():
|
||||
import struct
|
||||
|
||||
from luke_scribe.engine.owner import _pcm16_to_wav
|
||||
|
||||
wav = _pcm16_to_wav(b"\x00\x00" * 8000) # 1초 (16kHz mono)
|
||||
assert wav[:4] == b"RIFF"
|
||||
assert wav[8:12] == b"WAVE"
|
||||
assert wav[12:16] == b"fmt "
|
||||
sr = struct.unpack("<I", wav[24:28])[0]
|
||||
channels = struct.unpack("<H", wav[22:24])[0]
|
||||
bits = struct.unpack("<H", wav[34:36])[0]
|
||||
assert sr == 16000 and channels == 1 and bits == 16
|
||||
assert struct.unpack("<I", wav[40:44])[0] == 16000 # data 크기
|
||||
|
||||
|
||||
def test_emit_hypothesis_skips_silence():
|
||||
"""무음 청크는 decode 없이 빈 가설 반환 (할루시네이션 방지)."""
|
||||
engine = _SegFakeEngine()
|
||||
owner = _bare_owner(engine)
|
||||
out = owner.emit_hypothesis(b"\x00\x00" * 16000) # 완전 무음
|
||||
assert out["segments"] == []
|
||||
assert engine.path is None # decode 미실행 → 임시 파일도 안 만듦
|
||||
|
||||
|
||||
def test_emit_hypothesis_decodes_chunk_and_cleans_temp():
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
|
||||
engine = _SegFakeEngine()
|
||||
owner = _bare_owner(engine)
|
||||
# 1초 PCM16 — RMS 게이트를 통과하는 유성 신호 (무음이면 decode가 생략됨)
|
||||
chunk = b"".join(struct.pack("<h", random.randint(-6000, 6000)) for _ in range(16000))
|
||||
out = owner.emit_hypothesis(chunk)
|
||||
assert out["audio_sec"] == 1.0
|
||||
assert len(out["segments"]) == 1
|
||||
assert out["segments"][0].model_dump()["text"] == "안녕하세요"
|
||||
# 임시 WAV는 실시간 레인 decode 후 삭제됨
|
||||
assert engine.path is not None
|
||||
assert not os.path.exists(engine.path)
|
||||
|
||||
|
||||
def test_no_downgrade_on_success():
|
||||
owner = EngineOwner.__new__(EngineOwner)
|
||||
owner.settings = None
|
||||
|
||||
@@ -43,6 +43,25 @@ class TestRules:
|
||||
out = apply_rules(segs)
|
||||
assert "vLLM" in out["segments"][0].text
|
||||
|
||||
def test_vllm_restored_from_blm(self):
|
||||
# GPU 실전에서 재현된 오인식: vLLM → BLM
|
||||
segs = _segments(["오늘은 BLM 서버를 배포합니다"])
|
||||
out = apply_rules(segs)
|
||||
assert "vLLM" in out["segments"][0].text
|
||||
assert "BLM" not in out["segments"][0].text
|
||||
|
||||
def test_blm_boundary_required(self):
|
||||
# 단어 경계가 없으면 교정하지 않는다 (부분 문자열 보호)
|
||||
segs = _segments(["sublm 단어"])
|
||||
out = apply_rules(segs)
|
||||
assert "sublm" in out["segments"][0].text
|
||||
assert "vLLM" not in out["segments"][0].text
|
||||
|
||||
def test_blm_case_insensitive(self):
|
||||
segs = _segments(["blm 서버"])
|
||||
out = apply_rules(segs)
|
||||
assert "vLLM" in out["segments"][0].text
|
||||
|
||||
def test_whitespace_collapse(self):
|
||||
segs = _segments(["오늘 API 서버"])
|
||||
out = apply_rules(segs)
|
||||
|
||||
Reference in New Issue
Block a user