feat: implement full-platform STT API (v2.3 consensus plan)
Batch+realtime transcription API: faster-whisper engine w/ EngineOwner (single GPU owner, OOM downgrade chain, persisted attempted_profiles), hardware-adaptive device manager (T0-T3 VRAM tiers), Redis/in-proc job queue w/ leases + crash recovery, postprocess (glossary/rules/LLM w/ egress guard), privacy-first result store (UUID keys, source deleted after transcribe), retention sweeper, API-key auth (HMAC digests, scopes, job ownership), WebSocket realtime lane (LocalAgreement), CLI (detect/transcribe/bench/serve/key), Docker, benchmark runner. 127 mock-based tests pass; ruff clean. Includes verification checklist and autoplan review notes.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"""공유 테스트 픽스처.
|
||||
|
||||
모든 테스트는 GPU/모델/ffmpeg 없이 mock 기반으로 동작한다:
|
||||
- ``FakeEngineOwner``: 세그먼트를 즉시 생성 (실제 decode 없음)
|
||||
- ``FakeIngestor``: ffprobe/ffmpeg 대체
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.config import Settings
|
||||
|
||||
|
||||
class FakeSegments:
|
||||
"""세그먼트 목록을 반복하는 mock — 취소 시 중단."""
|
||||
|
||||
def __init__(self, segments: list[dict], cancel_on: callable | None = None) -> None:
|
||||
self._segs = iter(segments)
|
||||
self._cancel_on = cancel_on or (lambda: False)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if self._cancel_on():
|
||||
raise StopIteration
|
||||
return next(self._segs)
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
def __init__(self, segments: list[dict], *, fail_on: list[str] | None = None) -> None:
|
||||
self.segments = segments
|
||||
self.fail_on = fail_on or []
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None) -> object:
|
||||
self.calls.append({"audio": audio_path, "options": options})
|
||||
# 실제 faster-whisper를 import하지 않는 mock
|
||||
return type(
|
||||
"Outcome",
|
||||
(),
|
||||
{
|
||||
"segments": FakeSegments(self.segments, cancel_on=should_cancel),
|
||||
"info": {"language": "ko"},
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
class FakeEngineOwner:
|
||||
"""EngineOwner mock — 프로파일 강등/캡을 검증 가능하게 함."""
|
||||
|
||||
def __init__(
|
||||
self, engine: FakeEngine | None = None, segments: list[dict] | None = None
|
||||
) -> None:
|
||||
self.engine = engine or FakeEngine(segments or _default_segments())
|
||||
self.last_request = None
|
||||
|
||||
def transcribe(self, req) -> dict:
|
||||
self.last_request = req
|
||||
# 실제 EngineOwner처럼 첫 시도 프로파일을 기록 (downgrade_attempts 계산용)
|
||||
req.attempted_profiles.append(f"{req.options.device}/{req.options.compute_type or 'int8'}")
|
||||
outcome = self.engine.transcribe(
|
||||
req.audio_path, req.options, should_cancel=req.should_cancel
|
||||
)
|
||||
return {
|
||||
"segments": outcome.segments,
|
||||
"info": outcome.info,
|
||||
"device": req.options.device if req.options.device != "auto" else "cpu",
|
||||
"compute_type": req.options.compute_type or "int8",
|
||||
"attempted_profiles": list(req.attempted_profiles),
|
||||
}
|
||||
|
||||
def emit_hypothesis(self, pcm_chunk: bytes) -> dict:
|
||||
return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000}
|
||||
|
||||
def unload_all(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _default_segments() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"index": 0,
|
||||
"start": 0.0,
|
||||
"end": 2.5,
|
||||
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
|
||||
"avg_logprob": -0.2,
|
||||
"no_speech_prob": 0.01,
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"start": 2.5,
|
||||
"end": 4.0,
|
||||
"text": "Kubernetes 클러스터에 배포합니다.",
|
||||
"avg_logprob": -0.3,
|
||||
"no_speech_prob": 0.02,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class FakeIngestor:
|
||||
"""ffprobe/ffmpeg 없는 mock ingestor."""
|
||||
|
||||
def __init__(self, duration_sec: float = 10.0, codec: str = "mp3") -> None:
|
||||
self.duration_sec = duration_sec
|
||||
self.codec = codec
|
||||
self.cancelled = False
|
||||
|
||||
def ingest(self, source: Path, *, should_cancel=None):
|
||||
if should_cancel and should_cancel():
|
||||
from luke_scribe.errors import CancelledError
|
||||
|
||||
self.cancelled = True
|
||||
raise CancelledError("취소됨")
|
||||
from luke_scribe.audio.ingest import IngestResult, ProbeResult
|
||||
from luke_scribe.results.models import NormalizedAudio
|
||||
|
||||
class _Cleanup:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
return IngestResult(
|
||||
normalized_path="/tmp/fake-normalized.wav",
|
||||
normalized=NormalizedAudio(duration_sec=self.duration_sec),
|
||||
probe=ProbeResult(duration_sec=self.duration_sec, codec=self.codec, size_bytes=100),
|
||||
temp_dir="/tmp/fake-tmp",
|
||||
cleanup=lambda: None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
results_dir=str(tmp_path / "results"),
|
||||
api_key_file=str(tmp_path / "api_keys.json"),
|
||||
queue_backend="inproc",
|
||||
model_cache_dir=None,
|
||||
max_queue=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_owner() -> FakeEngineOwner:
|
||||
return FakeEngineOwner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ingestor() -> FakeIngestor:
|
||||
return FakeIngestor()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transcript_result() -> dict:
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"status": "completed",
|
||||
"source": {"name": "test.mp3", "codec": "mp3", "size_bytes": 100},
|
||||
"normalized_audio": {
|
||||
"duration_sec": 4.0,
|
||||
"audio_format": "pcm_s16le",
|
||||
"sample_rate": 16000,
|
||||
"channels": 1,
|
||||
},
|
||||
"execution": {
|
||||
"model": "large-v3-turbo",
|
||||
"device": "cpu",
|
||||
"compute_type": "int8",
|
||||
"language_requested": "ko",
|
||||
},
|
||||
"timings": {"transcription_sec": 0.5, "rtf": 0.125},
|
||||
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
|
||||
"segments": [
|
||||
{
|
||||
"index": 0,
|
||||
"start": 0.0,
|
||||
"end": 2.5,
|
||||
"text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.",
|
||||
"avg_logprob": -0.2,
|
||||
"no_speech_prob": 0.01,
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""API 통합 테스트 — TestClient 기반 인증/업로드/소유권/큐 흐름.
|
||||
|
||||
실제 모델·Redis 없이 in-proc 브로커 + mock 결과로 동작한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from luke_scribe.api.app import create_app
|
||||
from luke_scribe.config import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
results_dir=str(tmp_path / "results"),
|
||||
api_key_file=str(tmp_path / "api_keys.json"),
|
||||
api_keys="key-transcribe,key-admin:admin,transcribe",
|
||||
queue_backend="inproc",
|
||||
model_cache_dir=None,
|
||||
tunnel="none",
|
||||
max_queue=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings: Settings) -> TestClient:
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health_public(self, client: TestClient):
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_missing_key_rejected(self, client: TestClient):
|
||||
r = client.get("/v1/jobs")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_bad_key_rejected(self, client: TestClient):
|
||||
r = client.get("/v1/jobs", headers={"X-API-Key": "wrong-key"})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_admin_scope_enforced(self, client: TestClient):
|
||||
r = client.get("/v1/system", headers={"X-API-Key": "key-transcribe"})
|
||||
assert r.status_code == 403
|
||||
r2 = client.get("/v1/system", headers={"X-API-Key": "key-admin"})
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
assert body["capability_tier"] in ("T0", "T1", "T2", "T3")
|
||||
assert body["queue_depth"] == 0
|
||||
|
||||
|
||||
class TestJobs:
|
||||
def test_create_get_cancel_flow(self, client: TestClient, settings: Settings):
|
||||
headers = {"X-API-Key": "key-transcribe"}
|
||||
r = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("meeting.mp3", b"fake-audio-bytes", "audio/mpeg")},
|
||||
data={"options": json.dumps({"language": "ko", "model": "large-v3-turbo"})},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 202, r.text
|
||||
body = r.json()
|
||||
job_id = body["job_id"]
|
||||
assert body["status"] == "queued"
|
||||
assert body["queue_position"] == 0
|
||||
|
||||
# 조회
|
||||
r = client.get(f"/v1/jobs/{job_id}", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "queued"
|
||||
|
||||
# 결과는 아직 없음
|
||||
r = client.get(f"/v1/jobs/{job_id}/result", headers=headers)
|
||||
assert r.status_code == 409
|
||||
|
||||
# 취소
|
||||
r = client.delete(f"/v1/jobs/{job_id}", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "cancelled"
|
||||
|
||||
def test_ownership_enforced(self, client: TestClient):
|
||||
"""Eng P14: 다른 키가 만든 job 조회/취소 불가."""
|
||||
a = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("a.mp3", b"x", "audio/mpeg")},
|
||||
data={"options": "{}"},
|
||||
headers={"X-API-Key": "key-transcribe"},
|
||||
)
|
||||
job_id = a.json()["job_id"]
|
||||
r = client.get(f"/v1/jobs/{job_id}", headers={"X-API-Key": "key-admin"})
|
||||
assert r.status_code == 403
|
||||
r = client.delete(f"/v1/jobs/{job_id}", headers={"X-API-Key": "key-admin"})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_unknown_job_404(self, client: TestClient):
|
||||
r = client.get(
|
||||
"/v1/jobs/00000000-0000-0000-0000-000000000000",
|
||||
headers={"X-API-Key": "key-transcribe"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_bad_options_422(self, client: TestClient):
|
||||
r = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("a.mp3", b"x", "audio/mpeg")},
|
||||
data={"options": "not json {"},
|
||||
headers={"X-API-Key": "key-transcribe"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_bad_format_422(self, client: TestClient, settings: Settings):
|
||||
headers = {"X-API-Key": "key-transcribe"}
|
||||
r = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("a.mp3", b"x", "audio/mpeg")},
|
||||
data={"options": "{}"},
|
||||
headers=headers,
|
||||
)
|
||||
job_id = r.json()["job_id"]
|
||||
r2 = client.get(f"/v1/jobs/{job_id}/result?format=docx", headers=headers)
|
||||
assert r2.status_code == 422
|
||||
|
||||
def test_oversize_413(self, client: TestClient, settings: Settings):
|
||||
settings.max_upload_bytes = 10
|
||||
r = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("big.mp3", b"x" * 100, "audio/mpeg")},
|
||||
data={"options": "{}"},
|
||||
headers={"X-API-Key": "key-transcribe"},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
class TestQueueFull:
|
||||
def test_queue_full_429(self, client: TestClient, settings: Settings):
|
||||
settings.max_queue = 1
|
||||
headers = {"X-API-Key": "key-transcribe"}
|
||||
r1 = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("a.mp3", b"x", "audio/mpeg")},
|
||||
data={"options": "{}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r1.status_code == 202
|
||||
r2 = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("b.mp3", b"x", "audio/mpeg")},
|
||||
data={"options": "{}"},
|
||||
headers=headers,
|
||||
)
|
||||
assert r2.status_code == 429
|
||||
assert "Retry-After" in r2.headers
|
||||
|
||||
|
||||
class TestResultEndpoint:
|
||||
def test_result_after_processing(self, client: TestClient, settings: Settings, tmp_path):
|
||||
"""워커가 결과를 저장한 뒤 result 엔드포인트가 JSON/SRT를 반환."""
|
||||
from luke_scribe.results.models import TranscriptResult
|
||||
from luke_scribe.results.store import ResultStore
|
||||
|
||||
headers = {"X-API-Key": "key-transcribe"}
|
||||
r = client.post(
|
||||
"/v1/jobs",
|
||||
files={"file": ("a.mp3", b"x", "audio/mpeg")},
|
||||
data={"options": "{}"},
|
||||
headers=headers,
|
||||
)
|
||||
job_id = r.json()["job_id"]
|
||||
|
||||
# 워커 대신 결과를 직접 기록 (모델 없이 mock)
|
||||
result = TranscriptResult(
|
||||
status="completed",
|
||||
source={"name": "a.mp3", "codec": "mp3", "size_bytes": 1},
|
||||
normalized_audio={"duration_sec": 2.0},
|
||||
execution={"model": "large-v3-turbo", "device": "cpu", "compute_type": "int8"},
|
||||
text="테스트 전사 결과",
|
||||
segments=[{"index": 0, "start": 0.0, "end": 1.0, "text": "테스트 전사 결과"}],
|
||||
)
|
||||
from luke_scribe.jobqueue.jobs import JobStatus
|
||||
|
||||
store = ResultStore(settings.results_dir)
|
||||
store.write_result(job_id, result)
|
||||
# 상태를 completed로 (워커가 했을 일) — queued→processing→completed
|
||||
job = client.app.state.broker.get(job_id)
|
||||
job.transition(JobStatus.PROCESSING)
|
||||
job.transition(JobStatus.COMPLETED)
|
||||
client.app.state.broker.save_meta(job)
|
||||
|
||||
r_json = client.get(f"/v1/jobs/{job_id}/result?format=json", headers=headers)
|
||||
assert r_json.status_code == 200
|
||||
assert json.loads(r_json.json()["content"])["text"] == "테스트 전사 결과"
|
||||
|
||||
r_srt = client.get(f"/v1/jobs/{job_id}/result?format=srt", headers=headers)
|
||||
assert r_srt.status_code == 200
|
||||
assert "WEBVTT" not in r_srt.json()["content"] # srt 포맷
|
||||
assert "--> " in r_srt.json()["content"]
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Worker 통합 테스트 — 큐 → 클레임 → 전사 → 결과 저장 (전부 mock)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from luke_scribe.jobqueue.broker import InProcBroker
|
||||
from luke_scribe.jobqueue.jobs import Job, JobStatus
|
||||
from luke_scribe.jobqueue.worker import Worker, WorkerCallbacks
|
||||
from luke_scribe.results.store import ResultStore
|
||||
|
||||
from ..conftest import FakeEngineOwner, FakeIngestor
|
||||
|
||||
|
||||
def _job(**kw) -> Job:
|
||||
defaults = dict(
|
||||
type="file",
|
||||
lane="batch",
|
||||
options={
|
||||
"model": "large-v3-turbo",
|
||||
"language": "ko",
|
||||
"device": "cpu",
|
||||
"compute_type": "int8",
|
||||
},
|
||||
source_path="/tmp/fake-source.mp3",
|
||||
source_name="fake-source.mp3",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return Job(**defaults)
|
||||
|
||||
|
||||
class TestWorkerLifecycle:
|
||||
def test_complete_flow(self, settings, tmp_path):
|
||||
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
owner = FakeEngineOwner()
|
||||
done: list[str] = []
|
||||
|
||||
def on_done(job, result):
|
||||
done.append(job.id)
|
||||
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=owner,
|
||||
ingestor=FakeIngestor(duration_sec=4.0),
|
||||
callbacks=WorkerCallbacks(on_job_done=on_done),
|
||||
)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
|
||||
assert broker.get(job.id).status == JobStatus.COMPLETED
|
||||
assert done == [job.id]
|
||||
result = store.read_result(job.id)
|
||||
assert result is not None
|
||||
assert result.status == "completed"
|
||||
assert result.text # 세그먼트 텍스트 연결
|
||||
assert result.execution is not None
|
||||
assert result.execution.device == "cpu"
|
||||
# 강등 없음
|
||||
assert result.execution.downgrade_attempts == 0
|
||||
|
||||
def test_meta_written(self, settings, tmp_path):
|
||||
"""완료 시 job 메타(완료 시각 포함)가 결과 디렉터리에 저장."""
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=FakeEngineOwner(),
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
import json
|
||||
|
||||
meta = json.loads(
|
||||
tmp_path.joinpath("results", job.id, "meta.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert meta["status"] == "completed"
|
||||
assert meta["completed_at"] is not None
|
||||
|
||||
def test_transcription_failure_marks_failed(self, settings, tmp_path):
|
||||
"""엔진 오류 → failed + 오류 메타 저장."""
|
||||
|
||||
class BoomEngine:
|
||||
def transcribe(self, req):
|
||||
raise RuntimeError("engine exploded")
|
||||
|
||||
class BoomOwner:
|
||||
def __init__(self):
|
||||
self.engine = BoomEngine()
|
||||
|
||||
def transcribe(self, req):
|
||||
return self.engine.transcribe(req)
|
||||
|
||||
def unload_all(self):
|
||||
pass
|
||||
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=BoomOwner(),
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
assert broker.get(job.id).status == JobStatus.FAILED
|
||||
|
||||
def test_cancel_before_claim(self, settings, tmp_path):
|
||||
"""큐에서 취소된 job은 워커가 claim하지 않고 cancelled 처리."""
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=FakeEngineOwner(),
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
broker.cancel(job.id)
|
||||
worker.drain()
|
||||
assert broker.get(job.id).status == JobStatus.CANCELLED
|
||||
|
||||
def test_cancel_during_processing(self, settings, tmp_path):
|
||||
"""처리 중 취소 → 세그먼트 경계에서 CancelledError → cancelled (Eng P4)."""
|
||||
|
||||
class CancelAfterFirstOwner:
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
self.segments = [
|
||||
{"index": 0, "start": 0.0, "end": 1.0, "text": "첫 세그먼트"},
|
||||
{"index": 1, "start": 1.0, "end": 2.0, "text": "두 번째"},
|
||||
]
|
||||
|
||||
def transcribe(self, req):
|
||||
# worker는 token 미전달, 취소는 세그먼트 경계에서 검사됨
|
||||
return {
|
||||
"segments": iter(
|
||||
[
|
||||
{"index": 0, "start": 0.0, "end": 1.0, "text": "첫 세그먼트"},
|
||||
{"index": 1, "start": 1.0, "end": 2.0, "text": "두 번째"},
|
||||
]
|
||||
),
|
||||
"info": {"language": "ko"},
|
||||
"device": "cpu",
|
||||
"compute_type": "int8",
|
||||
"attempted_profiles": ["cpu/int8"],
|
||||
}
|
||||
|
||||
# 세그먼트를 먼저 소비한 뒤 취소하도록: 첫 next 후 should_cancel True
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
owner = CancelAfterFirstOwner()
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=owner,
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
# 처리 시작 전에 취소 요청을 미리 걸어 두면 claim 단계에서 cancelled 처리됨.
|
||||
# 대신 처리 중 취소를 시뮬레이션: claim 직후 cancel 호출
|
||||
claimed = broker.claim_next("w1", 60.0)
|
||||
broker.cancel(claimed.id)
|
||||
# 워커는 already-processing job을 직접 처리
|
||||
worker._process(claimed)
|
||||
assert broker.get(job.id).status == JobStatus.CANCELLED
|
||||
|
||||
|
||||
class TestPrivacyFirst:
|
||||
def test_source_deleted_after_completion(self, settings, tmp_path):
|
||||
"""plan §3.7e/§6.1: 전사 완료 후 업로드 원본 오디오 즉시 삭제."""
|
||||
from luke_scribe.results.store import ResultStore
|
||||
|
||||
settings.delete_source = True
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
broker = InProcBroker(settings)
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=FakeEngineOwner(),
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job()
|
||||
# source가 store에 저장된 파일을 가리키도록
|
||||
src = store.source_path_for(job.id, "meeting.mp3")
|
||||
src.write_bytes(b"fake-audio")
|
||||
job.source_path = str(src)
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
assert not src.exists() # 원본 삭제
|
||||
assert store.read_result(job.id) is not None # 결과는 보존
|
||||
|
||||
def test_source_kept_when_delete_source_false(self, settings, tmp_path):
|
||||
from luke_scribe.results.store import ResultStore
|
||||
|
||||
settings.delete_source = False
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
broker = InProcBroker(settings)
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=FakeEngineOwner(),
|
||||
ingestor=FakeIngestor(),
|
||||
)
|
||||
job = _job()
|
||||
src = store.source_path_for(job.id, "meeting.mp3")
|
||||
src.write_bytes(b"fake-audio")
|
||||
job.source_path = str(src)
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
assert src.exists()
|
||||
|
||||
|
||||
class TestWorkerProgress:
|
||||
def test_progress_emitted(self, settings, tmp_path):
|
||||
broker = InProcBroker(settings)
|
||||
store = ResultStore(str(tmp_path / "results"))
|
||||
worker = Worker(
|
||||
settings=settings,
|
||||
broker=broker,
|
||||
store=store,
|
||||
owner=FakeEngineOwner(segments=[{"index": 0, "start": 0.0, "end": 5.0, "text": "x"}]),
|
||||
ingestor=FakeIngestor(duration_sec=10.0),
|
||||
)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
worker.drain()
|
||||
final = broker.get(job.id)
|
||||
assert final.progress is not None
|
||||
assert final.progress <= 1.0
|
||||
assert final.processed_sec is not None
|
||||
|
||||
|
||||
class TestCrashRecovery:
|
||||
def test_stale_processing_recovered(self, settings, tmp_path):
|
||||
"""Eng P11: 워커 크래시(리스 만료) → 스타트업 reconciler가 failed 처리."""
|
||||
broker = InProcBroker(settings)
|
||||
job = _job()
|
||||
broker.enqueue(job)
|
||||
claimed = broker.claim_next("w1", 60.0)
|
||||
# 리스 만료 시뮬레이션
|
||||
claimed.lease_expires_at = time.time() - 10
|
||||
broker.save_meta(claimed)
|
||||
worker = Worker(
|
||||
settings=settings, broker=broker, store=ResultStore(str(tmp_path / "results"))
|
||||
)
|
||||
reclaimed = worker.start_reconcile()
|
||||
assert job.id in reclaimed
|
||||
assert broker.get(job.id).status == JobStatus.FAILED
|
||||
assert broker.get(job.id).error_code == "worker_crash"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Device Manager 단위 테스트 — 정밀도 결정, 능력 등급, override."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.devices.manager import DeviceManager
|
||||
from luke_scribe.devices.vram_probe import GpuInfo, SystemInfo
|
||||
from luke_scribe.errors import DeviceUnavailable
|
||||
|
||||
|
||||
def _sys(gpus: list[GpuInfo], ram_mb: int = 16384) -> SystemInfo:
|
||||
return SystemInfo(
|
||||
cpu_count=8, ram_total_mb=ram_mb, ram_free_mb=ram_mb // 2, disk_free_mb=50000, gpus=gpus
|
||||
)
|
||||
|
||||
|
||||
def _gpu(name: str, cc: str, total_mb: int, free_mb: int, index: int = 0) -> GpuInfo:
|
||||
return GpuInfo(
|
||||
index=index,
|
||||
name=name,
|
||||
compute_capability=cc,
|
||||
vram_total_mb=total_mb,
|
||||
vram_free_mb=free_mb,
|
||||
driver_version="550",
|
||||
runtime_cuda=None,
|
||||
)
|
||||
|
||||
|
||||
class TestPrecision:
|
||||
def test_cc_7_plus_12gb_free_float16(self):
|
||||
m = DeviceManager(_sys([_gpu("T4", "7.5", 15360, 14820)]))
|
||||
p = m.detect()
|
||||
assert p.selected_compute_type == "float16"
|
||||
assert p.selected_device == "cuda:0"
|
||||
|
||||
def test_cc_7_plus_low_free_int8_float16(self):
|
||||
m = DeviceManager(_sys([_gpu("L4", "8.9", 24576, 8000)]))
|
||||
p = m.detect()
|
||||
assert p.selected_compute_type == "int8_float16"
|
||||
|
||||
def test_pascal_1050_int8(self):
|
||||
m = DeviceManager(_sys([_gpu("GTX 1050", "6.1", 4096, 3584)]))
|
||||
p = m.detect()
|
||||
assert p.selected_compute_type == "int8"
|
||||
|
||||
def test_no_gpu_cpu_int8(self):
|
||||
m = DeviceManager(_sys([]))
|
||||
p = m.detect()
|
||||
assert p.selected_device == "cpu"
|
||||
assert p.selected_compute_type == "int8"
|
||||
assert p.capability_tier == "T0"
|
||||
assert any("CPU" in w for w in p.warnings)
|
||||
|
||||
|
||||
class TestCapabilityTier:
|
||||
def test_t3_both_models(self):
|
||||
m = DeviceManager(_sys([_gpu("A100", "9.0", 81920, 70000)]))
|
||||
p = m.detect()
|
||||
assert p.capability_tier == "T3"
|
||||
assert p.workers >= 1
|
||||
|
||||
def test_t1_small_gpu(self):
|
||||
m = DeviceManager(_sys([_gpu("RTX 3060", "8.6", 12288, 10000)]))
|
||||
p = m.detect()
|
||||
assert p.capability_tier in ("T1", "T2", "T3")
|
||||
|
||||
|
||||
class TestOverrides:
|
||||
def test_explicit_cuda_missing_fails(self):
|
||||
m = DeviceManager(_sys([]))
|
||||
with pytest.raises(DeviceUnavailable):
|
||||
m.detect(device="cuda:0")
|
||||
|
||||
def test_explicit_cuda_ok(self):
|
||||
m = DeviceManager(_sys([_gpu("T4", "7.5", 15360, 14820)]))
|
||||
p = m.detect(device="cuda:0", compute_type="float16")
|
||||
assert p.selected_device == "cuda:0"
|
||||
assert p.selection_source == "explicit"
|
||||
assert p.selected_compute_type == "float16"
|
||||
|
||||
def test_explicit_cpu(self):
|
||||
m = DeviceManager(_sys([_gpu("T4", "7.5", 15360, 14820)]))
|
||||
p = m.detect(device="cpu")
|
||||
assert p.selected_device == "cpu"
|
||||
|
||||
def test_explicit_bad_device(self):
|
||||
m = DeviceManager(_sys([]))
|
||||
with pytest.raises(DeviceUnavailable):
|
||||
m.detect(device="tpu:0")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""EngineOwner 단위 테스트 — OOM 강등 체인, attempted_profiles 영속화, 우선순위."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.engine.base import TranscriptionOptions
|
||||
from luke_scribe.engine.owner import MAX_DOWNGRADES, EngineOwner, InferenceRequest
|
||||
from luke_scribe.errors import OutOfMemory
|
||||
|
||||
|
||||
class OOMFakeEngine:
|
||||
"""첫 N번 호출에서 OOM, 이후 성공하는 mock."""
|
||||
|
||||
def __init__(self, oom_calls: int = 0) -> None:
|
||||
self.oom_calls = oom_calls
|
||||
self.calls: list[dict] = []
|
||||
self.closed = False
|
||||
|
||||
def transcribe(self, audio_path, options, should_cancel=None, download_progress=None):
|
||||
self.calls.append({"device": options.device, "compute_type": options.compute_type})
|
||||
if len(self.calls) <= self.oom_calls:
|
||||
raise OutOfMemory("CUDA OOM (mock)")
|
||||
return type("O", (), {"segments": iter([]), "info": {}})()
|
||||
|
||||
def unload_all(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _opts(**kw) -> TranscriptionOptions:
|
||||
kw.setdefault("model", "large-v3-turbo")
|
||||
kw.setdefault("device", "cuda:0")
|
||||
kw.setdefault("compute_type", "float16")
|
||||
return TranscriptionOptions(**kw)
|
||||
|
||||
|
||||
def test_no_downgrade_on_success():
|
||||
owner = EngineOwner.__new__(EngineOwner)
|
||||
owner.settings = None
|
||||
owner._engine = OOMFakeEngine(oom_calls=0)
|
||||
owner._lock = __import__("threading").Lock()
|
||||
owner._realtime_priority = __import__("threading").Lock()
|
||||
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
|
||||
req = InferenceRequest(audio_path="/tmp/x.wav", options=_opts())
|
||||
out = owner.transcribe(req)
|
||||
assert out["compute_type"] == "float16"
|
||||
assert len(out["attempted_profiles"]) == 1
|
||||
|
||||
|
||||
def test_downgrade_chain_on_oom():
|
||||
engine = OOMFakeEngine(oom_calls=1) # 첫 프로파일에서 OOM
|
||||
owner = EngineOwner.__new__(EngineOwner)
|
||||
owner.settings = None
|
||||
owner._engine = engine
|
||||
owner._lock = __import__("threading").Lock()
|
||||
owner._realtime_priority = __import__("threading").Lock()
|
||||
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
|
||||
req = InferenceRequest(audio_path="/tmp/x.wav", options=_opts())
|
||||
out = owner.transcribe(req)
|
||||
# float16 → int8_float16 (강등 1회)
|
||||
assert out["compute_type"] == "int8_float16"
|
||||
assert len(out["attempted_profiles"]) == 2
|
||||
|
||||
|
||||
def test_downgrade_capped_at_max():
|
||||
engine = OOMFakeEngine(oom_calls=99) # 항상 OOM
|
||||
owner = EngineOwner.__new__(EngineOwner)
|
||||
owner.settings = None
|
||||
owner._engine = engine
|
||||
owner._lock = __import__("threading").Lock()
|
||||
owner._realtime_priority = __import__("threading").Lock()
|
||||
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
|
||||
req = InferenceRequest(audio_path="/tmp/x.wav", options=_opts())
|
||||
with pytest.raises(OutOfMemory):
|
||||
owner.transcribe(req)
|
||||
# 강등 체인은 cpu까지 포함하지만 시도는 MAX_DOWNGRADES+1 이내
|
||||
assert len(engine.calls) <= MAX_DOWNGRADES + 1
|
||||
|
||||
|
||||
def test_attempted_profiles_persisted_for_retry():
|
||||
"""재큐(retry) 시 attempted_profiles가 유지되어 총 시도가 캡된다."""
|
||||
engine = OOMFakeEngine(oom_calls=0)
|
||||
owner = EngineOwner.__new__(EngineOwner)
|
||||
owner.settings = None
|
||||
owner._engine = engine
|
||||
owner._lock = __import__("threading").Lock()
|
||||
owner._realtime_priority = __import__("threading").Lock()
|
||||
owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0}
|
||||
# 이미 2회 시도한 이력이 Job에 영속화된 상황 → 재시도는 즉시 실패해야 함
|
||||
req = InferenceRequest(
|
||||
audio_path="/tmp/x.wav", options=_opts(), attempted_profiles=["float16", "int8_float16"]
|
||||
)
|
||||
with pytest.raises(OutOfMemory):
|
||||
owner.transcribe(req)
|
||||
assert len(engine.calls) == 0 # 새 시도 없음
|
||||
@@ -0,0 +1,85 @@
|
||||
"""포맷 렌더링 (json/txt/srt/vtt) + 결과 스키마 계약 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.results.formats import render
|
||||
from luke_scribe.results.models import TranscriptResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def result(transcript_result: dict) -> TranscriptResult:
|
||||
return TranscriptResult.model_validate(transcript_result)
|
||||
|
||||
|
||||
class TestFormats:
|
||||
def test_json_roundtrip(self, result: TranscriptResult):
|
||||
out = render(result, "json")
|
||||
data = json.loads(out)
|
||||
assert data["schema_version"] == "1.0"
|
||||
assert data["segments"][0]["text"].startswith("오늘")
|
||||
assert data["text"] # 계약: text = segment 텍스트 연결
|
||||
|
||||
def test_txt(self, result: TranscriptResult):
|
||||
out = render(result, "txt")
|
||||
assert "오늘 API 서버에서" in out
|
||||
assert out.endswith("\n")
|
||||
|
||||
def test_srt(self, result: TranscriptResult):
|
||||
out = render(result, "srt")
|
||||
assert "1" in out
|
||||
assert "00:00:00,000 --> 00:00:02,500" in out
|
||||
# 세그먼트 순번 + 타임스탬프 + 텍스트
|
||||
assert "오늘 API 서버에서" in out
|
||||
|
||||
def test_vtt(self, result: TranscriptResult):
|
||||
out = render(result, "vtt")
|
||||
assert out.startswith("WEBVTT")
|
||||
assert "00:00:00.000 --> 00:00:02.500" in out
|
||||
|
||||
def test_unsupported_format(self, result: TranscriptResult):
|
||||
with pytest.raises(ValueError):
|
||||
render(result, "xml") # type: ignore[arg-type]
|
||||
|
||||
def test_empty_segments_srt(self):
|
||||
r = TranscriptResult(status="completed", segments=[], text="")
|
||||
out = render(r, "srt")
|
||||
assert out == ""
|
||||
|
||||
|
||||
class TestSchemaContract:
|
||||
def test_status_completed_no_error(self, transcript_result: dict):
|
||||
result = TranscriptResult.model_validate(transcript_result)
|
||||
assert result.error is None
|
||||
|
||||
def test_failed_gets_default_error(self):
|
||||
r = TranscriptResult(status="failed", text="")
|
||||
assert r.error is not None
|
||||
assert r.error.code == "transcription_failed"
|
||||
|
||||
def test_cancelled_gets_default_error(self):
|
||||
r = TranscriptResult(status="cancelled", text="")
|
||||
assert r.error is not None
|
||||
assert r.error.code == "cancelled"
|
||||
|
||||
def test_completed_error_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
TranscriptResult(status="completed", error={"code": "x", "message": "y"}, text="")
|
||||
|
||||
def test_segment_index_must_be_nonneg(self):
|
||||
with pytest.raises(ValueError):
|
||||
TranscriptResult(
|
||||
status="completed", segments=[{"index": -1, "start": 0, "end": 1, "text": "x"}]
|
||||
)
|
||||
|
||||
def test_duration_gt_zero(self):
|
||||
with pytest.raises(ValueError):
|
||||
TranscriptResult(
|
||||
status="completed",
|
||||
normalized_audio={"duration_sec": 0},
|
||||
segments=[],
|
||||
text="",
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""AudioIngestor — ffprobe 검증, 상한(크기/길이), 취소·임시파일 정리 테스트.
|
||||
|
||||
ffprobe/ffmpeg 바이너리 없이 subprocess를 mock해서 동작을 검증한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.audio.ingest import AudioIngestor
|
||||
from luke_scribe.config import Settings
|
||||
from luke_scribe.errors import (
|
||||
AudioProbeFailed,
|
||||
CancelledError,
|
||||
InvalidInput,
|
||||
UnsupportedInputEnvelope,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
max_duration_sec=14400,
|
||||
max_upload_bytes=2 * 1024 * 1024 * 1024,
|
||||
)
|
||||
|
||||
|
||||
def _fake_probe_json(*, duration: str = "120.0", has_audio: bool = True) -> str:
|
||||
streams = (
|
||||
[{"codec_type": "audio", "codec_name": "mp3"}] if has_audio else [{"codec_type": "video"}]
|
||||
)
|
||||
return json.dumps({"format": {"duration": duration}, "streams": streams})
|
||||
|
||||
|
||||
class FakeSubprocess:
|
||||
"""ffprobe 호출에 대한 확정적 mock."""
|
||||
|
||||
def __init__(
|
||||
self, *, probe_json: str | None = None, probe_rc: int = 0, probe_err: str = ""
|
||||
) -> None:
|
||||
self.probe_json = probe_json
|
||||
self.probe_rc = probe_rc
|
||||
self.probe_err = probe_err
|
||||
self.calls: list[list[str]] = []
|
||||
|
||||
def run(self, cmd, **kwargs):
|
||||
self.calls.append(cmd)
|
||||
if "show_entries" in cmd:
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, self.probe_rc, stdout=self.probe_json or "", stderr=self.probe_err
|
||||
)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout=self.probe_json or "", stderr="")
|
||||
|
||||
|
||||
class FakeStream:
|
||||
"""Popen stdout/stderr 용 — 즉시 EOF."""
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
def test_probe_success(settings: Settings, tmp_path, monkeypatch):
|
||||
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="120.0"))
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
|
||||
src = tmp_path / "a.mp3"
|
||||
src.write_bytes(b"x" * 100)
|
||||
ing = AudioIngestor(settings)
|
||||
probe = ing.probe(src)
|
||||
assert probe.duration_sec == 120.0
|
||||
assert probe.codec == "mp3"
|
||||
|
||||
|
||||
def test_probe_missing_file(settings: Settings, tmp_path):
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(InvalidInput):
|
||||
ing.probe(tmp_path / "nope.mp3")
|
||||
|
||||
|
||||
def test_probe_size_limit(settings: Settings, tmp_path, monkeypatch):
|
||||
settings.max_upload_bytes = 100
|
||||
src = tmp_path / "big.mp3"
|
||||
src.write_bytes(b"x" * 200)
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(UnsupportedInputEnvelope):
|
||||
ing.probe(src)
|
||||
|
||||
|
||||
def test_probe_duration_limit(settings: Settings, tmp_path, monkeypatch):
|
||||
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="999999"))
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
|
||||
src = tmp_path / "long.mp3"
|
||||
src.write_bytes(b"x" * 10)
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(UnsupportedInputEnvelope):
|
||||
ing.probe(src)
|
||||
|
||||
|
||||
def test_probe_no_audio_stream(settings: Settings, tmp_path, monkeypatch):
|
||||
fake = FakeSubprocess(probe_json=_fake_probe_json(has_audio=False))
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
|
||||
src = tmp_path / "video.mp4"
|
||||
src.write_bytes(b"x" * 10)
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(AudioProbeFailed):
|
||||
ing.probe(src)
|
||||
|
||||
|
||||
def test_probe_ffprobe_missing(settings: Settings, tmp_path, monkeypatch):
|
||||
class NoFfprobe:
|
||||
def run(self, cmd, **kwargs):
|
||||
raise FileNotFoundError("ffprobe")
|
||||
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", NoFfprobe())
|
||||
src = tmp_path / "a.mp3"
|
||||
src.write_bytes(b"x")
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(AudioProbeFailed):
|
||||
ing.probe(src)
|
||||
|
||||
|
||||
def test_probe_bad_output(settings: Settings, tmp_path, monkeypatch):
|
||||
fake = FakeSubprocess(probe_json="not-json{")
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess", fake)
|
||||
src = tmp_path / "a.mp3"
|
||||
src.write_bytes(b"x")
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(AudioProbeFailed):
|
||||
ing.probe(src)
|
||||
|
||||
|
||||
class TestIngestCancellation:
|
||||
def test_cancel_before_ingest(self, settings: Settings, tmp_path):
|
||||
src = tmp_path / "a.mp3"
|
||||
src.write_bytes(b"x")
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(CancelledError):
|
||||
ing.ingest(src, should_cancel=lambda: True)
|
||||
|
||||
def test_temp_dir_cleaned_on_cancel(self, settings: Settings, tmp_path, monkeypatch):
|
||||
"""취소/실패 시 임시 디렉터리가 반드시 정리된다 (Eng P1)."""
|
||||
src = tmp_path / "a.mp3"
|
||||
src.write_bytes(b"x")
|
||||
|
||||
class CancellingPopen:
|
||||
"""무한 인코딩 중인 ffmpeg — wait 폴링에서 계속 TimeoutExpired."""
|
||||
|
||||
def __init__(self, cmd, **kwargs):
|
||||
self.stdout = FakeStream()
|
||||
self.stderr = FakeStream()
|
||||
self.pid = 99999999 # 존재하지 않는 pid → _kill_group의 ProcessLookupError 경로
|
||||
|
||||
def wait(self, timeout=None):
|
||||
raise subprocess.TimeoutExpired(cmd=[], timeout=0.5)
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
# ffprobe는 성공, ffmpeg Popen은 취소 폴링 루프에 진입
|
||||
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="60.0"))
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.run", fake.run)
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.Popen", CancellingPopen)
|
||||
monkeypatch.setattr(
|
||||
"luke_scribe.audio.ingest.tempfile.mkdtemp",
|
||||
lambda prefix="": str(tmp_path / "ingest-tmp"),
|
||||
)
|
||||
ing = AudioIngestor(settings)
|
||||
# 초기 검사 2회(프로브 전/후)는 통과, ffmpeg 폴링 루프에서 취소
|
||||
calls = {"n": 0}
|
||||
|
||||
def should_cancel():
|
||||
calls["n"] += 1
|
||||
return calls["n"] > 2
|
||||
|
||||
with pytest.raises(CancelledError):
|
||||
ing.ingest(src, should_cancel=should_cancel)
|
||||
assert not Path(tmp_path / "ingest-tmp").exists()
|
||||
|
||||
def test_temp_dir_cleaned_on_ffmpeg_failure(self, settings: Settings, tmp_path, monkeypatch):
|
||||
src = tmp_path / "a.mp3"
|
||||
src.write_bytes(b"x")
|
||||
|
||||
class FailingFfmpeg:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
self.stdout = FakeStream()
|
||||
self.stderr = FakeStream()
|
||||
self.returncode = 1
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 1
|
||||
|
||||
fake = FakeSubprocess(probe_json=_fake_probe_json(duration="60.0"))
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.Popen", FailingFfmpeg)
|
||||
monkeypatch.setattr("luke_scribe.audio.ingest.subprocess.run", fake.run)
|
||||
monkeypatch.setattr(
|
||||
"luke_scribe.audio.ingest.tempfile.mkdtemp",
|
||||
lambda prefix="": str(tmp_path / "ingest-tmp2"),
|
||||
)
|
||||
ing = AudioIngestor(settings)
|
||||
with pytest.raises(AudioProbeFailed):
|
||||
ing.ingest(src)
|
||||
assert not Path(tmp_path / "ingest-tmp2").exists()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Job 상태 머신 + in-proc 브로커 단위 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.errors import JobAlreadyTerminal, QueueFull
|
||||
from luke_scribe.jobqueue.broker import InProcBroker
|
||||
from luke_scribe.jobqueue.jobs import Job, JobStatus
|
||||
|
||||
|
||||
class TestJobStateMachine:
|
||||
def test_legal_transitions(self):
|
||||
job = Job()
|
||||
assert job.status == JobStatus.QUEUED
|
||||
job.transition(JobStatus.PROCESSING)
|
||||
assert job.status == JobStatus.PROCESSING
|
||||
job.transition(JobStatus.COMPLETED)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
|
||||
def test_illegal_transition_rejected(self):
|
||||
job = Job()
|
||||
job.transition(JobStatus.PROCESSING)
|
||||
job.transition(JobStatus.COMPLETED)
|
||||
with pytest.raises(JobAlreadyTerminal):
|
||||
job.transition(JobStatus.PROCESSING) # terminal에서 되돌아갈 수 없음
|
||||
|
||||
def test_queued_cancel(self):
|
||||
job = Job()
|
||||
job.transition(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
assert job.cancelled_at is not None
|
||||
|
||||
def test_lease_expiry(self):
|
||||
job = Job()
|
||||
job.refresh_lease(60.0, now=1000.0)
|
||||
assert not job.lease_expired(now=1050.0)
|
||||
assert job.lease_expired(now=1100.0)
|
||||
|
||||
def test_attempted_profiles_persisted(self):
|
||||
job = Job(attempted_profiles=["float16", "int8_float16"])
|
||||
assert job.to_meta()["attempted_profiles"] == ["float16", "int8_float16"]
|
||||
restored = Job.from_meta(job.to_meta())
|
||||
assert restored.attempted_profiles == ["float16", "int8_float16"]
|
||||
|
||||
|
||||
class TestInProcBroker:
|
||||
def test_enqueue_claim_complete(self):
|
||||
broker = InProcBroker()
|
||||
job = Job()
|
||||
broker.enqueue(job)
|
||||
claimed = broker.claim_next("w1", 60.0)
|
||||
assert claimed is not None
|
||||
assert claimed.status == JobStatus.PROCESSING
|
||||
broker.transition(claimed, JobStatus.COMPLETED)
|
||||
assert broker.get(job.id).status == JobStatus.COMPLETED
|
||||
|
||||
def test_queue_position(self):
|
||||
broker = InProcBroker()
|
||||
a, b = Job(), Job()
|
||||
broker.enqueue(a)
|
||||
broker.enqueue(b)
|
||||
assert a.queue_position == 0
|
||||
assert b.queue_position == 1
|
||||
|
||||
def test_queue_full(self):
|
||||
broker = InProcBroker()
|
||||
broker.settings.max_queue = 2
|
||||
broker.enqueue(Job())
|
||||
broker.enqueue(Job())
|
||||
with pytest.raises(QueueFull):
|
||||
broker.enqueue(Job())
|
||||
|
||||
def test_cancel_queued(self):
|
||||
broker = InProcBroker()
|
||||
job = Job()
|
||||
broker.enqueue(job)
|
||||
cancelled = broker.cancel(job.id)
|
||||
assert cancelled.status == JobStatus.CANCELLED
|
||||
assert broker.queue_depth() == 0
|
||||
|
||||
def test_cancel_processing_sets_flag(self):
|
||||
broker = InProcBroker()
|
||||
job = Job()
|
||||
broker.enqueue(job)
|
||||
broker.claim_next("w1", 60.0)
|
||||
cancelled = broker.cancel(job.id)
|
||||
assert cancelled.cancel_requested is True
|
||||
assert cancelled.status == JobStatus.PROCESSING # 워커가 세그먼트 경계에서 종료
|
||||
|
||||
def test_reconcile_stale_recovers_crash(self):
|
||||
"""Eng 리뷰 P11: 워커 크래시 → stale processing → failed 복구."""
|
||||
broker = InProcBroker()
|
||||
job = Job()
|
||||
broker.enqueue(job)
|
||||
broker.claim_next("w1", 60.0) # processing + lease 60s
|
||||
# lease 만료 시뮬레이션
|
||||
job2 = broker.get(job.id)
|
||||
job2.lease_expires_at = time.time() - 1
|
||||
broker.save_meta(job2)
|
||||
reclaimed = broker.reconcile_stale(60.0)
|
||||
assert job.id in reclaimed
|
||||
assert broker.get(job.id).status == JobStatus.FAILED
|
||||
assert broker.get(job.id).error_code == "worker_crash"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""KeyStore — 다이제스트 인증, 스코프, 키 생성 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.api.deps import KeyStore, Principal
|
||||
from luke_scribe.config import Settings
|
||||
from luke_scribe.errors import AuthError, ScopeDenied
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
api_keys="key-one,key-admin:admin,transcribe",
|
||||
api_key_file=str(tmp_path / "api_keys.json"),
|
||||
)
|
||||
|
||||
|
||||
class TestKeyStoreAuth:
|
||||
def test_plain_key_authenticates(self, settings: Settings):
|
||||
ks = KeyStore(settings)
|
||||
p = ks.authenticate("key-one")
|
||||
assert p.key_id.startswith("k-key-one")
|
||||
assert "transcribe" in p.scopes
|
||||
|
||||
def test_scoped_key(self, settings: Settings):
|
||||
ks = KeyStore(settings)
|
||||
p = ks.authenticate("key-admin")
|
||||
assert {"admin", "transcribe"} <= p.scopes
|
||||
|
||||
def test_wrong_key_rejected(self, settings: Settings):
|
||||
ks = KeyStore(settings)
|
||||
with pytest.raises(AuthError):
|
||||
ks.authenticate("key-wrong")
|
||||
|
||||
def test_empty_key_rejected(self, settings: Settings):
|
||||
ks = KeyStore(settings)
|
||||
with pytest.raises(AuthError):
|
||||
ks.authenticate(None)
|
||||
|
||||
def test_digest_only_on_disk(self, settings: Settings, tmp_path):
|
||||
"""평문 키는 저장하지 않고 다이제스트만 파일에 기록 (Eng P14)."""
|
||||
created = KeyStore(settings).create_key(
|
||||
scopes=["transcribe"], save_path=settings.api_key_file
|
||||
)
|
||||
raw = created["key"]
|
||||
data = json.loads(tmp_path.joinpath("api_keys.json").read_text())
|
||||
assert raw not in json.dumps(data)
|
||||
entry = data["keys"][0]
|
||||
assert entry["digest"] != raw
|
||||
assert len(entry["digest"]) == 64 # sha256 hex
|
||||
|
||||
def test_file_keys_loaded(self, settings: Settings, tmp_path):
|
||||
ks = KeyStore(settings)
|
||||
created = ks.create_key(scopes=["transcribe"], save_path=settings.api_key_file)
|
||||
# 새 인스턴스가 파일에서 로드
|
||||
ks2 = KeyStore(settings)
|
||||
p = ks2.authenticate(created["key"])
|
||||
assert "transcribe" in p.scopes
|
||||
|
||||
|
||||
class TestPrincipalScopes:
|
||||
def test_require_scope_ok(self):
|
||||
p = Principal(key_id="k-1", scopes={"transcribe"})
|
||||
p.require_scope("transcribe") # no raise
|
||||
|
||||
def test_require_scope_denied(self):
|
||||
p = Principal(key_id="k-1", scopes={"transcribe"})
|
||||
with pytest.raises(ScopeDenied):
|
||||
p.require_scope("admin")
|
||||
|
||||
def test_require_scope_empty(self):
|
||||
p = Principal(key_id="k-1", scopes=set())
|
||||
with pytest.raises(ScopeDenied):
|
||||
p.require_scope("transcribe")
|
||||
|
||||
|
||||
class TestDigest:
|
||||
def test_same_key_same_digest(self, settings: Settings):
|
||||
ks = KeyStore(settings)
|
||||
assert ks._digest("abc") == ks._digest("abc")
|
||||
assert ks._digest("abc") != ks._digest("abd")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""벤치마크 지표 단위 테스트 — WER/K-CER/entity 보존."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from luke_scribe.benchmark.metrics import (
|
||||
character_kcer,
|
||||
clip_metrics,
|
||||
entity_retention,
|
||||
normalize_kcer,
|
||||
word_wer,
|
||||
)
|
||||
|
||||
|
||||
class TestKcer:
|
||||
def test_normalization_nfkc_punct(self):
|
||||
# NFKC: 유사 문자 통일, 문장부호/공백 제거
|
||||
assert (
|
||||
normalize_kcer("오늘 API 서버에서, vLLM을 사용해 보겠습니다.")
|
||||
== "오늘api서버에서vllm을사용해보겠습니다"
|
||||
)
|
||||
|
||||
def test_exact_match_zero(self):
|
||||
assert character_kcer("오늘 API 사용", "오늘 API 사용") == 0.0
|
||||
|
||||
def test_punct_diff_zero(self):
|
||||
# 문장부호 차이는 K-CER에서 0 (정규화됨)
|
||||
assert character_kcer("오늘 API 사용.", "오늘 API 사용") == 0.0
|
||||
|
||||
def test_one_char_diff(self):
|
||||
ref = "오늘 API 사용"
|
||||
hyp = "오늘 APT 사용"
|
||||
assert 0 < character_kcer(ref, hyp) < 0.3
|
||||
|
||||
|
||||
class TestWer:
|
||||
def test_exact(self):
|
||||
assert word_wer("a b c", "a b c") == 0.0
|
||||
|
||||
def test_one_sub(self):
|
||||
assert word_wer("a b c", "a x c") == 1 / 3
|
||||
|
||||
def test_empty_ref(self):
|
||||
assert word_wer("", "") == 0.0
|
||||
assert word_wer("", "a") == 1.0
|
||||
|
||||
|
||||
class TestEntityRetention:
|
||||
def test_preserved(self):
|
||||
ref = "오늘 API 서버에서 vLLM을 사용합니다"
|
||||
hyp = "오늘 API 서버에서 vLLM을 사용합니다"
|
||||
entities = [
|
||||
{"canonical": "API", "surface": "API", "start_char": 3, "end_char": 6},
|
||||
{"canonical": "vLLM", "surface": "vLLM", "start_char": 12, "end_char": 16},
|
||||
]
|
||||
p, t = entity_retention(ref, hyp, entities)
|
||||
assert p == 2 and t == 2
|
||||
|
||||
def test_lost(self):
|
||||
ref = "오늘 API 서버"
|
||||
hyp = "오늘 에이피아이 서버"
|
||||
entities = [{"canonical": "API", "surface": "API", "start_char": 3, "end_char": 6}]
|
||||
p, t = entity_retention(ref, hyp, entities)
|
||||
assert p == 0 and t == 1
|
||||
|
||||
def test_one_occurrence_one_match(self):
|
||||
# 같은 entity가 두 번 등장 → 2개 annotation, 둘 다 보존
|
||||
ref = "API 서버와 API 게이트웨이"
|
||||
hyp = "API 서버와 API 게이트웨이"
|
||||
entities = [
|
||||
{"canonical": "API", "surface": "API", "start_char": 0, "end_char": 3},
|
||||
{"canonical": "API", "surface": "API", "start_char": 8, "end_char": 11},
|
||||
]
|
||||
p, t = entity_retention(ref, hyp, entities)
|
||||
assert p == 2 and t == 2
|
||||
|
||||
|
||||
class TestClipMetrics:
|
||||
def test_perfect(self):
|
||||
ref = "오늘 API 서버에서 vLLM을 사용합니다"
|
||||
m = clip_metrics(
|
||||
ref, ref, [{"canonical": "API", "surface": "API", "start_char": 3, "end_char": 6}]
|
||||
)
|
||||
assert m.wer == 0.0 and m.k_cer == 0.0
|
||||
assert m.entities_preserved == 1 and m.entities_total == 1
|
||||
@@ -0,0 +1,112 @@
|
||||
"""후처리 단위 테스트 — glossary/rules/confidence/span-aware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from luke_scribe.config import Settings
|
||||
from luke_scribe.engine.base import TranscriptionOptions
|
||||
from luke_scribe.postprocess.confidence import confidence_flags, segment_confidence
|
||||
from luke_scribe.postprocess.glossary import apply_glossary
|
||||
from luke_scribe.postprocess.llm import EgressGuard
|
||||
from luke_scribe.postprocess.pipeline import run_postprocess
|
||||
from luke_scribe.postprocess.rules import apply_rules
|
||||
from luke_scribe.results.models import Segment
|
||||
|
||||
|
||||
def _segments(texts: list[str], logprobs: list[float] | None = None) -> list[Segment]:
|
||||
out = []
|
||||
t = 0.0
|
||||
for i, text in enumerate(texts):
|
||||
lp = logprobs[i] if logprobs else -0.2
|
||||
out.append(
|
||||
Segment(index=i, start=t, end=t + 1.0, text=text, avg_logprob=lp, no_speech_prob=0.01)
|
||||
)
|
||||
t += 1.0
|
||||
return out
|
||||
|
||||
|
||||
class TestGlossary:
|
||||
def test_restores_romanized(self):
|
||||
segs = _segments(["브이엘엘엠 서버를 배포했습니다"])
|
||||
out = apply_glossary(segs, {"브이엘엘엠": "vLLM"})
|
||||
assert out["corrections"][0]["to"] == "vLLM"
|
||||
assert "vLLM" in out["segments"][0].text
|
||||
|
||||
def test_no_double_match(self):
|
||||
segs = _segments(["API API"])
|
||||
out = apply_glossary(segs, {"API": "API"})
|
||||
assert len(out["corrections"]) == 0 # 이미 표준 표기, 교정 없음
|
||||
|
||||
|
||||
class TestRules:
|
||||
def test_vllm_spaced(self):
|
||||
segs = _segments(["v l l m 추론 서버"])
|
||||
out = apply_rules(segs)
|
||||
assert "vLLM" in out["segments"][0].text
|
||||
|
||||
def test_whitespace_collapse(self):
|
||||
segs = _segments(["오늘 API 서버"])
|
||||
out = apply_rules(segs)
|
||||
assert out["segments"][0].text == "오늘 API 서버"
|
||||
|
||||
|
||||
class TestConfidence:
|
||||
def test_low_logprob_flagged(self):
|
||||
segs = _segments(["불확실한 전사"], logprobs=[-2.0])
|
||||
assert segs[0].index in confidence_flags(segs)
|
||||
|
||||
def test_high_confidence_not_flagged(self):
|
||||
segs = _segments(["확실한 전사"], logprobs=[-0.1])
|
||||
assert confidence_flags(segs) == []
|
||||
|
||||
def test_segment_confidence_range(self):
|
||||
assert (
|
||||
segment_confidence(Segment(index=0, start=0, end=1, text="x", avg_logprob=-0.5)) == 0.5
|
||||
)
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
def test_rules_mode_applies(self):
|
||||
segs = _segments(["v l l m 서버"])
|
||||
settings = Settings(_env_file=None, post_mode="rules", post_enabled=True)
|
||||
out = run_postprocess(segs, TranscriptionOptions(), settings)
|
||||
assert any("vLLM" in s.text for s in out["segments"])
|
||||
assert out["meta"]["mode"] == "rules"
|
||||
|
||||
def test_none_mode_unchanged(self):
|
||||
segs = _segments(["v l l m 서버"])
|
||||
settings = Settings(_env_file=None, post_mode="none", post_enabled=False)
|
||||
out = run_postprocess(segs, TranscriptionOptions(), settings)
|
||||
assert out["segments"][0].text == "v l l m 서버"
|
||||
|
||||
def test_span_aware_keeps_timestamps(self):
|
||||
# 교정 후에도 세그먼트 타임라인(start/end)은 보존
|
||||
segs = _segments(["브이엘엘엠 서버"])
|
||||
before = [(s.start, s.end) for s in segs]
|
||||
out = apply_glossary(segs, {"브이엘엘엠": "vLLM"})
|
||||
after = [(s.start, s.end) for s in out["segments"]]
|
||||
assert before == after
|
||||
|
||||
|
||||
class TestEgressGuard:
|
||||
def test_private_ip_rejected(self):
|
||||
guard = EgressGuard({"llm": "http://192.168.0.1:8080/v1"})
|
||||
assert guard.resolve("llm") is None
|
||||
|
||||
def test_http_scheme_rejected(self):
|
||||
guard = EgressGuard({"llm": "http://example.com/v1"})
|
||||
assert guard.resolve("llm") is None
|
||||
|
||||
def test_https_public_allowed(self, monkeypatch):
|
||||
# 오프라인 테스트 환경에서 DNS 해석을 공용 IP로 모킹
|
||||
monkeypatch.setattr(
|
||||
"luke_scribe.postprocess.llm.socket.gethostbyname", lambda host: "93.184.216.34"
|
||||
)
|
||||
guard = EgressGuard({"llm": "https://api.example.com/v1"})
|
||||
assert guard.resolve("llm") is not None
|
||||
|
||||
def test_private_hostname_resolved_rejected(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"luke_scribe.postprocess.llm.socket.gethostbyname", lambda host: "10.0.0.5"
|
||||
)
|
||||
guard = EgressGuard({"llm": "https://internal.example.com/v1"})
|
||||
assert guard.resolve("llm") is None
|
||||
@@ -0,0 +1,77 @@
|
||||
"""실시간 LocalAgreement 단위 테스트 — 버퍼 절단 불변식, 단조 타임스탬프."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from luke_scribe.pipeline.realtime import LocalAgreement
|
||||
from luke_scribe.results.models import Segment
|
||||
|
||||
|
||||
def _segs(texts: list[str], start: float = 0.0) -> list[Segment]:
|
||||
out = []
|
||||
t = start
|
||||
for i, text in enumerate(texts):
|
||||
out.append(Segment(index=i, start=t, end=t + 1.0, text=text))
|
||||
t += 1.0
|
||||
return out
|
||||
|
||||
|
||||
class TestAgreement:
|
||||
def test_no_confirmation_until_agreement_count(self):
|
||||
la = LocalAgreement(agreement_count=2)
|
||||
e1 = la.feed(_segs(["첫 번째 문장"]), 2.0)
|
||||
assert e1["confirmed"] == [] # 1회만으로는 미확정
|
||||
|
||||
def test_stable_prefix_confirmed(self):
|
||||
la = LocalAgreement(agreement_count=2)
|
||||
la.feed(_segs(["오늘 API 서버"]), 2.0)
|
||||
e2 = la.feed(_segs(["오늘 API 서버에서 vLLM"]), 2.0)
|
||||
confirmed = e2["confirmed"]
|
||||
assert confirmed and confirmed[0].text == "오늘 API 서버"
|
||||
assert e2["truncated"] is True
|
||||
|
||||
def test_confirmed_immutable(self):
|
||||
"""확정 세그먼트는 이후 가설에 의해 절대 변경되지 않는다 (불변식 1)."""
|
||||
la = LocalAgreement(agreement_count=2)
|
||||
la.feed(_segs(["오늘 API"]), 2.0)
|
||||
e2 = la.feed(_segs(["오늘 API 서버"]), 2.0)
|
||||
first_confirmed = [s.text for s in e2["confirmed"]]
|
||||
la.feed(_segs(["오늘 API 서버에서 vLLM 사용"]), 2.0)
|
||||
# 확정분은 그대로
|
||||
assert [s.text for s in la.state.confirmed_segments][
|
||||
: len(first_confirmed)
|
||||
] == first_confirmed
|
||||
|
||||
def test_no_duplicate_confirmation(self):
|
||||
"""이미 확정된 텍스트는 다음 가설에서 재발행되지 않는다 (Eng P18)."""
|
||||
la = LocalAgreement(agreement_count=2)
|
||||
la.feed(_segs(["안정된 발화"]), 2.0)
|
||||
e2 = la.feed(_segs(["안정된 발화", "다음 문장"]), 2.0)
|
||||
assert [s.text for s in e2["confirmed"]] == ["안정된 발화"]
|
||||
# 다음 피드: "안정된 발화"는 재발행 금지, 새 텍스트만 확정
|
||||
e3 = la.feed(_segs(["안정된 발화", "다음 문장", "셋째 문장"]), 2.0)
|
||||
assert [s.text for s in e3["confirmed"]] == ["다음 문장"]
|
||||
assert [s.text for s in la.state.confirmed_segments] == ["안정된 발화", "다음 문장"]
|
||||
|
||||
def test_monotonic_timestamps(self):
|
||||
"""확정 세그먼트 시간은 단조 증가 (불변식 3)."""
|
||||
la = LocalAgreement(agreement_count=2)
|
||||
la.feed(_segs(["첫 문장", "둘째 문장"]), 4.0)
|
||||
la.feed(_segs(["첫 문장", "둘째 문장", "셋째 문장"]), 4.0)
|
||||
ends = [s.end for s in la.state.confirmed_segments]
|
||||
assert all(b >= a for a, b in zip(ends, ends[1:], strict=False))
|
||||
|
||||
def test_buffer_truncation_invariant(self):
|
||||
"""확정 방출 후 보류 버퍼는 left_context 이하로 절단 (불변식 2)."""
|
||||
la = LocalAgreement(agreement_count=2, retained_left_context_sec=1.0)
|
||||
la.feed(_segs(["a", "b", "c"]), 3.0)
|
||||
e2 = la.feed(_segs(["a", "b", "c", "d", "e", "f"]), 3.0)
|
||||
assert len(e2["confirmed"]) >= 1
|
||||
# audio offset은 마지막 확정 end - left_context
|
||||
assert la.state.audio_offset_sec >= 0
|
||||
|
||||
def test_flush_confirms_pending(self):
|
||||
la = LocalAgreement(agreement_count=2)
|
||||
la.feed(_segs(["마지막 발화"]), 2.0)
|
||||
flushed = la.flush()
|
||||
assert flushed["confirmed"]
|
||||
assert "마지막 발화" in flushed["new_text"]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""ResultStore — UUID 키, 경로 트래버설 차단, 원자적 쓰기, 보관 TTL 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from luke_scribe.errors import OutputWriteError
|
||||
from luke_scribe.results.models import TranscriptResult
|
||||
from luke_scribe.results.retention import RetentionSweeper
|
||||
from luke_scribe.results.store import ResultStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path) -> ResultStore:
|
||||
return ResultStore(str(tmp_path / "results"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def result(transcript_result: dict) -> TranscriptResult:
|
||||
return TranscriptResult.model_validate(transcript_result)
|
||||
|
||||
|
||||
class TestResultStore:
|
||||
def test_write_read_roundtrip(self, store: ResultStore, result: TranscriptResult):
|
||||
job_id = str(uuid.uuid4())
|
||||
store.write_result(job_id, result)
|
||||
read = store.read_result(job_id)
|
||||
assert read is not None
|
||||
assert read.text == result.text
|
||||
assert read.segments[0].text == result.segments[0].text
|
||||
|
||||
def test_missing_result_returns_none(self, store: ResultStore):
|
||||
assert store.read_result(str(uuid.uuid4())) is None
|
||||
|
||||
def test_path_traversal_rejected(self, store: ResultStore):
|
||||
with pytest.raises(ValueError):
|
||||
store._job_dir("../../etc/passwd")
|
||||
with pytest.raises(ValueError):
|
||||
store._job_dir("..")
|
||||
|
||||
def test_source_path_sanitized(self, store: ResultStore):
|
||||
job_id = str(uuid.uuid4())
|
||||
p = store.source_path_for(job_id, "../../../evil.mp3")
|
||||
assert ".." not in str(p)
|
||||
assert p.name == "evil.mp3"
|
||||
|
||||
def test_symlink_job_dir_rejected(self, store: ResultStore, result: TranscriptResult):
|
||||
job_id = str(uuid.uuid4())
|
||||
real = store.root / f"real-{uuid.uuid4().hex}"
|
||||
real.mkdir(parents=True)
|
||||
link = store.root / job_id
|
||||
os.symlink(real, link)
|
||||
with pytest.raises(ValueError):
|
||||
store.write_result(job_id, result)
|
||||
|
||||
def test_atomic_write_no_partial_json(self, store: ResultStore, result: TranscriptResult):
|
||||
job_id = str(uuid.uuid4())
|
||||
target = store.write_result(job_id, result)
|
||||
# 임시 파일이 남지 않아야 한다
|
||||
leftovers = [p for p in target.parent.iterdir() if p.name.startswith(".result-")]
|
||||
assert leftovers == []
|
||||
|
||||
def test_delete_job(self, store: ResultStore, result: TranscriptResult):
|
||||
job_id = str(uuid.uuid4())
|
||||
store.write_result(job_id, result)
|
||||
store.delete_job(job_id)
|
||||
assert store.read_result(job_id) is None
|
||||
|
||||
def test_iter_job_dirs_includes_all(self, store: ResultStore, result: TranscriptResult):
|
||||
done_id = str(uuid.uuid4())
|
||||
store.write_result(done_id, result)
|
||||
empty_id = str(uuid.uuid4())
|
||||
store._job_dir(empty_id).mkdir(parents=True)
|
||||
entries = dict(store.iter_job_dirs())
|
||||
assert done_id in entries
|
||||
assert empty_id in entries
|
||||
|
||||
def test_delete_derived_keeps_result_and_meta(
|
||||
self, store: ResultStore, result: TranscriptResult
|
||||
):
|
||||
job_id = str(uuid.uuid4())
|
||||
src = store.source_path_for(job_id, "meeting.mp3")
|
||||
src.write_bytes(b"audio")
|
||||
store.write_result(job_id, result)
|
||||
store.write_source_metadata(job_id, {"status": "completed", "completed_at": 1.0})
|
||||
store.delete_derived(job_id)
|
||||
assert not src.exists() # 원본 오디오 삭제 (privacy-first)
|
||||
assert store.read_result(job_id) is not None # 결과 보존
|
||||
assert (store._job_dir(job_id) / "meta.json").exists()
|
||||
|
||||
|
||||
class TestRetentionSweeper:
|
||||
def _write_terminal(
|
||||
self, store: ResultStore, result: TranscriptResult, completed_at: float
|
||||
) -> str:
|
||||
job_id = str(uuid.uuid4())
|
||||
store.write_result(job_id, result)
|
||||
store.write_source_metadata(job_id, {"completed_at": completed_at})
|
||||
return job_id
|
||||
|
||||
def test_sweeps_stale_terminal(self, store: ResultStore, result: TranscriptResult):
|
||||
now = time.time()
|
||||
old = self._write_terminal(store, result, completed_at=now - 8 * 86400)
|
||||
fresh = self._write_terminal(store, result, completed_at=now - 3600)
|
||||
sweeper = RetentionSweeper(store, retention_days=7, now=now)
|
||||
removed = sweeper.sweep()
|
||||
assert old in removed
|
||||
assert fresh not in removed
|
||||
assert store.read_result(old) is None
|
||||
assert store.read_result(fresh) is not None
|
||||
|
||||
def test_iso_timestamp_parsed(self, store: ResultStore, result: TranscriptResult):
|
||||
"""ISO 8601 타임스탬프도 처리 (워커는 float, 다른 경로는 ISO 가능)."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = time.time()
|
||||
iso_old = datetime.fromtimestamp(now - 8 * 86400, tz=UTC).isoformat()
|
||||
job_id = str(uuid.uuid4())
|
||||
store.write_result(job_id, result)
|
||||
store.write_source_metadata(job_id, {"completed_at": iso_old})
|
||||
removed = RetentionSweeper(store, retention_days=7, now=now).sweep()
|
||||
assert job_id in removed
|
||||
|
||||
def test_mtime_fallback(self, store: ResultStore, result: TranscriptResult):
|
||||
"""메타가 없으면 result.json mtime 기준 폴백."""
|
||||
job_id = str(uuid.uuid4())
|
||||
p = store.write_result(job_id, result)
|
||||
old = time.time() - 8 * 86400
|
||||
os.utime(p, (old, old))
|
||||
removed = RetentionSweeper(store, retention_days=7, now=time.time()).sweep()
|
||||
assert job_id in removed
|
||||
|
||||
def test_retention_disabled(self, store: ResultStore, result: TranscriptResult):
|
||||
job_id = self._write_terminal(store, result, completed_at=time.time() - 8 * 86400)
|
||||
sweeper = RetentionSweeper(store, retention_days=0, now=time.time())
|
||||
assert sweeper.sweep() == []
|
||||
assert store.read_result(job_id) is not None
|
||||
|
||||
def test_sweeps_failed_job_with_meta_only(self, store: ResultStore, result: TranscriptResult):
|
||||
"""Eng 리뷰: failed/cancelled job(meta.json만)도 보관 정리 대상."""
|
||||
now = time.time()
|
||||
failed_id = str(uuid.uuid4())
|
||||
store._job_dir(failed_id).mkdir(parents=True)
|
||||
store.write_source_metadata(
|
||||
failed_id,
|
||||
{"status": "failed", "error_code": "worker_crash", "failed_at": now - 8 * 86400},
|
||||
)
|
||||
removed = RetentionSweeper(store, retention_days=7, now=now).sweep()
|
||||
assert failed_id in removed
|
||||
assert not store._job_dir(failed_id).exists()
|
||||
|
||||
def test_processing_job_not_swept(self, store: ResultStore, result: TranscriptResult):
|
||||
"""queued/processing(터미널 타임스탬프 없음)은 절대 삭제 안 함."""
|
||||
now = time.time()
|
||||
queued_id = str(uuid.uuid4())
|
||||
store._job_dir(queued_id).mkdir(parents=True)
|
||||
store.write_source_metadata(queued_id, {"status": "queued"})
|
||||
removed = RetentionSweeper(store, retention_days=7, now=now).sweep()
|
||||
assert queued_id not in removed
|
||||
assert store._job_dir(queued_id).exists()
|
||||
|
||||
|
||||
class TestAtomicFileWriter:
|
||||
def test_write_replaces(self, tmp_path):
|
||||
from luke_scribe.results.store import AtomicFileWriter
|
||||
|
||||
target = tmp_path / "out.txt"
|
||||
AtomicFileWriter.write(target, "hello")
|
||||
assert target.read_text() == "hello"
|
||||
AtomicFileWriter.write(target, "world")
|
||||
assert target.read_text() == "world"
|
||||
|
||||
def test_write_failure_raises_output_error(self, tmp_path):
|
||||
from luke_scribe.results.store import AtomicFileWriter
|
||||
|
||||
# 부모 경로가 파일이면 mkdir 실패 → OutputWriteError
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("not a dir")
|
||||
with pytest.raises(OutputWriteError):
|
||||
AtomicFileWriter.write(blocker / "x.txt", "data")
|
||||
Reference in New Issue
Block a user