feat(api): sync test API (serve) + opt-in LLM correction + cloudflared tunnel
- api/: FastAPI app, X-API-Key 인증(미설정 시 임시키), 엔진 load-once 풀 (+transcribe lock), POST /v1/transcribe(multipart, 동기), /health, /v1/system, /v1/models. 업로드 임시파일 finally 삭제(프라이버시). - postprocess/: llm.correct(scripts/llm_correct.py 승격; opt-in·allowlist·감사로그·재시도) + rules.normalize(EmbeddingGemma 등 정규화). - results/formats.py: txt/srt/vtt. connectivity/tunnel.py: cloudflared quick tunnel(Colab). - cli serve: uvicorn 단일워커 + --tunnel cloudflare; config llm_* 필드; pyproject api/queue extra 분리(+python-multipart, dev httpx). 검증: 22 단위테스트(API TestClient·formats·postprocess) + 실서버 e2e (/health·auth 401·실제 전사(JFK)·SRT·임시파일 삭제). KO 품질은 turbo/large-v3 필요(tiny는 한국어 degenerate). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-3
@@ -18,8 +18,10 @@ dependencies = [
|
|||||||
engine = ["faster-whisper>=1.0.3", "av>=11"]
|
engine = ["faster-whisper>=1.0.3", "av>=11"]
|
||||||
# GPU CUDA 런타임 (faster-whisper GPU 추론 시)
|
# GPU CUDA 런타임 (faster-whisper GPU 추론 시)
|
||||||
gpu = ["nvidia-cublas-cu12", "nvidia-cudnn-cu12"]
|
gpu = ["nvidia-cublas-cu12", "nvidia-cudnn-cu12"]
|
||||||
# P2 API + Queue
|
# 테스트 API (동기) — serve
|
||||||
api = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "redis>=5.0", "rq>=1.16"]
|
api = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9"]
|
||||||
|
# P2 비동기 큐 (보류)
|
||||||
|
queue = ["redis>=5.0", "rq>=1.16"]
|
||||||
# P5 옵션
|
# P5 옵션
|
||||||
diarize = ["pyannote.audio>=3.1"]
|
diarize = ["pyannote.audio>=3.1"]
|
||||||
llm = ["openai>=1.30"]
|
llm = ["openai>=1.30"]
|
||||||
@@ -35,4 +37,4 @@ build-backend = "hatchling.build"
|
|||||||
packages = ["src/luke_scribe"]
|
packages = ["src/luke_scribe"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8.2", "ruff>=0.5"]
|
dev = ["pytest>=8.2", "ruff>=0.5", "httpx>=0.27"]
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""STT 후처리 PoC — 음차된 영문 기술용어를 사내 LLM(OpenAI 호환)으로 복원.
|
||||||
|
|
||||||
|
게이트가 닿는 환경에서 실행:
|
||||||
|
export SCRIBE_LLM_BASE_URL=http://localhost:8080/v1
|
||||||
|
export SCRIBE_LLM_API_KEY=<사내 키>
|
||||||
|
export SCRIBE_LLM_MODEL=copilot-gpt-4o
|
||||||
|
python3 scripts/llm_correct.py # 내장 샘플로 데모
|
||||||
|
python3 scripts/llm_correct.py < my.txt # 임의 전사 교정
|
||||||
|
|
||||||
|
외부 의존성 없음(urllib). 향후 postprocess/llm.py(confidence-gated, 청크/러닝글로서리)로 발전.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
SYSTEM = (
|
||||||
|
"너는 한국어 STT 전사 후처리기다. 한국어 음성에 섞여 나온 영어 기술용어·고유명사가 "
|
||||||
|
"발음대로 한글로 음차되어 잘못 적힌 부분을 문맥과 지식으로 원래 영어 표기로 복원하라. "
|
||||||
|
"일반 한국어는 그대로 두고, 확실하지 않으면 바꾸지 마라. 설명 없이 교정된 전사문만 출력하라."
|
||||||
|
)
|
||||||
|
|
||||||
|
# turbo가 망친 실제 전사(EmbeddingGemma 강연) — 내장 데모용
|
||||||
|
SAMPLE = (
|
||||||
|
"그래서 오늘 준비한 내용은 기본적으로 인베딩 점마에 대해서 설명을 드릴 텐데요. "
|
||||||
|
"여러분들이 알고 계시는 랭기징 모델이 정말 사람이 생각하는 것처럼 하는데 "
|
||||||
|
"그 다음에 구글에 런칭한 오픈모델입니다. 인베딩 점마 라는 것을 소개를 해드릴 예정입니다. "
|
||||||
|
"그리고 어 재미나이 하고 이제 점마하고 두 가지가 있는데요. "
|
||||||
|
"구글 포 디벨로퍼스 사이트에 가시면 제가 올린 포스트도 보실 수 있는데."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def correct(text: str) -> str:
|
||||||
|
base = os.environ.get("SCRIBE_LLM_BASE_URL", "http://localhost:8080/v1").rstrip("/")
|
||||||
|
key = os.environ.get("SCRIBE_LLM_API_KEY", "")
|
||||||
|
model = os.environ.get("SCRIBE_LLM_MODEL", "copilot-gpt-4o")
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"temperature": 0,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{"role": "user", "content": text},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base + "/chat/completions",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": "Bearer " + key},
|
||||||
|
)
|
||||||
|
retries = 4
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||||
|
return json.loads(resp.read())["choices"][0]["message"]["content"]
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
raise # 실제 HTTP 응답(401/400 등) — 재시도 무의미
|
||||||
|
except (urllib.error.URLError, OSError) as exc: # 연결 reset/timeout 등 transient
|
||||||
|
if attempt == retries:
|
||||||
|
raise
|
||||||
|
print(f" [retry {attempt}/{retries - 1}] {type(exc).__name__} → 재시도", file=sys.stderr)
|
||||||
|
time.sleep(1.5 * attempt)
|
||||||
|
raise RuntimeError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
src = (sys.stdin.read().strip() if not sys.stdin.isatty() else "") or SAMPLE
|
||||||
|
print("=== 원본 ===\n" + src + "\n\n=== 교정 ===")
|
||||||
|
try:
|
||||||
|
print(correct(src))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
sys.exit(f"HTTP {exc.code}: {exc.read().decode()[:300]}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
sys.exit(f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP API (FastAPI) — 동기 테스트 API. 비동기 큐/실시간은 P2/P3."""
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""FastAPI 앱 팩토리."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from .deps import ensure_keys
|
||||||
|
from .routes.transcribe import router
|
||||||
|
|
||||||
|
logger = logging.getLogger("luke_scribe.api")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
logger.info("luke_scribe API ready · X-API-Key=%s", ensure_keys()[0])
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(title="luke_scribe", version="0.1.0", lifespan=lifespan)
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""인증 — X-API-Key (스펙 §3.8). 키 미설정 시 기동 때 임시 키 1개 생성·강제."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException, status
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
_ephemeral_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_keys() -> list[str]:
|
||||||
|
"""유효 키 목록. 설정이 없으면 임시 키를 1회 생성해 반환(앱이 출력)."""
|
||||||
|
global _ephemeral_key
|
||||||
|
if settings.api_keys:
|
||||||
|
return settings.api_keys
|
||||||
|
if _ephemeral_key is None:
|
||||||
|
_ephemeral_key = "sk-luke-" + secrets.token_urlsafe(24)
|
||||||
|
return [_ephemeral_key]
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(x_api_key: str | None = Header(default=None)) -> str:
|
||||||
|
if x_api_key not in ensure_keys():
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or missing X-API-Key")
|
||||||
|
return x_api_key
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""프로세스 레벨 엔진 캐시 — 모델 load-once 재사용 (스펙 §3.5).
|
||||||
|
|
||||||
|
전사는 `transcribe_lock`으로 직렬화(단일 GPU/CPU, 테스트 등급). uvicorn 단일 워커 전제.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from ..engine.faster_whisper_engine import FasterWhisperEngine
|
||||||
|
|
||||||
|
_engines: dict[tuple[str, str, str], FasterWhisperEngine] = {}
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
transcribe_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(
|
||||||
|
model: str, device: str, compute_type: str, cache_dir: str | None = None
|
||||||
|
) -> FasterWhisperEngine:
|
||||||
|
key = (model, device, compute_type)
|
||||||
|
eng = _engines.get(key)
|
||||||
|
if eng is None:
|
||||||
|
with _cache_lock:
|
||||||
|
eng = _engines.get(key)
|
||||||
|
if eng is None:
|
||||||
|
eng = FasterWhisperEngine(model, device, compute_type, cache_dir)
|
||||||
|
_engines[key] = eng
|
||||||
|
return eng
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""라우트 — /health, /v1/system, /v1/models, POST /v1/transcribe (동기)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from ...audio.ingest import probe_media
|
||||||
|
from ...config import settings
|
||||||
|
from ...devices import DeviceManager
|
||||||
|
from ...postprocess import llm as llm_correct
|
||||||
|
from ...postprocess import rules
|
||||||
|
from ...results import formats
|
||||||
|
from ..deps import require_api_key
|
||||||
|
from ..engine_pool import get_engine, transcribe_lock
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/system")
|
||||||
|
def system(): # noqa: ANN201 — DeviceProfile(pydantic) 직렬화
|
||||||
|
return DeviceManager.detect()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/models")
|
||||||
|
def models() -> dict:
|
||||||
|
profile = DeviceManager.detect()
|
||||||
|
return {
|
||||||
|
"tier": profile.tier.value,
|
||||||
|
"served": profile.served_models,
|
||||||
|
"realtime": settings.model_realtime,
|
||||||
|
"batch": settings.model_batch,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v1/transcribe")
|
||||||
|
def transcribe_ep( # noqa: PLR0913 — 요청 옵션 다수(스펙 options 스키마)
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
language: str | None = Form(None),
|
||||||
|
model: str | None = Form(None),
|
||||||
|
device: str = Form("auto"),
|
||||||
|
vad: bool = Form(True),
|
||||||
|
word_timestamps: bool = Form(False),
|
||||||
|
correct: bool = Form(False),
|
||||||
|
response_format: str = Form("json"),
|
||||||
|
_api_key: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
suffix = os.path.splitext(file.filename or "")[1] or ".bin"
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix="luke_up_", suffix=suffix)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as out:
|
||||||
|
while chunk := file.file.read(1 << 20):
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
info = probe_media(tmp)
|
||||||
|
if info.duration_s > settings.max_duration_s or info.size_bytes > settings.max_size_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
f"{info.duration_s:.0f}s/{info.size_bytes}B "
|
||||||
|
f"exceeds {settings.max_duration_s}s/{settings.max_size_bytes}B",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = DeviceManager.detect(force_device=(None if device == "auto" else device))
|
||||||
|
dev = "cpu" if profile.kind == "cpu" else "cuda"
|
||||||
|
model_name = model or settings.model_realtime
|
||||||
|
lang = language or settings.language
|
||||||
|
|
||||||
|
engine = get_engine(model_name, dev, profile.compute_type, settings.model_cache_dir)
|
||||||
|
with transcribe_lock:
|
||||||
|
segments, tinfo = engine.transcribe(
|
||||||
|
tmp, language=lang, word_timestamps=word_timestamps, vad=vad
|
||||||
|
)
|
||||||
|
seg_list = [
|
||||||
|
{"start": float(s.start), "end": float(s.end), "text": s.text.strip()}
|
||||||
|
for s in segments
|
||||||
|
]
|
||||||
|
|
||||||
|
text = " ".join(s["text"] for s in seg_list).strip()
|
||||||
|
corrected = False
|
||||||
|
if correct:
|
||||||
|
try:
|
||||||
|
text = rules.normalize(
|
||||||
|
llm_correct.correct(
|
||||||
|
text,
|
||||||
|
base_url=settings.llm_base_url,
|
||||||
|
api_key=settings.llm_api_key,
|
||||||
|
model=settings.llm_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
corrected = True
|
||||||
|
except llm_correct.LLMNotConfigured as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"correct=true but {exc}") from exc
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY, f"LLM correction failed: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response_format == "txt":
|
||||||
|
return PlainTextResponse(text)
|
||||||
|
if response_format == "srt":
|
||||||
|
return PlainTextResponse(formats.to_srt(seg_list))
|
||||||
|
if response_format == "vtt":
|
||||||
|
return PlainTextResponse(formats.to_vtt(seg_list))
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"segments": seg_list,
|
||||||
|
"language": getattr(tinfo, "language", None),
|
||||||
|
"model_used": model_name,
|
||||||
|
"corrected": corrected,
|
||||||
|
"duration_s": info.duration_s,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.remove(tmp) # 프라이버시: 모든 종료경로에서 임시파일 삭제
|
||||||
|
file.file.close()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""API 응답 스키마."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Segment(BaseModel):
|
||||||
|
start: float
|
||||||
|
end: float
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeResult(BaseModel):
|
||||||
|
text: str
|
||||||
|
segments: list[Segment]
|
||||||
|
language: str | None = None
|
||||||
|
model_used: str
|
||||||
|
corrected: bool = False
|
||||||
|
duration_s: float = 0.0
|
||||||
+43
-3
@@ -110,9 +110,49 @@ def bench(samples: str = typer.Option(None, help="라벨된 KO+EN 샘플 디렉
|
|||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def serve() -> None:
|
def serve(
|
||||||
"""API 서버 (P2)."""
|
host: str = typer.Option(None, help="bind host (기본 설정값)"),
|
||||||
_todo("serve", "→ P2 (FastAPI + Redis/RQ)")
|
port: int = typer.Option(None, help="bind port (기본 설정값)"),
|
||||||
|
tunnel: str = typer.Option("none", help="none|cloudflare (Colab 외부 노출)"),
|
||||||
|
) -> None:
|
||||||
|
"""테스트 API 서버 (동기 transcribe + opt-in 보정). AC-1/11/12 일부."""
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from .api.app import create_app
|
||||||
|
from .api.deps import ensure_keys
|
||||||
|
except ImportError as exc:
|
||||||
|
console.print(f"[red]API 의존성 미설치:[/] {exc}\n→ `uv sync --extra api --extra engine`")
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
bind_host = host or settings.host
|
||||||
|
bind_port = port or settings.port
|
||||||
|
key = ensure_keys()[0]
|
||||||
|
console.print(
|
||||||
|
f"[green]luke_scribe API[/] → http://{bind_host}:{bind_port} "
|
||||||
|
f"(X-API-Key: [bold]{key}[/])"
|
||||||
|
)
|
||||||
|
|
||||||
|
proc = None
|
||||||
|
if tunnel == "cloudflare":
|
||||||
|
try:
|
||||||
|
from .connectivity.tunnel import start_cloudflared
|
||||||
|
|
||||||
|
proc, public = start_cloudflared(bind_port)
|
||||||
|
console.print(
|
||||||
|
f"[green]public:[/] {public}" if public
|
||||||
|
else "[yellow]cloudflared URL 미수신(계속 진행).[/]"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
console.print(f"[yellow]터널 실패(무시): {exc}[/]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
uvicorn.run(create_app(), host=bind_host, port=bind_port, workers=1, log_level="info")
|
||||||
|
finally:
|
||||||
|
if proc is not None:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
@@ -34,5 +34,15 @@ class Settings(BaseSettings):
|
|||||||
# 모델 캐시 디렉터리 (None=HF 기본)
|
# 모델 캐시 디렉터리 (None=HF 기본)
|
||||||
model_cache_dir: str | None = None
|
model_cache_dir: str | None = None
|
||||||
|
|
||||||
|
# API 서버 (테스트 동기 API)
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
# LLM 보정 (opt-in, 사내/로컬 OpenAI 호환 백엔드)
|
||||||
|
llm_enabled: bool = False
|
||||||
|
llm_base_url: str | None = None # 예: http://192.168.0.123:8080/v1 (allowlist=이 endpoint만)
|
||||||
|
llm_api_key: str | None = None # env SCRIBE_LLM_API_KEY 로만 주입
|
||||||
|
llm_model: str = "copilot-gpt-4o"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""외부 노출 — Colab 등 공인 IP 부재 환경 (스펙 §8). MVP: cloudflared quick tunnel."""
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""cloudflared quick tunnel (스펙 §8). 바이너리 없으면 캐시에 다운로드. best-effort.
|
||||||
|
|
||||||
|
`serve --tunnel cloudflare` 가 호출 → 공개 https://<rand>.trycloudflare.com 발급(계정 불필요).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_RELEASE = "https://github.com/cloudflare/cloudflared/releases/latest/download"
|
||||||
|
_ASSETS = {
|
||||||
|
("Linux", "x86_64"): "cloudflared-linux-amd64",
|
||||||
|
("Linux", "aarch64"): "cloudflared-linux-arm64",
|
||||||
|
}
|
||||||
|
_URL_RE = re.compile(r"https://[-a-z0-9]+\.trycloudflare\.com")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_cloudflared() -> str:
|
||||||
|
found = shutil.which("cloudflared")
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
cache = os.path.expanduser("~/.cache/luke_scribe")
|
||||||
|
os.makedirs(cache, exist_ok=True)
|
||||||
|
path = os.path.join(cache, "cloudflared")
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
asset = _ASSETS.get((platform.system(), platform.machine()))
|
||||||
|
if not asset:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"cloudflared 자동설치 미지원: {platform.system()}/{platform.machine()} "
|
||||||
|
"— 수동 설치 후 PATH에 두세요."
|
||||||
|
)
|
||||||
|
urllib.request.urlretrieve(f"{_RELEASE}/{asset}", path) # noqa: S310
|
||||||
|
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def start_cloudflared(port: int, timeout: float = 30.0) -> tuple[subprocess.Popen, str | None]:
|
||||||
|
"""터널 프로세스 시작 → (proc, public_url). URL 못 받으면 url=None(프로세스는 유지)."""
|
||||||
|
binp = ensure_cloudflared()
|
||||||
|
proc = subprocess.Popen( # noqa: S603
|
||||||
|
[binp, "tunnel", "--no-autoupdate", "--url", f"http://localhost:{port}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = proc.stdout.readline() if proc.stdout else ""
|
||||||
|
if not line:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
m = _URL_RE.search(line)
|
||||||
|
if m:
|
||||||
|
return proc, m.group(0)
|
||||||
|
return proc, None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""후처리 — glossary/rules + (opt-in) LLM 보정 + confidence (스펙 §7)."""
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""LLM 보정 (스펙 §7 stage 3 / §3.8) — 음차된 영문 용어를 문맥+지식으로 복원.
|
||||||
|
|
||||||
|
OpenAI 호환 백엔드(사내/로컬). **opt-in**(요청 correct=true에서만 호출), **allowlist**(설정된
|
||||||
|
base_url만), **감사로그**(호출 1줄). transient(연결 reset/timeout) 재시도.
|
||||||
|
긴 입력 청크/러닝글로서리는 TODO — MVP는 단일 호출(짧은 클립엔 충분).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
logger = logging.getLogger("luke_scribe.postprocess.llm")
|
||||||
|
|
||||||
|
SYSTEM = (
|
||||||
|
"너는 한국어 STT 전사 후처리기다. 한국어 음성에 섞여 나온 영어 기술용어·고유명사가 "
|
||||||
|
"발음대로 한글로 음차되어 잘못 적힌 부분을 문맥과 지식으로 원래 영어 표기로 복원하라. "
|
||||||
|
"일반 한국어는 그대로 두고, 확실하지 않으면 바꾸지 마라. 설명 없이 교정된 전사문만 출력하라."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMNotConfigured(RuntimeError):
|
||||||
|
"""llm_base_url / llm_api_key 미설정."""
|
||||||
|
|
||||||
|
|
||||||
|
def correct(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
base_url: str | None,
|
||||||
|
api_key: str | None,
|
||||||
|
model: str = "copilot-gpt-4o",
|
||||||
|
retries: int = 4,
|
||||||
|
timeout: float = 90.0,
|
||||||
|
) -> str:
|
||||||
|
if not base_url or not api_key:
|
||||||
|
raise LLMNotConfigured("llm_base_url/llm_api_key 미설정 — correct를 쓰려면 SCRIBE_LLM_* 설정 필요")
|
||||||
|
url = base_url.rstrip("/") + "/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"temperature": 0,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{"role": "user", "content": text},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": "Bearer " + api_key},
|
||||||
|
)
|
||||||
|
# 감사로그 (allowlist=설정 endpoint, 호출 1줄)
|
||||||
|
logger.info("llm-correct egress endpoint=%s model=%s chars=%d", url, model, len(text))
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
raise # 실제 HTTP 응답(401/4xx) — 재시도 무의미
|
||||||
|
except (urllib.error.URLError, OSError): # 연결 reset/timeout 등 transient
|
||||||
|
if attempt == retries:
|
||||||
|
raise
|
||||||
|
time.sleep(1.0 * attempt)
|
||||||
|
raise RuntimeError("unreachable")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""결정적 정규화 (스펙 §7 stage 2). LLM 복원 뒤 정확한 표기로 보정.
|
||||||
|
|
||||||
|
발견 노트: LLM이 'Embedding Gemma'로 복원 → rules가 공식 표기 'EmbeddingGemma'로 정규화.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# 기본 내장 맵 (config/glossary로 확장 가능)
|
||||||
|
DEFAULT_RULES: dict[str, str] = {
|
||||||
|
"Embedding Gemma": "EmbeddingGemma",
|
||||||
|
"embedding gemma": "EmbeddingGemma",
|
||||||
|
"Google for developers": "Google for Developers",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str, extra: dict[str, str] | None = None) -> str:
|
||||||
|
for src, dst in {**DEFAULT_RULES, **(extra or {})}.items():
|
||||||
|
text = text.replace(src, dst)
|
||||||
|
return text
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""결과 포맷·보관 (스펙 §4). MVP: 출력 포맷(txt/srt/vtt)."""
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""세그먼트 → txt/srt/vtt 변환 (스펙 §4, AC-9). 세그먼트=dict{start,end,text}."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
Segment = dict # {"start": float, "end": float, "text": str}
|
||||||
|
|
||||||
|
|
||||||
|
def _hms(t: float) -> tuple[int, int, int, int]:
|
||||||
|
t = max(0.0, t)
|
||||||
|
h = int(t // 3600)
|
||||||
|
m = int((t % 3600) // 60)
|
||||||
|
s = int(t % 60)
|
||||||
|
ms = int(round((t - int(t)) * 1000))
|
||||||
|
if ms == 1000: # 반올림 보정
|
||||||
|
ms, s = 0, s + 1
|
||||||
|
return h, m, s, ms
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_srt(t: float) -> str:
|
||||||
|
h, m, s, ms = _hms(t)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_vtt(t: float) -> str:
|
||||||
|
h, m, s, ms = _hms(t)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def to_txt(segments: Sequence[Segment]) -> str:
|
||||||
|
return "\n".join(s["text"].strip() for s in segments)
|
||||||
|
|
||||||
|
|
||||||
|
def to_srt(segments: Sequence[Segment]) -> str:
|
||||||
|
out: list[str] = []
|
||||||
|
for i, s in enumerate(segments, 1):
|
||||||
|
out += [str(i), f"{_ts_srt(s['start'])} --> {_ts_srt(s['end'])}", s["text"].strip(), ""]
|
||||||
|
return "\n".join(out).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def to_vtt(segments: Sequence[Segment]) -> str:
|
||||||
|
out: list[str] = ["WEBVTT", ""]
|
||||||
|
for s in segments:
|
||||||
|
out += [f"{_ts_vtt(s['start'])} --> {_ts_vtt(s['end'])}", s["text"].strip(), ""]
|
||||||
|
return "\n".join(out).strip() + "\n"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""API — FastAPI TestClient. 엔진은 monkeypatch(가짜)로 모델 로드 회피."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import luke_scribe.api.routes.transcribe as route
|
||||||
|
from luke_scribe.api.app import create_app
|
||||||
|
from luke_scribe.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSeg:
|
||||||
|
def __init__(self, start: float, end: float, text: str) -> None:
|
||||||
|
self.start = start
|
||||||
|
self.end = end
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeEngine:
|
||||||
|
def transcribe(self, _audio, **_kw):
|
||||||
|
return [_FakeSeg(0.0, 1.0, "안녕 vLLM"), _FakeSeg(1.0, 2.0, "두번째")], SimpleNamespace(
|
||||||
|
language="ko"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
monkeypatch.setattr(route, "get_engine", lambda *a, **k: _FakeEngine())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
route, "probe_media", lambda p: SimpleNamespace(path=p, duration_s=2.0, size_bytes=1234)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(settings, "api_keys", ["testkey"])
|
||||||
|
return TestClient(create_app())
|
||||||
|
|
||||||
|
|
||||||
|
def _files():
|
||||||
|
return {"file": ("a.wav", b"RIFF0000WAVE", "audio/wav")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(client):
|
||||||
|
assert client.get("/health").json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_requires_key(client):
|
||||||
|
assert client.post("/v1/transcribe", files=_files()).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcribe_ok(client):
|
||||||
|
r = client.post(
|
||||||
|
"/v1/transcribe", files=_files(), headers={"X-API-Key": "testkey"}, data={"language": "ko"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["segments"][0]["text"] == "안녕 vLLM"
|
||||||
|
assert body["model_used"]
|
||||||
|
assert body["corrected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_413(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
route, "probe_media", lambda p: SimpleNamespace(path=p, duration_s=999999, size_bytes=1)
|
||||||
|
)
|
||||||
|
r = client.post("/v1/transcribe", files=_files(), headers={"X-API-Key": "testkey"})
|
||||||
|
assert r.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_srt_format(client):
|
||||||
|
r = client.post(
|
||||||
|
"/v1/transcribe",
|
||||||
|
files=_files(),
|
||||||
|
headers={"X-API-Key": "testkey"},
|
||||||
|
data={"response_format": "srt"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "00:00:00,000 --> 00:00:01,000" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_correct_path(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(route.llm_correct, "correct", lambda text, **k: text + " [보정]")
|
||||||
|
r = client.post(
|
||||||
|
"/v1/transcribe", files=_files(), headers={"X-API-Key": "testkey"}, data={"correct": "true"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["corrected"] is True
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""results.formats — txt/srt/vtt."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from luke_scribe.results import formats
|
||||||
|
|
||||||
|
SEGS = [
|
||||||
|
{"start": 0.0, "end": 1.5, "text": "안녕 world"},
|
||||||
|
{"start": 1.5, "end": 3.0, "text": "두번째"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt():
|
||||||
|
assert formats.to_txt(SEGS) == "안녕 world\n두번째"
|
||||||
|
|
||||||
|
|
||||||
|
def test_srt():
|
||||||
|
out = formats.to_srt(SEGS)
|
||||||
|
assert "1\n00:00:00,000 --> 00:00:01,500\n안녕 world" in out
|
||||||
|
assert "2\n00:00:01,500 --> 00:00:03,000\n두번째" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_vtt():
|
||||||
|
out = formats.to_vtt(SEGS)
|
||||||
|
assert out.startswith("WEBVTT")
|
||||||
|
assert "00:00:00.000 --> 00:00:01.500" in out
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""postprocess.rules / postprocess.llm (urllib monkeypatch)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from luke_scribe.postprocess import llm, rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_rules_normalize():
|
||||||
|
assert rules.normalize("구글 Embedding Gemma 소개") == "구글 EmbeddingGemma 소개"
|
||||||
|
assert rules.normalize("그대로") == "그대로"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_not_configured():
|
||||||
|
with pytest.raises(llm.LLMNotConfigured):
|
||||||
|
llm.correct("x", base_url=None, api_key=None)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, payload: dict) -> None:
|
||||||
|
self._p = payload
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return json.dumps(self._p).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_correct_monkeypatched(monkeypatch):
|
||||||
|
def fake_urlopen(_req, timeout=90): # noqa: ARG001
|
||||||
|
return _FakeResp({"choices": [{"message": {"content": "EmbeddingGemma 복원됨"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
out = llm.correct("인베딩 점마", base_url="http://x/v1", api_key="k", model="m")
|
||||||
|
assert out == "EmbeddingGemma 복원됨"
|
||||||
@@ -521,7 +521,7 @@ name = "cuda-bindings"
|
|||||||
version = "13.3.1"
|
version = "13.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-pathfinder" },
|
{ name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
||||||
@@ -554,34 +554,34 @@ wheels = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
cudart = [
|
cudart = [
|
||||||
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cufft = [
|
cufft = [
|
||||||
{ name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-cufft", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cufile = [
|
cufile = [
|
||||||
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cupti = [
|
cupti = [
|
||||||
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
curand = [
|
curand = [
|
||||||
{ name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-curand", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cusolver = [
|
cusolver = [
|
||||||
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
cusparse = [
|
cusparse = [
|
||||||
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvjitlink = [
|
nvjitlink = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvrtc = [
|
nvrtc = [
|
||||||
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
nvtx = [
|
nvtx = [
|
||||||
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1384,8 +1384,7 @@ dependencies = [
|
|||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
api = [
|
api = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "redis" },
|
{ name = "python-multipart" },
|
||||||
{ name = "rq" },
|
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
diarize = [
|
diarize = [
|
||||||
@@ -1402,9 +1401,14 @@ gpu = [
|
|||||||
llm = [
|
llm = [
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
]
|
]
|
||||||
|
queue = [
|
||||||
|
{ name = "redis" },
|
||||||
|
{ name = "rq" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
@@ -1423,16 +1427,18 @@ requires-dist = [
|
|||||||
{ name = "pyannote-audio", marker = "extra == 'diarize'", specifier = ">=3.1" },
|
{ name = "pyannote-audio", marker = "extra == 'diarize'", specifier = ">=3.1" },
|
||||||
{ name = "pydantic", specifier = ">=2.7" },
|
{ name = "pydantic", specifier = ">=2.7" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.3" },
|
{ name = "pydantic-settings", specifier = ">=2.3" },
|
||||||
{ name = "redis", marker = "extra == 'api'", specifier = ">=5.0" },
|
{ name = "python-multipart", marker = "extra == 'api'", specifier = ">=0.0.9" },
|
||||||
|
{ name = "redis", marker = "extra == 'queue'", specifier = ">=5.0" },
|
||||||
{ name = "rich", specifier = ">=13.7" },
|
{ name = "rich", specifier = ">=13.7" },
|
||||||
{ name = "rq", marker = "extra == 'api'", specifier = ">=1.16" },
|
{ name = "rq", marker = "extra == 'queue'", specifier = ">=1.16" },
|
||||||
{ name = "typer", specifier = ">=0.12" },
|
{ name = "typer", specifier = ">=0.12" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'api'", specifier = ">=0.29" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'api'", specifier = ">=0.29" },
|
||||||
]
|
]
|
||||||
provides-extras = ["engine", "gpu", "api", "diarize", "llm"]
|
provides-extras = ["engine", "gpu", "api", "queue", "diarize", "llm"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "pytest", specifier = ">=8.2" },
|
{ name = "pytest", specifier = ">=8.2" },
|
||||||
{ name = "ruff", specifier = ">=0.5" },
|
{ name = "ruff", specifier = ">=0.5" },
|
||||||
]
|
]
|
||||||
@@ -1836,7 +1842,7 @@ name = "nvidia-cublas"
|
|||||||
version = "13.1.1.3"
|
version = "13.1.1.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cuda-nvrtc" },
|
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
||||||
@@ -1911,7 +1917,7 @@ name = "nvidia-cudnn-cu13"
|
|||||||
version = "9.20.0.48"
|
version = "9.20.0.48"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas" },
|
{ name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
||||||
@@ -1923,7 +1929,7 @@ name = "nvidia-cufft"
|
|||||||
version = "12.0.0.61"
|
version = "12.0.0.61"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||||
@@ -1953,9 +1959,9 @@ name = "nvidia-cusolver"
|
|||||||
version = "12.0.4.66"
|
version = "12.0.4.66"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas" },
|
{ name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "nvidia-cusparse" },
|
{ name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||||
@@ -1967,7 +1973,7 @@ name = "nvidia-cusparse"
|
|||||||
version = "12.6.3.3"
|
version = "12.6.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink" },
|
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||||
@@ -2834,6 +2840,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytorch-lightning"
|
name = "pytorch-lightning"
|
||||||
version = "2.6.5"
|
version = "2.6.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user