The bench and CLI kept emitting BLM for vLLM. Three layers:
1. rules.py: add default rule BLM -> vLLM (word-boundary, case-insensitive) so the default post_mode=rules path restores it everywhere.
2. benchmark/runner.py: the bench never ran postprocessing - it measured raw engine text, so entity retention (66.7%) never reflected rules/glossary. _transcribe_clip now builds Segments and runs run_postprocess(settings, glossary) before metrics; manifest top-level glossary {pattern: replacement} is supported and recorded in run_config (post_mode/glossary).
3. glossary plumbing: CLI transcribe gains --glossary KEY=VALUE (repeatable, parsed + validated); BatchPipeline.run accepts glossary= and passes it to run_postprocess; the in-proc worker forwards job.options.glossary or post_correction.
Notebook: transcribe cells pass --glossary BLM=vLLM, bench manifest carries glossary, postprocess section documents rules/glossary/hotword.
+ 9 unit tests (BLM rule, boundary/case behavior, bench postprocess + glossary entity retention). 147 tests pass, ruff clean.
549 lines
32 KiB
Python
549 lines
32 KiB
Python
"""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"
|
|
"# 키 인증 검증 — 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(' 대시보드 (API Docs):', 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"
|
|
"- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 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()
|