feat: full-platform STT API (v2.3 consensus plan) #1
@@ -109,19 +109,19 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\nimport subprocess, json\nr = subprocess.run(\n ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n capture_output=True, text=True,\n)\nprint(r.stdout[-2500:] if r.stdout else '')\nprint(r.stderr[-800:] if r.stderr else '')\n"
|
"source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n# 후처리: 기본 rules가 vLLM→BLM 같은 흔한 오인식을 복원하고,\n# --glossary 'BLM=vLLM'으로 도메인 용어를 명시적으로 보강할 수 있다.\nimport subprocess, json\nr = subprocess.run(\n ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto',\n '--glossary', 'BLM=vLLM'],\n capture_output=True, text=True,\n)\nprint(r.stdout[-2500:] if r.stdout else '')\nprint(r.stderr[-800:] if r.stderr else '')\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": "### 5-2) 후처리 검증\n\nglossary(오인식 용어 복원) + hotword(용어 사전 주입) 동작을 확인합니다.\n`--hotword vLLM Kubernetes`를 주면 initial_prompt에 용어가 주입되어 보존률이 올라갑니다."
|
"source": "### 5-2) 후처리 검증\n\n후처리(rules/glossary) + hotword(용어 사전 주입) 동작을 확인합니다.\n- **rules (기본)**: `BLM → vLLM`, `v l l m → vLLM` 같은 흔한 오인식을 결정적으로 복원.\n- **glossary**: `--glossary '오인식=표준'`으로 도메인 용어를 명시적으로 보강 (반복 가능).\n- **hotword**: `--hotword vLLM Kubernetes` → initial_prompt 주입으로 보존률 향상."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": "# 8) hotword 포함 전사 (용어 보존 강화)\n!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes\n"
|
"source": "# 8) hotword + glossary 포함 전사 (용어 보존 강화)\n!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes --glossary BLM=vLLM\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n# entities는 {canonical, surface, start_char, end_char} dict여야 한다\n# (문자열이면 entity_retention이 .get() 호출에 실패해 clip이 실패 처리됨)\nimport yaml\n\nREF_TEXT = '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.'\nwith open('/content/reference.txt', 'w', encoding='utf-8') as f:\n f.write(REF_TEXT)\n\nentities = []\nfor name in ('vLLM', 'Kubernetes', 'GPU'):\n idx = REF_TEXT.index(name)\n entities.append({\n 'canonical': name, 'surface': name,\n 'start_char': idx, 'end_char': idx + len(name),\n })\n\nmanifest = {\n 'name': 'colab-quick',\n 'dataset_version': '1.0',\n 'language': 'ko',\n 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n 'clips': [\n {'id': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n 'reference_path': '/content/reference.txt',\n 'duration_sec': 10.5, 'entities': entities},\n ],\n}\nyaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n\n# 실행 (기본: turbo만 → 빠름)\n!luke-scribe bench /content/manifest.yaml --models large-v3-turbo --device auto --repeats 2 --output /content/bench-report.json 2>&1 | tail -20\n"
|
"source": "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n# entities는 {canonical, surface, start_char, end_char} dict여야 한다\n# (문자열이면 entity_retention이 .get() 호출에 실패해 clip이 실패 처리됨)\nimport yaml\n\nREF_TEXT = '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.'\nwith open('/content/reference.txt', 'w', encoding='utf-8') as f:\n f.write(REF_TEXT)\n\nentities = []\nfor name in ('vLLM', 'Kubernetes', 'GPU'):\n idx = REF_TEXT.index(name)\n entities.append({\n 'canonical': name, 'surface': name,\n 'start_char': idx, 'end_char': idx + len(name),\n })\n\nmanifest = {\n 'name': 'colab-quick',\n 'dataset_version': '1.0',\n 'language': 'ko',\n 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n # 벤치도 후처리를 적용해 실사용 지표를 측정 (vLLM→BLM 복원 포함)\n 'glossary': {'BLM': 'vLLM'},\n 'clips': [\n {'id': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n 'reference_path': '/content/reference.txt',\n 'duration_sec': 10.5, 'entities': entities},\n ],\n}\nyaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n\n# 실행 (기본: turbo만 → 빠름)\n!luke-scribe bench /content/manifest.yaml --models large-v3-turbo --device auto --repeats 2 --output /content/bench-report.json 2>&1 | tail -20\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
|
|||||||
@@ -192,9 +192,12 @@ def cells() -> list[dict]:
|
|||||||
"# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n"
|
"# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n"
|
||||||
"# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n"
|
"# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n"
|
||||||
"# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n"
|
"# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n"
|
||||||
|
"# 후처리: 기본 rules가 vLLM→BLM 같은 흔한 오인식을 복원하고,\n"
|
||||||
|
"# --glossary 'BLM=vLLM'으로 도메인 용어를 명시적으로 보강할 수 있다.\n"
|
||||||
"import subprocess, json\n"
|
"import subprocess, json\n"
|
||||||
"r = subprocess.run(\n"
|
"r = subprocess.run(\n"
|
||||||
" ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n"
|
" ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto',\n"
|
||||||
|
" '--glossary', 'BLM=vLLM'],\n"
|
||||||
" capture_output=True, text=True,\n"
|
" capture_output=True, text=True,\n"
|
||||||
")\n"
|
")\n"
|
||||||
"print(r.stdout[-2500:] if r.stdout else '')\n"
|
"print(r.stdout[-2500:] if r.stdout else '')\n"
|
||||||
@@ -202,12 +205,14 @@ def cells() -> list[dict]:
|
|||||||
),
|
),
|
||||||
md(
|
md(
|
||||||
"### 5-2) 후처리 검증\n\n"
|
"### 5-2) 후처리 검증\n\n"
|
||||||
"glossary(오인식 용어 복원) + hotword(용어 사전 주입) 동작을 확인합니다.\n"
|
"후처리(rules/glossary) + hotword(용어 사전 주입) 동작을 확인합니다.\n"
|
||||||
"`--hotword vLLM Kubernetes`를 주면 initial_prompt에 용어가 주입되어 보존률이 올라갑니다."
|
"- **rules (기본)**: `BLM → vLLM`, `v l l m → vLLM` 같은 흔한 오인식을 결정적으로 복원.\n"
|
||||||
|
"- **glossary**: `--glossary '오인식=표준'`으로 도메인 용어를 명시적으로 보강 (반복 가능).\n"
|
||||||
|
"- **hotword**: `--hotword vLLM Kubernetes` → initial_prompt 주입으로 보존률 향상."
|
||||||
),
|
),
|
||||||
code(
|
code(
|
||||||
"# 8) hotword 포함 전사 (용어 보존 강화)\n"
|
"# 8) hotword + glossary 포함 전사 (용어 보존 강화)\n"
|
||||||
"!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes\n"
|
"!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes --glossary BLM=vLLM\n"
|
||||||
),
|
),
|
||||||
md(
|
md(
|
||||||
"## 6) REST API 스모크\n\n"
|
"## 6) REST API 스모크\n\n"
|
||||||
@@ -454,6 +459,8 @@ def cells() -> list[dict]:
|
|||||||
" 'dataset_version': '1.0',\n"
|
" 'dataset_version': '1.0',\n"
|
||||||
" 'language': 'ko',\n"
|
" 'language': 'ko',\n"
|
||||||
" 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n"
|
" 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n"
|
||||||
|
" # 벤치도 후처리를 적용해 실사용 지표를 측정 (vLLM→BLM 복원 포함)\n"
|
||||||
|
" 'glossary': {'BLM': 'vLLM'},\n"
|
||||||
" 'clips': [\n"
|
" 'clips': [\n"
|
||||||
" {'id': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n"
|
" {'id': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n"
|
||||||
" 'reference_path': '/content/reference.txt',\n"
|
" 'reference_path': '/content/reference.txt',\n"
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ def run_benchmark(
|
|||||||
from ..errors import InvalidInput
|
from ..errors import InvalidInput
|
||||||
|
|
||||||
raise InvalidInput("manifest에 clips가 없습니다")
|
raise InvalidInput("manifest에 clips가 없습니다")
|
||||||
|
# manifest 최상위 glossary: {오인식 패턴: 표준 표기} — 벤치도 후처리를 적용한다
|
||||||
|
glossary = data.get("glossary") or None
|
||||||
|
|
||||||
report: dict = {
|
report: dict = {
|
||||||
"report_version": REPORT_VERSION,
|
"report_version": REPORT_VERSION,
|
||||||
@@ -71,6 +73,8 @@ def run_benchmark(
|
|||||||
"beam_size": 5,
|
"beam_size": 5,
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"vad_filter": True,
|
"vad_filter": True,
|
||||||
|
"post_mode": settings.post_mode if settings.post_enabled else "none",
|
||||||
|
"glossary": glossary or {},
|
||||||
},
|
},
|
||||||
"models": [],
|
"models": [],
|
||||||
"decision": {"status": "pending", "default_model": None, "reasons": []},
|
"decision": {"status": "pending", "default_model": None, "reasons": []},
|
||||||
@@ -81,7 +85,9 @@ def run_benchmark(
|
|||||||
owner = EngineOwner.get(settings)
|
owner = EngineOwner.get(settings)
|
||||||
model_results = {}
|
model_results = {}
|
||||||
for model in models:
|
for model in models:
|
||||||
agg = _run_model(owner, clips, model, device, compute_type, repeats, hotwords, report)
|
agg = _run_model(
|
||||||
|
owner, clips, model, device, compute_type, repeats, hotwords, report, settings, glossary
|
||||||
|
)
|
||||||
model_results[model] = agg
|
model_results[model] = agg
|
||||||
report["models"].append(agg["summary"])
|
report["models"].append(agg["summary"])
|
||||||
|
|
||||||
@@ -102,7 +108,9 @@ def run_benchmark(
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, report) -> dict:
|
def _run_model(
|
||||||
|
owner, clips, model, device, compute_type, repeats, hotwords, report, settings, glossary
|
||||||
|
) -> dict:
|
||||||
from ..engine.base import TranscriptionOptions
|
from ..engine.base import TranscriptionOptions
|
||||||
|
|
||||||
options = TranscriptionOptions(
|
options = TranscriptionOptions(
|
||||||
@@ -114,7 +122,7 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
|
|||||||
)
|
)
|
||||||
# warm-up (비평가 클립 1회)
|
# warm-up (비평가 클립 1회)
|
||||||
clip0 = clips[0]
|
clip0 = clips[0]
|
||||||
_transcribe_clip(owner, options, clip0)
|
_transcribe_clip(owner, options, clip0, settings, glossary)
|
||||||
|
|
||||||
rtf_samples: list[float] = []
|
rtf_samples: list[float] = []
|
||||||
rss_samples: list[float] = []
|
rss_samples: list[float] = []
|
||||||
@@ -141,7 +149,7 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
|
|||||||
clip_success = False
|
clip_success = False
|
||||||
for _ in range(repeats):
|
for _ in range(repeats):
|
||||||
try:
|
try:
|
||||||
result = _transcribe_clip(owner, options, clip)
|
result = _transcribe_clip(owner, options, clip, settings, glossary)
|
||||||
m = clip_metrics(ref_text, result["text"], entities)
|
m = clip_metrics(ref_text, result["text"], entities)
|
||||||
clip_rtfs.append(result["rtf"])
|
clip_rtfs.append(result["rtf"])
|
||||||
rss_samples.append(peak_process_rss_mb())
|
rss_samples.append(peak_process_rss_mb())
|
||||||
@@ -185,9 +193,15 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep
|
|||||||
return {"summary": summary}
|
return {"summary": summary}
|
||||||
|
|
||||||
|
|
||||||
def _transcribe_clip(owner, options, clip) -> dict:
|
def _transcribe_clip(owner, options, clip, settings, glossary) -> dict:
|
||||||
"""클립 전사 — 세그먼트 소비 + text/rtf 반환."""
|
"""클립 전사 — 후처리(glossary/rules) 적용 + text/rtf 반환.
|
||||||
|
|
||||||
|
벤치 지표는 원시 전사가 아니라 사용자가 실제로 받는 후처리 결과를 측정해야
|
||||||
|
한다 (vLLM→BLM 같은 오인식은 rules/glossary에서 복원된다).
|
||||||
|
"""
|
||||||
from ..engine.owner import InferenceRequest
|
from ..engine.owner import InferenceRequest
|
||||||
|
from ..postprocess.pipeline import run_postprocess
|
||||||
|
from ..results.models import Segment
|
||||||
|
|
||||||
audio_path = clip.get("audio_path")
|
audio_path = clip.get("audio_path")
|
||||||
if not audio_path:
|
if not audio_path:
|
||||||
@@ -196,11 +210,22 @@ def _transcribe_clip(owner, options, clip) -> dict:
|
|||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
req = InferenceRequest(audio_path=audio_path, options=options, lane="batch")
|
req = InferenceRequest(audio_path=audio_path, options=options, lane="batch")
|
||||||
outcome = owner.transcribe(req)
|
outcome = owner.transcribe(req)
|
||||||
texts = []
|
segments: list[Segment] = []
|
||||||
for seg in outcome["segments"]:
|
for idx, seg in enumerate(outcome["segments"]):
|
||||||
texts.append(seg.get("text", ""))
|
segments.append(
|
||||||
|
Segment(
|
||||||
|
index=idx,
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
post = run_postprocess(segments, options, settings, glossary=glossary)
|
||||||
|
text = " ".join(s.text.strip() for s in post["segments"] if s.text.strip())
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
return {"text": " ".join(t for t in texts if t), "rtf": elapsed / duration}
|
return {"text": text, "rtf": elapsed / duration}
|
||||||
|
|
||||||
|
|
||||||
def _decide(model_results: dict) -> dict:
|
def _decide(model_results: dict) -> dict:
|
||||||
|
|||||||
+12
-1
@@ -86,6 +86,9 @@ def transcribe(
|
|||||||
),
|
),
|
||||||
vad: bool = typer.Option(True, "--vad/--no-vad"),
|
vad: bool = typer.Option(True, "--vad/--no-vad"),
|
||||||
hotword: list[str] = typer.Option([], "--hotword", help="반복 가능"),
|
hotword: list[str] = typer.Option([], "--hotword", help="반복 가능"),
|
||||||
|
glossary: list[str] = typer.Option(
|
||||||
|
[], "--glossary", help="오인식 패턴=표준 표기, 반복 가능 (예: --glossary BLM=vLLM)"
|
||||||
|
),
|
||||||
output: Path | None = typer.Option(None, "--output", "-", help="결과 파일 (기본 stdout)"),
|
output: Path | None = typer.Option(None, "--output", "-", help="결과 파일 (기본 stdout)"),
|
||||||
force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"),
|
force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"),
|
||||||
word_timestamps: bool = typer.Option(False, "--word-timestamps"),
|
word_timestamps: bool = typer.Option(False, "--word-timestamps"),
|
||||||
@@ -113,9 +116,17 @@ def transcribe(
|
|||||||
hotwords=hotword,
|
hotwords=hotword,
|
||||||
word_timestamps=word_timestamps,
|
word_timestamps=word_timestamps,
|
||||||
)
|
)
|
||||||
|
glossary_dict: dict[str, str] = {}
|
||||||
|
for item in glossary:
|
||||||
|
if "=" not in item:
|
||||||
|
_fail(EXIT_INPUT, f"--glossary는 'KEY=VALUE' 형식이어야 합니다: {item}")
|
||||||
|
key, _, value = item.partition("=")
|
||||||
|
glossary_dict[key.strip()] = value.strip()
|
||||||
try:
|
try:
|
||||||
pipeline = BatchPipeline(settings=settings, token=token)
|
pipeline = BatchPipeline(settings=settings, token=token)
|
||||||
result = pipeline.run(source, options, source_name=source.name)
|
result = pipeline.run(
|
||||||
|
source, options, source_name=source.name, glossary=glossary_dict or None
|
||||||
|
)
|
||||||
except LukeScribeError as exc:
|
except LukeScribeError as exc:
|
||||||
if output is not None:
|
if output is not None:
|
||||||
failed = TranscriptResult(
|
failed = TranscriptResult(
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ class Worker:
|
|||||||
if k in TranscriptionOptions.__slots__
|
if k in TranscriptionOptions.__slots__
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = pipeline.run(job, options, progress_cb=progress_cb)
|
# API 옵션의 glossary/post_correction(dict)을 후처리 glossary로 전달
|
||||||
|
glossary = (job.options or {}).get("glossary") or (job.options or {}).get(
|
||||||
|
"post_correction"
|
||||||
|
)
|
||||||
|
result = pipeline.run(job, options, progress_cb=progress_cb, glossary=glossary)
|
||||||
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
|
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
|
||||||
self.store.write_result(job.id, result)
|
self.store.write_result(job.id, result)
|
||||||
current = self._transition(job, JobStatus.COMPLETED)
|
current = self._transition(job, JobStatus.COMPLETED)
|
||||||
|
|||||||
@@ -54,9 +54,13 @@ class BatchPipeline:
|
|||||||
*,
|
*,
|
||||||
progress_cb: Callable[[float, float], None] | None = None,
|
progress_cb: Callable[[float, float], None] | None = None,
|
||||||
source_name: str | None = None,
|
source_name: str | None = None,
|
||||||
|
glossary: dict[str, str] | None = None,
|
||||||
) -> TranscriptResult:
|
) -> TranscriptResult:
|
||||||
"""job(Job) 또는 source(Path)를 받아 전사.
|
"""job(Job) 또는 source(Path)를 받아 전사.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
glossary: {오인식 패턴: 표준 표기} — run_postprocess에 전달.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
completed TranscriptResult (후처리 포함).
|
completed TranscriptResult (후처리 포함).
|
||||||
"""
|
"""
|
||||||
@@ -116,7 +120,7 @@ class BatchPipeline:
|
|||||||
|
|
||||||
# 4) 후처리 (glossary/rules/LLM/confidence)
|
# 4) 후처리 (glossary/rules/LLM/confidence)
|
||||||
t2 = time.time()
|
t2 = time.time()
|
||||||
post_result = run_postprocess(segments, options, self.settings)
|
post_result = run_postprocess(segments, options, self.settings, glossary=glossary)
|
||||||
postprocess_sec = time.time() - t2
|
postprocess_sec = time.time() - t2
|
||||||
|
|
||||||
text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip())
|
text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip())
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from ..results.models import Segment
|
|||||||
# 흔한 오인식 패턴 → 표준 표기 (정규식)
|
# 흔한 오인식 패턴 → 표준 표기 (정규식)
|
||||||
DEFAULT_RULES: list[tuple[re.Pattern, str]] = [
|
DEFAULT_RULES: list[tuple[re.Pattern, str]] = [
|
||||||
(re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"),
|
(re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"),
|
||||||
|
# 흔한 오인식: vLLM → BLM (GPU 실전에서 재현). 기술 STT 도메인 전제로 복원.
|
||||||
|
(re.compile(r"\bblm\b", re.IGNORECASE), "vLLM"),
|
||||||
(re.compile(r"\bk ?u ?b ?e ?r ?n ?e ?t ?e ?s\b", re.IGNORECASE), "Kubernetes"),
|
(re.compile(r"\bk ?u ?b ?e ?r ?n ?e ?t ?e ?s\b", re.IGNORECASE), "Kubernetes"),
|
||||||
(re.compile(r"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"),
|
(re.compile(r"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"),
|
||||||
(re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"),
|
(re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"),
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""벤치마크 단위 테스트 — 후처리 적용 + glossary 지원.
|
||||||
|
|
||||||
|
벤치 지표는 원시 전사가 아니라 후처리(rules/glossary)를 거친 결과를 측정해야
|
||||||
|
한다 (vLLM→BLM 같은 오인식 복원 포함).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from luke_scribe.benchmark.runner import _run_model, _transcribe_clip
|
||||||
|
from luke_scribe.config import Settings
|
||||||
|
from luke_scribe.engine.base import TranscriptionOptions
|
||||||
|
|
||||||
|
REF_TEXT = "오늘은 vLLM 서버를 Kubernetes 클러스터에 배포합니다"
|
||||||
|
ENTITIES = [
|
||||||
|
{"canonical": "vLLM", "surface": "vLLM", "start_char": 4, "end_char": 8},
|
||||||
|
{"canonical": "Kubernetes", "surface": "Kubernetes", "start_char": 13, "end_char": 23},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeOwner:
|
||||||
|
"""세그먼트 dict를 반환하는 가짜 owner (dict 계약 사용)."""
|
||||||
|
|
||||||
|
def __init__(self, texts: list[str]) -> None:
|
||||||
|
self._texts = texts
|
||||||
|
|
||||||
|
def transcribe(self, req): # noqa: ANN001
|
||||||
|
segs = [
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"start": i * 2.0,
|
||||||
|
"end": i * 2.0 + 2.0,
|
||||||
|
"text": t,
|
||||||
|
"avg_logprob": -0.2,
|
||||||
|
"no_speech_prob": 0.01,
|
||||||
|
}
|
||||||
|
for i, t in enumerate(self._texts)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"segments": iter(segs),
|
||||||
|
"device": "cpu",
|
||||||
|
"compute_type": "int8",
|
||||||
|
"attempted_profiles": [{}],
|
||||||
|
"info": {"language": "ko"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscribeClipPostprocess:
|
||||||
|
def test_rules_fix_blm_to_vllm(self):
|
||||||
|
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
|
||||||
|
settings = Settings(_env_file=None, post_mode="rules", post_enabled=True)
|
||||||
|
clip = {"audio_path": "x.mp3", "duration_sec": 10.0}
|
||||||
|
out = _transcribe_clip(owner, TranscriptionOptions(), clip, settings, None)
|
||||||
|
assert "vLLM" in out["text"]
|
||||||
|
assert "BLM" not in out["text"]
|
||||||
|
|
||||||
|
def test_glossary_applied(self):
|
||||||
|
owner = _FakeOwner(["브이엘엘엠 서버"])
|
||||||
|
settings = Settings(_env_file=None, post_mode="glossary", post_enabled=True)
|
||||||
|
clip = {"audio_path": "x.mp3", "duration_sec": 10.0}
|
||||||
|
out = _transcribe_clip(
|
||||||
|
owner, TranscriptionOptions(), clip, settings, {"브이엘엘엠": "vLLM"}
|
||||||
|
)
|
||||||
|
assert "vLLM" in out["text"]
|
||||||
|
|
||||||
|
def test_postprocess_disabled_keeps_raw(self):
|
||||||
|
owner = _FakeOwner(["오늘은 BLM 서버"])
|
||||||
|
settings = Settings(_env_file=None, post_mode="rules", post_enabled=False)
|
||||||
|
clip = {"audio_path": "x.mp3", "duration_sec": 10.0}
|
||||||
|
out = _transcribe_clip(owner, TranscriptionOptions(), clip, settings, None)
|
||||||
|
assert "BLM" in out["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunModelPostprocess:
|
||||||
|
def _report(self) -> dict:
|
||||||
|
return {"run_config": {"hotword_set": []}}
|
||||||
|
|
||||||
|
def _clip(self, ref_path: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": "c1",
|
||||||
|
"audio_path": "x.mp3",
|
||||||
|
"reference_path": ref_path,
|
||||||
|
"duration_sec": 10.0,
|
||||||
|
"entities": ENTITIES,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_entity_retention_full_with_rules(self, tmp_path):
|
||||||
|
ref = tmp_path / "ref.txt"
|
||||||
|
ref.write_text(REF_TEXT, encoding="utf-8")
|
||||||
|
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
|
||||||
|
settings = Settings(_env_file=None, post_mode="rules", post_enabled=True)
|
||||||
|
agg = _run_model(
|
||||||
|
owner,
|
||||||
|
[self._clip(str(ref))],
|
||||||
|
"large-v3-turbo",
|
||||||
|
"cpu",
|
||||||
|
"int8",
|
||||||
|
1,
|
||||||
|
[],
|
||||||
|
self._report(),
|
||||||
|
settings,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert agg["summary"]["entity_retention"] == 1.0
|
||||||
|
assert agg["summary"]["failure_rate"] == 0.0
|
||||||
|
|
||||||
|
def test_glossary_raises_entity_retention(self, tmp_path):
|
||||||
|
ref = tmp_path / "ref.txt"
|
||||||
|
ref.write_text(REF_TEXT, encoding="utf-8")
|
||||||
|
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
|
||||||
|
settings = Settings(_env_file=None, post_mode="glossary", post_enabled=True)
|
||||||
|
agg = _run_model(
|
||||||
|
owner,
|
||||||
|
[self._clip(str(ref))],
|
||||||
|
"large-v3-turbo",
|
||||||
|
"cpu",
|
||||||
|
"int8",
|
||||||
|
1,
|
||||||
|
[],
|
||||||
|
self._report(),
|
||||||
|
settings,
|
||||||
|
{"BLM": "vLLM"},
|
||||||
|
)
|
||||||
|
assert agg["summary"]["entity_retention"] == 1.0
|
||||||
|
|
||||||
|
def test_raw_text_fails_entity_retention_without_postprocess(self, tmp_path):
|
||||||
|
# 후처리 없이 raw 전사("BLM")를 측정하면 vLLM 엔티티가 보존되지 않는다
|
||||||
|
ref = tmp_path / "ref.txt"
|
||||||
|
ref.write_text(REF_TEXT, encoding="utf-8")
|
||||||
|
owner = _FakeOwner(["오늘은 BLM 서버를 Kubernetes 클러스터에 배포합니다"])
|
||||||
|
settings = Settings(_env_file=None, post_mode="none", post_enabled=False)
|
||||||
|
agg = _run_model(
|
||||||
|
owner,
|
||||||
|
[self._clip(str(ref))],
|
||||||
|
"large-v3-turbo",
|
||||||
|
"cpu",
|
||||||
|
"int8",
|
||||||
|
1,
|
||||||
|
[],
|
||||||
|
self._report(),
|
||||||
|
settings,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert agg["summary"]["entity_retention"] < 1.0
|
||||||
@@ -43,6 +43,25 @@ class TestRules:
|
|||||||
out = apply_rules(segs)
|
out = apply_rules(segs)
|
||||||
assert "vLLM" in out["segments"][0].text
|
assert "vLLM" in out["segments"][0].text
|
||||||
|
|
||||||
|
def test_vllm_restored_from_blm(self):
|
||||||
|
# GPU 실전에서 재현된 오인식: vLLM → BLM
|
||||||
|
segs = _segments(["오늘은 BLM 서버를 배포합니다"])
|
||||||
|
out = apply_rules(segs)
|
||||||
|
assert "vLLM" in out["segments"][0].text
|
||||||
|
assert "BLM" not in out["segments"][0].text
|
||||||
|
|
||||||
|
def test_blm_boundary_required(self):
|
||||||
|
# 단어 경계가 없으면 교정하지 않는다 (부분 문자열 보호)
|
||||||
|
segs = _segments(["sublm 단어"])
|
||||||
|
out = apply_rules(segs)
|
||||||
|
assert "sublm" in out["segments"][0].text
|
||||||
|
assert "vLLM" not in out["segments"][0].text
|
||||||
|
|
||||||
|
def test_blm_case_insensitive(self):
|
||||||
|
segs = _segments(["blm 서버"])
|
||||||
|
out = apply_rules(segs)
|
||||||
|
assert "vLLM" in out["segments"][0].text
|
||||||
|
|
||||||
def test_whitespace_collapse(self):
|
def test_whitespace_collapse(self):
|
||||||
segs = _segments(["오늘 API 서버"])
|
segs = _segments(["오늘 API 서버"])
|
||||||
out = apply_rules(segs)
|
out = apply_rules(segs)
|
||||||
|
|||||||
Reference in New Issue
Block a user