From 3dfa6605034cb3875581748d8fec561a6b14342e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 21:41:42 +0900 Subject: [PATCH] feat: dashboard UI + key API + realtime decode Full HTML dashboard (dark theme, vanilla JS, no external deps) served at / and /dashboard with five tabs: system status (admin), file upload-to-transcribe with progress/result/downloads, job history with cancel/result modal, realtime mic demo over the existing WebSocket, and API key create/list. Backend: POST/GET /v1/keys (admin; raw key returned once, digest-only storage); KeyStore.list_keys(); EngineOwner.emit_hypothesis is now a real implementation (PCM16 chunk -> WAV -> realtime-lane decode with the single GPU lock) instead of a stub. Notebook: tunnel cell links /dashboard and smoke-checks the HTML. + 6 tests (dashboard public HTML, key create/list/scope, WAV header, emit_hypothesis cleanup). 153 tests pass, ruff clean, JS syntax verified with node --check. --- notebooks/luke-scribe-colab.ipynb | 4 +- scripts/build_colab_notebook.py | 13 +- src/luke_scribe/api/app.py | 4 +- src/luke_scribe/api/deps.py | 7 + src/luke_scribe/api/routes/dashboard.py | 22 + src/luke_scribe/api/routes/keys.py | 35 + src/luke_scribe/api/schemas.py | 4 + src/luke_scribe/api/static/dashboard.html | 854 ++++++++++++++++++++++ src/luke_scribe/engine/owner.py | 75 +- tests/integration/test_api.py | 38 + tests/unit/test_engine_owner.py | 68 ++ 11 files changed, 1111 insertions(+), 13 deletions(-) create mode 100644 src/luke_scribe/api/routes/dashboard.py create mode 100644 src/luke_scribe/api/routes/keys.py create mode 100644 src/luke_scribe/api/static/dashboard.html diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 30e6425..721c16a 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ 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)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept 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) ──\nTUNNEL_URL = None\nif 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)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif 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) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ 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)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 대시보드 HTML 스모크 (공개 — 키 불필요)\ntry:\n d = urllib.request.urlopen('http://localhost:8000/dashboard', timeout=5)\n print('대시보드 HTML:', d.status, len(d.read()), 'bytes')\nexcept Exception as exc:\n print('대시보드 로드 실패:', exc)\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept 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) ──\nTUNNEL_URL = None\nif 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)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif 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) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `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\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-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\nColab 커널은 실행 중에는 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\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `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\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-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\nColab 커널은 실행 중에는 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\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index ab05e66..45d6f06 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -265,6 +265,13 @@ def cells() -> list[dict]: " 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" @@ -290,7 +297,8 @@ def cells() -> list[dict]: "\n" "if TUNNEL_URL:\n" " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" - " print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\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" @@ -494,7 +502,8 @@ def cells() -> list[dict]: "### Cloudflare 터널로 외부에서 접속 (선택)\n\n" "서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n" "- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n" - "- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\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" diff --git a/src/luke_scribe/api/app.py b/src/luke_scribe/api/app.py index 91577bf..6874718 100644 --- a/src/luke_scribe/api/app.py +++ b/src/luke_scribe/api/app.py @@ -28,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") @@ -130,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) diff --git a/src/luke_scribe/api/deps.py b/src/luke_scribe/api/deps.py index 860b6f0..7b9a13a 100644 --- a/src/luke_scribe/api/deps.py +++ b/src/luke_scribe/api/deps.py @@ -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)}" diff --git a/src/luke_scribe/api/routes/dashboard.py b/src/luke_scribe/api/routes/dashboard.py new file mode 100644 index 0000000..5940d5e --- /dev/null +++ b/src/luke_scribe/api/routes/dashboard.py @@ -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") diff --git a/src/luke_scribe/api/routes/keys.py b/src/luke_scribe/api/routes/keys.py new file mode 100644 index 0000000..d45a2cb --- /dev/null +++ b/src/luke_scribe/api/routes/keys.py @@ -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()} diff --git a/src/luke_scribe/api/schemas.py b/src/luke_scribe/api/schemas.py index d70d2a6..746d2cb 100644 --- a/src/luke_scribe/api/schemas.py +++ b/src/luke_scribe/api/schemas.py @@ -28,6 +28,10 @@ class TranscribeOptions(BaseModel): diarize: bool = False +class KeyCreateRequest(BaseModel): + scopes: list[str] = Field(default_factory=lambda: ["transcribe"]) + + class JobCreateResponse(BaseModel): job_id: str status: str diff --git a/src/luke_scribe/api/static/dashboard.html b/src/luke_scribe/api/static/dashboard.html new file mode 100644 index 0000000..0920664 --- /dev/null +++ b/src/luke_scribe/api/static/dashboard.html @@ -0,0 +1,854 @@ + + + + + +luke_scribe 대시보드 + + + +
+
luke_scribe v0.1 · 대시보드
+
+ 연결 확인 중… + +
+ + +
+
+ +
+ + + +
+
+

🖥️시스템 상태

+
+
연결 상태
+
능력 등급
+
GPU
+
VRAM
+
컴퓨트 타입
+
워커
+
큐 깊이
+
모델
+
+

+
+
+

📊장치 상세

+

+    
+
+ + +
+
+

🎙️파일 전사

+
+
🎧 파일을 끌어다 놓거나 클릭하여 선택
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ + +
+ +
+ +
+ + +
+
+

📋작업 히스토리 3초 자동 갱신

+
+ + + +
상태파일job id진행률작업
+
+
+
+ + +
+
+

실시간 마이크 전사 (WebSocket)

+

마이크 오디오를 16kHz PCM으로 보내 서버가 실시간 가설/확정 텍스트를 반환합니다. 첫 가설은 모델 로드로 지연될 수 있습니다.

+ + +
+
+
+
+
+
+ + +
+
+

🔑키 생성 (admin 스코프 필요)

+
+ + +
+
+ +
+
+

🗝️키 목록

+ + + +
key id스코프
+
+
+ +
privacy-first · 모든 처리는 로컬/자체 서버에서 수행됩니다
+
+ + + +
+ + + + diff --git a/src/luke_scribe/engine/owner.py b/src/luke_scribe/engine/owner.py index dda5fd4..088819d 100644 --- a/src/luke_scribe/engine/owner.py +++ b/src/luke_scribe/engine/owner.py @@ -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,28 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"] MAX_DOWNGRADES = 2 +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,16 +174,50 @@ 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 스텁이었던 + 실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다. """ - if self._stats.get("realtime_decode_ready"): - # 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments - pass - return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000} + from ..results.models import Segment + + wav = _pcm16_to_wav(pcm_chunk) + fd, path = tempfile.mkstemp(suffix=".wav") + try: + with os.fdopen(fd, "wb") as 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() diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 54c7bd1..39e5412 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -44,6 +44,44 @@ 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)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전).""" diff --git a/tests/unit/test_engine_owner.py b/tests/unit/test_engine_owner.py index c7f2899..9e3e9ce 100644 --- a/tests/unit/test_engine_owner.py +++ b/tests/unit/test_engine_owner.py @@ -34,6 +34,74 @@ 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("