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.
This commit is contained in:
@@ -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)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
|
||||
|
||||
@@ -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("<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_decodes_chunk_and_cleans_temp():
|
||||
import os
|
||||
|
||||
engine = _SegFakeEngine()
|
||||
owner = _bare_owner(engine)
|
||||
chunk = b"\x00\x00" * 16000 # 1초 PCM16
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user