feat: full-platform STT API (v2.3 consensus plan) #1
File diff suppressed because one or more lines are too long
@@ -265,6 +265,13 @@ def cells() -> list[dict]:
|
|||||||
" print(open('/content/logs/server.log').read()[-1500:])\n"
|
" print(open('/content/logs/server.log').read()[-1500:])\n"
|
||||||
" raise SystemExit('서버 기동 실패')\n"
|
" raise SystemExit('서버 기동 실패')\n"
|
||||||
"\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"
|
"# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n"
|
||||||
"req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n"
|
"req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n"
|
||||||
"try:\n"
|
"try:\n"
|
||||||
@@ -290,7 +297,8 @@ def cells() -> list[dict]:
|
|||||||
"\n"
|
"\n"
|
||||||
"if TUNNEL_URL:\n"
|
"if TUNNEL_URL:\n"
|
||||||
" print('🌐 Cloudflare 터널 (외부 접속):', 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"
|
" print(' 상태 (health):', TUNNEL_URL + '/health')\n"
|
||||||
" # ── 외부 접속 검증 ──\n"
|
" # ── 외부 접속 검증 ──\n"
|
||||||
" # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n"
|
" # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n"
|
||||||
@@ -494,7 +502,8 @@ def cells() -> list[dict]:
|
|||||||
"### Cloudflare 터널로 외부에서 접속 (선택)\n\n"
|
"### Cloudflare 터널로 외부에서 접속 (선택)\n\n"
|
||||||
"서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n"
|
"서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n"
|
||||||
"- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\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"
|
"- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n"
|
||||||
"- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n"
|
"- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n"
|
||||||
"- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n"
|
"- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from ..errors import (
|
|||||||
)
|
)
|
||||||
from ..results.store import ResultStore
|
from ..results.store import ResultStore
|
||||||
from .deps import KeyStore
|
from .deps import KeyStore
|
||||||
from .routes import admin, jobs, stream
|
from .routes import admin, dashboard, jobs, keys, stream
|
||||||
|
|
||||||
logger = logging.getLogger("luke_scribe.api")
|
logger = logging.getLogger("luke_scribe.api")
|
||||||
|
|
||||||
@@ -130,9 +130,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(dashboard.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
app.include_router(stream.router)
|
app.include_router(stream.router)
|
||||||
|
app.include_router(keys.router)
|
||||||
|
|
||||||
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
|
# ── 오류 → HTTP 매핑 (LukeScribeError envelope) ──
|
||||||
@app.exception_handler(AuthError)
|
@app.exception_handler(AuthError)
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ class KeyStore:
|
|||||||
return Principal(key_id=key_id, scopes=entry["scopes"])
|
return Principal(key_id=key_id, scopes=entry["scopes"])
|
||||||
raise AuthError("유효하지 않은 API 키")
|
raise AuthError("유효하지 않은 API 키")
|
||||||
|
|
||||||
|
def list_keys(self) -> list[dict]:
|
||||||
|
"""키 ID/스코프 목록 (raw 키는 절대 노출하지 않음)."""
|
||||||
|
return [
|
||||||
|
{"id": key_id, "scopes": sorted(entry["scopes"])}
|
||||||
|
for key_id, entry in sorted(self._keys.items())
|
||||||
|
]
|
||||||
|
|
||||||
def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict:
|
def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict:
|
||||||
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
|
"""새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장."""
|
||||||
raw = f"luke-{secrets.token_urlsafe(32)}"
|
raw = f"luke-{secrets.token_urlsafe(32)}"
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()}
|
||||||
@@ -28,6 +28,10 @@ class TranscribeOptions(BaseModel):
|
|||||||
diarize: bool = False
|
diarize: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class KeyCreateRequest(BaseModel):
|
||||||
|
scopes: list[str] = Field(default_factory=lambda: ["transcribe"])
|
||||||
|
|
||||||
|
|
||||||
class JobCreateResponse(BaseModel):
|
class JobCreateResponse(BaseModel):
|
||||||
job_id: str
|
job_id: str
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -0,0 +1,854 @@
|
|||||||
|
<!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 || []).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 = () => {
|
||||||
|
if (rt && !rt.stopped) setRtStatus("연결 종료", "err");
|
||||||
|
$("#rt-wave").classList.remove("live");
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
} 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>
|
||||||
@@ -18,6 +18,9 @@ EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
@@ -31,6 +34,28 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"]
|
|||||||
MAX_DOWNGRADES = 2
|
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
|
@dataclass
|
||||||
class InferenceRequest:
|
class InferenceRequest:
|
||||||
audio_path: str
|
audio_path: str
|
||||||
@@ -149,16 +174,50 @@ class EngineOwner:
|
|||||||
return profiles
|
return profiles
|
||||||
|
|
||||||
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
|
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
|
||||||
"""실시간 레인 가설 생성 (v0.1 스텁).
|
"""실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode.
|
||||||
|
|
||||||
실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()``를
|
청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를
|
||||||
호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며,
|
반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던
|
||||||
GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락).
|
실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다.
|
||||||
"""
|
"""
|
||||||
if self._stats.get("realtime_decode_ready"):
|
from ..results.models import Segment
|
||||||
# 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments
|
|
||||||
|
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
|
pass
|
||||||
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
|
|
||||||
|
|
||||||
def unload_all(self) -> None:
|
def unload_all(self) -> None:
|
||||||
self._engine.unload_all()
|
self._engine.unload_all()
|
||||||
|
|||||||
@@ -44,6 +44,44 @@ class TestHealth:
|
|||||||
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
|
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboard:
|
||||||
|
def test_dashboard_html_public(self, client: TestClient):
|
||||||
|
"""대시보드 HTML은 공개 — 인증은 클라이언트에서 API 키 입력."""
|
||||||
|
for path in ("/", "/dashboard"):
|
||||||
|
r = client.get(path)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "text/html" in r.headers["content-type"]
|
||||||
|
assert "luke_scribe" in r.text
|
||||||
|
assert "실시간" in r.text # 전체 기능 포함
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeys:
|
||||||
|
def test_create_and_list_key(self, client: TestClient):
|
||||||
|
headers = {"X-API-Key": "key-admin"}
|
||||||
|
r = client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["key"].startswith("luke-")
|
||||||
|
assert body["key_id"].startswith("k-")
|
||||||
|
# raw 키는 1회만 노출 — 목록에는 다이제스트 ID만
|
||||||
|
r2 = client.get("/v1/keys", headers=headers)
|
||||||
|
keys = r2.json()["keys"]
|
||||||
|
assert body["key_id"] in [k["id"] for k in keys]
|
||||||
|
assert all("key" not in k for k in keys)
|
||||||
|
|
||||||
|
def test_admin_scope_required(self, client: TestClient):
|
||||||
|
headers = {"X-API-Key": "key-transcribe"}
|
||||||
|
assert (
|
||||||
|
client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
assert client.get("/v1/keys", headers=headers).status_code == 403
|
||||||
|
|
||||||
|
def test_no_auth_401(self, client: TestClient):
|
||||||
|
assert client.get("/v1/keys").status_code == 401
|
||||||
|
assert client.post("/v1/keys", json={"scopes": []}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
class TestAutoWorker:
|
class TestAutoWorker:
|
||||||
def test_auto_worker_off_by_default(self, client: TestClient):
|
def test_auto_worker_off_by_default(self, client: TestClient):
|
||||||
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
|
"""기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전)."""
|
||||||
|
|||||||
@@ -34,6 +34,74 @@ def _opts(**kw) -> TranscriptionOptions:
|
|||||||
return TranscriptionOptions(**kw)
|
return TranscriptionOptions(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
class _SegFakeEngine:
|
||||||
|
"""emit_hypothesis 테스트용 — WAV 경로 기록 + 세그먼트 반환."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.path = None
|
||||||
|
|
||||||
|
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None):
|
||||||
|
self.path = audio_path
|
||||||
|
segs = [
|
||||||
|
{
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 1.2,
|
||||||
|
"text": "안녕하세요",
|
||||||
|
"avg_logprob": -0.2,
|
||||||
|
"no_speech_prob": 0.01,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return type("O", (), {"segments": iter(segs), "info": {"language": "ko"}})()
|
||||||
|
|
||||||
|
def unload_all(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_owner(engine) -> EngineOwner:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from luke_scribe.config import Settings
|
||||||
|
|
||||||
|
owner = EngineOwner.__new__(EngineOwner)
|
||||||
|
owner.settings = Settings(_env_file=None)
|
||||||
|
owner._engine = engine
|
||||||
|
owner._lock = threading.Lock()
|
||||||
|
owner._realtime_priority = threading.Lock()
|
||||||
|
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
|
||||||
|
return owner
|
||||||
|
|
||||||
|
|
||||||
|
def test_pcm16_to_wav_header():
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from luke_scribe.engine.owner import _pcm16_to_wav
|
||||||
|
|
||||||
|
wav = _pcm16_to_wav(b"\x00\x00" * 8000) # 1초 (16kHz mono)
|
||||||
|
assert wav[:4] == b"RIFF"
|
||||||
|
assert wav[8:12] == b"WAVE"
|
||||||
|
assert wav[12:16] == b"fmt "
|
||||||
|
sr = struct.unpack("<I", wav[24:28])[0]
|
||||||
|
channels = struct.unpack("<H", wav[22:24])[0]
|
||||||
|
bits = struct.unpack("<H", wav[34:36])[0]
|
||||||
|
assert sr == 16000 and channels == 1 and bits == 16
|
||||||
|
assert struct.unpack("<I", wav[40:44])[0] == 16000 # data 크기
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_hypothesis_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():
|
def test_no_downgrade_on_success():
|
||||||
owner = EngineOwner.__new__(EngineOwner)
|
owner = EngineOwner.__new__(EngineOwner)
|
||||||
owner.settings = None
|
owner.settings = None
|
||||||
|
|||||||
Reference in New Issue
Block a user