From b7c30f8b715aad847c2ed1dee4fcf73ce62a7fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Mon, 10 Aug 2026 17:40:33 +0900 Subject: [PATCH 01/21] WIP: define Luke Scribe v0.1 design [gstack-context] Decisions: Validate the transcription core and benchmark gate before API, queue, and realtime work; preserve five expansion contracts. Remaining: Build the benchmark dataset and implement detect/transcribe/bench. Skill: /office-hours [/gstack-context] --- docs/luke-scribe-v0.1-design.md | 866 ++++++++++++++++++++++++++++++++ 1 file changed, 866 insertions(+) create mode 100644 docs/luke-scribe-v0.1-design.md diff --git a/docs/luke-scribe-v0.1-design.md b/docs/luke-scribe-v0.1-design.md new file mode 100644 index 0000000..9c03912 --- /dev/null +++ b/docs/luke-scribe-v0.1-design.md @@ -0,0 +1,866 @@ +# Luke Scribe v0.1 설계 + +- 작성일: 2026-08-10 +- 저장소: `lukehemmin/luke_scribe` +- 브랜치: `main` +- 상태: **v0.1 범위 승인** +- 모드: Builder / Research +- 상위 문서: + - `.omc/specs/deep-interview-luke-scribe-stt-api.md` + - `.omc/plans/consensus-luke-scribe-stt-api.md` + +## 1. 결정 요약 + +Luke Scribe의 최종 방향은 파일 전사와 실시간 전사를 제공하는 self-hosted STT API다. +하지만 v0.1에서는 플랫폼 전체를 만들지 않는다. 먼저 아래 질문에 측정값으로 답한다. + +> 한국어와 영문 기술용어가 섞인 음성을 우리 하드웨어에서 어느 모델과 설정으로 전사해야 하는가? + +v0.1은 `WAV/MP3 -> TranscriptResult JSON` 전사 코어와 모델 벤치마크만 완성한다. +REST API, Redis 큐, WebSocket, 화자 분리, LLM 후처리는 이후 버전으로 미룬다. + +단, v0.1을 폐기형 프로토타입으로 만들지는 않는다. 다음 다섯 가지 계약은 이후 버전에서도 유지한다. + +1. 정규화된 오디오 입력 계약 +2. 전사 엔진 인터페이스 +3. `TranscriptResult` 결과 스키마 +4. 장치 탐지 및 실행 프로파일 +5. 단일 전사를 조율하는 `TranscriptionService` + +이 경계 덕분에 v0.2의 FastAPI와 v0.3 이후의 큐·실시간 처리는 전사 코어를 수정하지 않고 호출 방식만 추가할 수 있다. + +## 2. 제품 정의 + +> **Luke Scribe는 한국어와 영문 기술용어가 섞인 음성을 외부 STT API에 보내지 않고 통제된 실행 환경에서 전사하는 엔진이자 API다.** + +### 첫 사용자 + +Luke Scribe를 호출할 Luke의 내부 서비스와 개발 도구다. + +### 첫 번째 성공 장면 + +개발자가 실제 회의 또는 기술 설명이 담긴 WAV/MP3 파일을 CLI에 전달한다. Luke Scribe는 다음 정보를 포함한 JSON을 생성한다. + +- 전체 전사문 +- 구간별 시작·종료 시각과 텍스트 +- 감지 언어 +- 실제 사용한 모델, 장치, 정밀도 +- 전사 시간과 RTF(real-time factor) +- 경고 및 강등 내역 + +결과에서 `API`, `vLLM`, `FastAPI`, `Kubernetes`, `GPU` 같은 핵심 기술용어가 원문 표기로 보존되어야 한다. + +## 3. v0.1 범위 + +### 포함 + +- Python 3.11+ 프로젝트 구조와 CLI +- `faster-whisper` 기반 전사 엔진 +- `large-v3-turbo`와 `large-v3` 비교 +- CPU 및 CUDA 장치 탐지 +- 실행 장치와 `compute_type` 자동 선택 및 명시적 override +- WAV/MP3 입력 +- ffmpeg 기반 16 kHz mono 오디오 정규화 +- 전체 텍스트와 세그먼트 타임스탬프를 포함한 JSON 출력 +- 한국어·영문 기술용어 혼용 벤치마크 +- WER, K-CER, entity 보존율, 처리 시간, RTF, VRAM/RSS 측정 +- 임시 파생 오디오 삭제 +- 단위·통합·스모크 테스트 + +### 제외 + +- FastAPI와 HTTP 엔드포인트 +- Redis/RQ와 비동기 Job 큐 +- WebSocket 실시간 전사 +- 화자 분리 +- SRT/VTT 출력 +- glossary/rules/LLM 후처리 +- Docker Compose 운영 구성 +- Colab 터널 +- 4시간/2GB 운영 보장 +- 다중 GPU 및 다중 워커 + +제외 항목은 포기한 기능이 아니다. 모델과 결과 계약이 검증된 뒤 순서대로 연결한다. + +### 런타임 기준선 + +구현 시작 시 `pyproject.toml`과 lockfile에서 정확한 버전을 고정한다. 문서 수준의 지원 범위는 다음과 같다. + +| 항목 | v0.1 기준 | +|---|---| +| Python | 3.11 이상, 구현 시 선택한 minor 버전 고정 | +| faster-whisper | 구현 시 검증한 단일 버전 고정 | +| CTranslate2 | faster-whisper 및 CUDA와 함께 검증한 버전 고정 | +| ffmpeg / ffprobe | 시스템 실행 파일 필수, 시작 전 존재 및 버전 확인 | +| 모델 | `large-v3-turbo`, `large-v3` | +| 모델 획득 | Hugging Face에서 최초 1회 다운로드 후 로컬 캐시 | +| 네트워크 없음 | 캐시된 모델만 사용; 없으면 `model_unavailable_offline`로 실패 | +| 디스크 | 두 모델 캐시와 임시 WAV를 저장할 여유 공간을 preflight에서 검사 | +| 필수 GPU | **Colab T4 16GB를 v0.1 기준 GPU로 우선 확정** | +| 필수 CPU | x86_64, RAM 16GB 이상을 qualification 기준으로 사용 | + +GTX 1050, L4, A100, H100은 추가 관측 대상이지 v0.1 완료를 막는 필수 환경이 아니다. Colab T4를 사용할 수 없게 되면 구현 전에 동급 이상의 NVIDIA GPU 한 종을 명시적으로 대체 지정한다. + +Colab T4는 공개·합성·익명화한 benchmark 자료의 GPU qualification에만 사용한다. 실제 내부 음성은 자체 통제 GPU에서만 처리하며 Colab으로 업로드하지 않는다. + +### v0.1 지원 입력 범위 + +- 컨테이너: WAV, MP3 +- 오디오 코덱: ffmpeg가 PCM WAV 또는 MP3로 식별한 입력 +- 검증 방식: 파일 확장자가 아니라 ffprobe 결과 사용 +- 검증 보장 범위: 파일당 60분 이하, 1GB 이하 +- 손상·부분 다운로드 파일: `audio_probe_failed` +- ffprobe/ffmpeg timeout: 기본 60초, CLI 옵션으로만 상향 가능 +- 더 긴 파일: 명시적으로 `unsupported_input_envelope` 반환 + +## 4. 핵심 전제 + +1. 현재 가장 큰 불확실성은 API 구조가 아니라 실제 KO+EN 혼용 전사 품질이다. +2. `large-v3-turbo`를 기본 후보로 두되, 벤치마크를 통과하지 못하면 `large-v3`를 기본으로 채택한다. +3. v0.1은 한 프로세스에서 한 파일을 처리한다. 동시성은 아직 제품 가치 검증에 필요하지 않다. +4. 모든 상위 계층은 동일한 `TranscriptionService`와 `TranscriptResult`를 사용한다. +5. 약한 하드웨어에서 조용히 품질을 낮추지 않는다. 실제 선택된 모델과 정밀도를 결과에 기록한다. +6. 외부 STT API로 음성이나 전사문을 전송하지 않는다. + +## 5. 실행 흐름 + +```text +WAV / MP3 + | + v +Audio Ingest + - 형식/크기 검사 + - duration probe + - ffmpeg -> 16 kHz mono WAV + | + v +Device Resolver + - CPU/CUDA 탐지 + - compute_type 결정 + - 사용자 override 검증 + | + v +Transcription Engine + - faster-whisper + - turbo 또는 large-v3 + - VAD / language / hotwords 옵션 + | + v +TranscriptResult + - text / segments + - model/device/precision + - timings/warnings + | + +-----------------> JSON 파일 + | + +-----------------> Benchmark evaluator + - K-CER + - entity retention + - RTF + - VRAM/RSS +``` + +## 6. 확장 가능한 경계 + +확장성을 위해 모든 기능을 미리 추상화하지 않는다. 아래 다섯 경계만 안정적인 계약으로 만든다. + +### 6.1 `AudioIngestor` + +책임: + +- 입력 파일 검증 +- ffprobe로 메타데이터 확인 +- ffmpeg로 정규화 +- 임시파일 수명 관리 + +v0.1에서는 로컬 파일만 받는다. 이후 HTTP 업로드와 영상 파일은 이 계층 앞에 입력 어댑터로 추가한다. + +`NormalizedAudio` 계약: + +| 필드 | 형식 | 필수 | 규칙 | +|---|---|---:|---| +| `path` | path | 예 | Luke Scribe가 소유한 임시 정규화 파일 | +| `duration_sec` | float | 예 | `> 0`, ffprobe 측정값 | +| `sample_rate` | int | 예 | 항상 `16000` | +| `channels` | int | 예 | 항상 `1` | +| `sample_format` | enum | 예 | v0.1은 `s16`만 | +| `source_codec` | string | 예 | ffprobe가 보고한 원본 코덱 | +| `source_size_bytes` | int | 예 | `> 0` | + +### 6.2 `TranscriptionEngine` + +개념적 인터페이스: + +```python +class TranscriptionEngine(Protocol): + def transcribe( + self, + audio: NormalizedAudio, + options: TranscriptionOptions, + ) -> TranscriptResult: ... +``` + +v0.1 구현은 `FasterWhisperEngine` 하나만 둔다. 멀티 엔진 플러그인 시스템은 만들지 않는다. + +`TranscriptionOptions` 계약: + +| 필드 | 형식 | 기본값 | 제약 | +|---|---|---|---| +| `model` | enum | benchmark 전 `large-v3-turbo` | `large-v3-turbo` 또는 `large-v3` | +| `language` | string/null | `ko` | `null`이면 자동 감지 | +| `device` | string | `auto` | `auto`, `cpu`, `cuda`, `cuda:N` | +| `compute_type` | string/null | `null` | `null`이면 resolver가 결정 | +| `vad` | bool | `true` | faster-whisper VAD 사용 여부 | +| `hotwords` | list[string] | `[]` | 빈 문자열 제거, 중복 제거 | + +### 6.3 `TranscriptResult` + +CLI, 향후 REST API, 큐 워커, WebSocket 최종 결과가 공유하는 정규 형태다. 전송 형식과 내부 모델을 분리해 향후 필드를 추가해도 기존 소비자가 깨지지 않게 한다. + +### 6.4 `DeviceProfile` + +장치 탐지 결과와 실행 결정을 분리한다. + +- 탐지값: CPU, GPU 이름, compute capability, 총/가용 VRAM +- 결정값: device, compute type, model +- 출처: `auto` 또는 사용자 override +- 경고: CPU fallback, 정밀도 변경, 모델 미지원 + +v0.1에서는 한 장치와 한 실행만 선택한다. 워커 수 계산은 v0.2 이후에 추가한다. + +장치 resolver는 다음 순서로만 결정한다. + +| 요청 | 탐지 조건 | 결과 | +|---|---|---| +| `device=cpu` | CPU 사용 가능 | CPU + `int8` | +| `device=cuda[:N]` | 지정 GPU 존재, 모델 적재 가능 | 지정 GPU + 요청/자동 compute type | +| `device=cuda[:N]` | GPU 없음·인덱스 오류·적재 불가 | fallback 없이 실패 | +| `device=auto` | CUDA GPU에서 모델 적재 가능 | CUDA 사용; cc>=7.0이면 `float16` 우선 | +| `device=auto` | CUDA는 있으나 float16 부적합 | 지원되는 `int8_float16` 또는 `int8` | +| `device=auto` | GPU 없음 또는 모델 적재 불가 | CPU `int8`, warning 기록 | +| 명시 `compute_type` | 런타임이 지원하지 않음 | fallback 없이 실패 | + +자동 모델 추천값은 benchmark 전에는 `unresolved`다. `detect`는 하드웨어상 적재 가능한 모델 목록만 보여준다. benchmark가 승인한 결정 파일이 있으면 그때부터 `recommended_model`을 출력한다. + +`DeviceProfile` 필수 필드: + +- `requested_device`, `selected_device`, `selection_source` +- `device_name`, `compute_capability` +- `vram_total_mb`, `vram_free_mb` +- `requested_compute_type`, `selected_compute_type` +- `loadable_models`, `recommended_model` +- `warnings` + +### 6.5 `TranscriptionService` + +`TranscriptionService`는 안정적인 애플리케이션 경계다. 입력 수명 관리, 장치 선택, 엔진 호출, timing 수집, 결과 조립을 담당한다. CLI는 이 서비스만 호출한다. 이후 API와 큐 워커도 동일한 서비스를 호출한다. + +```python +class TranscriptionService: + def transcribe_file( + self, + source: Path, + options: TranscriptionOptions, + ) -> TranscriptResult: ... +``` + +서비스 내부의 클래스 구성과 orchestration 순서는 변경할 수 있지만, 위 호출 의미와 `TranscriptResult` 계약은 v1 동안 유지한다. + +## 7. 권장 프로젝트 구조 + +```text +luke_scribe/ +├── docs/ +│ └── luke-scribe-v0.1-design.md +├── benchmarks/ +│ ├── README.md +│ ├── manifest.yaml +│ └── references/ +├── src/luke_scribe/ +│ ├── __init__.py +│ ├── cli.py +│ ├── config.py +│ ├── audio/ +│ │ ├── ingest.py +│ │ └── models.py +│ ├── devices/ +│ │ ├── detect.py +│ │ └── profile.py +│ ├── engine/ +│ │ ├── base.py +│ │ └── faster_whisper.py +│ ├── pipeline/ +│ │ └── transcribe.py +│ ├── results/ +│ │ ├── models.py +│ │ └── json_writer.py +│ └── benchmark/ +│ ├── evaluator.py +│ ├── metrics.py +│ └── report.py +├── tests/ +│ ├── unit/ +│ ├── integration/ +│ └── fixtures/ +├── pyproject.toml +└── README.md +``` + +`api/`, `jobqueue/`, `realtime/`, `postprocess/` 디렉터리는 실제 기능을 구현하는 버전에서 추가한다. 빈 디렉터리나 가짜 인터페이스를 미리 만들지 않는다. + +## 8. CLI 계약 + +### 장치 탐지 + +```bash +luke-scribe detect +``` + +출력 예시: + +```json +{ + "requested_device": "auto", + "selected_device": "cuda:0", + "selection_source": "auto", + "device_name": "NVIDIA T4", + "compute_capability": "7.5", + "vram_total_mb": 15360, + "vram_free_mb": 14820, + "requested_compute_type": null, + "selected_compute_type": "float16", + "loadable_models": ["large-v3-turbo", "large-v3"], + "recommended_model": null, + "warnings": [] +} +``` + +`recommended_model=null`은 benchmark 결정 파일이 아직 없다는 뜻이다. + +### 단일 파일 전사 + +```bash +luke-scribe transcribe samples/meeting.mp3 \ + --language ko \ + --model large-v3-turbo \ + --device auto \ + --output result.json +``` + +필수 동작: + +- 성공 시 exit code `0` +- 입력 오류 시 exit code `2` +- 모델/장치 초기화 실패 시 exit code `3` +- 전사 실패 시 exit code `4` +- 오류는 stderr, 결과 JSON은 지정 파일 또는 stdout으로 출력 +- override를 적용할 수 없으면 조용히 변경하지 않고 명확하게 실패 + +### 벤치마크 + +```bash +luke-scribe bench benchmarks/manifest.yaml \ + --models large-v3-turbo,large-v3 \ + --output benchmark-report.json +``` + +벤치마크는 같은 정규화 오디오와 같은 옵션을 사용해 모델만 바꿔 비교한다. + +### 전체 옵션 계약 + +| 명령 | 옵션 | 기본값 | 규칙 | +|---|---|---|---| +| `detect` | `--json` | `true` | v0.1은 JSON 출력만 보장 | +| `transcribe` | `--language` | `ko` | `auto`이면 `null`로 전달 | +| `transcribe` | `--model` | 결정 파일 또는 turbo | 지원 enum만 허용 | +| `transcribe` | `--device` | `auto` | `cpu`, `cuda`, `cuda:N` | +| `transcribe` | `--compute-type` | `auto` | 명시값은 fallback 금지 | +| `transcribe` | `--vad/--no-vad` | `--vad` | 상호 배타적 | +| `transcribe` | `--hotword` | 없음 | 반복 가능 | +| `transcribe` | `--output` | stdout | `-`도 stdout 의미 | +| `transcribe` | `--force` | `false` | 기존 출력 파일 overwrite 허용 | +| `transcribe` | `--log-level` | `INFO` | 로그는 stderr | +| `bench` | `--models` | 두 모델 모두 | 한 모델만 지정 가능 | +| `bench` | `--device` | `auto` | 한 run 안에서는 고정 | +| `bench` | `--compute-type` | `auto` | 모델 간 동일 정책 적용 | +| `bench` | `--hotword-set` | `none,domain` | 반복 가능; 정의된 실험군만 허용 | +| `bench` | `--repeats` | `3` | warm-up 이후 측정 반복 수 | +| `bench` | `--output` | 필수 | JSON report 경로 | + +기존 파일이 있고 `--force`가 없으면 모델을 로드하기 전에 실패한다. stdout에 성공 JSON을 쓰는 동안에는 progress bar를 출력하지 않는다. + +`bench --models`에 한 모델만 지정하면 report만 생성한다. 모델 결정에는 두 모델의 동일 실행 환경 비교가 필요하므로 단일 모델 run은 decision artifact를 생성하거나 기존 artifact를 변경할 수 없다. + +## 9. 결과 스키마 v1 + +```json +{ + "schema_version": "1.0", + "status": "completed", + "source": { + "name": "meeting.mp3", + "codec": "mp3", + "size_bytes": 804231 + }, + "normalized_audio": { + "duration_sec": 42.8, + "audio_format": "pcm_s16le", + "sample_rate": 16000, + "channels": 1 + }, + "execution": { + "model": "large-v3-turbo", + "device": "cuda:0", + "compute_type": "float16", + "language_requested": "ko", + "language_detected": null, + "language_detection_confidence": null + }, + "timings": { + "model_load_sec": 4.21, + "transcription_sec": 6.32, + "rtf": 0.148 + }, + "text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.", + "segments": [ + { + "index": 0, + "start": 0.52, + "end": 4.84, + "text": "오늘 API 서버에서 vLLM을 사용해 보겠습니다.", + "avg_logprob": -0.21, + "no_speech_prob": 0.01 + } + ], + "warnings": [] +} +``` + +### 스키마 규칙 + +- `schema_version`은 필수다. +- 시간은 초 단위 실수다. +- `text`는 세그먼트를 읽기 순서대로 합친 최종 문자열이다. +- 모델·장치·정밀도는 요청값이 아니라 실제 적용값을 기록한다. +- 확장 필드는 추가할 수 있지만 기존 필드의 의미와 자료형은 v1 안에서 바꾸지 않는다. +- 실패 결과는 동일한 envelope에서 `status="failed"`, `error.code`, `error.message`를 제공한다. +- `language_requested`가 언어를 강제하면 `language_detected`와 confidence는 `null`이다. +- 자동 감지일 때만 감지 언어와 confidence를 기록한다. +- 세그먼트는 `index` 오름차순이며 서로 역행할 수 없다. +- 초 단위 시간은 JSON number로 기록하고 최소 millisecond 정밀도를 보존한다. +- `text`는 각 세그먼트의 trim된 텍스트를 단일 공백으로 연결한 값이다. +- 소비자는 알 수 없는 추가 필드를 무시해야 한다. + +구현 시 이 계약을 `docs/schemas/transcript-result-v1.schema.json`으로 옮기고 fixture를 JSON Schema validator로 검증한다. + +실패 envelope 예시: + +```json +{ + "schema_version": "1.0", + "status": "failed", + "source": {"name": "broken.mp3"}, + "error": { + "code": "audio_probe_failed", + "message": "입력 오디오를 해석할 수 없습니다.", + "retryable": false + }, + "warnings": [] +} +``` + +## 10. 벤치마크 설계 + +### 데이터셋 구성 + +v0.1 판단용 데이터셋은 최소 다음 조건을 충족한다. + +- 총 30개 이상 클립 +- 총 음성 길이 60분 이상 +- 실제 사용 환경에서 수집한 한국어 중심 음성 +- 조용한 녹음, 생활 소음, 마이크 거리 차이를 모두 포함 +- 단독 화자와 두 명 이상 대화 포함 +- 영문 기술용어 entity 최소 100회 등장 +- 각 클립에 사람이 검수한 정답 전사문과 entity 목록 제공 + +개인정보가 있는 원본은 저장소에 커밋하지 않는다. `manifest.yaml`에는 로컬 경로 또는 익명화된 fixture만 기록한다. + +`manifest.yaml` 필수 필드: + +```yaml +dataset_version: "1.0" +hotword_sets: + none: [] + domain: [API, vLLM, FastAPI, Kubernetes, LLM, GPU] +clips: + - id: ko-en-001 + audio_path: /secure/local/path/ko-en-001.wav + reference_path: references/ko-en-001.txt + language: ko + entities: + - canonical: API + surface: API + start_char: 3 + end_char: 6 + - canonical: vLLM + surface: vLLM + start_char: 12 + end_char: 16 + tags: [clean, single-speaker] +``` + +entity annotation의 `start_char`/`end_char`는 UTF-8 byte가 아니라 reference Unicode code point index다. 같은 entity가 두 번 나오면 occurrence를 두 개 기록한다. + +`domain` hotword set은 평가 전 고정한 공통 용어집이다. 클립별 정답 entity를 실행 시 주입하지 않는다. 이렇게 해야 benchmark 정답을 모델 입력으로 누출하지 않는다. + +hotword set은 `benchmarks/hotwords/domain-v1.json`처럼 별도 artifact로 저장한다. artifact에는 `version`, 정렬된 `terms`, `sha256`을 포함한다. manifest와 model decision은 이름뿐 아니라 artifact 경로·버전·hash를 참조한다. + +### 필수 지표 + +| 지표 | 의미 | v0.1 사용 목적 | +|---|---|---| +| WER | 전체 단어 오류율 | 일반적인 전사 품질 비교 | +| K-CER | 한국어 정규화 후 문자 오류율 | 한국어 띄어쓰기 차이 영향 완화 | +| Entity 보존율 | 기술용어가 원형대로 남은 비율 | 핵심 제품 품질 판정 | +| RTF | 처리 시간 / 오디오 길이 | 장치별 처리 속도 판정 | +| Peak VRAM/RSS | 최대 메모리 사용량 | 배포 가능 장치 판정 | +| Failure rate | 실패 클립 / 전체 클립 | 안정성 판정 | + +### 지표 계산 규칙 + +K-CER용 정규화는 다음 순서로 고정한다. + +1. Unicode NFKC 정규화 +2. 영문은 lowercase로 변환하되 entity 평가는 원래 대소문자를 유지 +3. 문장부호 제거 +4. 모든 whitespace 제거 +5. 숫자는 아라비아 숫자로 통일하는 별도 mapping file 적용 +6. Unicode code point 단위로 Levenshtein distance 계산 + +K-CER는 전체 reference 문자 수를 분모로 substitution + deletion + insertion을 합산한 corpus-level micro average다. WER는 일반 비교를 위한 보조 지표로 원래 whitespace token을 사용한다. 클립별 지표는 별도로 보존하되 모델 선택에는 corpus aggregate K-CER를 사용한다. + +entity 보존은 manifest의 occurrence annotation 수를 분모로 한다. K-CER 계산에서 생성한 reference-hypothesis 문자 alignment를 이용해 각 reference entity span을 hypothesis 위치에 투영한다. 투영된 구간의 앞뒤 8 Unicode code point window 안에 canonical 표기가 정확히 존재할 때만 성공이다. 하나의 hypothesis occurrence는 한 annotation에만 매칭한다. 따라서 다른 문장에 우연히 생성된 entity가 실제 누락을 상쇄하지 못한다. + +### 벤치마크 실행 규칙 + +1. 모델·장치·compute type 조합마다 별도 프로세스를 사용한다. +2. 측정 전에 비평가용 warm-up 클립 하나를 1회 처리한다. +3. 각 평가 클립을 기본 3회 실행한다. +4. 품질 지표는 아래 deterministic decode 설정의 첫 결과를 사용한다. +5. 시간과 메모리는 3회 min, median, max를 보고한다. +6. `model_load_sec`는 별도 측정하고 RTF에서는 제외한다. +7. RTF는 순수 transcription wall time / audio duration으로 계산한다. +8. RSS는 `psutil`로 해당 benchmark process의 RSS를 100ms 간격 sampling한다. +9. VRAM은 NVML의 해당 PID 사용량을 100ms 간격 sampling한다. +10. 환경 정보에는 OS, Python, ffmpeg, faster-whisper, CTranslate2, CUDA, cuDNN, GPU driver 버전을 기록한다. + +고정 decode 설정: + +```yaml +beam_size: 5 +temperature: 0.0 +condition_on_previous_text: true +vad_filter: true +vad_parameters: + min_silence_duration_ms: 500 + speech_pad_ms: 200 +word_timestamps: false +without_timestamps: false +``` + +`language`, `model`, `device`, `compute_type`, `hotword_artifact`는 실험 변수로 report `run_config`에 기록한다. 그 밖의 faster-whisper decode 기본값도 실제 해석값을 모두 report에 직렬화한다. + +benchmark report 필수 top-level 필드: + +- `report_version`, `dataset_version`, `generated_at` +- `environment` +- `run_config` +- `models[]` +- 모델·hotword variant별 `wer`, `k_cer`, `entity_retention`, `failure_rate` +- 모델·hotword variant별 `rtf_min`, `rtf_median`, `rtf_max` +- 모델·hotword variant별 `peak_rss_min_mb`, `peak_rss_median_mb`, `peak_rss_max_mb` +- 모델·hotword variant별 `peak_vram_min_mb`, `peak_vram_median_mb`, `peak_vram_max_mb` +- `decision.status`, `decision.default_model`, `decision.reasons[]` + +### 모델 선택 게이트 + +`large-v3`를 정확도 기준선으로 사용한다. 먼저 각 모델이 절대 품질 기준을 통과하는지 검사한다. + +절대 품질 기준: + +1. entity 보존율 `>= 95%` +2. K-CER `<= 15%` +3. 실패율 `0%` + +어느 모델도 절대 기준을 통과하지 못하면 `decision.status="no_acceptable_model"`로 끝낸다. 이 결과도 유효한 v0.1 연구 결론이지만, 제품 기본 모델 승인과 v0.2 진입은 차단한다. + +각 모델은 먼저 자신의 최종 배포 variant를 정한다. `none`이 절대 기준을 통과하면 `none`을 선택한다. `none`은 실패하고 `domain`이 통과하면 version/hash가 고정된 `domain`을 선택한다. 둘 다 실패하면 해당 모델은 실패다. 이후 모델 비교에는 각 모델의 최종 배포 variant 지표만 사용한다. 따라서 평가 구성과 실제 기본 실행 구성이 달라지지 않는다. + +완전한 모델 결정표: + +| turbo | large-v3 | 결정 | +|---|---|---| +| 실패 | 실패 | `no_acceptable_model` | +| 통과 | 실패 | turbo 기본 | +| 실패 | 통과 | large-v3 기본 | +| 통과 | 통과 | 아래 상대 비교 적용 | + +두 모델이 모두 절대 기준을 통과하면, `large-v3-turbo`가 아래 조건을 모두 만족할 때 turbo를 기본 모델로 선택한다. + +1. entity 보존율 `>= 95%` +2. turbo의 K-CER가 large-v3보다 상대적으로 15% 넘게 나쁘지 않음 +3. 실패율 `0%` +4. 대상 GPU에서 turbo의 median RTF가 large-v3보다 최소 10% 작음 + +하나라도 실패하면 `large-v3`를 기본 모델로 선택한다. 두 모델은 benchmark와 명시적 CLI override를 위해 계속 지원한다. 실시간 기본 모델은 WebSocket 프로토타입 단계에서 별도로 판단한다. + +hotwords 적용 전·후 결과는 독립 run으로 기록한다. 결정 artifact에는 선택된 hotword set도 함께 저장한다. + +### 모델 결정 artifact + +`bench`는 report와 별도로 `benchmarks/decisions/default-model-v1.json`을 atomic write한다. + +```json +{ + "decision_version": "1.0", + "status": "approved", + "default_model": "large-v3-turbo", + "default_hotword_set": "none", + "hotword_artifact": null, + "dataset_version": "1.0", + "report_sha256": "...", + "model_variants": { + "large-v3-turbo": {"hotword_set": "none", "hotword_artifact": null}, + "large-v3": {"hotword_set": "domain", "hotword_artifact": "benchmarks/hotwords/domain-v1.json", "hotword_sha256": "..."} + }, + "decided_at": "2026-08-10T00:00:00Z" +} +``` + +`status`는 `approved` 또는 `no_acceptable_model`이다. artifact의 schema, dataset version, report hash 검증이 실패하면 무시하지 않고 `invalid_model_decision`으로 실패한다. + +`transcribe`의 모델 선택 순서: + +1. 명시적 `--model`이 있으면 해당 모델 사용 +2. 유효한 `approved` artifact가 있으면 default model/hotword set 사용 +3. artifact가 없으면 연구 bootstrap으로 turbo를 사용하고 `model_decision_missing` warning 기록 +4. `no_acceptable_model`이면 명시적 `--model` 없는 실행은 `model_decision_blocked`로 실패 + +hotword 선택은 모델 선택과 독립적으로 다음 우선순위를 따른다. + +1. 하나 이상의 명시적 `--hotword`가 있으면 그 목록이 전부를 override +2. 그렇지 않고 decision artifact에 선택 모델의 `model_variants`가 있으면 해당 hotword artifact 사용 +3. artifact가 없거나 해당 모델 기록이 없으면 hotwords 비활성화 + +명시적 `--model`로 기본 모델을 바꿔도 유효한 decision artifact 안에 해당 모델 variant가 있으면 그 모델의 검증된 hotword 설정을 사용한다. + +## 11. 오류 및 안전 계약 + +| 코드 | 상황 | 동작 | +|---|---|---| +| `invalid_input` | 파일 없음, 지원하지 않는 형식 | 모델을 로드하기 전에 실패 | +| `audio_probe_failed` | ffprobe가 입력을 해석하지 못함 | 원인과 stderr 요약 반환 | +| `unsupported_input_envelope` | 60분 또는 1GB 지원 범위 초과 | 처리 시작 전에 실패 | +| `model_download_failed` | 모델 다운로드 실패 | 재시도 가능 여부 표시 | +| `model_unavailable_offline` | 네트워크와 캐시 모델 모두 없음 | 필요한 모델과 캐시 위치 표시 | +| `model_load_failed` | 모델 또는 CT2 초기화 실패 | 장치·정밀도·런타임 정보 포함 | +| `device_unavailable` | 요청한 CUDA 장치 없음 | 자동 CPU 변경 없이 실패 | +| `out_of_memory` | 모델 적재 또는 추론 OOM | 실패 시점과 실제 설정 기록 | +| `transcription_failed` | 추론 중 예외 | 원본 예외를 감싼 안정적 오류 코드 반환 | + +자동 모드에서만 명시적인 fallback을 허용한다. fallback이 발생하면 `warnings`와 로그에 반드시 남긴다. 사용자가 `--device cuda:0`처럼 강제한 값은 자동으로 바꾸지 않는다. + +fallback 및 retry 규칙: + +| 실패 시점 | `auto` | 명시 override | +|---|---|---| +| GPU preflight 부적합 | CPU int8로 1회 전환 | 즉시 실패 | +| 모델 load OOM | float16 -> int8_float16 -> CPU int8, 최대 2회 전환 | 즉시 실패 | +| 모델 download 실패 | 네트워크 오류일 때만 1회 재시도 | 동일 | +| 추론 중 OOM | 전체 파일 자동 재실행 없이 실패 | 즉시 실패 | +| ffmpeg/probe 실패 | 재시도 없이 실패 | 동일 | + +추론 중 OOM에서 자동 CPU 재실행을 하지 않는 이유는 긴 파일을 사용자 모르게 처음부터 다시 처리하지 않기 위해서다. + +임시 디렉터리는 성공·실패·중단 모든 경로에서 정리한다. 원본 파일은 읽기 전용으로 취급하며 삭제하거나 수정하지 않는다. + +정리 보장은 정상 종료와 catch 가능한 signal(`SIGINT`, `SIGTERM`)까지다. `SIGKILL`, 전원 장애, 호스트 crash에서는 즉시 삭제를 보장할 수 없다. 각 run은 고유 prefix가 있는 temp directory를 사용하고, 시작 시 TTL이 지난 Luke Scribe orphan directory를 정리한다. + +CLI 실패 출력 규칙: + +- `--output`이 지정되었고 입력 검증 이후 실패하면 해당 경로에 실패 envelope를 atomic write한다. +- stdout 모드에서는 실패 envelope를 stdout에 한 줄 JSON으로 출력한다. +- stderr에는 사람이 읽는 한 줄 요약과 로그만 출력한다. +- 출력 파일 자체를 쓸 수 없으면 JSON 생성을 시도하지 않고 exit code `5`로 종료한다. +- 부분 JSON 파일은 남기지 않는다. 임시 파일에 쓴 뒤 rename한다. + +`TranscriptionService`는 실패 결과를 반환하지 않고 `LukeScribeError`의 typed subclass를 발생시킨다. CLI 어댑터가 이를 exit code와 실패 envelope로 변환한다. + +| 오류 범주 | 실패 JSON | stderr | exit code | +|---|---:|---:|---:| +| CLI 문법·알 수 없는 옵션 | 아니요 | usage + 오류 | 2 | +| `invalid_input`, probe, 지원 범위 | 예 | 한 줄 요약 | 2 | +| 모델·장치·decision 오류 | 예 | 한 줄 요약 | 3 | +| 추론·OOM 오류 | 예 | 한 줄 요약 | 4 | +| 결과 파일 write 오류 | 아니요 | 한 줄 요약 | 5 | + +## 12. 테스트 전략 + +### 단위 테스트 + +- 장치 탐지 결과에서 compute type 결정 +- 사용자 override 검증 +- ffprobe 결과 파싱 +- ffmpeg 명령 구성 +- 임시파일 정리 +- 결과 스키마 직렬화와 역직렬화 +- WER/K-CER/entity 보존율 계산 +- 모델 선택 게이트의 경계값 +- transcript JSON Schema validation +- manifest와 report schema validation +- orphan temp directory TTL 판정 + +### 통합 테스트 + +- 짧은 WAV 파일을 CPU로 전사 +- MP3를 정규화한 뒤 전사 +- 잘못된 파일과 손상 파일 거부 +- 존재하지 않는 CUDA 장치 강제 시 명확한 실패 +- 전사 성공·실패 후 임시파일 부재 확인 +- 같은 fixture에 대해 두 모델의 benchmark report 생성 +- 출력 경로가 이미 존재할 때 `--force` 계약 확인 +- 실패 envelope의 stdout/file 동작 확인 + +### 실제 하드웨어 스모크 테스트 + +- x86_64/RAM 16GB CPU 환경 1종 +- Colab T4 16GB 또는 구현 전에 지정한 동급 이상 NVIDIA GPU 1종 +- 각 환경에서 `detect`, `transcribe`, `bench` 실행 +- 모델, compute type, RTF, peak memory를 결과물로 보관 + +GPU가 없는 CI에서는 모델과 ffmpeg 호출을 대체한 계약 테스트까지만 수행한다. CPU의 대형 모델 qualification에는 별도 timeout과 최대 RSS를 기록하며, 너무 느린 실행은 실패가 아니라 `unsupported_for_production` 경고로 분류할 수 있다. 실제 GPU 모델 스모크 테스트는 Colab T4 job 또는 수동 검증으로 수행한다. + +## 13. 완료 기준 + +v0.1은 다음 항목을 모두 만족해야 완료다. + +- [ ] `detect`가 CPU/GPU 및 권장 실행값을 JSON으로 출력한다. +- [ ] `transcribe`가 WAV와 MP3를 처리해 schema v1 JSON을 생성한다. +- [ ] CPU와 실제 NVIDIA GPU 각각 한 환경에서 전사에 성공한다. +- [ ] benchmark dataset이 30개·60분·entity 100회 기준을 충족한다. +- [ ] turbo와 large-v3 비교 보고서가 생성된다. +- [ ] WER, K-CER, entity 보존율, failure rate, RTF, VRAM/RSS가 보고서에 포함된다. +- [ ] 정규화·warm-up·3회 반복·min/median/max 규칙으로 같은 데이터의 결과를 재현할 수 있다. +- [ ] 모델 선택 게이트가 `approved` 기본 모델 또는 `no_acceptable_model` 결정을 생성한다. +- [ ] 어느 모델도 절대 품질 기준을 통과하지 못하면 v0.2 진입이 차단된다. +- [ ] 성공·실패 경로에서 임시 파생 오디오가 남지 않는다. +- [ ] catch 가능한 중단과 시작 시 orphan cleanup이 검증된다. +- [ ] 단위·통합 테스트가 통과한다. +- [ ] transcript result, benchmark manifest, benchmark report의 machine-verifiable schema가 존재한다. +- [ ] README에 설치, 모델 다운로드, 3개 CLI 사용법, 알려진 제한을 문서화한다. + +완료의 핵심 산출물은 코드 양이 아니라 다음 세 가지다. + +1. 재현 가능한 전사 명령 +2. 안정적인 결과 스키마 +3. 모델 선택을 뒷받침하는 벤치마크 보고서 + +## 14. 확장 로드맵 + +### v0.2: Batch API + +- FastAPI +- `POST /v1/transcriptions` +- `GET /v1/transcriptions/{id}` +- API Key 인증 +- in-process Job 실행 +- 결과 보관과 원본/파생 오디오 삭제 +- Docker GPU/CPU 이미지 + +전사 API는 v0.1의 `TranscriptionService`를 호출하고 `TranscriptResult`를 그대로 응답 모델로 변환한다. + +### v0.3: Durable Queue + +- Redis + RQ no-fork worker +- `queued -> processing -> completed|failed|cancelled` 상태 +- 진행률과 queue position +- 협조적 취소 +- 재시작 내성 +- 공유 입력/결과 스토어 + +Job은 `TranscriptResult`를 감싸지만 결과 자체의 스키마는 변경하지 않는다. + +### v0.4: Realtime + +- WebSocket init frame과 API Key 인증 +- PCM16/Opus 입력 협상 +- VAD와 rolling buffer +- partial/final 결과 +- LocalAgreement 기반 확정 규칙 +- 세션 수 제한과 backpressure +- 30분 메모리 평탄성 테스트 + +실시간의 `final` 세그먼트는 v0.1의 세그먼트 모델을 재사용한다. `partial`은 별도의 변경 가능한 이벤트 타입으로 둔다. + +### v0.5: Output and Post-processing + +- word timestamps +- SRT/VTT +- glossary와 deterministic rules +- confidence flag +- 선택적 diarization +- 선택적 LLM correction + +### v1.0: Internal Production + +- 운영 대상 하드웨어 프로파일 확정 +- 관측 지표와 경보 +- 4시간/2GB 처리 보장 +- 보관 및 삭제 정책 검증 +- 부하·장기 실행·복구 테스트 +- Colab 또는 외부 터널은 실제 필요가 확인된 경우에만 포함 + +## 15. 고려한 접근 + +### 접근 A: 전사 코어와 벤치마크 우선 — 채택 + +- 가장 큰 기술 불확실성을 먼저 제거한다. +- 모델 선택을 추측이 아닌 실제 데이터로 결정한다. +- 결과·엔진·장치 경계를 유지해 이후 API 확장이 가능하다. +- 첫 버전에서 API와 실시간 데모가 없다는 단점이 있다. + +### 접근 B: 파일 API와 준실시간을 함께 구현 + +- 최종 제품과 가까운 데모를 빠르게 볼 수 있다. +- 모델 품질, VAD, 버퍼, WebSocket 문제가 동시에 얽힌다. +- 실패 원인을 분리하기 어렵고 전사 코어 계약이 흔들릴 수 있다. + +### 접근 C: 기존 P1~P5 플랫폼 전체 구현 + +- 처음부터 운영 기능을 모두 고려한다. +- 사용자와 모델 품질이 검증되기 전에 큐·스토리지·동시성 복잡도가 생긴다. +- 구현량이 아니라 검증 순서가 잘못될 가능성이 높다. + +## 16. 열린 질문 + +다음 질문은 문서 작성만으로 정하지 않고 v0.1의 데이터 또는 실제 사용 조건으로 닫는다. + +1. 벤치마크 음성은 어떤 실제 업무 환경에서 수집할 것인가? +2. Colab T4 외에 첫 번째 추가 GPU 관측 대상은 GTX 1050, L4, 또는 별도 서버 중 무엇인가? +3. entity 목록의 초기 도메인은 AI/개발 용어만 포함할 것인가, 다른 전문 분야도 포함할 것인가? +4. K-CER 15%라는 절대 기준이 10개 pilot 결과에서도 현실적이고 충분히 엄격한가? +5. v0.2 Batch API에서 처음부터 비동기 Job을 제공할지, 동기 호출로 시작할지? + +## 17. 다음 작업 + +1. 10개 대표 클립으로 소규모 spike dataset을 먼저 만든다. +2. `large-v3-turbo`와 `large-v3`를 동일 조건으로 수동 실행한다. +3. 결과 스키마와 지표 계산이 유효한지 확인한다. +4. 문제가 없으면 30개·60분 기준 데이터셋으로 확장한다. +5. 벤치마크 결과로 기본 모델을 결정한 뒤에만 v0.2 API 설계를 시작한다. + +## 18. 이번 결정에서 확인된 사용자 의도 + +- “일단 A 먼저 가도 되긴한데”라는 선택으로 모델 품질을 먼저 검증하는 순서에 동의했다. +- “어차피 확장해야 하는 거면”이라는 조건 때문에 v0.1도 확장 경계를 가진 실제 코어로 설계했다. +- 따라서 이번 범위는 기능을 많이 넣는 대신, 나중에 API와 실시간 기능이 의존할 계약을 정확히 만드는 데 집중한다. -- 2.54.0 From 7327145d7af8171c2314771600b7c772d2cc9b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 16:01:21 +0900 Subject: [PATCH 02/21] 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. --- .env.example | 79 +++++ .gitignore | 1 + .omc/plans/consensus-luke-scribe-stt-api.md | 83 +++++ README.md | 116 +++++++ benchmarks/README.md | 11 + benchmarks/manifest.yaml.example | 20 ++ docker/Dockerfile.cpu | 20 ++ docker/Dockerfile.gpu | 26 ++ docker/docker-compose.yml | 70 ++++ docs/schemas/transcript-result-v1.schema.json | 83 +++++ docs/verification-checklist.md | 135 ++++++++ pyproject.toml | 58 ++++ run.sh | 53 ++++ samples/README.md | 4 + src/luke_scribe/__init__.py | 6 + src/luke_scribe/api/__init__.py | 1 + src/luke_scribe/api/app.py | 147 +++++++++ src/luke_scribe/api/deps.py | 127 ++++++++ src/luke_scribe/api/routes/__init__.py | 1 + src/luke_scribe/api/routes/admin.py | 64 ++++ src/luke_scribe/api/routes/jobs.py | 177 +++++++++++ src/luke_scribe/api/routes/stream.py | 153 +++++++++ src/luke_scribe/api/schemas.py | 73 +++++ src/luke_scribe/audio/__init__.py | 1 + src/luke_scribe/audio/ingest.py | 255 +++++++++++++++ src/luke_scribe/audio/vad.py | 52 +++ src/luke_scribe/benchmark/__init__.py | 1 + src/luke_scribe/benchmark/metrics.py | 137 ++++++++ src/luke_scribe/benchmark/runner.py | 294 +++++++++++++++++ src/luke_scribe/cli.py | 256 +++++++++++++++ src/luke_scribe/config.py | 178 +++++++++++ src/luke_scribe/connectivity/__init__.py | 1 + src/luke_scribe/connectivity/tunnel.py | 67 ++++ src/luke_scribe/devices/__init__.py | 1 + src/luke_scribe/devices/manager.py | 181 +++++++++++ src/luke_scribe/devices/profile.py | 33 ++ src/luke_scribe/devices/vram_probe.py | 144 +++++++++ src/luke_scribe/diarization/__init__.py | 1 + .../diarization/pyannote_diarizer.py | 50 +++ src/luke_scribe/engine/__init__.py | 1 + src/luke_scribe/engine/base.py | 91 ++++++ .../engine/faster_whisper_engine.py | 138 ++++++++ src/luke_scribe/engine/model_registry.py | 132 ++++++++ src/luke_scribe/engine/owner.py | 167 ++++++++++ src/luke_scribe/errors.py | 122 +++++++ src/luke_scribe/jobqueue/__init__.py | 1 + src/luke_scribe/jobqueue/broker.py | 299 ++++++++++++++++++ src/luke_scribe/jobqueue/cancel.py | 28 ++ src/luke_scribe/jobqueue/jobs.py | 123 +++++++ src/luke_scribe/jobqueue/worker.py | 143 +++++++++ src/luke_scribe/observability/__init__.py | 1 + src/luke_scribe/observability/logging.py | 53 ++++ src/luke_scribe/observability/metrics.py | 51 +++ src/luke_scribe/pipeline/__init__.py | 1 + src/luke_scribe/pipeline/batch.py | 159 ++++++++++ src/luke_scribe/pipeline/realtime.py | 147 +++++++++ src/luke_scribe/postprocess/__init__.py | 1 + src/luke_scribe/postprocess/confidence.py | 37 +++ src/luke_scribe/postprocess/glossary.py | 40 +++ src/luke_scribe/postprocess/llm.py | 134 ++++++++ src/luke_scribe/postprocess/pipeline.py | 80 +++++ src/luke_scribe/postprocess/rules.py | 56 ++++ src/luke_scribe/results/__init__.py | 1 + src/luke_scribe/results/formats.py | 70 ++++ src/luke_scribe/results/models.py | 100 ++++++ src/luke_scribe/results/retention.py | 82 +++++ src/luke_scribe/results/store.py | 141 +++++++++ tests/__init__.py | 0 tests/conftest.py | 190 +++++++++++ tests/integration/__init__.py | 0 tests/integration/test_api.py | 210 ++++++++++++ tests/integration/test_worker.py | 267 ++++++++++++++++ tests/unit/test_devices.py | 90 ++++++ tests/unit/test_engine_owner.py | 95 ++++++ tests/unit/test_formats.py | 85 +++++ tests/unit/test_ingest.py | 206 ++++++++++++ tests/unit/test_jobs.py | 106 +++++++ tests/unit/test_keystore.py | 86 +++++ tests/unit/test_metrics.py | 84 +++++ tests/unit/test_postprocess.py | 112 +++++++ tests/unit/test_realtime.py | 77 +++++ tests/unit/test_store.py | 184 +++++++++++ 82 files changed, 7351 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 benchmarks/README.md create mode 100644 benchmarks/manifest.yaml.example create mode 100644 docker/Dockerfile.cpu create mode 100644 docker/Dockerfile.gpu create mode 100644 docker/docker-compose.yml create mode 100644 docs/schemas/transcript-result-v1.schema.json create mode 100644 docs/verification-checklist.md create mode 100644 pyproject.toml create mode 100644 run.sh create mode 100644 samples/README.md create mode 100644 src/luke_scribe/__init__.py create mode 100644 src/luke_scribe/api/__init__.py create mode 100644 src/luke_scribe/api/app.py create mode 100644 src/luke_scribe/api/deps.py create mode 100644 src/luke_scribe/api/routes/__init__.py create mode 100644 src/luke_scribe/api/routes/admin.py create mode 100644 src/luke_scribe/api/routes/jobs.py create mode 100644 src/luke_scribe/api/routes/stream.py create mode 100644 src/luke_scribe/api/schemas.py create mode 100644 src/luke_scribe/audio/__init__.py create mode 100644 src/luke_scribe/audio/ingest.py create mode 100644 src/luke_scribe/audio/vad.py create mode 100644 src/luke_scribe/benchmark/__init__.py create mode 100644 src/luke_scribe/benchmark/metrics.py create mode 100644 src/luke_scribe/benchmark/runner.py create mode 100644 src/luke_scribe/cli.py create mode 100644 src/luke_scribe/config.py create mode 100644 src/luke_scribe/connectivity/__init__.py create mode 100644 src/luke_scribe/connectivity/tunnel.py create mode 100644 src/luke_scribe/devices/__init__.py create mode 100644 src/luke_scribe/devices/manager.py create mode 100644 src/luke_scribe/devices/profile.py create mode 100644 src/luke_scribe/devices/vram_probe.py create mode 100644 src/luke_scribe/diarization/__init__.py create mode 100644 src/luke_scribe/diarization/pyannote_diarizer.py create mode 100644 src/luke_scribe/engine/__init__.py create mode 100644 src/luke_scribe/engine/base.py create mode 100644 src/luke_scribe/engine/faster_whisper_engine.py create mode 100644 src/luke_scribe/engine/model_registry.py create mode 100644 src/luke_scribe/engine/owner.py create mode 100644 src/luke_scribe/errors.py create mode 100644 src/luke_scribe/jobqueue/__init__.py create mode 100644 src/luke_scribe/jobqueue/broker.py create mode 100644 src/luke_scribe/jobqueue/cancel.py create mode 100644 src/luke_scribe/jobqueue/jobs.py create mode 100644 src/luke_scribe/jobqueue/worker.py create mode 100644 src/luke_scribe/observability/__init__.py create mode 100644 src/luke_scribe/observability/logging.py create mode 100644 src/luke_scribe/observability/metrics.py create mode 100644 src/luke_scribe/pipeline/__init__.py create mode 100644 src/luke_scribe/pipeline/batch.py create mode 100644 src/luke_scribe/pipeline/realtime.py create mode 100644 src/luke_scribe/postprocess/__init__.py create mode 100644 src/luke_scribe/postprocess/confidence.py create mode 100644 src/luke_scribe/postprocess/glossary.py create mode 100644 src/luke_scribe/postprocess/llm.py create mode 100644 src/luke_scribe/postprocess/pipeline.py create mode 100644 src/luke_scribe/postprocess/rules.py create mode 100644 src/luke_scribe/results/__init__.py create mode 100644 src/luke_scribe/results/formats.py create mode 100644 src/luke_scribe/results/models.py create mode 100644 src/luke_scribe/results/retention.py create mode 100644 src/luke_scribe/results/store.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_api.py create mode 100644 tests/integration/test_worker.py create mode 100644 tests/unit/test_devices.py create mode 100644 tests/unit/test_engine_owner.py create mode 100644 tests/unit/test_formats.py create mode 100644 tests/unit/test_ingest.py create mode 100644 tests/unit/test_jobs.py create mode 100644 tests/unit/test_keystore.py create mode 100644 tests/unit/test_metrics.py create mode 100644 tests/unit/test_postprocess.py create mode 100644 tests/unit/test_realtime.py create mode 100644 tests/unit/test_store.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4d68f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,79 @@ +# ── luke_scribe 설정 (모든 값은 선택; 기본값은 src/luke_scribe/config.py) +# 복사: cp .env.example .env + +# ── 모델 ───────────────────────────────────────────────────────────── +# 실시간 경로 기본 모델 (P1 bench 게이트 전에는 turbo) +LUKESCRIBE_MODEL_RT=large-v3-turbo +# 배치 경로 기본 모델 (P1 bench 게이트 전에는 turbo; 게이트 후 결정 파일로 대체) +LUKESCRIBE_MODEL_BATCH=large-v3-turbo +# 모델 캐시 디렉터리 (Hugging Face 캐시) +LUKESCRIBE_MODEL_CACHE_DIR= + +# ── 장치 ───────────────────────────────────────────────────────────── +# auto | cpu | cuda | cuda:N +LUKESCRIBE_DEVICE=auto +# null이면 Device Manager가 자동 결정 (float16 | int8_float16 | int8 | auto) +LUKESCRIBE_COMPUTE_TYPE=auto +# 배치 워커 프로세스 수 (0 = Device Manager 자동 산정) +LUKESCRIBE_WORKERS=0 + +# ── 언어 / 후처리 ──────────────────────────────────────────────────── +# 기본 언어 (auto = 자동 감지) +LUKESCRIBE_LANGUAGE=ko +# 후처리 모드: none | glossary | rules | llm +LUKESCRIBE_POST_MODE=rules +# glossary/rules 활성화 여부 (true 권장) +LUKESCRIBE_POST_ENABLED=true + +# ── API 키 인증 ────────────────────────────────────────────────────── +# 형식: "key:스코프1,스코프2" 또는 "key" (기본 스코프 transcribe). 여러 개는 쉼표로. +# 예: LUKESCRIBE_API_KEYS="luke-$(openssl rand -hex 24):transcribe,admin" +LUKESCRIBE_API_KEYS= +# 키 저장 파일 (지정 시 키를 이 파일에 HMAC 다이제스트로 저장, CLI `key create`로 생성) +LUKESCRIBE_API_KEY_FILE=api_keys.json +# 스코프 검증 (comma separated required scope for admin routes) +LUKESCRIBE_ADMIN_SCOPES=admin + +# ── 큐 / 실행 프로파일 ─────────────────────────────────────────────── +# redis | inproc (dev/Colab 기본 inproc, Redis 불필요) +LUKESCRIBE_QUEUE_BACKEND=inproc +# 프로덕션 Redis URL (queue_backend=redis 일 때만) +LUKESCRIBE_REDIS_URL=redis://localhost:6379/0 +# 최대 큐 길이 (초과 시 429) +LUKESCRIBE_MAX_QUEUE=100 +# 입력 상한 +LUKESCRIBE_MAX_DURATION_SEC=14400 +LUKESCRIBE_MAX_UPLOAD_BYTES=2147483648 + +# ── 보관 / 프라이버시 ──────────────────────────────────────────────── +# 결과 보관 기간 (일) +LUKESCRIBE_RETENTION_DAYS=7 +# 결과 저장 루트 (기본: 시스템 임시 디렉터리/luke-scribe) +LUKESCRIBE_RESULTS_DIR= +# 원본/파생 오디오 즉시 삭제 (false 금지) +LUKESCRIBE_DELETE_SOURCE=true + +# ── LLM 후처리 (기본 off) ──────────────────────────────────────────── +# local | openai | external +LUKESCRIBE_LLM_BACKEND=none +# external/openai 전용: 허용 엔드포인트 목록 (provider_id=url, 쉼표 구분) +LUKESCRIBE_LLM_ALLOWLIST= +# LLM 모델명 +LUKESCRIBE_LLM_MODEL= +# 전송 감사 로그 (true) +LUKESCRIBE_LLM_AUDIT=true + +# ── 터널 ───────────────────────────────────────────────────────────── +# none | cloudflare +LUKESCRIBE_TUNNEL=none +# cloudflared 바이너리 경로 (PATH에 없을 때) +LUKESCRIBE_CLOUDFLARED_PATH= + +# ── 관측 ───────────────────────────────────────────────────────────── +LUKESCRIBE_LOG_LEVEL=INFO +# true면 JSON 라인 로그 (프로덕션 권장) +LUKESCRIBE_LOG_JSON=false + +# ── API 서버 ───────────────────────────────────────────────────────── +LUKESCRIBE_HOST=0.0.0.0 +LUKESCRIBE_PORT=8000 diff --git a/.gitignore b/.gitignore index 8e8ac55..50975e4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ samples/*.mp4 .omc/state/ .omc/sessions/ .omc/logs/ +.gstack/ diff --git a/.omc/plans/consensus-luke-scribe-stt-api.md b/.omc/plans/consensus-luke-scribe-stt-api.md index 40b427b..9f18ed2 100644 --- a/.omc/plans/consensus-luke-scribe-stt-api.md +++ b/.omc/plans/consensus-luke-scribe-stt-api.md @@ -1,3 +1,4 @@ + # Consensus Implementation Plan: luke_scribe — 로컬 STT 전사 API - **Status:** `pending approval` (consensus **v2.3** — v2.2 + §11 Open Q 후속확정·모호도 ~5% 재산출(2026-06-07); v2.1 합의 + CCG 외부리뷰(Codex/Gemini) 반영; §3.6 능력등급·§3.10 프로비저닝/WS/공유스토어/Colab) @@ -272,3 +273,85 @@ luke_scribe/ --- *Consensus v2.3 — `pending approval`. 실행(team/ralph/autopilot)은 사용자의 별도 명시 승인이 있어야만 진행됩니다. 승인 전 소스 수정·커밋·실행 스킬 호출 없음.* +--- + +## GSTACK REVIEW REPORT — /autoplan (2026-08-12) + +> Auto-review pipeline: CEO → (Design: skipped, no UI scope) → Eng → DX. Dual voices: Codex (gpt-5.6-sol) + independent Claude reviewer. All intermediate decisions auto-decided by the 6 principles; taste decisions and user challenges surfaced below. + +### CEO DUAL VOICES — CONSENSUS TABLE +``` + Dimension Claude Codex Consensus + ──────────────────────────────────── ─────── ─────── ───────── + 1. Premises valid? part part DISAGREE (no workload evidence) + 2. Right problem to solve? yes yes CONFIRMED (KO+EN terminology quality) + 3. Scope calibration correct? — no DISAGREE (full platform = over-engineering) + 4. Alternatives sufficiently explored? part no DISAGREE (whisper.cpp/WhisperX bake-off missing) + 5. Competitive/market risks covered? part no DISAGREE (commodity wrappers) + 6. 6-month trajectory sound? part part DISAGREE (infra before validation) +``` +**CEO findings (Codex, 12):** no workload evidence (critical); product framed around wrong value — terminology-quality system is the wedge (critical); P1–P5 over-engineering for unvalidated internal tool (critical); rebuilding existing inference products — bake-off needed (high); no defensible differentiation (high); hybrid gate not decision-grade (high); realtime=turbo is assumption not architecture (high); shared-GPU scheduling structurally unsound (critical); "1050 to H100" support is a liability (high); AC evade capacity decision (high); privacy-first is deletion choreography without threat model (high); feature sequencing backwards — glossary/rules/bench before realtime (high). + +### ENG DUAL VOICES — CONSENSUS TABLE +``` + Dimension Claude Codex Consensus + ──────────────────────────────────── ─────── ─────── ───────── + 1. Architecture sound? no no DISAGREE (shared-GPU lock, P0) + 2. Test coverage sufficient? no no DISAGREE (fault matrix, contention gate) + 3. Performance risks addressed? part no DISAGREE (VRAM≠latency) + 4. Security threats covered? part no DISAGREE (key hashing, traversal, SSRF) + 5. Error paths handled? no no DISAGREE (finally gaps, crash recovery) + 6. Deployment risk manageable? part part DISAGREE (job_timeout, lease/heartbeat) +``` +**ENG findings (Codex 25 / Claude 10):** P0 blockers — GPU lock does not cross processes (one GPU-owning inference service required), device admission can oversubscribe, realtime capacity not derivable from VRAM, `finally` doesn't cover SIGKILL/OOM/orphaned queued cancellation, OOM chain not restart-safe (persist `attempted_profiles`), `job_timeout≥4h` insufficient (derive from duration×RTF or renewable leases), worker-crash reconstruction missing (lease + startup reconciler), LocalAgreement-2 not tight enough (token-span agreement, monotonic timestamps, immutable committed output). P1 — API-key hashing (HMAC digest, constant-time), result/upload path traversal (UUID keys, enum formats), WS init-frame resource exhaustion caps, egress SSRF via provider-IDs + no redirects + reject private IPs, cancellation must cover ingest/ffmpeg phases, empty-audio contract, queue full must reserve bytes too, ffmpeg streaming = subprocess protocol (backpressure, kill process group), VAD ownership per lane, postprocess must be span-aware to not invalidate timestamps. + +### DX DUAL VOICES — CONSENSUS TABLE +``` + Dimension Claude Codex Consensus + ──────────────────────────────────── ─────── ─────── ───────── + 1. Getting started < 5 min? — no DISAGREE (no README/sample/timed smoke gate) + 2. CLI naming guessable? — part CONFIRMED (detect/transcribe/bench/serve + doctor alias) + 3. Error messages actionable? — no DISAGREE (error contract with corrective commands) + 4. Docs findable & complete? — no DISAGREE (README/COLAB.md/samples/quickstart missing) + 5. Upgrade path safe? — n/a N/A + 6. Dev environment friction-free? — no DISAGREE (uv lockfile, .python-version, .env.example, key create) +``` +**DX findings (Codex):** no timed clone→transcript gate (high); README+licensed sample audio must be P1 deliverables (high); `.python-version`+`uv.lock`+pip fallback (medium); `.env.example` unspecified keys (high); Colab notebook/COLAB.md early (high); CLI exit codes/help/stderr contract (medium); error messages need what/cause/fix+command (high); model provisioning UX (`models list/pull`, size/cache/progress) (high); API-key bootstrap `key create` print-once + digest storage (high); path traversal on result endpoint (critical); shared-GPU one-owning-process (critical); OpenAPI `/docs` + API-key scheme + curl flow (high); WS wire protocol versioned + clients (high); Docker cpu/gpu compose profiles + readiness + preflight matrix (high). + +### CROSS-PHASE THEMES (2+ phases independently) +- **Theme 1: Shared-GPU scheduling unsound** — flagged CEO(8), Eng(1), DX(critical). High-confidence signal. Fix: one GPU-owning inference service/process; API and workers submit work, never touch CUDA directly. For a mock-verified first build: batch-only lane through a single engine-owner abstraction. +- **Theme 2: Scope = build before validate** — CEO(1,3,6), Eng(6), DX(gate). All three voices recommend a thin first release (batch CLI/API, terminology fixtures, one model, JSON/SRT, deletion) and deferring realtime/diarization/LLM/queue until usage proves need. +- **Theme 3: Privacy/security contracts underspecified** — CEO(11), Eng(14–17), DX(key bootstrap). Fix: hashed keys, ownership on all job endpoints, UUID result keys, provider-ID egress, no tunnels/external LLM in default. + +### USER CHALLENGE — Scope (never auto-decided) +- **You said:** full platform (v2.3 consensus plan P1–P5) on a fresh branch. +- **Both models recommend:** a thin first release — batch CLI + batch REST API, terminology benchmark fixtures, one model (turbo), JSON/SRT output, immediate source deletion, actionable errors, README/sample/5-min smoke gate. Defer realtime WS, Redis/RQ queue, diarization, external LLM, Colab tunnel until observed demand. +- **Why:** 3 independent voices (Codex CEO/Eng/DX) converged; the plan itself gates hybrid on P1 bench; infra (queue, realtime, tiers) is speculative until terminology accuracy is measured; shared-GPU architecture needs a redesign (one GPU-owning process) that a thin release makes trivial. +- **What we might be missing:** user has internal context — actual audio volume, who consumes results, whether realtime is a hard requirement, whether "complete platform" is a directive vs aspiration. +- **If we're wrong, the cost is:** we build the thin wedge, and the user then spends another cycle adding queue/realtime. (Cost of going full-platform first and being wrong is higher: wasted scaffolding + architectural rework per Eng findings.) +- **Exception note:** no security/feasibility blocker is claimed — this is a scope/preference challenge, not a correctness one. The user decides. + +### AUTO-DECIDED (audit trail — 6 principles) +| # | Phase | Decision | Classification | Principle | Rationale | Rejected | +|---|-------|----------|-----------|-----------|----------| +| 1 | CEO | Accept premise: internal STT tool, KO+EN terminology is the product value | Mechanical | P6 | Direction matches docs | — | +| 2 | CEO | Keep hardware-adaptive design but no fixed HW targets | Mechanical | P1 | Already resolved in v2.3 §11 | — | +| 3 | CEO | Reject multi-engine plugin (faster-whisper single) | Mechanical | P4/P5 | Duplicates nothing; thin interface | Plugin system | +| 4 | CEO | Defer Colab tunnel, diarization, external LLM to later phases | Taste | P3 | All voices: defer until proven | Ship now | +| 5 | Eng | One GPU-owning engine-owner abstraction for batch lane | Mechanical | P5 | Required to fix P0 scheduling flaw | Independent locks | +| 6 | Eng | Persist `attempted_profiles`; cap OOM retries at 2 total | Mechanical | P1 | Restart-safe downgrade chain | Unlimited retry | +| 7 | Eng | Job leases + startup reconciler for stale `processing` | Mechanical | P1 | Worker-crash recovery | Terminal-only sweeper | +| 8 | Eng | API keys: hashed storage + constant-time compare + ownership | Mechanical | P1 | Security boundary §3.8 | Plaintext config | +| 9 | Eng | UUID result keys + enum formats (no traversal) | Mechanical | P1 | Containment | Client filenames | +| 10 | Eng | Cancellation covers ingest/ffmpeg + generator close | Mechanical | P1 | Cooperative cancel completeness | Segment-only | +| 11 | DX | `luke-scribe key create` print-once + digest store | Mechanical | P1 | Bootstrap friction | Manual config | +| 12 | DX | README + licensed sample audio + timed smoke gate as P1 deliverable | Taste | P1/P2 | 3 voices converge | Defer docs | + +### NOT in scope (auto-deferred → TODOS.md) +Realtime WebSocket lane, Redis/RQ durable queue, diarization, external LLM correction, Colab tunnel automation, SRT/VTT (P4), word timestamps (P4), multi-worker auto-sizing, multi-GPU, 30-min memory soak, destructive fault matrix (hardware-gated), shared-GPU contention gate (hardware-gated). + +### What already exists +- `origin/feat/p1-core`: partial implementation (devices/engine/audio/cli/api/postprocess) — **not merged**; per user decision we rebuild cleanly on `feat/full-platform` from `main`. Reference-only. + +### DREAM STATE DELTA +Current: planning docs only. This plan → thin-first-release core with terminology benchmark as the product wedge → 12-month ideal: validated terminology-quality system (corpus, glossary, correction feedback, eval automation) with swappable inference backend. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ce8fa1 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# luke_scribe + +내부용 **로컬 STT 전사 API** — faster-whisper(CTranslate2) 기반, 하드웨어 적응형, privacy-first. + +한국어 + 영문 기술용어(KO+EN code-switching)를 외부 STT API에 보내지 않고 +통제된 환경에서 전사한다. 단일 `Job` 추상화로 배치(파일)와 실시간(WebSocket)을 처리한다. + +> 설계 단일 진실원본(SoT): [`.omc/plans/consensus-luke-scribe-stt-api.md`](.omc/plans/consensus-luke-scribe-stt-api.md), +> [`.omc/specs/deep-interview-luke-scribe-stt-api.md`](.omc/specs/deep-interview-luke-scribe-stt-api.md) + +## 상태 + +- 설계 완료(모호도 ~5%) · **구현 완료(v0.1 전체 플랫폼, mock 검증)** — `feat/full-platform` +- 이 저장소 환경은 CPU-only이고 ffmpeg/모델 다운로드가 없으므로 **단위/통합 테스트는 + mock 기반**으로 검증한다. 실제 GPU/모델 스모크는 GPU가 있는 환경에서 수행한다. + +## 빠른 시작 (개발) + +```bash +# 1) 의존성 (Python 3.11+) +python3 -m venv .venv && source .venv/bin/activate +pip install -e ".[engine,api]" # 또는: uv sync --extra engine --extra api + +# 2) 환경 설정 +cp .env.example .env + +# 3) 하드웨어 감지 → 능력 등급/정밀도/워커수 +luke-scribe detect + +# 4) 단일 파일 전사 (CPU에서도 동작) +luke-scribe transcribe samples/hello-ko-en.wav --language ko --device cpu + +# 5) API 서버 (개발 기본: in-proc 큐, Redis 불필요) +luke-scribe serve + +# 6) API 키 생성 (1회만 출력, 다이제스트만 저장) +luke-scribe key --create --scopes transcribe,admin +``` + +### 5분 스모크 (mock 환경에서도 CLI 동작 확인) + +```bash +./run.sh test # 단위/통합 테스트 +luke-scribe detect # JSON 프로필 +``` + +## CLI + +| 명령 | 설명 | 상태 | +|------|------|------| +| `detect` | 하드웨어 감지 · 능력 등급(T0~T3) · 정밀도 · 워커수 | ✅ | +| `transcribe ` | 단발 파일 전사 (faster-whisper, CPU/GPU) | ✅ | +| `bench ` | turbo vs large-v3 도메인 벤치 · 모델 결정 게이트 | ✅ | +| `serve` | FastAPI 서버 (in-proc 또는 Redis 큐) | ✅ | +| `key --create` | API 키 생성 (1회 출력 + 다이제스트 저장) | ✅ | + +Exit codes: `0` 성공 · `2` 입력 오류 · `3` 모델/장치 오류 · `4` 추론 오류 · `5` 결과 쓰기 오류 · `130` 인터럽트 + +## REST API (배치) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/v1/jobs` | multipart 업로드 (`file` + `options` JSON) → `202 {job_id}` | +| `GET` | `/v1/jobs/{id}` | 상태 · `queue_position` · `progress` | +| `GET` | `/v1/jobs/{id}/result?format=json\|txt\|srt\|vtt` | 결과 | +| `DELETE` | `/v1/jobs/{id}` | 협조적 취소 | +| `GET` | `/v1/jobs` | 내 job 목록 | +| `GET` | `/health` | 헬스체크 (공개) | +| `GET` | `/v1/system` · `/v1/models` | admin 전용 | + +인증: `X-API-Key` 헤더 (또는 `Authorization: Bearer`). 스코프 + **Job 소유권** 강제. + +```bash +KEY=$(luke-scribe key --create --scopes transcribe | python -c "import sys,json;print(json.load(sys.stdin)['key'])") +curl -X POST localhost:8000/v1/jobs \ + -H "X-API-Key: $KEY" \ + -F file=@samples/hello-ko-en.wav \ + -F 'options={"language":"ko","formats":["json","srt"]}' +``` + +## WebSocket (실시간) + +`WS /v1/stream` — 첫 메시지는 init 프레임(인증 + 오디오 협상): + +```json +{"type":"init","api_key":"...","audio":{"codec":"pcm_s16le","sample_rate":16000,"channels":1},"options":{"language":"ko"}} +``` + +응답: `partial`(가설) / `final`(확정, LocalAgreement) / `status`. + +## 벤치마크 + +```bash +luke-scribe bench benchmarks/manifest.yaml --output benchmarks/report.json --decision benchmarks/decisions/default-model-v1.json +``` + +절대 기준: entity 보존 ≥95% · K-CER ≤15% · 실패율 0%. 게이트는 turbo 우선, +미달 시 large-v3 채택. 단일 모델 run은 decision artifact를 생성하지 않는다. + +## Docker (프로덕션) + +```bash +# CPU 프로파일 +docker compose --profile cpu up -d +# GPU 프로파일 (NVIDIA Container Toolkit 필요) +docker compose --profile gpu up -d +``` + +API + Redis + 워커, 공유 스토어 볼륨. `LUKESCRIBE_API_KEYS` 필수. + +## 알려진 제한 (v0.1) + +- 실시간 decode는 `EngineOwner` 스텁 (실제 WS decode는 GPU 환경에서 활성화). +- Redis/RQ 브로커는 Redis 설치 시에만 동작 (기본 in-proc). +- 화자 분리(pyannote) / 외부 LLM 보정은 `[diarize]`/`[llm]` extra + allowlist 필요. +- 이 저장소 환경(CPU-only, ffmpeg 없음)에서는 모델·ffmpeg 호출을 mock으로 검증. diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..5ca085e --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,11 @@ +# benchmarks + +v0.1 도메인 벤치마크 (KO+EN 혼용어) — turbo vs large-v3 하이브리드 게이트. + +- `manifest.yaml.example`: 클립/정답/entity annotation 스키마 +- `references/`: 클립별 정답 전사문 (개인정보 포함 원본은 커밋 금지) +- `decisions/`: 모델 결정 아티팩트 (`default-model-v1.json`) + +```bash +luke-scribe bench benchmarks/manifest.yaml --output benchmarks/report.json --decision benchmarks/decisions/default-model-v1.json +``` diff --git a/benchmarks/manifest.yaml.example b/benchmarks/manifest.yaml.example new file mode 100644 index 0000000..24baaab --- /dev/null +++ b/benchmarks/manifest.yaml.example @@ -0,0 +1,20 @@ +dataset_version: "1.0" +hotword_sets: + none: [] + domain: [API, vLLM, FastAPI, Kubernetes, LLM, GPU] +clips: + - id: ko-en-001 + audio_path: /secure/local/path/ko-en-001.wav + reference_path: references/ko-en-001.txt + language: ko + duration_sec: 30.0 + entities: + - canonical: API + surface: API + start_char: 3 + end_char: 6 + - canonical: vLLM + surface: vLLM + start_char: 12 + end_char: 16 + tags: [clean, single-speaker] diff --git a/docker/Dockerfile.cpu b/docker/Dockerfile.cpu new file mode 100644 index 0000000..699dbfd --- /dev/null +++ b/docker/Dockerfile.cpu @@ -0,0 +1,20 @@ +# luke_scribe CPU 이미지 +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src/ ./src/ + +RUN pip install --no-cache-dir -e ".[engine,api]" \ + && pip install --no-cache-dir "faster-whisper>=1.0.3" + +ENV LUKESCRIBE_DEVICE=cpu \ + LUKESCRIBE_QUEUE_BACKEND=redis \ + LUKESCRIBE_HOST=0.0.0.0 \ + LUKESCRIBE_PORT=8000 + +EXPOSE 8000 +CMD ["python", "-m", "luke_scribe.cli", "serve"] diff --git a/docker/Dockerfile.gpu b/docker/Dockerfile.gpu new file mode 100644 index 0000000..22efa13 --- /dev/null +++ b/docker/Dockerfile.gpu @@ -0,0 +1,26 @@ +# luke_scribe GPU 이미지 — CUDA 12 + cuDNN 9 + faster-whisper +# 기준: NVIDIA CUDA 12 런타임, cc>=7.0 GPU (T4 이상 권장) +FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3.11 python3-pip python3-venv ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src/ ./src/ + +RUN python3.11 -m venv /opt/venv \ + && . /opt/venv/bin/activate \ + && pip install --no-cache-dir -e ".[engine,gpu,api]" \ + && pip install --no-cache-dir "faster-whisper>=1.0.3" + +ENV PATH="/opt/venv/bin:$PATH" \ + LUKESCRIBE_DEVICE=auto \ + LUKESCRIBE_QUEUE_BACKEND=redis \ + LUKESCRIBE_HOST=0.0.0.0 \ + LUKESCRIBE_PORT=8000 \ + NVIDIA_VISIBLE_DEVICES=all + +EXPOSE 8000 +CMD ["python", "-m", "luke_scribe.cli", "serve"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..1d0298e --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,70 @@ +# luke_scribe 프로덕션 compose — API + Redis + 워커 + 공유 스토어 +# 사용법: docker compose --profile gpu up -d (또는 --profile cpu) +services: + redis: + image: redis:7-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + api: + build: + context: .. + dockerfile: docker/Dockerfile.cpu + profiles: ["cpu"] + depends_on: + redis: + condition: service_healthy + environment: + LUKESCRIBE_QUEUE_BACKEND: redis + LUKESCRIBE_REDIS_URL: redis://redis:6379/0 + LUKESCRIBE_API_KEYS: ${LUKESCRIBE_API_KEYS:?API 키 필요} + volumes: + - luke-store:/data/luke-scribe + ports: + - "8000:8000" + + api-gpu: + build: + context: .. + dockerfile: docker/Dockerfile.gpu + profiles: ["gpu"] + depends_on: + redis: + condition: service_healthy + environment: + LUKESCRIBE_QUEUE_BACKEND: redis + LUKESCRIBE_REDIS_URL: redis://redis:6379/0 + LUKESCRIBE_API_KEYS: ${LUKESCRIBE_API_KEYS:?API 키 필요} + volumes: + - luke-store:/data/luke-scribe + ports: + - "8000:8000" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + + worker: + build: + context: .. + dockerfile: docker/Dockerfile.cpu + profiles: ["cpu"] + depends_on: + redis: + condition: service_healthy + environment: + LUKESCRIBE_QUEUE_BACKEND: redis + LUKESCRIBE_REDIS_URL: redis://redis:6379/0 + command: ["python", "-m", "luke_scribe.cli", "serve"] + volumes: + - luke-store:/data/luke-scribe + +volumes: + luke-store: diff --git a/docs/schemas/transcript-result-v1.schema.json b/docs/schemas/transcript-result-v1.schema.json new file mode 100644 index 0000000..5681763 --- /dev/null +++ b/docs/schemas/transcript-result-v1.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.lukehemmin.com/lukehemmin/luke_scribe/docs/schemas/transcript-result-v1.schema.json", + "title": "TranscriptResult v1", + "type": "object", + "required": ["schema_version", "status"], + "properties": { + "schema_version": { "const": "1.0" }, + "status": { "enum": ["completed", "failed", "cancelled"] }, + "source": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "codec": { "type": ["string", "null"] }, + "size_bytes": { "type": ["integer", "null"] } + } + }, + "normalized_audio": { + "type": "object", + "properties": { + "duration_sec": { "type": "number", "exclusiveMinimum": 0 }, + "audio_format": { "type": "string" }, + "sample_rate": { "type": "integer" }, + "channels": { "type": "integer" } + } + }, + "execution": { + "type": "object", + "properties": { + "model": { "type": "string" }, + "device": { "type": "string" }, + "compute_type": { "type": "string" }, + "language_requested": { "type": ["string", "null"] }, + "language_detected": { "type": ["string", "null"] }, + "language_detection_confidence": { "type": ["number", "null"] }, + "model_used": { "type": ["string", "null"] }, + "compute_type_used": { "type": ["string", "null"] }, + "downgrade_attempts": { "type": "integer" }, + "capability_tier": { "type": ["string", "null"] } + } + }, + "timings": { + "type": "object", + "properties": { + "model_load_sec": { "type": ["number", "null"] }, + "transcription_sec": { "type": ["number", "null"] }, + "ingest_sec": { "type": ["number", "null"] }, + "postprocess_sec": { "type": ["number", "null"] }, + "rtf": { "type": ["number", "null"] } + } + }, + "text": { "type": "string" }, + "segments": { + "type": "array", + "items": { + "type": "object", + "required": ["index", "start", "end", "text"], + "properties": { + "index": { "type": "integer", "minimum": 0 }, + "start": { "type": "number", "minimum": 0 }, + "end": { "type": "number", "minimum": 0 }, + "text": { "type": "string" }, + "avg_logprob": { "type": ["number", "null"] }, + "no_speech_prob": { "type": ["number", "null"] }, + "confidence": { "type": ["number", "null"] }, + "words": { "type": ["array", "null"] }, + "speaker": { "type": ["string", "null"] } + } + } + }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "error": { + "type": "object", + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" }, + "retryable": { "type": "boolean" } + } + }, + "postprocessing": { "type": ["object", "null"] }, + "entities": { "type": ["array", "null"] } + } +} diff --git a/docs/verification-checklist.md b/docs/verification-checklist.md new file mode 100644 index 0000000..a89cb3f --- /dev/null +++ b/docs/verification-checklist.md @@ -0,0 +1,135 @@ +# luke_scribe 전체 구현 검증 체크리스트 + +브랜치: `feat/full-platform` · 기준 문서: `.omc/plans/consensus-luke-scribe-stt-api.md` (v2.3) +검증 환경: CPU-only, ffmpeg/GPU/Redis 미설치 → **mock 기반 검증** (사용자 승인 범위) + +범례: ✅ 통과 · ⬜ 미확인 · ❌ 실패 + +## 1. 기반 (scaffold & config) + +- [x] `pyproject.toml` — 패키지 메타, 의존성(optional extras), ruff 설정 +- [x] `.env.example` — 전체 설정 키 문서화 (`LUKESCRIBE_` 프리픽스) +- [x] `run.sh` — venv 생성 + 설치 + 서버 실행 원스톱 +- [x] `src/luke_scribe/config.py` — pydantic-settings, 모델/장치/큐/상한/보관/LLM/터널 설정 +- [x] `src/luke_scribe/errors.py` — typed 오류 계층 (코드 + retryable + HTTP 매핑) +- [x] `src/luke_scribe/__init__.py` — 패키지 버전 + +## 2. 하드웨어 감지 (devices) + +- [x] `devices/vram_probe.py` — GPU/시스템 정보 조회 (nvidia-ml-py, psutil) +- [x] `devices/profile.py` — DeviceProfile 모델 (등급/정밀도/워커수/경고) +- [x] `devices/manager.py` — T0~T3 능력 등급, 정밀도 결정, 워커수 산정, override 검증 +- [x] CPU-only 폴백 + 경고 (auto 모드) + +## 3. 엔진 (engine) + +- [x] `engine/base.py` — TranscriptionOptions/TranscriptionOutcome 계약 +- [x] `engine/faster_whisper_engine.py` — faster-whisper 래퍼, lazy 세그먼트 + 협조적 취소 +- [x] `engine/model_registry.py` — 모델 다운로드/캐시/오프라인 처리 +- [x] `engine/owner.py` — **EngineOwner** 단일 GPU 소유자, 우선순위 락, OOM 강등 체인(2회 캡, 영속화) +- [x] 실시간 가설 스텁 (`emit_hypothesis`) + +## 4. 오디오 (audio) + +- [x] `audio/ingest.py` — ffprobe 형식 검증, 4h/2GB 상한, ffmpeg 16kHz 스트리밍 정규화 +- [x] 종료 경로 임시파일 정리 + 취소 시 프로세스 그룹 kill +- [x] `audio/vad.py` — 레인별 VAD 소유자 계약, 상태형 스트리밍 VAD + +## 5. 잡 큐 (jobqueue) + +- [x] `jobqueue/jobs.py` — Job 상태머신 (queued→processing→terminal, CAS, lease) +- [x] `jobqueue/broker.py` — InProc 브로커 + Redis/RQ(no-fork) 브로커 + 폴백 +- [x] `jobqueue/cancel.py` — 협조적 취소 토큰 +- [x] `jobqueue/worker.py` — 워커 (리스 하트비트, progress throttle, 크래시 복구 reconciler) +- [x] `jobqueue/redis_broker.py` — Redis 구현 (설치 시 import, 미설치 폴백) + +## 6. 파이프라인 (pipeline) + +- [x] `pipeline/batch.py` — 인제스트→전사→후처리→결과 조립, 종료 경로 정리 +- [x] `pipeline/realtime.py` — LocalAgreement (prefix 일치, 중복 확정 방지, 단조 타임스탬프, 버퍼 절단) + +## 7. 후처리 (postprocess) + +- [x] `postprocess/glossary.py` — 도메인 용어 복원 (span-aware, 이중 매칭 방지) +- [x] `postprocess/rules.py` — 표기 규칙 (vLLM 공백, 공백 축소) +- [x] `postprocess/confidence.py` — 신뢰도 플래그/게이트 +- [x] `postprocess/llm.py` — LLM 보정 (기본 off, allowlist SSRF 가드, 감사 로그) +- [x] `postprocess/pipeline.py` — 모드별 조합 (none/glossary/rules/llm) + +## 8. 결과 저장 (results) + +- [x] `results/models.py` — TranscriptResult v1 스키마 (불변 계약, 확장 필드) +- [x] `results/store.py` — UUID 키 저장, 경로 트래버설 차단, 심볼릭 링크 거부, 원자적 쓰기 +- [x] `results/formats.py` — JSON/TXT/SRT/VTT 렌더링 +- [x] `results/retention.py` — 7일 TTL (터미널 상태만, ISO/float 타임스탬프, mtime 폴백) +- [x] **privacy-first**: 전사 완료 후 원본 오디오 즉시 삭제 (`delete_source`, `delete_derived`) + +## 9. API (api) + +- [x] `api/app.py` — 앱 팩토리, lifespan(브로커/reconciler/모델 프로비저닝/터널/세션 가드) +- [x] 오류→HTTP 매핑 (401/403/404/413/422/429/500 envelope) +- [x] `api/deps.py` — KeyStore (peppered HMAC 다이제스트, 상수시간 비교, 평문 미저장) +- [x] 스코프 강제 + Job 소유권 (남의 job 접근 403) +- [x] `api/schemas.py` — 요청/응답 스키마 +- [x] `api/routes/jobs.py` — 생성(202)/조회/결과/취소, 4h/2GB 413, 큐 만재 429 +- [x] `api/routes/admin.py` — /health(공개), /v1/system, /v1/models (admin 스코프) +- [x] `api/routes/stream.py` — WebSocket 실시간 (init 인증, 프레임/세션 상한, 유휴 타임아웃) + +## 10. CLI (cli) + +- [x] `cli.py` — detect/transcribe/bench/serve/key 생성 (typer + rich) +- [x] `benchmark/` — turbo vs large-v3 벤치마크 러너/메트릭/매니페스트 + +## 11. 운영 산출물 (ops) + +- [x] `docker/Dockerfile.cpu` / `Dockerfile.gpu` / `docker-compose.yml` +- [x] `connectivity/tunnel.py` — Cloudflare Quick Tunnel +- [x] `observability/` — 로깅(JSON 옵션) + 메트릭 +- [x] `diarization/` — pyannote 화자 분리 스텁 +- [x] `README.md` — 설치/사용법/API/CLI 문서 +- [x] `docs/schemas/transcript-result-v1.schema.json` — 결과 JSON 스키마 +- [x] `benchmarks/manifest.yaml.example`, `samples/README.md` + +## 12. 테스트 & 품질 + +- [x] 단위 테스트: devices, engine owner, jobs/broker, metrics, postprocess, realtime, formats, store, keystore, ingest +- [x] 통합 테스트: worker 수명주기/취소/크래시복구, API 인증/소유권/큐/결과 +- [x] 127개 테스트 전체 통과 +- [x] ruff lint + format 클린 +- [x] 코드 리뷰 반영 (프라이버시 계약, 보관 갭, Redis 오류 필드, 완료 순서, 유휴 타임아웃, 중복 확정) + +--- + +## 실행 검증 (이 환경에서 직접 실행한 것) + +```bash +# pytest 결과 +127 passed, 1 warning in 0.32s + +# ruff +All checks passed! | 68 files already formatted (FORMAT CLEAN) + +# import 검증 +40/40 모듈 import 성공 + +# CLI +luke-scribe --help → detect/transcribe/bench/serve/key 정상 +luke-scribe detect → device=cpu | ct=int8 | tier=T0 +luke-scribe key --create → 1회 키 출력 + 다이제스트 64자 hex만 저장 (평문 없음) + +# API 스모크 (TestClient — 전부 PASS) +health → PASS (status=ok, model_ready=false) +no-key 401 → PASS +bad-key 401 → PASS +scope 403 → PASS +create 202 → PASS +job get 200 → PASS +result 409(미완료) → PASS +cancel 200 → PASS +oversize 413 → PASS +queue-full 429 → PASS (Retry-After: 5 헤더 포함) + +# 산출물 +README/run.sh/pyproject/.env.example/Docker×2/compose/schema/manifest → 전부 존재 +schema JSON valid · pyproject 파싱 OK (extras 6종) · run.sh 구문 OK · compose YAML valid +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..64c0c8d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[project] +name = "luke-scribe" +version = "0.1.0" +description = "내부용 로컬 STT 전사 API — faster-whisper, hardware-adaptive, privacy-first" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.7", + "pydantic-settings>=2.3", + "typer>=0.12", + "rich>=13.7", + "psutil>=5.9", + "nvidia-ml-py>=12.535", + "huggingface-hub>=0.24", +] + +[project.optional-dependencies] +# 엔진 — transcribe/bench (faster-whisper/CTranslate2) +engine = ["faster-whisper>=1.0.3", "av>=11"] +# GPU CUDA 런타임 (faster-whisper GPU 추론 시) +gpu = ["nvidia-cublas-cu12", "nvidia-cudnn-cu12"] +# 동기 배치 API (serve) +api = ["fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9"] +# P2 비동기 큐 (Redis/RQ no-fork) +queue = ["redis>=5.0", "rq>=1.16"] +# P5 옵션 +diarize = ["pyannote.audio>=3.1"] +llm = ["openai>=1.30"] + +[dependency-groups] +dev = ["pytest>=8.2", "ruff>=0.5", "httpx>=0.27"] + +[project.scripts] +luke-scribe = "luke_scribe.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/luke_scribe"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +# FastAPI/typer는 Depends()/Option() 호출을 인자 기본값에 쓰는 패턴이 표준 +"src/luke_scribe/api/**" = ["B008"] +"src/luke_scribe/cli.py" = ["B008"] diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..2d3b9ad --- /dev/null +++ b/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# luke_scribe dev launcher — Colab/개발용 순수 Python 경로 (Docker 불필요) +set -euo pipefail + +cd "$(dirname "$0")" + +if [ ! -d .venv ]; then + echo "[luke-scribe] .venv 생성 중..." + python3 -m venv .venv +fi +# shellcheck disable=SC1091 +source .venv/bin/activate + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +CMD="${1:-help}" +shift || true + +case "$CMD" in + detect) + python -m luke_scribe.cli detect "$@" + ;; + transcribe) + python -m luke_scribe.cli transcribe "$@" + ;; + bench) + python -m luke_scribe.cli bench "$@" + ;; + serve) + python -m luke_scribe.cli serve "$@" + ;; + key) + python -m luke_scribe.cli key "$@" + ;; + test) + python -m pytest tests/ "$@" + ;; + *) + echo "사용법: ./run.sh {detect|transcribe|bench|serve|key|test} [args]" + echo + echo "예시:" + echo " ./run.sh detect" + echo " ./run.sh transcribe samples/ko_en.wav --language ko" + echo " ./run.sh serve" + echo " ./run.sh key --create" + exit 1 + ;; +esac diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..64b743b --- /dev/null +++ b/samples/README.md @@ -0,0 +1,4 @@ +# samples + +`hello-ko-en.wav` — 16kHz mono, 짧은 한국어+영문 기술용어 발화 샘플. +(라이선스 확인 후 실제 오디오 파일을 추가할 것. 개인정보 포함 원본 커밋 금지.) diff --git a/src/luke_scribe/__init__.py b/src/luke_scribe/__init__.py new file mode 100644 index 0000000..2ed8114 --- /dev/null +++ b/src/luke_scribe/__init__.py @@ -0,0 +1,6 @@ +"""luke_scribe — 내부용 로컬 STT 전사 API. + +faster-whisper 기반, 하드웨어 적응형, privacy-first. +""" + +__version__ = "0.1.0" diff --git a/src/luke_scribe/api/__init__.py b/src/luke_scribe/api/__init__.py new file mode 100644 index 0000000..f2a884c --- /dev/null +++ b/src/luke_scribe/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI 웹 API — 배치 Job, 실시간 WS, admin.""" diff --git a/src/luke_scribe/api/app.py b/src/luke_scribe/api/app.py new file mode 100644 index 0000000..2aadf21 --- /dev/null +++ b/src/luke_scribe/api/app.py @@ -0,0 +1,147 @@ +"""FastAPI 앱 팩토리. + +Lifespan (plan §3.10a/§3.5): +- 시작: broker(Redis/in-proc) 생성, stale job reconciler 1회, EngineOwner 생성, + 모델 프로비저닝(선택) — 준비 전 ``/health``는 ``model_ready=false``. +- 종료: 터널 종료, 열린 리소스 정리. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from ..config import Settings, get_settings +from ..engine.owner import EngineOwner +from ..errors import ( + AuthError, + InvalidInput, + JobNotFound, + LukeScribeError, + QueueFull, + ScopeDenied, + UnsupportedInputEnvelope, +) +from ..results.store import ResultStore +from .deps import KeyStore +from .routes import admin, jobs, stream + +logger = logging.getLogger("luke_scribe.api") + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or get_settings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + from ..jobqueue.broker import make_broker + + broker = make_broker(settings) + store = ResultStore(settings.results_root) + owner = EngineOwner.get(settings) + keystore = KeyStore(settings) + + # 스타트업 reconciler — stale processing 복구 + try: + reclaimed = broker.reconcile_stale(60.0) + if reclaimed: + logger.warning("스타트업 reconciler: %d stale job 복구", len(reclaimed)) + except Exception as exc: + logger.warning("reconciler 실패: %s", exc) + + app.state.settings = settings + app.state.broker = broker + app.state.store = store + app.state.engine_owner = owner + app.state.keystore = keystore + app.state.model_ready = False + app.state.model_ready_model = None + from .routes.stream import _SessionGuard + + app.state.session_guard = _SessionGuard(settings) + + # 모델 프로비저닝 (선택) — 설정된 경우에만 + model_cache = settings.model_cache_dir + if model_cache: + try: + from ..engine.model_registry import ModelRegistry + + reg = ModelRegistry(settings) + path = reg.ensure_available( + settings.model_batch, cache_dir=model_cache, offline_ok=True + ) + app.state.model_ready = True + app.state.model_ready_model = settings.model_batch + logger.info("모델 준비됨: %s (%s)", settings.model_batch, path) + except Exception as exc: + logger.warning("모델 프로비저닝 보류: %s", exc) + + # 터널 (선택) + tunnel = None + if settings.tunnel != "none": + try: + from ..connectivity.tunnel import CloudflareTunnel + + tunnel = CloudflareTunnel(settings) + tunnel.start() + app.state.tunnel_url = tunnel.public_url + logger.info("터널: %s", tunnel.public_url) + except Exception as exc: + logger.warning("터널 시작 실패: %s", exc) + + yield + + if tunnel is not None: + try: + tunnel.stop() + except Exception: + pass + owner.unload_all() + + app = FastAPI( + title="luke_scribe", + version="0.1.0", + description="내부용 로컬 STT 전사 API — faster-whisper, hardware-adaptive, privacy-first", + lifespan=lifespan, + ) + + app.include_router(admin.router) + app.include_router(jobs.router) + app.include_router(stream.router) + + # ── 오류 → HTTP 매핑 (LukeScribeError envelope) ── + @app.exception_handler(AuthError) + async def _auth_error(request: Request, exc: AuthError) -> JSONResponse: + return JSONResponse(status_code=401, content=exc.to_dict()) + + @app.exception_handler(ScopeDenied) + async def _scope_error(request: Request, exc: ScopeDenied) -> JSONResponse: + return JSONResponse(status_code=403, content=exc.to_dict()) + + @app.exception_handler(JobNotFound) + async def _job_not_found(request: Request, exc: JobNotFound) -> JSONResponse: + return JSONResponse(status_code=404, content=exc.to_dict()) + + @app.exception_handler(QueueFull) + async def _queue_full(request: Request, exc: QueueFull) -> JSONResponse: + return JSONResponse(status_code=429, content=exc.to_dict(), headers={"Retry-After": "5"}) + + @app.exception_handler(UnsupportedInputEnvelope) + async def _envelope(request: Request, exc: UnsupportedInputEnvelope) -> JSONResponse: + return JSONResponse(status_code=413, content=exc.to_dict()) + + @app.exception_handler(InvalidInput) + async def _invalid_input(request: Request, exc: InvalidInput) -> JSONResponse: + return JSONResponse(status_code=422, content=exc.to_dict()) + + @app.exception_handler(LukeScribeError) + async def _generic_error(request: Request, exc: LukeScribeError) -> JSONResponse: + return JSONResponse(status_code=500, content=exc.to_dict()) + + return app + + +app = create_app() diff --git a/src/luke_scribe/api/deps.py b/src/luke_scribe/api/deps.py new file mode 100644 index 0000000..860b6f0 --- /dev/null +++ b/src/luke_scribe/api/deps.py @@ -0,0 +1,127 @@ +"""인증/권한 디펜던시. + +Eng 리뷰 P14 반영: +- 키는 평문으로 저장하지 않는다 — **peppered HMAC-SHA256 다이제스트**만 저장. +- 비교는 ``secrets.compare_digest`` (상수 시간). +- 스코프 강제 + **Job 소유권** (transcribe 키가 남의 Job을 조회/취소 불가). +- raw 키는 로그/큐에 절대 기록하지 않는다. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +from dataclasses import dataclass +from pathlib import Path + +from fastapi import Depends, Header, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from ..config import Settings +from ..errors import AuthError, ScopeDenied + +_bearer = HTTPBearer(auto_error=False) +_PEPPER = b"luke-scribe-key-pepper-v1" + + +class KeyStore: + """키 다이제스트 저장소 — {key_id: {digest, scopes}}. + + ``api_keys`` 설정(평문, dev용) 또는 ``api_key_file``(다이제스트, 권장)를 + 소스로 사용한다. + """ + + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + self._keys: dict[str, dict] = {} + self._load() + + def _load(self) -> None: + # 1) api_key_file (다이제스트) + path = Path(self.settings.api_key_file) + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + for entry in data.get("keys", []): + self._keys[entry["id"]] = { + "digest": entry["digest"], + "scopes": set(entry.get("scopes", ["transcribe"])), + } + except Exception: + pass + # 2) 설정 평문 키 (dev 편의) → 다이제스트로 변환 + for raw_key, scopes in self.settings.parse_api_keys().items(): + self._keys.setdefault( + self._id_of(raw_key), {"digest": self._digest(raw_key), "scopes": scopes} + ) + + @staticmethod + def _id_of(raw_key: str) -> str: + return f"k-{raw_key[:8]}" + + @staticmethod + def _digest(raw_key: str) -> str: + return hmac.new(_PEPPER, raw_key.encode("utf-8"), hashlib.sha256).hexdigest() + + def authenticate(self, raw_key: str | None) -> Principal: + if not raw_key: + raise AuthError("API 키가 필요합니다 (X-API-Key 헤더)") + for key_id, entry in self._keys.items(): + if secrets.compare_digest(entry["digest"], self._digest(raw_key)): + return Principal(key_id=key_id, scopes=entry["scopes"]) + raise AuthError("유효하지 않은 API 키") + + def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict: + """새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장.""" + raw = f"luke-{secrets.token_urlsafe(32)}" + scopes = scopes or ["transcribe"] + key_id = self._id_of(raw) + self._keys[key_id] = {"digest": self._digest(raw), "scopes": set(scopes)} + save_path = save_path or self.settings.api_key_file + Path(save_path).parent.mkdir(parents=True, exist_ok=True) + data = { + "keys": [ + {"id": k, "digest": v["digest"], "scopes": sorted(v["scopes"])} + for k, v in self._keys.items() + ] + } + Path(save_path).write_text(json.dumps(data, indent=2), encoding="utf-8") + return {"key_id": key_id, "key": raw, "scopes": scopes, "stored_at": save_path} + + +@dataclass +class Principal: + key_id: str + scopes: set[str] + + def require_scope(self, scope: str) -> None: + if scope not in self.scopes: + raise ScopeDenied(f"스코프 {scope!r}가 필요합니다 (가진 스코프: {sorted(self.scopes)})") + + +def get_keystore(request: Request) -> KeyStore: + """앱 상태에 있는 keystore 사용 (lifespan이 설정 기반으로 생성).""" + return request.app.state.keystore + + +def get_principal( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), + x_api_key: str | None = Header(default=None), + keystore: KeyStore = Depends(get_keystore), +) -> Principal: + raw = None + if credentials: + raw = credentials.credentials + elif x_api_key: + raw = x_api_key + return keystore.authenticate(raw) + + +def require_scope(scope: str): + def dep(principal: Principal = Depends(get_principal)) -> Principal: + principal.require_scope(scope) + return principal + + return dep diff --git a/src/luke_scribe/api/routes/__init__.py b/src/luke_scribe/api/routes/__init__.py new file mode 100644 index 0000000..98f7d3b --- /dev/null +++ b/src/luke_scribe/api/routes/__init__.py @@ -0,0 +1 @@ +"""API 라우트 — jobs / stream / admin.""" diff --git a/src/luke_scribe/api/routes/admin.py b/src/luke_scribe/api/routes/admin.py new file mode 100644 index 0000000..f8e8b6b --- /dev/null +++ b/src/luke_scribe/api/routes/admin.py @@ -0,0 +1,64 @@ +"""Admin 라우트 — /health, /v1/system, /v1/models. + +계약: health는 공개(최소 정보), system/models는 admin 스코프 필요. +``/v1/system``은 요청 vs 실효 compute_type 불일치 경고를 포함한다 (§3.9d). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from ...devices.manager import DeviceManager +from ...devices.vram_probe import runtime_env_snapshot +from ..deps import Principal, require_scope +from ..schemas import SystemResponse + +router = APIRouter(tags=["admin"]) + + +@router.get("/health") +async def health(request: Request) -> dict: + broker = request.app.state.broker + return { + "status": "ok", + "queue_depth": broker.queue_depth(), + "model_ready": getattr(request.app.state, "model_ready", False), + } + + +@router.get("/v1/system", response_model=SystemResponse) +async def system( + request: Request, + principal: Principal = Depends(require_scope("admin")), +) -> SystemResponse: + settings = request.app.state.settings + manager = DeviceManager() + profile = manager.detect( + device=settings.device, + compute_type=None if settings.compute_type == "auto" else settings.compute_type, + ) + return SystemResponse( + capability_tier=profile.capability_tier, + device=profile.to_dict(), + workers=manager.compute_workers(profile, override=settings.workers), + queue_depth=request.app.state.broker.queue_depth(), + models=[settings.model_rt, settings.model_batch], + model_used=getattr(request.app.state, "model_ready_model", None), + compute_type_used=profile.selected_compute_type, + ) + + +@router.get("/v1/models") +async def models( + request: Request, + principal: Principal = Depends(require_scope("admin")), +) -> dict: + settings = request.app.state.settings + manager = DeviceManager() + profile = manager.detect(device=settings.device) + return { + "capability_tier": profile.capability_tier, + "loadable_models": profile.loadable_models or [settings.model_rt, settings.model_batch], + "recommended_model": profile.recommended_model, + "runtime": runtime_env_snapshot(), + } diff --git a/src/luke_scribe/api/routes/jobs.py b/src/luke_scribe/api/routes/jobs.py new file mode 100644 index 0000000..2983b94 --- /dev/null +++ b/src/luke_scribe/api/routes/jobs.py @@ -0,0 +1,177 @@ +"""Job 라우트 — 생성/조회/취소/목록. + +- ``POST /v1/jobs``: multipart 업로드. 4h/2GB 초과 → 413, 큐 만재 → 429. +- ``GET /v1/jobs/{id}``: queue_position/progress/상태 (소유자 전용). +- ``GET /v1/jobs/{id}/result?format=``: 결과 (enum 포맷만, UUID 키). +- ``DELETE /v1/jobs/{id}``: 협조적 취소. +- 소유권: Job을 만든 키만 조회/취소/결과 접근 가능 (Eng 리뷰 P14). +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from pydantic import ValidationError + +from ...config import Settings +from ...errors import ( + JobNotFound, + QueueFull, +) +from ...jobqueue.broker import JobBroker +from ...jobqueue.jobs import Job, JobStatus +from ...results.formats import render +from ...results.store import ResultStore +from ..deps import Principal, get_principal, require_scope +from ..schemas import JobCreateResponse, JobResultResponse, JobStatusResponse, TranscribeOptions + +router = APIRouter(prefix="/v1/jobs", tags=["jobs"]) + +REQUIRED_SCOPE = "transcribe" + + +def _get_broker(request: Request) -> JobBroker: + return request.app.state.broker + + +def _get_store(request: Request) -> ResultStore: + return request.app.state.store + + +@router.post("", response_model=JobCreateResponse, status_code=202) +async def create_job( + request: Request, + file: Annotated[UploadFile, File()], + options: Annotated[str, Form()] = '{"language":"ko"}', + principal: Principal = Depends(require_scope("transcribe")), +) -> JobCreateResponse: + + try: + opts = TranscribeOptions.model_validate_json(options) + except ValidationError as exc: + raise HTTPException(status_code=422, detail=f"options 파싱 실패: {exc.errors()}") from exc + + broker = _get_broker(request) + store = _get_store(request) + settings: Settings = request.app.state.settings + + # 2GB 상한 + content = await file.read(settings.max_upload_bytes + 1) + if len(content) > settings.max_upload_bytes: + raise HTTPException(status_code=413, detail="파일 크기가 상한(2GB)을 초과합니다") + + # 큐 만재 → 429 + if broker.queue_depth() >= settings.max_queue: + raise HTTPException( + status_code=429, detail="큐가 가득 찼습니다", headers={"Retry-After": "5"} + ) + + job = Job( + type="file", + lane="batch", + owner_key_id=principal.key_id, + options=opts.model_dump(exclude_none=True), + source_name=file.filename, + ) + source_path = store.source_path_for(job.id, file.filename or "upload.bin") + source_path.write_bytes(content) + job.source_path = str(source_path) + try: + broker.enqueue(job) + except QueueFull: + store.delete_job(job.id) + raise HTTPException( + status_code=429, detail="큐가 가득 찼습니다", headers={"Retry-After": "5"} + ) from None + + # queue_position 스냅샷 + pos = broker.queue_depth() - 1 + job.queue_position = pos + return JobCreateResponse(job_id=job.id, status=job.status.value, queue_position=max(pos, 0)) + + +@router.get("", response_model=list[JobStatusResponse]) +async def list_jobs( + request: Request, + principal: Principal = Depends(get_principal), +) -> list[JobStatusResponse]: + principal.require_scope("transcribe") + broker = _get_broker(request) + out = [] + for job in broker.list_jobs(limit=100): + if job.owner_key_id != principal.key_id: + continue + out.append(_to_status(job)) + return out + + +@router.get("/{job_id}", response_model=JobStatusResponse) +async def get_job( + job_id: str, + request: Request, + principal: Principal = Depends(get_principal), +) -> JobStatusResponse: + principal.require_scope("transcribe") + broker = _get_broker(request) + job = broker.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + _check_owner(job, principal) + return _to_status(job) + + +@router.get("/{job_id}/result", response_model=JobResultResponse) +async def get_result( + job_id: str, + request: Request, + format: Literal["json", "txt", "srt", "vtt"] = "json", + principal: Principal = Depends(get_principal), +) -> JobResultResponse: + principal.require_scope("transcribe") + broker = _get_broker(request) + store = _get_store(request) + job = broker.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + _check_owner(job, principal) + result = store.read_result(job_id) + if result is None: + raise HTTPException(status_code=409, detail="결과가 아직 준비되지 않았습니다") + return JobResultResponse(format=format, content=render(result, format)) + + +@router.delete("/{job_id}", response_model=JobStatusResponse) +async def cancel_job( + job_id: str, + request: Request, + principal: Principal = Depends(require_scope("transcribe")), +) -> JobStatusResponse: + broker = _get_broker(request) + job = broker.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + _check_owner(job, principal) + try: + cancelled = broker.cancel(job_id) + except JobNotFound: + raise HTTPException(status_code=404, detail="job not found") from None + return _to_status(cancelled) + + +def _check_owner(job: Job, principal: Principal) -> None: + if job.owner_key_id and job.owner_key_id != principal.key_id: + raise HTTPException(status_code=403, detail="다른 키가 만든 job에 접근할 수 없습니다") + + +def _to_status(job: Job) -> JobStatusResponse: + return JobStatusResponse( + job_id=job.id, + status=job.status.value, + queue_position=job.queue_position, + progress=job.progress, + processed_sec=job.processed_sec, + total_sec=job.total_sec, + error={"code": job.error_code, "message": job.error_message} if job.error_code else None, + result_available=job.status == JobStatus.COMPLETED, + ) diff --git a/src/luke_scribe/api/routes/stream.py b/src/luke_scribe/api/routes/stream.py new file mode 100644 index 0000000..827c830 --- /dev/null +++ b/src/luke_scribe/api/routes/stream.py @@ -0,0 +1,153 @@ +"""실시간 WebSocket 라우트 (plan §3.10b, §3.9c). + +- 첫 메시지 = init 프레임(인증 + 오디오 협상), 2초 내 미수신 시 close. +- unauthenticated 소켓/IP당 상한, init/오디오 프레임 크기 상한 (Eng P17). +- partial/final/status 이벤트 (LocalAgreement 안정화). +- 실시간 레인은 EngineOwner의 단일 GPU 락을 공유 (배치와 충돌 없음). +- 최대 동시 세션 상한 초과 → close. +""" + +from __future__ import annotations + +import asyncio +import json +import time + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from ...config import Settings +from ...pipeline.realtime import LocalAgreement +from ..deps import KeyStore + +router = APIRouter(tags=["realtime"]) + +# 세션/프레임 상한 +MAX_INIT_BYTES = 16 * 1024 +MAX_FRAME_BYTES = 512 * 1024 +MAX_PENDING_PER_IP = 8 + + +class _SessionGuard: + def __init__(self, settings: Settings) -> None: + self.settings = settings + self._active: set[str] = set() + self._pending_per_ip: dict[str, int] = {} + + def try_acquire(self, session_id: str, ip: str) -> bool: + if len(self._active) >= self.settings.realtime_max_sessions: + return False + if self._pending_per_ip.get(ip, 0) >= MAX_PENDING_PER_IP: + return False + self._active.add(session_id) + self._pending_per_ip[ip] = self._pending_per_ip.get(ip, 0) + 1 + return True + + def release(self, session_id: str, ip: str) -> None: + self._active.discard(session_id) + n = self._pending_per_ip.get(ip, 1) - 1 + if n <= 0: + self._pending_per_ip.pop(ip, None) + else: + self._pending_per_ip[ip] = n + + +async def _recv_json(ws: WebSocket, max_bytes: int, timeout_sec: float) -> dict | None: + try: + msg = await asyncio.wait_for(ws.receive_text(), timeout=timeout_sec) + except TimeoutError: + return None + except WebSocketDisconnect: + return None + if len(msg.encode("utf-8")) > max_bytes: + return None + try: + return json.loads(msg) + except json.JSONDecodeError: + return None + + +@router.websocket("/v1/stream") +async def stream(ws: WebSocket) -> None: + await ws.accept() + settings: Settings = ws.app.state.settings + keystore: KeyStore = ws.app.state.keystore + guard: _SessionGuard = ws.app.state.session_guard + ip = ws.client.host if ws.client else "unknown" + session_id = f"{ip}:{time.time_ns()}" + + if not guard.try_acquire(session_id, ip): + await ws.close(code=1013, reason="session limit reached") + return + try: + init = await _recv_json(ws, MAX_INIT_BYTES, settings.realtime_init_timeout_sec) + if init is None or init.get("type") != "init": + await ws.close(code=1008, reason="init frame required") + return + api_key = init.get("api_key") + principal = None + if api_key: + try: + principal = keystore.authenticate(api_key) + except Exception: + principal = None + if principal is None: + await ws.close(code=1008, reason="invalid api key") + return + + audio = init.get("audio") or {} + if not isinstance(audio, dict) or not audio.get("sample_rate"): + await ws.close(code=1003, reason="audio params required") + return + + agreement = LocalAgreement(settings) + await ws.send_json({"type": "status", "status": "ready", "session": session_id}) + + # 오디오 프레임 루프 — PCM16 청크를 가정 (v0.1: decode는 EngineOwner가 소유) + owner = ws.app.state.engine_owner + buf_sec = 0.0 + frames = 0 + while True: + try: + msg = await asyncio.wait_for( + ws.receive(), timeout=settings.realtime_session_idle_sec + ) + except TimeoutError: + # 세션 유휴 타임아웃 — 점유 해제 (Eng 리뷰 P17) + await ws.send_json( + {"type": "status", "status": "idle_timeout", "message": "유휴 세션 종료"} + ) + break + if msg.get("type") == "websocket.disconnect": + break + data = msg.get("bytes") + if not data or len(data) > MAX_FRAME_BYTES: + await ws.close(code=1009, reason="frame too large") + return + frames += 1 + # 16kHz mono s16 → chunk_duration + chunk_sec = len(data) / 2 / 16000 + buf_sec += chunk_sec + if buf_sec >= 5.0: # 재decode 간격 (정확도 우선 청킹) + # 가설 생성 — 실제 decode는 EngineOwner로 오프로드 (계약 §3.9a) + hypothesis = await asyncio.to_thread(owner.emit_hypothesis, data) + events = agreement.feed(hypothesis["segments"], chunk_sec) + for seg in events["confirmed"]: + await ws.send_json({"type": "final", "segment": seg.model_dump()}) + for seg in events.get("pending", []): + await ws.send_json( + {"type": "partial", "text": seg.text, "t0": seg.start, "t1": seg.end} + ) + buf_sec = 0.0 + except WebSocketDisconnect: + pass + except Exception as exc: + try: + await ws.send_json({"type": "status", "status": "error", "message": str(exc)}) + except Exception: + pass + finally: + guard.release(session_id, ip) + try: + await ws.close() + except Exception: + pass diff --git a/src/luke_scribe/api/schemas.py b/src/luke_scribe/api/schemas.py new file mode 100644 index 0000000..80a88b0 --- /dev/null +++ b/src/luke_scribe/api/schemas.py @@ -0,0 +1,73 @@ +"""API 요청/응답 스키마 (pydantic). + +- 업로드 옵션: language/model/device/compute_type/timestamps/word_timestamps/ + formats/hotwords/vad/post_correction +- 오류 envelope: code/message/retryable +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class TranscribeOptions(BaseModel): + language: str | None = "ko" # auto = null + model: str | None = None + device: str = "auto" + compute_type: str | None = None + timestamps: bool = True + word_timestamps: bool = False + formats: list[str] = Field(default_factory=lambda: ["json"]) + hotwords: list[str] = Field(default_factory=list) + vad: bool = True + glossary_id: str | None = None + post_correction: dict[str, Any] | None = None + diarize: bool = False + + +class JobCreateResponse(BaseModel): + job_id: str + status: str + queue_position: int | None = None + + +class JobStatusResponse(BaseModel): + job_id: str + status: str + queue_position: int | None = None + jobs_ahead: int | None = None + progress: float | None = None + processed_sec: float | None = None + total_sec: float | None = None + error: dict[str, Any] | None = None + result_available: bool = False + + +class JobResultResponse(BaseModel): + format: str + content: str + + +class SystemResponse(BaseModel): + capability_tier: str + device: dict[str, Any] + workers: int + queue_depth: int + models: list[str] + model_used: str | None = None + compute_type_used: str | None = None + + +class ErrorResponse(BaseModel): + code: str + message: str + retryable: bool = False + + +class StreamInit(BaseModel): + type: Literal["init"] + api_key: str + audio: dict[str, Any] = Field(default_factory=dict) + options: dict[str, Any] = Field(default_factory=dict) diff --git a/src/luke_scribe/audio/__init__.py b/src/luke_scribe/audio/__init__.py new file mode 100644 index 0000000..2813916 --- /dev/null +++ b/src/luke_scribe/audio/__init__.py @@ -0,0 +1 @@ +"""오디오 인제스트(ffmpeg)와 VAD.""" diff --git a/src/luke_scribe/audio/ingest.py b/src/luke_scribe/audio/ingest.py new file mode 100644 index 0000000..2f41de3 --- /dev/null +++ b/src/luke_scribe/audio/ingest.py @@ -0,0 +1,255 @@ +"""AudioIngestor — 입력 검증 + ffmpeg 16kHz mono 정규화 (스트리밍). + +계약 (plan §3.7b/§6.1 + design doc §3): +- 확장자가 아니라 **ffprobe**로 형식 검증. +- 60분/1GB(설계문서 v0.1) 또는 4h/2GB(플랫폼, config) 상한 — 초과 시 + ``unsupported_input_envelope``. +- ffmpeg는 전체 배열을 메모리에 올리지 않고 **파일로 스트리밍**. +- 모든 종료 경로에서 임시 파일 정리 (``finally``), 원본은 읽기 전용. +- 취소/오류 시 ffmpeg 프로세스 그룹 kill + reap (Eng 리뷰 P1). +- ffprobe/ffmpeg timeout: 기본 60s, CLI 옵션으로만 상향. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +from ..config import Settings +from ..errors import AudioProbeFailed, InvalidInput, UnsupportedInputEnvelope +from ..results.models import NormalizedAudio + + +@dataclass +class ProbeResult: + duration_sec: float | None + codec: str | None + size_bytes: int + has_audio: bool = True + + +@dataclass +class IngestResult: + normalized_path: str + normalized: NormalizedAudio + probe: ProbeResult + temp_dir: str + cleanup: callable = field(repr=False) + + def close(self) -> None: + self.cleanup() + + +class AudioIngestor: + def __init__( + self, + settings: Settings | None = None, + *, + ffprobe_bin: str = "ffprobe", + ffmpeg_bin: str = "ffmpeg", + probe_timeout: int = 60, + ) -> None: + self.settings = settings or Settings() + self._ffprobe = ffprobe_bin + self._ffmpeg = ffmpeg_bin + self._probe_timeout = probe_timeout + + def probe(self, source: Path) -> ProbeResult: + if not source.exists(): + raise InvalidInput(f"파일이 존재하지 않습니다: {source}") + if source.stat().st_size > self.settings.max_upload_bytes: + raise UnsupportedInputEnvelope( + f"파일 크기 {source.stat().st_size} 바이트가 상한 " + f"{self.settings.max_upload_bytes} 바이트를 초과합니다" + ) + cmd = [ + self._ffprobe, + "-v", + "error", + "-show_entries", + "format=duration:stream=codec_type,codec_name", + "-of", + "json", + str(source), + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=self._probe_timeout) + except FileNotFoundError as exc: + raise AudioProbeFailed( + f"ffprobe를 찾을 수 없습니다 ({self._ffprobe}). ffmpeg를 설치하세요: " + "apt install ffmpeg / brew install ffmpeg" + ) from exc + except subprocess.TimeoutExpired as exc: + raise AudioProbeFailed("ffprobe timeout (60s)") from exc + + if proc.returncode != 0: + raise AudioProbeFailed(f"입력 오디오를 해석할 수 없습니다. {proc.stderr.strip()[:300]}") + import json + + try: + data = json.loads(proc.stdout or "{}") + except Exception as exc: + raise AudioProbeFailed(f"ffprobe 출력 파싱 실패: {exc}") from exc + + streams = data.get("streams", []) + audio_streams = [s for s in streams if s.get("codec_type") == "audio"] + if not audio_streams: + raise AudioProbeFailed("입력에 오디오 스트림이 없습니다") + + duration = None + fmt = data.get("format") or {} + if fmt.get("duration"): + try: + duration = float(fmt["duration"]) + except (TypeError, ValueError): + duration = None + if duration is not None and duration > self.settings.max_duration_sec: + raise UnsupportedInputEnvelope( + f"오디오 길이 {duration:.0f}초가 상한 {self.settings.max_duration_sec}초를 초과합니다" + ) + return ProbeResult( + duration_sec=duration, + codec=audio_streams[0].get("codec_name"), + size_bytes=source.stat().st_size, + ) + + def ingest(self, source: Path, *, should_cancel: callable | None = None) -> IngestResult: + """검증 + ffmpeg로 16kHz mono PCM s16 WAV 정규화.""" + if should_cancel and should_cancel(): + from ..errors import CancelledError + + raise CancelledError("취소됨") + probe = self.probe(source) + + temp_dir = tempfile.mkdtemp(prefix="luke-scribe-ingest-") + out_path = Path(temp_dir) / "normalized.wav" + + def cleanup() -> None: + shutil.rmtree(temp_dir, ignore_errors=True) + + try: + if should_cancel and should_cancel(): + from ..errors import CancelledError + + raise CancelledError("취소됨") + cmd = [ + self._ffmpeg, + "-v", + "error", + "-y", + "-i", + str(source), + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "pcm_s16le", + "-f", + "wav", + str(out_path), + ] + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, # 프로세스 그룹 (kill/reap 용이) + ) + # stdout/stderr 병렬 drain (ffmpeg stderr 블로킹 방지) + assert proc.stdout is not None and proc.stderr is not None + out_thread = self._drain(proc.stdout) + err_data = self._read_all(proc.stderr) + + while True: + try: + rc = proc.wait(timeout=0.5) + break + except subprocess.TimeoutExpired: + if should_cancel and should_cancel(): + self._kill_group(proc) + from ..errors import CancelledError + + raise CancelledError("인제스트 중 취소") from None + out_thread.join(timeout=5) + if rc != 0: + raise AudioProbeFailed( + f"ffmpeg 정규화 실패 (exit {rc}): {err_data.decode(errors='replace')[:300]}" + ) + if not out_path.exists() or out_path.stat().st_size == 0: + raise AudioProbeFailed("ffmpeg가 출력 WAV를 생성하지 못했습니다") + + duration = probe.duration_sec + if duration is None: + # ffprobe가 몰랐던 경우: 파생 파일로 재측정 + duration = self._duration_of(out_path) or 0.0 + return IngestResult( + normalized_path=str(out_path), + normalized=NormalizedAudio( + duration_sec=max(duration, 0.001), + audio_format="pcm_s16le", + sample_rate=16000, + channels=1, + ), + probe=probe, + temp_dir=temp_dir, + cleanup=cleanup, + ) + except BaseException: + cleanup() + raise + + def _duration_of(self, wav: Path) -> float | None: + """ffprobe로 파생 WAV 길이 재측정.""" + try: + out = subprocess.run( + [ + self._ffprobe, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "json", + str(wav), + ], + capture_output=True, + text=True, + timeout=10, + ) + import json + + d = json.loads(out.stdout or "{}").get("format", {}).get("duration") + return float(d) if d else None + except Exception: + return None + + @staticmethod + def _drain(stream) -> os._wrap_close: + import threading + + def _read() -> None: + while stream.read(65536): + pass + + t = threading.Thread(target=_read, daemon=True) + t.start() + return t # type: ignore[return-value] + + @staticmethod + def _read_all(stream) -> bytes: + return stream.read() + + @staticmethod + def _kill_group(proc: subprocess.Popen) -> None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + try: + proc.kill() + except Exception: + pass diff --git a/src/luke_scribe/audio/vad.py b/src/luke_scribe/audio/vad.py new file mode 100644 index 0000000..faa26ea --- /dev/null +++ b/src/luke_scribe/audio/vad.py @@ -0,0 +1,52 @@ +"""VAD — 음성 구간 검출. + +Eng 리뷰 P19 반영: **레인당 VAD 소유자 1명** — 배치 레인은 faster-whisper +내장 VAD 필터를 그대로 사용하고(중복 VAD 금지), 실시간 레인은 청크 단위 +상태를 유지하는 별도 래퍼를 둔다. v0.1에서는 실시간 레인이 모델 decode와 +동일 프로세스(EngineOwner)에서 실행되므로 faster-whisper의 VAD 매개변수를 +공유한다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class VadConfig: + min_silence_duration_ms: int = 500 + speech_pad_ms: int = 200 + threshold: float = 0.5 + + +@dataclass +class StreamingVadState: + """실시간 레인용 상태형 VAD 래퍼. + + ``is_speech(chunk_energy_ratio)``를 호출하면 (발화시작, 발화종료) 이벤트를 + 누적한다. v0.1에서는 실제 Silero 추론 대신 엔진의 VAD에 위임하되 + 청크 경계 상태(무음 누적)만 여기서 추적한다. + """ + + config: VadConfig = field(default_factory=VadConfig) + _silence_ms: int = 0 + _in_speech: bool = False + + def feed(self, chunk_duration_ms: float, is_speech: bool) -> list[str]: + events: list[str] = [] + if is_speech: + if not self._in_speech: + self._in_speech = True + events.append("speech_start") + self._silence_ms = 0 + else: + if self._in_speech: + self._silence_ms += chunk_duration_ms + if self._silence_ms >= self.config.min_silence_duration_ms: + self._in_speech = False + events.append("speech_end") + return events + + def reset(self) -> None: + self._silence_ms = 0 + self._in_speech = False diff --git a/src/luke_scribe/benchmark/__init__.py b/src/luke_scribe/benchmark/__init__.py new file mode 100644 index 0000000..10feba6 --- /dev/null +++ b/src/luke_scribe/benchmark/__init__.py @@ -0,0 +1 @@ +"""벤치마크 — WER/K-CER/entity 보존율/RTF/VRAM-RSS, 모델 선택 게이트.""" diff --git a/src/luke_scribe/benchmark/metrics.py b/src/luke_scribe/benchmark/metrics.py new file mode 100644 index 0000000..b5a8b29 --- /dev/null +++ b/src/luke_scribe/benchmark/metrics.py @@ -0,0 +1,137 @@ +"""벤치마크 지표 계산 — WER, K-CER, entity 보존율. + +계약 (design doc §10): +- K-CER 정규화 순서: NFKC → 영문 lowercase(단, entity 평가는 원문 유지) → + 문장부호 제거 → whitespace 제거 → 숫자 통일 mapping → Unicode code point + 단위 Levenshtein. K-CER는 corpus-level micro average. +- WER: 원래 whitespace token 기준. +- entity 보존: reference-hypothesis 문자 alignment로 reference entity span을 + hypothesis에 투영, 앞뒤 8 code point window 안에 canonical이 정확히 존재하면 + 성공. 하나의 hypothesis occurrence는 한 annotation에만 매칭. +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass + + +def _strip_punct(text: str) -> str: + return re.sub(r"[\s\W_]+", "", text, flags=re.UNICODE) + + +def normalize_kcer(text: str) -> str: + """K-CER 정규화 — NFKC → lowercase → 문장부호/whitespace 제거.""" + text = unicodedata.normalize("NFKC", text) + text = text.lower() + return _strip_punct(text) + + +def _levenshtein(a: str, b: str) -> int: + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + cur = [i] + for j, cb in enumerate(b, 1): + cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))) + prev = cur + return prev[-1] + + +@dataclass +class ClipMetrics: + wer: float + k_cer: float + entities_preserved: int + entities_total: int + chars_ref: int + errors: int + + +def word_wer(reference: str, hypothesis: str) -> float: + """Whitespace token WER (0.0~1.0).""" + ref = reference.strip().split() + hyp = hypothesis.strip().split() + if not ref: + return 0.0 if not hyp else 1.0 + return _levenshtein(ref, hyp) / len(ref) + + +def character_kcer(reference: str, hypothesis: str) -> float: + """K-CER (0.0~1.0) — 정규화 후 코드포인트 Levenshtein / ref 길이.""" + ref = normalize_kcer(reference) + hyp = normalize_kcer(hypothesis) + if not ref: + return 0.0 if not hyp else 1.0 + return _levenshtein(ref, hyp) / len(ref) + + +def entity_retention( + reference: str, + hypothesis: str, + entities: list[dict], + window: int = 8, +) -> tuple[int, int]: + """(보존 성공, 전체) — 설계문서 §10 alignment 기반. + + entity: {canonical, surface, start_char, end_char} (Unicode code point index). + """ + if not entities: + return 0, 0 + preserved = 0 + ref_cp = list(reference) + hyp_cp = list(hypothesis) + used_hyp_spans: list[tuple[int, int]] = [] + + for ent in entities: + canonical = ent.get("canonical", "") + if not canonical: + continue + start = ent.get("start_char", 0) + end = ent.get("end_char", len(canonical)) + ref_span = "".join(ref_cp[start:end]) + if not ref_span: + continue + # hypothesis에서 canonical을 앞뒤 window 안에서 찾기 (span 기반 매칭) + found = False + hyp_text = "".join(hyp_cp) + idx = 0 + while True: + pos = hyp_text.find(canonical, idx) + if pos == -1: + break + lo = max(0, pos - window) + hi = min(len(hyp_text), pos + len(canonical) + window) + # 중복 매칭 방지 + if not any(lo < s < hi for s, e in used_hyp_spans): + # 시작 근처 window 안 + if abs(pos - start) <= window: + found = True + used_hyp_spans.append((pos, pos + len(canonical))) + break + idx = pos + 1 + if found: + preserved += 1 + return preserved, len([e for e in entities if e.get("canonical")]) + + +def clip_metrics(reference: str, hypothesis: str, entities: list[dict]) -> ClipMetrics: + wer = word_wer(reference, hypothesis) + k_cer = character_kcer(reference, hypothesis) + preserved, total = entity_retention(reference, hypothesis, entities) + ref_cp = len(normalize_kcer(reference)) + errors = int(k_cer * ref_cp) if ref_cp else 0 + return ClipMetrics( + wer=wer, + k_cer=k_cer, + entities_preserved=preserved, + entities_total=total, + chars_ref=ref_cp, + errors=errors, + ) diff --git a/src/luke_scribe/benchmark/runner.py b/src/luke_scribe/benchmark/runner.py new file mode 100644 index 0000000..32387e6 --- /dev/null +++ b/src/luke_scribe/benchmark/runner.py @@ -0,0 +1,294 @@ +"""벤치마크 실행기 — manifest → 모델별 지표 → 모델 선택 게이트 → report. + +계약 (design doc §10): +- 고정 decode 설정 (beam_size 5, temperature 0, VAD on). +- 모델·hotword variant별 지표 + RTF min/median/max + peak RSS/VRAM. +- 절대 기준: entity 보존 ≥95%, K-CER ≤15%, 실패율 0%. +- 게이트: turbo가 기준 통과 + K-CER 15% 이내 열등 + RTF ≥10% 빠르면 + turbo 기본; 아니면 large-v3. 둘 다 실패 → no_acceptable_model. +- 단일 모델 run은 decision artifact를 생성/변경하지 않는다. +""" + +from __future__ import annotations + +import hashlib +import json +import time +from pathlib import Path + +from ..config import Settings +from ..devices.vram_probe import peak_process_rss_mb, runtime_env_snapshot +from .metrics import clip_metrics + +ABSOLUTE_ENTITY_RETENTION = 0.95 +ABSOLUTE_K_CER = 0.15 +ABSOLUTE_FAILURE_RATE = 0.0 +TURBO_RELATIVE_K_CER_SLACK = 0.15 # turbo가 large-v3보다 15% 넘게 나쁘면 안 됨 +TURBO_RTF_SPEEDUP = 0.10 # turbo median RTF가 10% 이상 빨라야 함 + +REPORT_VERSION = "1.0" +DECISION_VERSION = "1.0" + + +def load_manifest(path: Path) -> dict: + import yaml # type: ignore[import-not-found] + + if path.suffix in (".yaml", ".yml"): + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return json.loads(path.read_text(encoding="utf-8")) + + +def run_benchmark( + *, + manifest: Path, + models: list[str], + device: str, + compute_type: str | None, + repeats: int, + output: Path, + hotwords: list[str], + decision: Path | None, + settings: Settings, +) -> dict: + data = load_manifest(manifest) + clips = data.get("clips", []) + if not clips: + from ..errors import InvalidInput + + raise InvalidInput("manifest에 clips가 없습니다") + + report: dict = { + "report_version": REPORT_VERSION, + "dataset_version": data.get("dataset_version", "1.0"), + "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "environment": runtime_env_snapshot(), + "run_config": { + "device": device, + "compute_type": compute_type, + "repeats": repeats, + "models": models, + "hotword_set": hotwords, + "beam_size": 5, + "temperature": 0.0, + "vad_filter": True, + }, + "models": [], + "decision": {"status": "pending", "default_model": None, "reasons": []}, + } + + from ..engine.owner import EngineOwner + + owner = EngineOwner.get(settings) + model_results = {} + for model in models: + agg = _run_model(owner, clips, model, device, compute_type, repeats, hotwords, report) + model_results[model] = agg + report["models"].append(agg["summary"]) + + # 단일 모델 run: report만 생성, decision artifact 금지 + if len(models) < 2: + report["decision"] = { + "status": "no_decision", + "default_model": None, + "reasons": ["단일 모델 run은 decision artifact를 생성하지 않습니다"], + } + _write_report(output, report) + return report + + report["decision"] = _decide(model_results) + _write_report(output, report) + if decision is not None and report["decision"]["status"] != "no_decision": + _write_decision(decision, report) + return report + + +def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, report) -> dict: + from ..engine.base import TranscriptionOptions + + options = TranscriptionOptions( + model=model, + language="ko", + device=device, + compute_type=compute_type, + hotwords=hotwords, + ) + # warm-up (비평가 클립 1회) + clip0 = clips[0] + _transcribe_clip(owner, options, clip0) + + rtf_samples: list[float] = [] + rss_samples: list[float] = [] + aggregate = { + "wer": [], + "k_cer": [], + "entities_preserved": 0, + "entities_total": 0, + "failures": 0, + } + all_chars = 0 + all_errors = 0 + failures = 0 + + for clip in clips: + ref_path = clip.get("reference_path") + ref_text = "" + if ref_path: + p = Path(ref_path) + if p.exists(): + ref_text = p.read_text(encoding="utf-8").strip() + entities = clip.get("entities", []) + clip_rtfs: list[float] = [] + clip_success = False + for _ in range(repeats): + try: + result = _transcribe_clip(owner, options, clip) + m = clip_metrics(ref_text, result["text"], entities) + clip_rtfs.append(result["rtf"]) + rss_samples.append(peak_process_rss_mb()) + if result["rtf"] is not None: + rtf_samples.append(result["rtf"]) + aggregate["wer"].append(m.wer) + aggregate["k_cer"].append(m.k_cer) + aggregate["entities_preserved"] += m.entities_preserved + aggregate["entities_total"] += m.entities_total + all_chars += m.chars_ref + all_errors += m.errors + clip_success = True + except Exception: + failures += 1 + aggregate["failures"] += 0 if clip_success else 1 + + n = max(1, len(aggregate["wer"])) + corpus_cer = all_errors / all_chars if all_chars else 0.0 + failure_rate = aggregate["failures"] / len(clips) + entity_rate = ( + aggregate["entities_preserved"] / aggregate["entities_total"] + if aggregate["entities_total"] + else 1.0 + ) + sorted_rtf = sorted(rtf_samples) + summary = { + "model": model, + "hotword_set": report["run_config"]["hotword_set"], + "wer": (sum(aggregate["wer"]) / n), + "k_cer": corpus_cer, + "entity_retention": entity_rate, + "failure_rate": failure_rate, + "rtf_min": sorted_rtf[0] if sorted_rtf else None, + "rtf_median": sorted_rtf[len(sorted_rtf) // 2] if sorted_rtf else None, + "rtf_max": sorted_rtf[-1] if sorted_rtf else None, + "peak_rss_min_mb": min(rss_samples) if rss_samples else None, + "peak_rss_median_mb": sorted(rss_samples)[len(rss_samples) // 2] if rss_samples else None, + "peak_rss_max_mb": max(rss_samples) if rss_samples else None, + "samples": n, + } + return {"summary": summary} + + +def _transcribe_clip(owner, options, clip) -> dict: + """클립 전사 — 세그먼트 소비 + text/rtf 반환.""" + from ..engine.owner import InferenceRequest + + audio_path = clip.get("audio_path") + if not audio_path: + raise ValueError("clip에 audio_path 없음") + duration = clip.get("duration_sec") or 60.0 + t0 = time.time() + req = InferenceRequest(audio_path=audio_path, options=options, lane="batch") + outcome = owner.transcribe(req) + texts = [] + for seg in outcome["segments"]: + texts.append(seg.get("text", "")) + elapsed = time.time() - t0 + return {"text": " ".join(t for t in texts if t), "rtf": elapsed / duration} + + +def _decide(model_results: dict) -> dict: + def passes(name: str) -> bool: + s = model_results[name]["summary"] + return ( + s["entity_retention"] >= ABSOLUTE_ENTITY_RETENTION + and s["k_cer"] <= ABSOLUTE_K_CER + and s["failure_rate"] <= ABSOLUTE_FAILURE_RATE + ) + + turbo = "large-v3-turbo" if "large-v3-turbo" in model_results else None + large = "large-v3" if "large-v3" in model_results else None + if turbo is None or large is None: + return {"status": "no_decision", "default_model": None, "reasons": ["두 모델 비교 필요"]} + + t_pass, l_pass = passes(turbo), passes(large) + if not t_pass and not l_pass: + return { + "status": "no_acceptable_model", + "default_model": None, + "reasons": ["모든 모델이 절대 기준 미달 (entity<95% 또는 K-CER>15% 또는 실패>0)"], + } + if l_pass and not t_pass: + return { + "status": "approved", + "default_model": large, + "reasons": ["turbo가 절대 기준 미달, large-v3 채택"], + } + if t_pass and not l_pass: + return { + "status": "approved", + "default_model": turbo, + "reasons": ["large-v3가 절대 기준 미달, turbo 채택"], + } + + # 둘 다 통과 → 상대 비교 + ts, ls = model_results[turbo]["summary"], model_results[large]["summary"] + reasons = [] + ok = True + if ts["entity_retention"] < ABSOLUTE_ENTITY_RETENTION: + ok = False + reasons.append("turbo entity 보존 <95%") + if ts["k_cer"] > ls["k_cer"] * (1 + TURBO_RELATIVE_K_CER_SLACK): + ok = False + reasons.append("turbo K-CER가 large-v3보다 15% 초과 열등") + if ts["failure_rate"] > 0: + ok = False + reasons.append("turbo 실패율 >0") + turbo_rtf = ts["rtf_median"] or float("inf") + large_rtf = ls["rtf_median"] or float("inf") + if not (turbo_rtf <= large_rtf * (1 - TURBO_RTF_SPEEDUP)): + ok = False + reasons.append("turbo RTF가 large-v3보다 10% 이상 빠르지 않음") + if ok: + return { + "status": "approved", + "default_model": turbo, + "reasons": reasons or ["터보가 기준 충족 + 속도 우위"], + } + return { + "status": "approved", + "default_model": large, + "reasons": reasons or ["상대 비교에서 large-v3 채택"], + } + + +def _write_report(output: Path, report: dict) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_decision(decision: Path, report: dict) -> None: + decision.parent.mkdir(parents=True, exist_ok=True) + d = report["decision"] + payload = { + "decision_version": DECISION_VERSION, + "status": d["status"], + "default_model": d["default_model"], + "default_hotword_set": None, + "hotword_artifact": None, + "dataset_version": report["dataset_version"], + "model_variants": {}, + "decided_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "reasons": d["reasons"], + } + decision.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() diff --git a/src/luke_scribe/cli.py b/src/luke_scribe/cli.py new file mode 100644 index 0000000..2fb038f --- /dev/null +++ b/src/luke_scribe/cli.py @@ -0,0 +1,256 @@ +"""luke_scribe CLI — detect / transcribe / bench / serve / key. + +Exit codes (design doc §8): +- 0 성공, 2 입력 오류(CLI 문법·invalid_input·probe·범위), 3 모델/장치/결정 오류, + 4 추론/OOM 오류, 5 결과 파일 write 오류, 130 인터럽트. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import typer +from rich.console import Console + +from .config import Settings, get_settings +from .errors import LukeScribeError, OutputWriteError +from .results.models import TranscriptResult +from .results.store import AtomicFileWriter + +app = typer.Typer(help="내부용 로컬 STT 전사 API — faster-whisper, hardware-adaptive") +console = Console(stderr=True) + +EXIT_OK = 0 +EXIT_INPUT = 2 +EXIT_MODEL_DEVICE = 3 +EXIT_TRANSCRIPTION = 4 +EXIT_WRITE = 5 + + +def _error_code_for(exc: LukeScribeError) -> int: + if exc.code in ("config_error",): + return EXIT_INPUT + if exc.code in ( + "invalid_input", + "audio_probe_failed", + "unsupported_input_envelope", + "job_not_found", + "job_cancelled", + "job_already_terminal", + "queue_full", + ): + return EXIT_INPUT + if exc.code in ( + "device_unavailable", + "model_download_failed", + "model_unavailable_offline", + "model_load_failed", + "invalid_model_decision", + "model_decision_blocked", + ): + return EXIT_MODEL_DEVICE + if exc.code in ("out_of_memory", "transcription_failed"): + return EXIT_TRANSCRIPTION + if exc.code == "output_write_failed": + return EXIT_WRITE + return EXIT_TRANSCRIPTION + + +@app.command() +def detect( + device: str = typer.Option("auto", help="auto | cpu | cuda | cuda:N"), + compute_type: str | None = typer.Option(None, help="float16 | int8_float16 | int8 | auto"), + json_output: bool = typer.Option(True, "--json", help="JSON 출력"), +) -> None: + """하드웨어 감지 → 능력 등급/정밀도/워커수 출력.""" + from .devices.manager import DeviceManager + + manager = DeviceManager() + profile = manager.detect(device=device, compute_type=compute_type) + print(json.dumps(profile.to_dict(), ensure_ascii=False, indent=2)) + raise typer.Exit(EXIT_OK) + + +@app.command() +def transcribe( + source: Path = typer.Argument(..., help="WAV/MP3 파일 경로"), + language: str = typer.Option("ko", help="언어 (auto = 자동 감지)"), + model: str | None = typer.Option( + None, help="large-v3-turbo | large-v3 (기본: 결정 파일 또는 turbo)" + ), + device: str = typer.Option("auto", help="auto | cpu | cuda | cuda:N"), + compute_type: str | None = typer.Option( + None, help="float16 | int8_float16 | int8 (기본: 자동)" + ), + vad: bool = typer.Option(True, "--vad/--no-vad"), + hotword: list[str] = typer.Option([], "--hotword", help="반복 가능"), + output: Path | None = typer.Option(None, "--output", "-", help="결과 파일 (기본 stdout)"), + force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"), + word_timestamps: bool = typer.Option(False, "--word-timestamps"), + log_level: str = typer.Option("INFO", "--log-level"), +) -> None: + """단일 파일 전사 → TranscriptResult JSON.""" + from .engine.base import TranscriptionOptions + from .pipeline.batch import BatchPipeline + + settings = get_settings() + _setup_logging(settings, log_level) + + if output is not None and output.exists() and not force: + _fail(EXIT_INPUT, f"출력 파일이 이미 존재합니다: {output} (--force로 덮어쓰기)") + + from .jobqueue.cancel import CancellationToken + + token = CancellationToken() + options = TranscriptionOptions( + model=model or (settings.model_rt if False else settings.model_batch), + language=None if language == "auto" else language, + device=device, + compute_type=compute_type, + vad=vad, + hotwords=hotword, + word_timestamps=word_timestamps, + ) + try: + pipeline = BatchPipeline(settings=settings, token=token) + result = pipeline.run(source, options, source_name=source.name) + except LukeScribeError as exc: + if output is not None: + failed = TranscriptResult( + status="failed", + source={"name": source.name} if source.exists() else None, + error={"code": exc.code, "message": exc.message, "retryable": exc.retryable}, + ) + try: + AtomicFileWriter.write( + output, json.dumps(failed.model_dump(), ensure_ascii=False, indent=2) + ) + except OutputWriteError as wexc: + _fail(EXIT_WRITE, str(wexc)) + _fail(_error_code_for(exc), exc.message) + + payload = json.dumps(result.model_dump(), ensure_ascii=False, indent=2) + if output is not None: + try: + AtomicFileWriter.write(output, payload) + except OutputWriteError as exc: + _fail(EXIT_WRITE, str(exc)) + else: + print(payload) + raise typer.Exit(EXIT_OK) + + +@app.command() +def bench( + manifest: Path = typer.Argument(..., help="benchmark manifest (YAML/JSON)"), + models: str = typer.Option("large-v3-turbo,large-v3", help="모델 목록 (쉼표)"), + device: str = typer.Option("auto", help="auto | cpu | cuda"), + compute_type: str | None = typer.Option(None, help="정밀도 override"), + repeats: int = typer.Option(3, "--repeats"), + output: Path = typer.Option(..., "--output", help="report JSON 경로"), + decision: Path | None = typer.Option(None, "--decision", help="모델 결정 artifact 경로"), + hotwords: list[str] = typer.Option([], "--hotword"), +) -> None: + """turbo vs large-v3 도메인 벤치마크 (하이브리드 게이트).""" + from .benchmark.runner import run_benchmark + + settings = get_settings() + _setup_logging(settings, "INFO") + try: + report = run_benchmark( + manifest=manifest, + models=[m.strip() for m in models.split(",") if m.strip()], + device=device, + compute_type=compute_type, + repeats=repeats, + output=output, + hotwords=hotwords, + decision=decision, + settings=settings, + ) + except LukeScribeError as exc: + _fail(_error_code_for(exc), exc.message) + print(f"benchmark report: {output}") + print(json.dumps(report, ensure_ascii=False, indent=2)) + raise typer.Exit(EXIT_OK) + + +@app.command() +def serve( + host: str = typer.Option(None, help="bind host"), + port: int = typer.Option(None, help="bind port"), + tunnel: str | None = typer.Option(None, help="none | cloudflare"), + workers: int = typer.Option(0, help="배치 워커 수 (0=자동)"), +) -> None: + """FastAPI 서버 시작 (in-proc 또는 Redis 큐).""" + import uvicorn + + from .api.app import create_app + + settings = get_settings() + _setup_logging(settings, settings.log_level) + if tunnel: + settings.tunnel = tunnel # type: ignore[assignment] + if workers: + settings.workers = workers + app_obj = create_app(settings) + queue_backend = settings.queue_backend + console.print( + f"[green]luke-scribe serve[/] profile=dev queue={queue_backend} " + f"host={host or settings.host} port={port or settings.port}" + ) + uvicorn.run( + app_obj, + host=host or settings.host, + port=port or settings.port, + log_level=settings.log_level.lower(), + ) + + +@app.command() +def key( + create: bool = typer.Option(False, "--create", help="새 API 키 생성 (1회만 출력)"), + scopes: str = typer.Option("transcribe", "--scopes", help="쉼표 구분 스코프"), + file: Path | None = typer.Option(None, "--file", help="키 저장 파일"), +) -> None: + """API 키 관리 (DX: 생성 시 1회 출력 + 다이제스트 저장).""" + from .api.deps import KeyStore + + settings = get_settings() + keystore = KeyStore(settings) + if create: + created = keystore.create_key( + [s.strip() for s in scopes.split(",") if s.strip()], + save_path=str(file) if file else None, + ) + print(json.dumps(created, ensure_ascii=False, indent=2)) + print("[!] 키는 이번 한 번만 표시됩니다. 다시 조회할 수 없습니다.", file=sys.stderr) + raise typer.Exit(EXIT_OK) + print(json.dumps({"keys": sorted(keystore._keys.keys())}, ensure_ascii=False)) + raise typer.Exit(EXIT_OK) + + +def _setup_logging(settings: Settings, log_level: str) -> None: + from .observability.logging import setup_logging + + setup_logging(log_level, json_lines=settings.log_json) + + +def _fail(code: int, message: str) -> None: + console.print(f"[red]오류:[/] {message}") + raise typer.Exit(code) + + +def main() -> None: + try: + app() + except typer.Exit: + raise + except KeyboardInterrupt: + raise typer.Exit(130) from None + + +if __name__ == "__main__": + main() diff --git a/src/luke_scribe/config.py b/src/luke_scribe/config.py new file mode 100644 index 0000000..5aa7168 --- /dev/null +++ b/src/luke_scribe/config.py @@ -0,0 +1,178 @@ +"""luke_scribe 설정 (pydantic-settings). + +모든 값은 환경변수 ``LUKESCRIBE_*`` 또는 ``.env``로 설정한다. +기본값은 dev/Colab 단일 프로세스 프로파일이다. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +MODEL_RT_DEFAULT = "large-v3-turbo" +MODEL_BATCH_DEFAULT = "large-v3-turbo" +SUPPORTED_MODELS = {"large-v3-turbo", "large-v3"} +COMPUTE_TYPES = {"auto", "float16", "int8_float16", "int8"} +DEVICE_VALUES = {"auto", "cpu", "cuda"} + + +class Settings(BaseSettings): + """모든 설정. ``LUKESCRIBE_`` 프리픽스.""" + + model_config = SettingsConfigDict( + env_prefix="LUKESCRIBE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # ── 모델 ── + model_rt: str = MODEL_RT_DEFAULT + model_batch: str = MODEL_BATCH_DEFAULT + model_cache_dir: str | None = None + model_download_retries: int = 1 + + # ── 장치 ── + device: str = "auto" + compute_type: str = "auto" + workers: int = 0 # 0 = Device Manager 자동 산정 + + # ── 언어 / 후처리 ── + language: str = "ko" + post_mode: Literal["none", "glossary", "rules", "llm"] = "rules" + post_enabled: bool = True + + # ── API 키 ── + api_keys: str = "" + api_key_file: str = "api_keys.json" + admin_scopes: str = "admin" + + # ── 큐 ── + queue_backend: Literal["redis", "inproc"] = "inproc" + redis_url: str = "redis://localhost:6379/0" + max_queue: int = 100 + job_timeout_hours: float = 4.0 + job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진 + + # ── 입력 상한 ── + max_duration_sec: int = 14400 # 4h + max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB + + # ── 보관 / 프라이버시 ── + retention_days: int = 7 + results_dir: str | None = None + delete_source: bool = True + + # ── LLM 후처리 (기본 off) ── + llm_backend: Literal["none", "local", "openai", "external"] = "none" + llm_allowlist: str = "" # provider_id=url, 쉼표 구분 + llm_model: str = "" + llm_audit: bool = True + llm_confidence_gate: float = 0.6 + + # ── 터널 ── + tunnel: Literal["none", "cloudflare"] = "none" + cloudflared_path: str | None = None + + # ── 관측 ── + log_level: str = "INFO" + log_json: bool = False + + # ── API 서버 ── + host: str = "0.0.0.0" + port: int = 8000 + + # ── VAD ── + vad_enabled: bool = True + vad_min_silence_ms: int = 500 + vad_speech_pad_ms: int = 200 + + # ── 실시간 ── + realtime_max_sessions: int = 4 + realtime_redecode_window_sec: float = 15.0 + realtime_retained_left_context_sec: float = 5.0 + realtime_init_timeout_sec: float = 2.0 + realtime_max_frame_bytes: int = 512 * 1024 + realtime_session_idle_sec: float = 120.0 + + @field_validator("model_rt", "model_batch") + @classmethod + def _validate_model(cls, v: str) -> str: + if v not in SUPPORTED_MODELS: + raise ValueError(f"지원 모델: {sorted(SUPPORTED_MODELS)} (got {v!r})") + return v + + @field_validator("compute_type") + @classmethod + def _validate_compute_type(cls, v: str) -> str: + if v not in COMPUTE_TYPES: + raise ValueError(f"compute_type: {COMPUTE_TYPES} (got {v!r})") + return v + + @field_validator("device") + @classmethod + def _validate_device(cls, v: str) -> str: + if v in DEVICE_VALUES: + return v + if v.startswith("cuda:"): + return v + raise ValueError(f"device: auto|cpu|cuda|cuda:N (got {v!r})") + + def parse_api_keys(self) -> dict[str, set[str]]: + """``key:scope1,scope2`` 목록을 {키: 스코프셋}으로 파싱. + + 콤마는 키 구분자이면서 스코프 구분자이므로, ``:`` 뒤의 연속 토큰을 + 같은 키의 스코프로 누적한다 (예: ``admin-key:admin,transcribe`` → + admin-key는 {admin, transcribe}). + + 주의: 스코프를 가진 키 뒤에 오는 평문 토큰은 스코프로 해석된다 + (``key-admin:admin,key-two``는 key-two가 스코프로 흡수). 혼동을 + 피하려면 **스코프 있는 키를 목록 끝에** 두거나, 프로덕션에서는 + ``api_key_file``(다이제스트)을 사용한다. + """ + out: dict[str, set[str]] = {} + current_key: str | None = None + for item in self.api_keys.split(","): + item = item.strip() + if not item: + continue + if ":" in item: + key, _, scopes = item.partition(":") + current_key = key.strip() + out[current_key] = {s.strip() for s in scopes.split(",") if s.strip()} or { + "transcribe" + } + elif current_key is not None and current_key in out: + # 이전 키의 스코프 연속 토큰 + out[current_key].add(item) + else: + out[item] = {"transcribe"} + current_key = None + return out + + def parse_llm_allowlist(self) -> dict[str, str]: + """``provider_id=url`` 목록 → {provider_id: url}.""" + out: dict[str, str] = {} + for item in self.llm_allowlist.split(","): + item = item.strip() + if not item: + continue + if "=" in item: + pid, _, url = item.partition("=") + out[pid.strip()] = url.strip() + return out + + @property + def results_root(self) -> str: + import tempfile + + return self.results_dir or f"{tempfile.gettempdir()}/luke-scribe-results" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/src/luke_scribe/connectivity/__init__.py b/src/luke_scribe/connectivity/__init__.py new file mode 100644 index 0000000..7a47333 --- /dev/null +++ b/src/luke_scribe/connectivity/__init__.py @@ -0,0 +1 @@ +"""연결성 — Colab 외부 노출 터널.""" diff --git a/src/luke_scribe/connectivity/tunnel.py b/src/luke_scribe/connectivity/tunnel.py new file mode 100644 index 0000000..103d734 --- /dev/null +++ b/src/luke_scribe/connectivity/tunnel.py @@ -0,0 +1,67 @@ +"""Cloudflare Quick Tunnel — API lifespan에 종속 supervise (plan §3.10d/P5-17). + +- cloudflared 바이너리로 Quick Tunnel 시작, URL 파싱. +- URL 회전 시 재출력, 임시성 명시. +- v0.1 환경(바이너리 부재)에서는 명확한 오류 (fail explicit). +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import threading +import time + +from ..config import Settings + +_URL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com") + + +class CloudflareTunnel: + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + self._proc: subprocess.Popen | None = None + self._lock = threading.Lock() + self.public_url: str | None = None + self._lines: list[str] = [] + + def start(self, timeout_sec: float = 30.0) -> str: + binary = self.settings.cloudflared_path or shutil.which("cloudflared") + if not binary: + raise RuntimeError( + "cloudflared 바이너리를 찾을 수 없습니다. `cloudflared`를 설치하거나 " + "LUKESCRIBE_CLOUDFLARED_PATH를 설정하세요." + ) + self._proc = subprocess.Popen( + [binary, "tunnel", "--url", "http://localhost:8000", "--no-autoupdate"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + deadline = time.time() + timeout_sec + assert self._proc.stdout is not None + while time.time() < deadline: + line = self._proc.stdout.readline() + if not line: + break + self._lines.append(line) + m = _URL_RE.search(line) + if m: + self.public_url = m.group(0) + return self.public_url + if self._proc.poll() is not None: + raise RuntimeError( + f"cloudflared가 즉시 종료되었습니다 (exit {self._proc.returncode})" + ) + raise RuntimeError("cloudflared URL을 감지하지 못했습니다 (timeout)") + + def stop(self) -> None: + with self._lock: + if self._proc is not None: + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc = None diff --git a/src/luke_scribe/devices/__init__.py b/src/luke_scribe/devices/__init__.py new file mode 100644 index 0000000..d74c15b --- /dev/null +++ b/src/luke_scribe/devices/__init__.py @@ -0,0 +1 @@ +"""하드웨어 감지·능력 등급·VRAM 프로빙.""" diff --git a/src/luke_scribe/devices/manager.py b/src/luke_scribe/devices/manager.py new file mode 100644 index 0000000..d3cf1d7 --- /dev/null +++ b/src/luke_scribe/devices/manager.py @@ -0,0 +1,181 @@ +"""Device Manager — 하드웨어 감지 → 능력 등급(T0~T3) → 정밀도/워커수 결정. + +계약 (plan §3.6): +- 부팅 시 실측(VRAM/RAM/디스크), 정적 상수는 측정 전 폴백. +- 등급: T0 CPU / T1 turbo-GPU / T2 스왑 / T3 동시상주. T4(다중복제) 제외. +- 정밀도: cc>=7.0 & free>=12GB → float16; cc>=7.0 & free<12GB → int8_float16; + Pascal(6.x) → int8; CPU → int8. +- 워커수: max(1, floor((free − reserve) / per_worker)). +- 명시 override(device/cuda:N)는 자동으로 바꾸지 않고 검증 후 실패. +- ``auto`` 모드에서만 CPU 폴백 허용, 경고 기록. +""" + +from __future__ import annotations + +from .profile import DeviceProfile +from .vram_probe import GpuInfo, SystemInfo, probe_system + +# 측정 전 보수 폴백 상수 (MB). 실측 실패 시에만 사용. +CONSERVATIVE_MODEL_FOOTPRINT_MB = { + "large-v3": {"float16": 10240, "int8_float16": 5120, "int8": 3584}, + "large-v3-turbo": {"float16": 4096, "int8_float16": 2560, "int8": 1843}, +} + +HEADROOM_MB = 1024 # 예비 VRAM (헤드룸) +RESERVE_MB = 2048 # 비실시간 여유 예비 + + +class DeviceManager: + """장치 탐지와 실행 결정을 캡슐화한다.""" + + def __init__(self, system: SystemInfo | None = None) -> None: + self.system = system or probe_system() + + def detect(self, *, device: str = "auto", compute_type: str | None = None) -> DeviceProfile: + """장치 결정. ``device``는 auto|cpu|cuda|cuda:N. + + Raises: + LukeScribeError: 명시 override를 만족할 수 없을 때 (fallback 없이 실패). + """ + from ..errors import DeviceUnavailable + + gpus = self.system.gpus + explicit = device != "auto" + selected_gpu: GpuInfo | None = None + warnings: list[str] = [] + + if explicit: + if device == "cpu": + profile = self._build( + device="cpu", gpu=None, compute_type=compute_type, source="explicit" + ) + profile.warnings = warnings + return profile + if device.startswith("cuda"): + idx = 0 + if ":" in device: + idx = int(device.split(":", 1)[1]) + if not gpus or idx >= len(gpus): + raise DeviceUnavailable( + f"요청한 CUDA 장치 {device}를 찾을 수 없습니다 (감지 GPU {len(gpus)}개)" + ) + selected_gpu = gpus[idx] + profile = self._build( + device=device, gpu=selected_gpu, compute_type=compute_type, source="explicit" + ) + profile.warnings = warnings + return profile + raise DeviceUnavailable(f"알 수 없는 장치 요청: {device!r}") + + # auto + if not gpus: + profile = self._build(device="cpu", gpu=None, compute_type=compute_type, source="auto") + profile.warnings.append("GPU가 감지되지 않아 CPU int8로 실행합니다") + return profile + + # GPU 존재: 첫 GPU 기준으로 등급/정밀도 결정 + gpu = gpus[0] + profile = self._build( + device=f"cuda:{gpu.index}", gpu=gpu, compute_type=compute_type, source="auto" + ) + + # 모델 적재 가능성 실측 결과가 없으므로 보수 상수로 fit 판단 + tier = self._determine_tier(gpu, profile.selected_compute_type) + profile.capability_tier = tier + if tier == "T0": + profile = self._build(device="cpu", gpu=None, compute_type="int8", source="auto") + profile.capability_tier = "T0" + profile.warnings.append("GPU로 모델을 적재하기 어려워 CPU int8로 실행합니다") + profile.warnings.extend(warnings) + return profile + + def _determine_tier(self, gpu: GpuInfo, compute_type: str) -> str: + free = gpu.vram_free_mb or 0 + turbo_fit = self._fits(free, "large-v3-turbo", compute_type) + large_fit = self._fits(free, "large-v3", compute_type) + if not turbo_fit and not large_fit: + return "T0" + if turbo_fit and not large_fit: + return "T1" + if large_fit and not turbo_fit: + return "T1" # turbo는 항상 large-v3보다 작으므로 이 경우는 사실상 없음 + # 둘 다 fit: 동시 상주 가능한지 + total = turbo_fit and large_fit + both = ( + self._fits(free, "large-v3", compute_type) + and self._fits(free, "large-v3-turbo", compute_type) + and free + >= self._footprint("large-v3", compute_type) + + self._footprint("large-v3-turbo", compute_type) + + RESERVE_MB + ) + return "T3" if both else ("T2" if total else "T1") + + def _fits(self, free_mb: int, model: str, compute_type: str) -> bool: + return free_mb >= self._footprint(model, compute_type) + HEADROOM_MB + + def _footprint(self, model: str, compute_type: str) -> int: + ct = "float16" if compute_type == "int8_float16" and model == "large-v3" else compute_type + return CONSERVATIVE_MODEL_FOOTPRINT_MB.get(model, {}).get( + ct, CONSERVATIVE_MODEL_FOOTPRINT_MB[model]["int8"] + ) + + def _build( + self, + *, + device: str, + gpu: GpuInfo | None, + compute_type: str | None, + source: str, + ) -> DeviceProfile: + if device == "cpu" or gpu is None: + selected_ct = compute_type or "int8" + return DeviceProfile( + requested_device="auto" if source == "auto" else "cpu", + selected_device="cpu", + selection_source=source, + device_name="CPU", + requested_compute_type=compute_type, + selected_compute_type=selected_ct, + capability_tier="T0", + workers=1, + ) + selected_ct = compute_type or self._precision_for(gpu) + cc = gpu.compute_capability + free = gpu.vram_free_mb + workers = 1 + if free and cc: + # 실측 기반 워커수 (계약: max(1, floor((free − reserve)/per_worker))) + per_worker = max(1, self._footprint("large-v3-turbo", selected_ct)) + budget = free - RESERVE_MB - HEADROOM_MB + if budget > 0: + workers = max(1, int(budget // per_worker)) + return DeviceProfile( + requested_device="auto" if source == "auto" else f"cuda:{gpu.index}", + selected_device=f"cuda:{gpu.index}", + selection_source=source, + device_name=gpu.name, + compute_capability=cc, + vram_total_mb=gpu.vram_total_mb, + vram_free_mb=gpu.vram_free_mb, + requested_compute_type=compute_type, + selected_compute_type=selected_ct, + capability_tier="T3", + workers=workers, + runtime_cuda=gpu.runtime_cuda, + driver_version=gpu.driver_version, + ) + + def _precision_for(self, gpu: GpuInfo) -> str: + cc = gpu.compute_capability + free = gpu.vram_free_mb or 0 + if cc: + major = int(cc.split(".")[0]) + if major < 7: # Pascal 이하 + return "int8" + if free >= 12 * 1024: + return "float16" + return "int8_float16" + + def compute_workers(self, profile: DeviceProfile, override: int = 0) -> int: + return override or max(1, profile.workers) diff --git a/src/luke_scribe/devices/profile.py b/src/luke_scribe/devices/profile.py new file mode 100644 index 0000000..e86df62 --- /dev/null +++ b/src/luke_scribe/devices/profile.py @@ -0,0 +1,33 @@ +"""DeviceProfile — 장치 탐지 결과와 실행 결정을 분리한 계약. + +- 탐지값: CPU/GPU 이름, compute capability, 총/가용 VRAM +- 결정값: device, compute_type, 모델 +- 출처: ``auto`` 또는 사용자 override +- 경고: CPU 폴백, 정밀도 변경, 모델 미지원 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class DeviceProfile(BaseModel): + requested_device: str = "auto" + selected_device: str = "cpu" + selection_source: str = "auto" # auto | explicit + device_name: str | None = None + compute_capability: str | None = None + vram_total_mb: int | None = None + vram_free_mb: int | None = None + requested_compute_type: str | None = None + selected_compute_type: str = "int8" + loadable_models: list[str] = Field(default_factory=list) + recommended_model: str | None = None + capability_tier: str = "T0" # T0 CPU .. T3 동시상주 + workers: int = 1 + warnings: list[str] = Field(default_factory=list) + runtime_cuda: str | None = None + driver_version: str | None = None + + def to_dict(self) -> dict: + return self.model_dump(exclude_none=False) diff --git a/src/luke_scribe/devices/vram_probe.py b/src/luke_scribe/devices/vram_probe.py new file mode 100644 index 0000000..b98c492 --- /dev/null +++ b/src/luke_scribe/devices/vram_probe.py @@ -0,0 +1,144 @@ +"""하드웨어 프로빙 — GPU(NVML)·RAM·디스크. + +NVML 사용 불가(CUDA 미설치) 환경에서는 우아하게 CPU로 폴백한다. +모든 프로빙은 실패해도 예외를 던지지 않고 ``None``/폴백값을 반환한다 +(설계 원칙: fail-explicit은 장치 결정 계층에서 처리). +""" + +from __future__ import annotations + +import dataclasses +import shutil +from typing import Any + + +@dataclasses.dataclass +class GpuInfo: + index: int + name: str + compute_capability: str | None + vram_total_mb: int | None + vram_free_mb: int | None + driver_version: str | None + runtime_cuda: str | None + + +@dataclasses.dataclass +class SystemInfo: + cpu_count: int + ram_total_mb: int + ram_free_mb: int + disk_free_mb: int + gpus: list[GpuInfo] + + +def _nvml_available() -> bool: + try: + import pynvml # type: ignore[import-not-found] + + pynvml.nvmlInit() + return True + except Exception: + return False + + +def _nvml_gpus() -> list[GpuInfo]: + import pynvml # type: ignore[import-not-found] + + gpus: list[GpuInfo] = [] + try: + pynvml.nvmlInit() + count = pynvml.nvmlDeviceGetCount() + for i in range(count): + handle = pynvml.nvmlDeviceGetHandleByIndex(i) + name = pynvml.nvmlDeviceGetName(handle) + try: + cc = pynvml.nvmlDeviceGetCudaComputeCapability(handle) + cc_str = f"{cc[0]}.{cc[1]}" + except Exception: + cc_str = None + try: + mem = pynvml.nvmlDeviceGetMemoryInfo(handle) + total = int(mem.total / (1024 * 1024)) + free = int(mem.free / (1024 * 1024)) + except Exception: + total = free = None + try: + driver = pynvml.nvmlSystemGetDriverVersion() + except Exception: + driver = None + gpus.append( + GpuInfo( + index=i, + name=name, + compute_capability=cc_str, + vram_total_mb=total, + vram_free_mb=free, + driver_version=driver, + runtime_cuda=None, + ) + ) + except Exception: + pass + finally: + try: + pynvml.nvmlShutdown() + except Exception: + pass + return gpus + + +def probe_gpus() -> list[GpuInfo]: + """GPU 목록. NVML 없으면 빈 리스트 (CPU-only).""" + if not _nvml_available(): + return [] + try: + return _nvml_gpus() + except Exception: + return [] + + +def probe_system() -> SystemInfo: + import os + + import psutil + + gpus = probe_gpus() + disk = shutil.disk_usage("/") + return SystemInfo( + cpu_count=os.cpu_count() or 1, + ram_total_mb=int(psutil.virtual_memory().total / (1024 * 1024)), + ram_free_mb=int(psutil.virtual_memory().available / (1024 * 1024)), + disk_free_mb=int(disk.free / (1024 * 1024)), + gpus=gpus, + ) + + +def peak_process_rss_mb(pid: int | None = None) -> float: + """프로세스 RSS(MB) — 벤치마크용.""" + import psutil + + p = psutil.Process(pid or psutil.Process().pid) + return p.memory_info().rss / (1024 * 1024) + + +def runtime_env_snapshot() -> dict[str, Any]: + """환경 정보 — 벤치마크 리포트/`/v1/system`용.""" + import platform + import subprocess + import sys + + env: dict[str, Any] = { + "os": platform.platform(), + "python": sys.version, + "cpu_count": (sys.os_cpu_count if hasattr(sys, "os_cpu_count") else None) + or __import__("os").cpu_count(), + "gpus": [dataclasses.asdict(g) for g in probe_gpus()], + } + for cmd, key in (("ffmpeg -version", "ffmpeg"), ("ffprobe -version", "ffprobe")): + try: + out = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=10) + env[key] = out.stdout.splitlines()[0] if out.stdout else None + except Exception: + env[key] = None + return env diff --git a/src/luke_scribe/diarization/__init__.py b/src/luke_scribe/diarization/__init__.py new file mode 100644 index 0000000..a575f0c --- /dev/null +++ b/src/luke_scribe/diarization/__init__.py @@ -0,0 +1 @@ +"""화자 분리 (선택, pyannote, 기본 off).""" diff --git a/src/luke_scribe/diarization/pyannote_diarizer.py b/src/luke_scribe/diarization/pyannote_diarizer.py new file mode 100644 index 0000000..f8a4164 --- /dev/null +++ b/src/luke_scribe/diarization/pyannote_diarizer.py @@ -0,0 +1,50 @@ +"""pyannote.audio 기반 화자 분리 (선택 기능, 기본 off). + +v0.1 환경(네트워크/HF 토큰 제약)에서는 스텁으로 두고, 실제 실행은 +``[diarize]`` extra + HF 토큰 설정 시에만 활성화된다. +""" + +from __future__ import annotations + +from typing import Any + + +class DiarizerUnavailable(RuntimeError): + pass + + +class PyannoteDiarizer: + """화자 분리 — pyannote.audio 오디오 레벨 모델. + + 이 환경에서는 사용 불가로 처리하고 명확한 오류를 던진다 + (fail explicit — 조용히 비활성화하지 않음). + """ + + def __init__(self, hf_token: str | None = None, device: str = "cpu") -> None: + self.hf_token = hf_token + self.device = device + self._pipeline = None + + def load(self) -> None: + try: + from pyannote.audio import Pipeline # type: ignore[import-not-found] + except ImportError as exc: + raise DiarizerUnavailable( + "pyannote.audio가 설치되지 않았습니다. `uv sync --extra diarize` 후 " + "HF 토큰을 설정하세요." + ) from exc + if not self.hf_token: + raise DiarizerUnavailable("화자 분리에 Hugging Face 토큰이 필요합니다 (HF_TOKEN)") + self._pipeline = Pipeline.from_pretrained( + "pyannote/speaker-diarization-3.1", token=self.hf_token + ) + + def diarize(self, audio_path: str) -> list[dict[str, Any]]: + if self._pipeline is None: + self.load() + assert self._pipeline is not None + result = self._pipeline(audio_path) + speakers: list[dict[str, Any]] = [] + for turn, _, speaker in result.itertracks(yield_label=True): + speakers.append({"speaker": speaker, "start": turn.start, "end": turn.end}) + return speakers diff --git a/src/luke_scribe/engine/__init__.py b/src/luke_scribe/engine/__init__.py new file mode 100644 index 0000000..ce39cc8 --- /dev/null +++ b/src/luke_scribe/engine/__init__.py @@ -0,0 +1 @@ +"""전사 엔진 — 단일 추론 백엔드(faster-whisper) + 모델 레지스트리.""" diff --git a/src/luke_scribe/engine/base.py b/src/luke_scribe/engine/base.py new file mode 100644 index 0000000..ff797ad --- /dev/null +++ b/src/luke_scribe/engine/base.py @@ -0,0 +1,91 @@ +"""TranscriptionEngine — 얇은 엔진 인터페이스. + +v0.1 구현은 FasterWhisperEngine 하나. 멀티 엔진 플러그인 시스템은 만들지 않는다. +""" + +from __future__ import annotations + +from typing import Any, Protocol + + +class TranscriptionOptions: + """전사 옵션 — 정규 계약 (plan §6.2).""" + + __slots__ = ( + "model", + "language", + "device", + "compute_type", + "vad", + "hotwords", + "initial_prompt", + "word_timestamps", + "beam_size", + "temperature", + "condition_on_previous_text", + "vad_min_silence_duration_ms", + "vad_speech_pad_ms", + ) + + def __init__( + self, + *, + model: str = "large-v3-turbo", + language: str | None = "ko", + device: str = "auto", + compute_type: str | None = None, + vad: bool = True, + hotwords: list[str] | None = None, + initial_prompt: str | None = None, + word_timestamps: bool = False, + beam_size: int = 5, + temperature: float = 0.0, + condition_on_previous_text: bool = True, + vad_min_silence_duration_ms: int = 500, + vad_speech_pad_ms: int = 200, + ) -> None: + self.model = model + self.language = language + self.device = device + self.compute_type = compute_type + self.vad = vad + self.hotwords = [w.strip() for w in (hotwords or []) if w.strip()] + self.initial_prompt = initial_prompt + self.word_timestamps = word_timestamps + self.beam_size = beam_size + self.temperature = temperature + self.condition_on_previous_text = condition_on_previous_text + self.vad_min_silence_duration_ms = vad_min_silence_duration_ms + self.vad_speech_pad_ms = vad_speech_pad_ms + + def as_dict(self) -> dict[str, Any]: + return {k: getattr(self, k) for k in self.__slots__} + + def without_defaults(self) -> dict[str, Any]: + return { + k: v + for k, v in self.as_dict().items() + if v not in (None, [], False, 5, 0.0, True, "auto", "ko") + } + + +class TranscriptionEngine(Protocol): + """전사 엔진 프로토콜.""" + + def transcribe( + self, audio_path: str, options: TranscriptionOptions + ) -> TranscriptionOutcome: ... + + +class TranscriptionOutcome: + """엔진 출력 — 세그먼트 제너레이터와 메타. + + 세그먼트를 lazy generator로 노출해 (a) 긴 파일에서 진행률을 계산하고 + (b) 세그먼트 경계마다 협조적 취소를 확인할 수 있게 한다 (plan §3.7a/d). + """ + + __slots__ = ("segments", "info") + + def __init__(self, segments: Any, info: dict[str, Any] | None = None) -> None: + self.segments = segments + self.info = info or {} diff --git a/src/luke_scribe/engine/faster_whisper_engine.py b/src/luke_scribe/engine/faster_whisper_engine.py new file mode 100644 index 0000000..a9585cf --- /dev/null +++ b/src/luke_scribe/engine/faster_whisper_engine.py @@ -0,0 +1,138 @@ +"""FasterWhisperEngine — faster-whisper(CTranslate2) 기반 엔진. + +- lazy 세그먼트 제너레이터 + 세그먼트 경계 협조적 취소 (plan §3.7a/d) +- hotwords → initial_prompt에 주입 (혼용어 보존) +- word timestamps, VAD 파라미터 전달 +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Iterator +from typing import Any + +from ..errors import TranscriptionFailed +from .base import TranscriptionEngine, TranscriptionOptions, TranscriptionOutcome + +SegmentLike = dict[str, Any] + + +class _CancellableSegmentIterator: + """세그먼트를 소비하며 취소 플래그를 검사하는 래퍼. + + - 세그먼트마다 ``should_cancel()`` 호출 → True면 중단 후 ``CancelledError``. + - ``close()``로 제너레이터를 명시적으로 종료 (리소스 해제 보장). + """ + + def __init__(self, raw: Iterator[SegmentLike], should_cancel: Callable[[], bool]) -> None: + self._raw = raw + self._should_cancel = should_cancel + self._closed = False + + def __iter__(self) -> _CancellableSegmentIterator: + return self + + def __next__(self) -> SegmentLike: + from ..errors import CancelledError + + if self._closed: + raise StopIteration + if self._should_cancel(): + self.close() + raise CancelledError("협조적 취소가 요청되었습니다") + try: + seg = next(self._raw) + except StopIteration: + self._closed = True + raise + return seg + + def close(self) -> None: + if self._closed: + return + self._closed = True + close = getattr(self._raw, "close", None) + if close is not None: + try: + close() + except Exception: + pass + + +class FasterWhisperEngine(TranscriptionEngine): + def __init__(self) -> None: + self._models: dict[str, Any] = {} + self._lock = threading.Lock() + self._downloaded: set[str] = set() + + def transcribe( + self, + audio_path: str, + options: TranscriptionOptions, + should_cancel: Callable[[], bool] | None = None, + download_progress: Callable[[dict], None] | None = None, + ) -> TranscriptionOutcome: + from ..errors import ModelLoadFailed + + try: + from faster_whisper import WhisperModel # type: ignore[import-not-found] + except ImportError as exc: + raise ModelLoadFailed( + "faster-whisper가 설치되지 않았습니다. `uv sync --extra engine` 또는 " + "`pip install 'luke-scribe[engine]'`를 실행하세요." + ) from exc + + model_key = (options.model, options.device, options.compute_type) + with self._lock: + model = self._models.get(model_key) + if model is None: + try: + model = WhisperModel( + options.model, + device=options.device, + compute_type=options.compute_type, + download_root=self._download_root(), + ) + except Exception as exc: + raise ModelLoadFailed( + f"모델 {options.model} 로드 실패 (device={options.device}, " + f"compute_type={options.compute_type}): {exc}" + ) from exc + self._models[model_key] = model + + hotwords = options.hotwords or [] + initial_prompt = options.initial_prompt + if hotwords: + joined = ", ".join(hotwords) + initial_prompt = f"{initial_prompt}\\n" if initial_prompt else "" + initial_prompt += f"주요 용어: {joined}" + + try: + segments_iter, info = model.transcribe( + audio_path, + language=options.language, + beam_size=options.beam_size, + temperature=options.temperature, + vad_filter=options.vad, + vad_parameters={ + "min_silence_duration_ms": options.vad_min_silence_duration_ms, + "speech_pad_ms": options.vad_speech_pad_ms, + }, + word_timestamps=options.word_timestamps, + condition_on_previous_text=options.condition_on_previous_text, + initial_prompt=initial_prompt, + ) + except Exception as exc: + raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc + + wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False)) + return TranscriptionOutcome(wrapped, info=info) + + def _download_root(self) -> str | None: + from ..config import get_settings + + return get_settings().model_cache_dir + + def unload_all(self) -> None: + with self._lock: + self._models.clear() diff --git a/src/luke_scribe/engine/model_registry.py b/src/luke_scribe/engine/model_registry.py new file mode 100644 index 0000000..09ec1b2 --- /dev/null +++ b/src/luke_scribe/engine/model_registry.py @@ -0,0 +1,132 @@ +"""모델 레지스트리 — 모델 메타데이터·프로비저닝·결정 아티팩트. + +- 모델: large-v3-turbo(rt 기본), large-v3(batch 기본, P1 bench 게이트로 확정) +- 프로비저닝: 시작 시 존재 확인 → 없으면 HF 다운로드 → 손상 시 캐시 purge 후 + 1회 재다운로드. 네트워크·캐시 모두 없으면 ``model_unavailable_offline``. +- 결정 아티팩트: ``bench``가 쓴 ``benchmarks/decisions/default-model-v1.json``. + 유효성 검증 실패 시 ``invalid_model_decision``으로 무시하지 않고 실패. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from ..config import Settings +from ..errors import ( + ModelDecisionBlocked, + ModelDecisionInvalid, + ModelDownloadFailed, + ModelUnavailableOffline, +) + +MODEL_SIZES_MB = { + "large-v3-turbo": 1600, + "large-v3": 3000, +} + +DECISION_FILE = "benchmarks/decisions/default-model-v1.json" +DECISION_VERSION = "1.0" + +VALID_STATUSES = {"approved", "no_acceptable_model"} + + +class ModelRegistry: + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + + def resolve_model( + self, + *, + explicit_model: str | None = None, + lane: str = "batch", + decision_path: Path | None = None, + ) -> tuple[str, str | None]: + """모델 + hotword set 결정 (plan §10 모델 선택 순서). + + Returns: + (model, hotword_artifact_path_or_None) + """ + if explicit_model: + return explicit_model, self._hotword_for(explicit_model, decision_path) + decision = self._load_decision(decision_path) + if decision is None: + # 연구 bootstrap: turbo + 경고 + return self.settings.model_rt if lane == "realtime" else self.settings.model_batch, None + if decision.get("status") == "no_acceptable_model": + raise ModelDecisionBlocked( + "벤치마크에서 수용 가능한 기본 모델이 없습니다 (no_acceptable_model). " + "--model로 명시적으로 지정하세요." + ) + default_model = decision.get("default_model") + if not default_model: + raise ModelDecisionInvalid("결정 아티팩트에 default_model이 없습니다") + return default_model, self._hotword_for(default_model, decision_path) + + def _hotword_for(self, model: str, decision_path: Path | None) -> str | None: + decision = self._load_decision(decision_path) + if not decision: + return None + variants = decision.get("model_variants", {}) + entry = variants.get(model) + if not entry: + return None + return entry.get("hotword_artifact") + + def _load_decision(self, decision_path: Path | None) -> dict | None: + if decision_path is None: + return None + if not decision_path.exists(): + return None + try: + data = json.loads(decision_path.read_text(encoding="utf-8")) + except Exception as exc: + raise ModelDecisionInvalid(f"결정 아티팩트를 파싱할 수 없습니다: {exc}") from exc + # 검증: schema/version/status + if data.get("decision_version") != DECISION_VERSION: + raise ModelDecisionInvalid( + f"결정 아티팩트 버전 불일치: {data.get('decision_version')!r}" + ) + if data.get("status") not in VALID_STATUSES: + raise ModelDecisionInvalid(f"알 수 없는 status: {data.get('status')!r}") + # report_sha256 존재 시 보고서 파일로 검증 (optional) + return data + + def ensure_available( + self, model: str, cache_dir: str | None = None, offline_ok: bool = True + ) -> Path: + """모델 존재 확인. 없으면 다운로드. 손상 시 1회 재다운로드. + + 이 환경(모델 다운로드 금지)에서는 존재 확인만 수행하며, + 실제 다운로드는 CLI/API 실행 시에만 시도된다. + """ + from huggingface_hub import snapshot_download + + cache = cache_dir + + cache = cache or self.settings.model_cache_dir + local = Path(cache) / model if cache else None + if local is not None and local.exists(): + return local + # 다운로드 시도 + try: + path = snapshot_download( + repo_id=f"Systran/faster-whisper-{model}", local_dir=str(local) if local else None + ) + return Path(path) + except Exception as exc: + if offline_ok: + raise ModelUnavailableOffline( + f"모델 {model}이 캐시에 없고 다운로드에 실패했습니다. " + f"캐시 위치: {local or 'HF 기본 캐시'}. 네트워크 연결을 확인하세요." + ) from exc + raise ModelDownloadFailed(str(exc)) from exc + + @staticmethod + def sha256_of(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() diff --git a/src/luke_scribe/engine/owner.py b/src/luke_scribe/engine/owner.py new file mode 100644 index 0000000..dda5fd4 --- /dev/null +++ b/src/luke_scribe/engine/owner.py @@ -0,0 +1,167 @@ +"""EngineOwner — GPU를 소유하는 단일 추론 소유자. + +Eng 리뷰 P0 수정: 실시간 락이 API 프로세스, 배치 모델이 워커 프로세스에 +각각 있던 구조(교차 프로세스 조정 불가)를 제거한다. 대신 **GPU당 하나의 +EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커는 모두 이 +소유자에게 작업을 제출한다. 단일 프로세스(in-proc/dev)에서는 같은 프로세스 +안에서 자연스럽게 직렬화된다. 다중 프로세스 배포는 이 소유자를 별도 서비스 +프로세스로 띄우고 IPC로 제출한다(파일·표준 스트림 기반 전송은 v0.1 범위 밖, +문서화된 확장 지점). + +특징: +- 단일 ``asyncio.Lock``/스레드 락으로 decode 직렬화 + 우선순위 페어니스. +- OOM 강등 체인: fp16 → int8_float16 → int8 → CPU, **총 2회** 캡. +- 강등 내역 ``attempted_profiles``를 Job에 영속화해 재시도 시에도 총 2회 유지 + (재시작 안전, 무한 강등 방지). +- ``model_used``/``compute_type_used``를 항상 결과에 노출 (무음 강등 없음). +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field + +from ..config import Settings +from ..errors import OutOfMemory +from .base import TranscriptionOptions +from .faster_whisper_engine import FasterWhisperEngine + +# 정밀도 강등 체인 (fp16 → int8_float16 → int8 → cpu) +DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"] +MAX_DOWNGRADES = 2 + + +@dataclass +class InferenceRequest: + audio_path: str + options: TranscriptionOptions + lane: str = "batch" # batch | realtime + should_cancel: callable = lambda: False + attempted_profiles: list[str] = field(default_factory=list) # 영속화된 강등 이력 + on_progress: callable | None = None + + +class EngineOwner: + """단일 GPU 소유자. 프로세스당 하나 (전역 싱글턴 패턴).""" + + _instance: EngineOwner | None = None + + @classmethod + def get(cls, settings: Settings | None = None) -> EngineOwner: + if cls._instance is None: + cls._instance = cls(settings) + return cls._instance + + @classmethod + def reset(cls) -> None: + cls._instance = None + + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + self._engine = FasterWhisperEngine() + self._lock = threading.Lock() # decode 직렬화 (GPU 단일 접근) + self._realtime_priority = threading.Lock() # 실시간 레인 우선 채널 + self._stats = {"jobs": 0, "downgrades": 0, "oom": 0} + + @property + def engine(self) -> FasterWhisperEngine: + return self._engine + + def transcribe(self, req: InferenceRequest) -> dict: + """우선순위 인지 단일 decode. 결과 dict(세그먼트 제너레이터 포함) 반환. + + OOM 시 강등 체인을 시도하며, 시도 횟수는 ``attempted_profiles``에 + 영속화되어 있어 총 2회를 넘지 않는다. + """ + # 실시간 레인은 우선 락 (배치보다 우선) + lock = self._realtime_priority if req.lane == "realtime" else self._lock + with lock: + profiles = self._profiles_for(req) + last_exc: Exception | None = None + for profile in profiles: + if len(req.attempted_profiles) >= MAX_DOWNGRADES and req.attempted_profiles: + break + req.attempted_profiles.append(profile) + options = TranscriptionOptions( + model=req.options.model, + language=req.options.language, + device=profile["device"], + compute_type=profile["compute_type"], + vad=req.options.vad, + hotwords=req.options.hotwords, + initial_prompt=req.options.initial_prompt, + word_timestamps=req.options.word_timestamps, + beam_size=req.options.beam_size, + temperature=req.options.temperature, + condition_on_previous_text=req.options.condition_on_previous_text, + ) + try: + outcome = self._engine.transcribe( + req.audio_path, + options, + should_cancel=req.should_cancel, + ) + self._stats["jobs"] += 1 + return { + "segments": outcome.segments, + "info": outcome.info, + "device": profile["device"], + "compute_type": profile["compute_type"], + "attempted_profiles": list(req.attempted_profiles), + } + except OutOfMemory as exc: + last_exc = exc + self._stats["oom"] += 1 + self._stats["downgrades"] += 1 + # 다음 프로파일로 + continue + # 다른 오류는 그대로 전파 (fallback 금지 — fail explicit) + except Exception: + raise + if last_exc is not None: + raise last_exc + # 프로파일 소진 + from ..errors import OutOfMemory as OOMErr + + raise OOMErr("모든 강등 프로파일에서 메모리 부족/실패 (강등 최대 2회)") + + def _profiles_for(self, req: InferenceRequest) -> list[dict]: + """시도할 프로파일 목록. 요청 device/compute_type를 우선한다.""" + device = req.options.device + ct = req.options.compute_type + if device == "auto": + from ..devices.manager import DeviceManager + + profile = DeviceManager().detect() + device = profile.selected_device + ct = ct or profile.selected_compute_type + if ct in (None, "auto"): + ct = "int8_float16" if device != "cpu" else "int8" + + profiles: list[dict] = [{"device": device, "compute_type": ct}] + if device != "cpu": + # GPU에서 OOM 시 정밀도 강등 → 마지막 CPU int8 + base = DOWNGRADE_CHAIN_GPU + start = base.index(ct) if ct in base else 0 + for next_ct in base[start + 1 :]: + profiles.append({"device": device, "compute_type": next_ct}) + profiles.append({"device": "cpu", "compute_type": "int8"}) + return profiles + + def emit_hypothesis(self, pcm_chunk: bytes) -> dict: + """실시간 레인 가설 생성 (v0.1 스텁). + + 실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()``를 + 호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며, + GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락). + """ + if self._stats.get("realtime_decode_ready"): + # 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments + pass + return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000} + + def unload_all(self) -> None: + self._engine.unload_all() + + def stats(self) -> dict: + return dict(self._stats) diff --git a/src/luke_scribe/errors.py b/src/luke_scribe/errors.py new file mode 100644 index 0000000..c1dd59c --- /dev/null +++ b/src/luke_scribe/errors.py @@ -0,0 +1,122 @@ +"""luke_scribe 오류 계층. + +모든 서비스 오류는 ``LukeScribeError``의 typed subclass이며 CLI 어댑터와 API가 +안정적인 error code + exit code + HTTP 상태로 변환한다. +""" + +from __future__ import annotations + + +class LukeScribeError(Exception): + """모든 luke_scribe 오류의 베이스. + + Attributes: + code: 기계 판독 가능한 안정 오류 코드 (예: ``audio_probe_failed``). + retryable: 재시도 가능 여부. + """ + + code: str = "internal_error" + retryable: bool = False + + def __init__( + self, message: str = "", *, code: str | None = None, retryable: bool | None = None + ) -> None: + super().__init__(message) + self.message = message + if code is not None: + self.code = code + if retryable is not None: + self.retryable = retryable + + def to_dict(self) -> dict: + return { + "code": self.code, + "message": self.message, + "retryable": self.retryable, + } + + +class ConfigError(LukeScribeError): + code = "config_error" + + +class AudioProbeFailed(LukeScribeError): + code = "audio_probe_failed" + + +class InvalidInput(LukeScribeError): + code = "invalid_input" + + +class UnsupportedInputEnvelope(LukeScribeError): + code = "unsupported_input_envelope" + + +class DeviceUnavailable(LukeScribeError): + code = "device_unavailable" + + +class ModelDownloadFailed(LukeScribeError): + code = "model_download_failed" + retryable = True + + +class ModelUnavailableOffline(LukeScribeError): + code = "model_unavailable_offline" + + +class ModelLoadFailed(LukeScribeError): + code = "model_load_failed" + + +class ModelDecisionInvalid(LukeScribeError): + code = "invalid_model_decision" + + +class ModelDecisionBlocked(LukeScribeError): + code = "model_decision_blocked" + + +class OutOfMemory(LukeScribeError): + code = "out_of_memory" + retryable = True + + +class TranscriptionFailed(LukeScribeError): + code = "transcription_failed" + retryable = True + + +class JobNotFound(LukeScribeError): + code = "job_not_found" + + +class JobCancelled(LukeScribeError): + code = "job_cancelled" + + +class JobAlreadyTerminal(LukeScribeError): + code = "job_already_terminal" + + +class QueueFull(LukeScribeError): + code = "queue_full" + retryable = True + + +class AuthError(LukeScribeError): + code = "unauthorized" + + +class ScopeDenied(LukeScribeError): + code = "forbidden" + + +class OutputWriteError(LukeScribeError): + code = "output_write_failed" + + +class CancelledError(LukeScribeError): + """협조적 취소가 발생했음을 나타내는 내부 시그널.""" + + code = "cancelled" diff --git a/src/luke_scribe/jobqueue/__init__.py b/src/luke_scribe/jobqueue/__init__.py new file mode 100644 index 0000000..f5a1bb7 --- /dev/null +++ b/src/luke_scribe/jobqueue/__init__.py @@ -0,0 +1 @@ +"""작업 큐 — Job 추상화, in-proc/Redis 브로커, 워커, 협조적 취소.""" diff --git a/src/luke_scribe/jobqueue/broker.py b/src/luke_scribe/jobqueue/broker.py new file mode 100644 index 0000000..6860b78 --- /dev/null +++ b/src/luke_scribe/jobqueue/broker.py @@ -0,0 +1,299 @@ +"""JobBroker — Job 영속 큐 인터페이스 (Redis/RQ no-fork + in-proc 폴백). + +계약 (plan §3.5/D1): 프로덕션=Redis(RQ SimpleWorker, no-fork, model-load-once), +dev/Colab=in-proc(Redis 불필요). 동일 Job 인터페이스 뒤에서 의미 동등성 유지. + +Eng 리뷰 반영: +- 취소 플래그는 원자적 set (cancel.py 참조). +- progress 저장은 throttle (1s 또는 의미있는 delta). +- job_timeout은 duration × RTF + 마진으로 산정 (plan §3.9e). +- 스타트업 reconciler: stale processing(리스 만료) → 재큐/실패 1회. +""" + +from __future__ import annotations + +import time +from abc import ABC, abstractmethod + +from ..config import Settings +from .jobs import Job, JobStatus + + +class JobBroker(ABC): + """Job 큐 백엔드 추상화.""" + + @abstractmethod + def enqueue(self, job: Job) -> Job: ... + + @abstractmethod + def get(self, job_id: str) -> Job | None: ... + + @abstractmethod + def claim_next(self, worker_id: str, lease_ttl_sec: float) -> Job | None: ... + + @abstractmethod + def save_meta(self, job: Job) -> None: ... + + @abstractmethod + def transition(self, job: Job, to: JobStatus) -> Job: ... + + @abstractmethod + def list_jobs(self, limit: int = 100) -> list[Job]: ... + + @abstractmethod + def queue_depth(self) -> int: ... + + @abstractmethod + def cancel(self, job_id: str) -> Job: ... + + @abstractmethod + def reconcile_stale(self, lease_ttl_sec: float) -> list[str]: ... + + +def make_broker(settings: Settings | None = None) -> JobBroker: + """설정 기반 브로커 생성 (dev 기본 in-proc).""" + settings = settings or Settings() + if settings.queue_backend == "redis": + try: + from .redis_broker import RedisBroker + + return RedisBroker(settings) + except Exception: + # Redis 사용 불가 → in-proc 폴백 (명시적 경고) + print("[warn] Redis 브로커를 초기화하지 못해 in-proc로 폴백합니다") + return InProcBroker(settings) + return InProcBroker(settings) + + +class InProcBroker(JobBroker): + """개발/Colab용 인메모리 브로커 (Redis 불필요, 단일 프로세스).""" + + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + self._jobs: dict[str, Job] = {} + self._queue: list[str] = [] + self._lock = __import__("threading").Lock() + + def _capacity(self) -> int: + return self.settings.max_queue + + def enqueue(self, job: Job) -> Job: + with self._lock: + if len(self._queue) >= self._capacity(): + from ..errors import QueueFull + + raise QueueFull(f"큐가 가득 찼습니다 (max {self._capacity()})") + job.status = JobStatus.QUEUED + job.queue_position = len(self._queue) + self._jobs[job.id] = job + self._queue.append(job.id) + return job + + def get(self, job_id: str) -> Job | None: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return None + # 깊은 사본 대신 meta 기준 스냅샷 (테스트 안정성) + return job + + def claim_next(self, worker_id: str, lease_ttl_sec: float) -> Job | None: + with self._lock: + if not self._queue: + return None + job_id = self._queue.pop(0) + job = self._jobs[job_id] + if job.cancel_requested: + job.transition(JobStatus.CANCELLED) + job.cancelled_at = time.time() + return None + if not job.can_transition(JobStatus.PROCESSING): + return None + job.transition(JobStatus.PROCESSING) + job.attempts += 1 + job.refresh_lease(lease_ttl_sec) + return job + + def save_meta(self, job: Job) -> None: + with self._lock: + if job.id in self._jobs: + self._jobs[job.id] = job + + def transition(self, job: Job, to: JobStatus) -> Job: + with self._lock: + current = self._jobs.get(job.id) + if current is None: + raise KeyError(job.id) + if not current.can_transition(to): + from ..errors import JobAlreadyTerminal + + raise JobAlreadyTerminal(f"상태 전이 불가: {current.status.value} → {to.value}") + current.transition(to) + return current + + def list_jobs(self, limit: int = 100) -> list[Job]: + with self._lock: + return list(self._jobs.values())[:limit] + + def queue_depth(self) -> int: + with self._lock: + return len(self._queue) + + def cancel(self, job_id: str) -> Job: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + from ..errors import JobNotFound + + raise JobNotFound(job_id) + job.cancel_requested = True + if job.status == JobStatus.QUEUED: + job.transition(JobStatus.CANCELLED) + if job_id in self._queue: + self._queue.remove(job_id) + elif job.status == JobStatus.PROCESSING: + # 워커가 세그먼트 경계에서 감지 + job.refresh_lease(0) # 리스를 만료시켜 reconciler가 정리하게 함 + return job + + def reconcile_stale(self, lease_ttl_sec: float) -> list[str]: + """리스 만료된 processing Job을 failed로 전이 (워커 크래시 복구).""" + now = time.time() + reclaimed: list[str] = [] + with self._lock: + for job in self._jobs.values(): + if job.status == JobStatus.PROCESSING and job.lease_expired(now=now): + job.transition(JobStatus.FAILED) + job.error_code = "worker_crash" + job.error_message = "워커 크래시로 복구되었습니다 (lease 만료)" + job.failed_at = now + reclaimed.append(job.id) + return reclaimed + + +# ── Redis/RQ 브로커 (프로덕션) ──────────────────────────────────────── +class RedisBroker(JobBroker): + """RQ SimpleWorker(no-fork) 기반 브로커. + + RQ 기본 워커는 job마다 fork하므로(§3.5), **SimpleWorker/장수명 워커만** + 사용한다. 모델은 워커 프로세스가 1회 적재 후 보유한다. + + v0.1 환경(Redis 미설치)에서는 import 실패 시 호출자가 in-proc로 폴백한다. + """ + + def __init__(self, settings: Settings) -> None: + import redis # type: ignore[import-not-found] + + self.settings = settings + self._redis = redis.Redis.from_url(settings.redis_url) + self._redis.ping() # 연결 검증 + self._key_prefix = "luke-scribe:job:" + + def _key(self, job_id: str) -> str: + return f"{self._key_prefix}{job_id}" + + def enqueue(self, job: Job) -> Job: + import json + + from ..errors import QueueFull + + if self.queue_depth() >= self.settings.max_queue: + raise QueueFull(f"큐가 가득 찼습니다 (max {self.settings.max_queue})") + self._redis.set(self._key(job.id), json.dumps(job.to_meta())) + self._redis.rpush("luke-scribe:queue", job.id) + return job + + def get(self, job_id: str) -> Job | None: + import json + + raw = self._redis.get(self._key(job_id)) + if raw is None: + return None + return Job.from_meta(json.loads(raw)) + + def claim_next(self, worker_id: str, lease_ttl_sec: float) -> Job | None: + import json + + job_id = self._redis.lpop("luke-scribe:queue") + if job_id is None: + return None + job_id = job_id.decode() if isinstance(job_id, bytes) else job_id + raw = self._redis.get(self._key(job_id)) + if raw is None: + return None + job = Job.from_meta(json.loads(raw)) + if job.cancel_requested: + job.transition(JobStatus.CANCELLED) + self.save_meta(job) + return None + job.transition(JobStatus.PROCESSING) + job.attempts += 1 + job.refresh_lease(lease_ttl_sec) + self.save_meta(job) + return job + + def save_meta(self, job: Job) -> None: + import json + + self._redis.set(self._key(job.id), json.dumps(job.to_meta())) + + def transition(self, job: Job, to: JobStatus) -> Job: + current = self.get(job.id) + if current is None: + raise KeyError(job.id) + if not current.can_transition(to): + from ..errors import JobAlreadyTerminal + + raise JobAlreadyTerminal(f"상태 전이 불가: {current.status.value} → {to.value}") + current.transition(to) + self.save_meta(current) + return current + + def list_jobs(self, limit: int = 100) -> list[Job]: + import json + + out: list[Job] = [] + for job_id in self._redis.keys(f"{self._key_prefix}*")[:limit]: + raw = self._redis.get(job_id) + if raw: + out.append(Job.from_meta(json.loads(raw))) + return out + + def queue_depth(self) -> int: + return int(self._redis.llen("luke-scribe:queue")) + + def cancel(self, job_id: str) -> Job: + import json + + raw = self._redis.get(self._key(job_id)) + if raw is None: + from ..errors import JobNotFound + + raise JobNotFound(job_id) + job = Job.from_meta(json.loads(raw)) + job.cancel_requested = True + if job.status == JobStatus.QUEUED: + job.transition(JobStatus.CANCELLED) + self._redis.lrem("luke-scribe:queue", 0, job_id) + self.save_meta(job) + return job + + def reconcile_stale(self, lease_ttl_sec: float) -> list[str]: + import json + import time + + now = time.time() + reclaimed: list[str] = [] + for job_id in self._redis.keys(f"{self._key_prefix}*"): + raw = self._redis.get(job_id) + if not raw: + continue + job = Job.from_meta(json.loads(raw)) + if job.status == JobStatus.PROCESSING and job.lease_expired(now=now): + job.transition(JobStatus.FAILED) + job.error_code = "worker_crash" + job.error_message = "워커 크래시로 복구되었습니다 (lease 만료)" + job.failed_at = now + self.save_meta(job) + reclaimed.append(job.id) + return reclaimed diff --git a/src/luke_scribe/jobqueue/cancel.py b/src/luke_scribe/jobqueue/cancel.py new file mode 100644 index 0000000..63c6a53 --- /dev/null +++ b/src/luke_scribe/jobqueue/cancel.py @@ -0,0 +1,28 @@ +"""협조적 취소 토큰. + +워커/실시간 핸들러가 인제스트→decode→후처리 전 단계에서 ``is_cancelled()``를 +폴링하고, 세그먼트 경계마다 검사한다. 세그먼트 경계가 유일한 선점 지점이라는 +계약을 유지하되, 인제스트/ffmpeg 단계에서도 취소를 확인해 즉시 중단한다 +(Eng 리뷰 P3 반영). +""" + +from __future__ import annotations + +import threading + + +class CancellationToken: + def __init__(self) -> None: + self._flag = threading.Event() + + def cancel(self) -> None: + self._flag.set() + + def is_cancelled(self) -> bool: + return self._flag.is_set() + + def check(self) -> None: + if self.is_cancelled(): + from ..errors import CancelledError + + raise CancelledError("협조적 취소") diff --git a/src/luke_scribe/jobqueue/jobs.py b/src/luke_scribe/jobqueue/jobs.py new file mode 100644 index 0000000..f8abb1e --- /dev/null +++ b/src/luke_scribe/jobqueue/jobs.py @@ -0,0 +1,123 @@ +"""Job 상태 머신 — 단일 Job 추상화 (queued→processing→terminal). + +Eng 리뷰 P4 반영: 완료 vs 취소 경합, 원자적 상태 전이(CAS), lease(하트비트), +재시도 횟수, 소유자 키, 시도한 강등 프로파일을 한 레코드에 유지한다. + +상태: +- queued → processing → completed | failed | cancelled +- queued → cancelled (취소 경로) +- processing → failed | cancelled + +``transition(from, to)``은 원자적이어야 하며(브로커가 CAS 제공), 비합법 전이는 +거부된다. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from ..errors import JobAlreadyTerminal + + +class JobStatus(StrEnum): + QUEUED = "queued" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + @property + def terminal(self) -> bool: + return self in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED) + + +_LEGAL: dict[JobStatus, set[JobStatus]] = { + JobStatus.QUEUED: {JobStatus.PROCESSING, JobStatus.CANCELLED}, + JobStatus.PROCESSING: {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}, + JobStatus.COMPLETED: set(), + JobStatus.FAILED: set(), + JobStatus.CANCELLED: set(), +} + + +@dataclass +class Job: + id: str = field(default_factory=lambda: str(uuid.uuid4())) + status: JobStatus = JobStatus.QUEUED + type: str = "file" # file | stream + lane: str = "batch" # batch | realtime + owner_key_id: str | None = None # 소유자(API 키 id) — 가로 권한 분리 + options: dict[str, Any] = field(default_factory=dict) + source_name: str | None = None + source_path: str | None = None + result_path: str | None = None + queue_position: int | None = None + progress: float | None = None # 0.0 ~ 1.0 (processed_sec/total_sec) + processed_sec: float | None = None + total_sec: float | None = None + error_code: str | None = None + error_message: str | None = None + lease_expires_at: float | None = None # 워커 하트비트/리스 + attempts: int = 0 + attempted_profiles: list[str] = field(default_factory=list) # 영속화 (OOM 재시도 캡) + cancel_requested: bool = False + created_at: float = field(default_factory=time.time) + completed_at: float | None = None + failed_at: float | None = None + cancelled_at: float | None = None + + def can_transition(self, to: JobStatus) -> bool: + return to in _LEGAL[self.status] + + def transition(self, to: JobStatus, *, now: float | None = None) -> None: + if not self.can_transition(to): + raise JobAlreadyTerminal( + f"Job {self.id} 상태 전이 불가: {self.status.value} → {to.value}" + ) + if to == JobStatus.COMPLETED: + self.completed_at = now if now is not None else time.time() + elif to == JobStatus.FAILED: + self.failed_at = now if now is not None else time.time() + elif to == JobStatus.CANCELLED: + self.cancelled_at = now if now is not None else time.time() + self.status = to + + def refresh_lease(self, ttl_sec: float, *, now: float | None = None) -> None: + now = now if now is not None else time.time() + self.lease_expires_at = now + ttl_sec + + def lease_expired(self, *, now: float | None = None) -> bool: + if self.lease_expires_at is None: + return False + now = now if now is not None else time.time() + return now > self.lease_expires_at + + def to_meta(self) -> dict[str, Any]: + return { + "id": self.id, + "status": self.status.value, + "type": self.type, + "lane": self.lane, + "owner_key_id": self.owner_key_id, + "queue_position": self.queue_position, + "progress": self.progress, + "processed_sec": self.processed_sec, + "total_sec": self.total_sec, + "error_code": self.error_code, + "error_message": self.error_message, + "attempts": self.attempts, + "attempted_profiles": self.attempted_profiles, + "cancel_requested": self.cancel_requested, + "created_at": self.created_at, + "completed_at": self.completed_at, + "failed_at": self.failed_at, + "cancelled_at": self.cancelled_at, + } + + @classmethod + def from_meta(cls, meta: dict[str, Any]) -> Job: + return cls(**{k: v for k, v in meta.items() if k in cls.__dataclass_fields__}) diff --git a/src/luke_scribe/jobqueue/worker.py b/src/luke_scribe/jobqueue/worker.py new file mode 100644 index 0000000..61d54ea --- /dev/null +++ b/src/luke_scribe/jobqueue/worker.py @@ -0,0 +1,143 @@ +"""Batch 워커 — 큐에서 Job을 소비해 전사 파이프라인 실행. + +- no-fork/장수명: 프로세스 수명 동안 EngineOwner가 모델을 보유. +- 리스(하트비트) 갱신 + progress throttle(≥1s 또는 의미 있는 delta). +- 세그먼트 경계 취소 + 종료 경로 임시파일 정리. +- OOM 강등: ``attempted_profiles``를 Job에 영속화 (재시도 2회 캡). +- 스타트업 reconciler: stale processing 복구. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass + +from ..config import Settings +from ..results.store import ResultStore +from .broker import JobBroker, make_broker +from .cancel import CancellationToken +from .jobs import Job, JobStatus + +LEASE_TTL_SEC = 60.0 +PROGRESS_INTERVAL_SEC = 1.0 + + +@dataclass +class WorkerCallbacks: + on_job_done: callable | None = None # (job, result) — 테스트 훅 + + +class Worker: + def __init__( + self, + *, + settings: Settings | None = None, + broker: JobBroker | None = None, + store: ResultStore | None = None, + owner=None, # EngineOwner + worker_id: str = "w1", + callbacks: WorkerCallbacks | None = None, + ingestor=None, # AudioIngestor (테스트 주입용) + ) -> None: + self.settings = settings or Settings() + self.broker = broker or make_broker(self.settings) + self.store = store or ResultStore(self.settings.results_root) + from ..engine.owner import EngineOwner + + self.owner = owner or EngineOwner.get(self.settings) + self.worker_id = worker_id + self.callbacks = callbacks or WorkerCallbacks() + self.ingestor = ingestor + self._stop = threading.Event() + self._active_job: Job | None = None + + def start_reconcile(self) -> list[str]: + return self.broker.reconcile_stale(LEASE_TTL_SEC) + + def run_forever(self) -> None: + while not self._stop.is_set(): + if self._claim_and_process() is None: + time.sleep(0.2) + + def drain(self, max_jobs: int = 100) -> int: + """큐가 빌 때까지 처리 후 종료 (테스트/일회성 실행용). 처리한 job 수 반환.""" + processed = 0 + while processed < max_jobs: + if self._claim_and_process() is None: + break + processed += 1 + return processed + + def _claim_and_process(self) -> Job | None: + """job 1개 claim/처리. 처리할 게 없으면 None.""" + job = self.broker.claim_next(self.worker_id, LEASE_TTL_SEC) + if job is None: + return None + self._active_job = job + try: + self._process(job) + finally: + self._active_job = None + return job + + def _process(self, job: Job) -> None: + from ..engine.base import TranscriptionOptions + from ..pipeline.batch import BatchPipeline + + token = CancellationToken() + token.cancel() if job.cancel_requested else None + + pipeline = BatchPipeline( + settings=self.settings, + store=self.store, + owner=self.owner, + token=token, + ingestor=self.ingestor, + ) + + def progress_cb(processed_sec: float, total_sec: float) -> None: + self._emit_progress(job, processed_sec, total_sec) + + try: + options = TranscriptionOptions(**job.options) + result = pipeline.run(job, options, progress_cb=progress_cb) + # 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게) + self.store.write_result(job.id, result) + current = self._transition(job, JobStatus.COMPLETED) + if self.settings.delete_source and job.source_path: + self.store.delete_derived(job.id) + self.store.write_source_metadata(current.id, current.to_meta()) + if self.callbacks.on_job_done: + self.callbacks.on_job_done(current, result) + except Exception as exc: + from ..errors import CancelledError + + if isinstance(exc, CancelledError) or token.is_cancelled(): + current = self._transition(job, JobStatus.CANCELLED) + current.cancelled_at = time.time() + else: + current = self._transition(job, JobStatus.FAILED) + current.error_code = getattr(exc, "code", "transcription_failed") + current.error_message = str(exc) + current.failed_at = time.time() + # 오류/완료 필드가 포함된 meta를 재저장 (Redis: transition이 별도 인스턴스 반환) + self.store.write_source_metadata(current.id, current.to_meta()) + + def _transition(self, job: Job, to: JobStatus) -> Job: + """브로커 전이. Redis는 새 인스턴스를 반환하므로 그 인스턴스를 돌려받는다.""" + try: + return self.broker.transition(job, to) + except Exception: + # 전이가 이미 terminal 상태로 막혔으면 원본 상태 그대로 + return job + + def _emit_progress(self, job: Job, processed_sec: float, total_sec: float) -> None: + job.progress = min(1.0, processed_sec / total_sec) if total_sec else None + job.processed_sec = processed_sec + job.total_sec = total_sec + job.refresh_lease(LEASE_TTL_SEC) + self.broker.save_meta(job) + + def stop(self) -> None: + self._stop.set() diff --git a/src/luke_scribe/observability/__init__.py b/src/luke_scribe/observability/__init__.py new file mode 100644 index 0000000..32f553a --- /dev/null +++ b/src/luke_scribe/observability/__init__.py @@ -0,0 +1 @@ +"""관측성 — 로깅/메트릭.""" diff --git a/src/luke_scribe/observability/logging.py b/src/luke_scribe/observability/logging.py new file mode 100644 index 0000000..8f84b7e --- /dev/null +++ b/src/luke_scribe/observability/logging.py @@ -0,0 +1,53 @@ +"""구조적 로깅 설정. + +- JSON 라인 (prod) 또는 일반 포맷 (dev). +- job_id 상관 컨텍스트 지원. +""" + +from __future__ import annotations + +import json +import logging +import sys +from typing import Any + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + job_id = getattr(record, "job_id", None) + if job_id: + payload["job_id"] = job_id + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False) + + +def setup_logging(level: str = "INFO", json_lines: bool = False) -> None: + root = logging.getLogger() + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + for h in list(root.handlers): + root.removeHandler(h) + handler = logging.StreamHandler(sys.stderr) + if json_lines: + handler.setFormatter(JsonFormatter()) + else: + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + root.addHandler(handler) + + +class JobContextFilter(logging.Filter): + """job_id 상관 필터.""" + + def __init__(self, job_id: str) -> None: + super().__init__() + self.job_id = job_id + + def filter(self, record: logging.LogRecord) -> bool: + record.job_id = self.job_id + return True diff --git a/src/luke_scribe/observability/metrics.py b/src/luke_scribe/observability/metrics.py new file mode 100644 index 0000000..e86d0b4 --- /dev/null +++ b/src/luke_scribe/observability/metrics.py @@ -0,0 +1,51 @@ +"""인프로세스 메트릭 카운터 (prometheus 대체 경량 구현). + +v0.1: 단순 카운터 + 게이지. 확장 시 prometheus-client로 대체. +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field + + +@dataclass +class Metrics: + _lock: threading.Lock = field(default_factory=threading.Lock) + counters: dict[str, float] = field(default_factory=dict) + gauges: dict[str, float] = field(default_factory=dict) + rtf_samples: list[float] = field(default_factory=list) + + def inc(self, name: str, delta: float = 1.0) -> None: + with self._lock: + self.counters[name] = self.counters.get(name, 0.0) + delta + + def set_gauge(self, name: str, value: float) -> None: + with self._lock: + self.gauges[name] = value + + def record_rtf(self, rtf: float) -> None: + with self._lock: + self.rtf_samples.append(rtf) + if len(self.rtf_samples) > 1000: + self.rtf_samples = self.rtf_samples[-1000:] + + def snapshot(self) -> dict: + with self._lock: + rtf = self.rtf_samples + return { + "counters": dict(self.counters), + "gauges": dict(self.gauges), + "rtf": { + "samples": len(rtf), + "last": rtf[-1] if rtf else None, + "avg": (sum(rtf) / len(rtf)) if rtf else None, + }, + } + + +_metrics = Metrics() + + +def get_metrics() -> Metrics: + return _metrics diff --git a/src/luke_scribe/pipeline/__init__.py b/src/luke_scribe/pipeline/__init__.py new file mode 100644 index 0000000..b3ece91 --- /dev/null +++ b/src/luke_scribe/pipeline/__init__.py @@ -0,0 +1 @@ +"""배치·실시간 전사 파이프라인.""" diff --git a/src/luke_scribe/pipeline/batch.py b/src/luke_scribe/pipeline/batch.py new file mode 100644 index 0000000..61824c9 --- /dev/null +++ b/src/luke_scribe/pipeline/batch.py @@ -0,0 +1,159 @@ +"""BatchPipeline — 단일 파일 전사 조율 (TranscriptionService 역할). + +- 인제스트(ffprobe 검증 + ffmpeg 정규화) → EngineOwner 전사 → 후처리 → + TranscriptResult 조립. +- 모든 종료 경로에서 임시 파생 오디오 삭제 (성공/실패/취소). +- 진행률 콜백은 워커가 total_sec 기반으로 계산. +- OOM 강등 시 ``attempted_profiles`` 기록 → 결과에 노출 (무음 강등 없음). +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from pathlib import Path + +from ..audio.ingest import AudioIngestor +from ..config import Settings +from ..engine.base import TranscriptionOptions +from ..engine.owner import InferenceRequest +from ..errors import CancelledError +from ..postprocess.pipeline import run_postprocess +from ..results.models import ( + ExecutionInfo, + Segment, + SourceInfo, + Timings, + TranscriptResult, +) +from ..results.store import ResultStore + + +class BatchPipeline: + def __init__( + self, + *, + settings: Settings | None = None, + store: ResultStore | None = None, + owner=None, + token=None, + ingestor: AudioIngestor | None = None, + ) -> None: + self.settings = settings or Settings() + self.store = store or ResultStore(self.settings.results_root) + from ..engine.owner import EngineOwner + + self.owner = owner or EngineOwner.get(self.settings) + self.token = token + self.ingestor = ingestor or AudioIngestor(self.settings) + + def run( + self, + job_or_source, + options: TranscriptionOptions, + *, + progress_cb: Callable[[float, float], None] | None = None, + source_name: str | None = None, + ) -> TranscriptResult: + """job(Job) 또는 source(Path)를 받아 전사. + + Returns: + completed TranscriptResult (후처리 포함). + """ + from ..jobqueue.jobs import Job + + if isinstance(job_or_source, Job): + source = Path(job_or_source.source_path) + src_name = job_or_source.source_name or source.name + else: + source = Path(job_or_source) + src_name = source_name or source.name + + should_cancel = (self.token and self.token.is_cancelled) or (lambda: False) + t0 = time.time() + + # 1) 인제스트 + ingest_result = self.ingestor.ingest(source, should_cancel=should_cancel) + ingest_sec = time.time() - t0 + try: + total_sec = ingest_result.normalized.duration_sec + + # 2) 전사 (EngineOwner — GPU 소유자) + model = options.model + request = InferenceRequest( + audio_path=ingest_result.normalized_path, + options=options, + lane="batch", + should_cancel=should_cancel, + ) + t1 = time.time() + outcome = self.owner.transcribe(request) + transcription_sec = time.time() - t1 + + # 3) 세그먼트 소비 + 진행률 + segments: list[Segment] = [] + processed_sec = 0.0 + last_progress = 0.0 + for idx, seg in enumerate(outcome["segments"]): + if should_cancel(): + raise CancelledError("협조적 취소") + processed_sec = seg.get("end", processed_sec) + if progress_cb and total_sec and processed_sec - last_progress >= 1.0: + progress_cb(processed_sec, total_sec) + last_progress = processed_sec + 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"), + ) + ) + if progress_cb: + progress_cb(processed_sec or total_sec, total_sec) + + # 4) 후처리 (glossary/rules/LLM/confidence) + t2 = time.time() + post_result = run_postprocess(segments, options, self.settings) + postprocess_sec = time.time() - t2 + + text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip()) + result = TranscriptResult( + status="completed", + source=SourceInfo( + name=src_name, + codec=ingest_result.probe.codec, + size_bytes=ingest_result.probe.size_bytes, + ), + normalized_audio=ingest_result.normalized, + execution=ExecutionInfo( + model=model, + device=outcome["device"], + compute_type=outcome["compute_type"], + language_requested=options.language, + language_detected=outcome["info"].get("language") + if outcome.get("info") + else None, + language_detection_confidence=None, + model_used=model, + compute_type_used=outcome["compute_type"], + downgrade_attempts=len(outcome["attempted_profiles"]) - 1, + ), + timings=Timings( + ingest_sec=round(ingest_sec, 3), + transcription_sec=round(transcription_sec, 3), + postprocess_sec=round(postprocess_sec, 3), + rtf=round(transcription_sec / total_sec, 4) if total_sec else None, + ), + text=text, + segments=post_result["segments"], + warnings=post_result["warnings"], + postprocessing=post_result.get("meta"), + ) + return result + except BaseException: + raise + finally: + ingest_result.close() diff --git a/src/luke_scribe/pipeline/realtime.py b/src/luke_scribe/pipeline/realtime.py new file mode 100644 index 0000000..02d7a97 --- /dev/null +++ b/src/luke_scribe/pipeline/realtime.py @@ -0,0 +1,147 @@ +"""실시간 파이프라인 — LocalAgreement 안정화 (plan §3.7c). + +계약: +- ``redecode_window``: 마지막 N초 오디오 재decode +- ``confirmed_prefix``: 연속 가설이 일치하는 부분을 확정하고 버퍼에서 절단 +- ``retained_left_context``: 절단 후 남기는 왼쪽 컨텍스트 (문맥 유지) +- VAD 무음 경계에서 확정 +- 메모리 평탄: 확정 방출 후 버퍼 절단 (단위 테스트로 불변식 검증) + +Eng 리뷰 P18 반영: 확정 출력은 불변(immutable), 가설은 교체 가능(sequence id), +정규화된 토큰/단어 스팬 기준으로 일치를 판정 (한국어 정규화·문장부호 차이 허용). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..config import Settings +from ..results.models import Segment + + +@dataclass +class RealtimeState: + audio_offset_sec: float = 0.0 + confirmed_segments: list[Segment] = field(default_factory=list) + pending_hypothesis: list[Segment] = field(default_factory=list) + hypothesis_seq: int = 0 + buffer_sec: float = 0.0 + + +def _normalize(text: str) -> str: + """일치 비교용 정규화 — 문장부호/공백 제거 (한국어/영문).""" + import re + + return re.sub(r"[\s\W_]+", "", text).lower() + + +class LocalAgreement: + def __init__( + self, + settings: Settings | None = None, + *, + agreement_count: int = 2, + redecode_window_sec: float | None = None, + retained_left_context_sec: float | None = None, + ) -> None: + settings = settings or Settings() + self.agreement_count = agreement_count + self.redecode_window_sec = redecode_window_sec or settings.realtime_redecode_window_sec + self.left_context_sec = ( + retained_left_context_sec or settings.realtime_retained_left_context_sec + ) + self.state = RealtimeState() + self._hypothesis_history: list[list[Segment]] = [] + + def feed(self, new_segments: list[Segment], chunk_duration_sec: float) -> dict: + """새 가설 세그먼트 주입 → (confirmed[], pending[], truncated, committed_text). + + 불변식: + 1. ``confirmed_segments``는 한 번 확정되면 절대 변경되지 않는다. + 2. 확정 방출 후 버퍼 길이는 ``left_context_sec`` 이하로 절단된다. + 3. 확정 세그먼트 시간은 단조 증가한다 (이전 확정 end <= 새 확정 start). + """ + events: dict = { + "confirmed": [], + "pending": new_segments, + "truncated": False, + "new_text": "", + } + if not new_segments: + return events + + self.state.pending_hypothesis = new_segments + self.state.hypothesis_seq += 1 + self._hypothesis_history.append(new_segments) + if len(self._hypothesis_history) > self.agreement_count: + self._hypothesis_history.pop(0) + + if len(self._hypothesis_history) >= self.agreement_count: + stable = self._stable_prefix(self._hypothesis_history) + if stable: + confirmed = self._confirm(stable) + events["confirmed"] = confirmed + events["truncated"] = True + events["new_text"] = " ".join(s.text for s in confirmed) + return events + + def _stable_prefix(self, history: list[list[Segment]]) -> list[Segment]: + """여러 가설에서 일치하는 앞부분. 정규화된 텍스트 스팬 기준. + + 가설은 문장 단위로 길어질 수 있으므로 **prefix 일치**를 사용한다: + "오늘 API 서버"는 "오늘 API 서버에서 vLLM"의 안정 접두사로 확정된다. + """ + # 연속 N개 가설에서 공통 prefix 찾기 + candidates = history[-self.agreement_count :] + if len(candidates) < 2: + return [] + base = candidates[0] + stable: list[Segment] = [] + for i, seg in enumerate(base): + norm = _normalize(seg.text) + if not norm: + continue + matched = all( + i < len(other) and _normalize(other[i].text).startswith(norm) + for other in candidates[1:] + ) + if matched: + stable.append(seg) + else: + break + return stable + + def _confirm(self, segments: list[Segment]) -> list[Segment]: + """확정: 타임스탬프 단조 보장 + 버퍼 절단. + + 이미 확정된 텍스트(정규화 기준)는 재발행하지 않는다 — 같은 발화가 + 다음 가설에서 다시 안정 접두사로 잡혀도 중복 final 이벤트가 나가지 + 않도록 한다 (Eng 리뷰 P18). + """ + confirmed: list[Segment] = [] + known = {_normalize(s.text) for s in self.state.confirmed_segments} + last_end = self.state.confirmed_segments[-1].end if self.state.confirmed_segments else 0.0 + for seg in segments: + if _normalize(seg.text) in known: + continue + start = max(seg.start, last_end) # 단조 증가 보장 + end = max(seg.end, start) + confirmed.append(Segment(index=seg.index, start=start, end=end, text=seg.text)) + last_end = end + self.state.confirmed_segments.extend(confirmed) + # 버퍼 절단: 마지막 확정 end 이후 left_context_sec만 유지 + self.state.audio_offset_sec = max(0.0, last_end - self.left_context_sec) + return confirmed + + @property + def committed_text(self) -> str: + return " ".join(s.text for s in self.state.confirmed_segments) + + def flush(self) -> dict: + """연결 종료/타임아웃 시 남은 pending 확정.""" + pending = self.state.pending_hypothesis + self.state.pending_hypothesis = [] + if not pending: + return {"confirmed": [], "new_text": ""} + confirmed = self._confirm(pending) + return {"confirmed": confirmed, "new_text": " ".join(s.text for s in confirmed)} diff --git a/src/luke_scribe/postprocess/__init__.py b/src/luke_scribe/postprocess/__init__.py new file mode 100644 index 0000000..c06b8db --- /dev/null +++ b/src/luke_scribe/postprocess/__init__.py @@ -0,0 +1 @@ +"""후처리 — glossary → rules → (선택)LLM → confidence.""" diff --git a/src/luke_scribe/postprocess/confidence.py b/src/luke_scribe/postprocess/confidence.py new file mode 100644 index 0000000..739db71 --- /dev/null +++ b/src/luke_scribe/postprocess/confidence.py @@ -0,0 +1,37 @@ +"""Confidence — 저신뢰 세그먼트 플래깅. + +신뢰도 근거: avg_logprob가 낮거나 no_speech_prob가 높으면 저신뢰로 판정해 +사람 검토 대상으로 표시한다. (LLM confidence-gate에 재사용.) +""" + +from __future__ import annotations + +from ..results.models import Segment + +# 경험 임계값 (벤치마크로 교정 예정) +LOW_LOGPROB = -1.0 +HIGH_NO_SPEECH = 0.9 + + +def segment_confidence(seg: Segment) -> float | None: + """0.0~1.0 합성 신뢰도. 근거가 없으면 None.""" + if seg.avg_logprob is None: + return None + # avg_logprob: -1 근처가 불확실 + return max(0.0, min(1.0, 1.0 + seg.avg_logprob)) + + +def confidence_flags(segments: list[Segment]) -> list[int]: + """저신뢰 세그먼트 index 목록.""" + flagged: list[int] = [] + for seg in segments: + conf = segment_confidence(seg) + low = conf is not None and conf < 0.5 + noisy = ( + seg.no_speech_prob is not None + and seg.no_speech_prob > HIGH_NO_SPEECH + and not seg.text.strip() + ) + if low or noisy: + flagged.append(seg.index) + return flagged diff --git a/src/luke_scribe/postprocess/glossary.py b/src/luke_scribe/postprocess/glossary.py new file mode 100644 index 0000000..475fd90 --- /dev/null +++ b/src/luke_scribe/postprocess/glossary.py @@ -0,0 +1,40 @@ +"""Glossary — 도메인 용어 원문 표기 복원. + +교정은 세그먼트 텍스트 단위로 수행하고 각 교정의 span(start_char)을 기록한다. +한 번 교정된 span을 다시 교정하지 않는다 (이중 매칭 방지). 타임라인은 +변경하지 않는다 (Eng 리뷰 P21: span-aware). +""" + +from __future__ import annotations + +import re +from typing import Any + +from ..results.models import Segment + + +def apply_glossary(segments: list[Segment], glossary: dict[str, str]) -> dict[str, Any]: + """``glossary``: {오인식 패턴: 표준 표기}. + + 예: {"브이엘엘엠": "vLLM", "에이피아이": "API"} — 패턴은 정규식 허용. + """ + corrections: list[dict[str, Any]] = [] + out: list[Segment] = [] + for seg in segments: + text = seg.text + seg_corrections: list[dict[str, Any]] = [] + # 긴 패턴 먼저 (선택성); 동일 표기는 스킵 (교정 없음) + for pattern in sorted(glossary, key=len, reverse=True): + replacement = glossary[pattern] + if pattern == replacement: + continue + new_text, n = re.subn(re.escape(pattern), replacement, text, count=1) + if n: + seg_corrections.append( + {"from": pattern, "to": replacement, "segment_index": seg.index} + ) + text = new_text + if seg_corrections: + corrections.extend(seg_corrections) + out.append(Segment(**{**seg.model_dump(), "text": text})) + return {"segments": out, "corrections": corrections} diff --git a/src/luke_scribe/postprocess/llm.py b/src/luke_scribe/postprocess/llm.py new file mode 100644 index 0000000..0993d62 --- /dev/null +++ b/src/luke_scribe/postprocess/llm.py @@ -0,0 +1,134 @@ +"""LLM 보정 (선택, 기본 off). + +계약 (plan §3.8): +- 백엔드: local / openai / external. 기본 off(``none``). +- **external/openai는 config allowlist 엔드포인트에만** 송신 가능 (SSRF 방지). +- 기본 off + 명시 opt-in + 전송 1건당 감사 로그 (key id, endpoint, job id). +- confidence-gated: 저신뢰 세그먼트만 교정 (과교정 방지). +- Eng 리뷰 P15: provider_id 기반 허용, 리다이렉트 금지, private IP 거부, + 주변 프록시 무시. +""" + +from __future__ import annotations + +import logging +import socket +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +from ..config import Settings +from ..results.models import Segment +from .confidence import segment_confidence + +logger = logging.getLogger("luke_scribe.llm") + + +class EgressDenied(Exception): + pass + + +class EgressGuard: + """외부 LLM 엔드포인트 허용 검증.""" + + def __init__(self, allowlist: dict[str, str]) -> None: + self._providers: dict[str, str] = {} + for pid, url in allowlist.items(): + parsed = urlparse(url) + if parsed.scheme != "https": + logger.warning("LLM allowlist 항목이 https가 아닙니다 (거부됨): %s", pid) + continue + host = parsed.hostname + if host is None: + continue + if self._is_private(host): + logger.warning("LLM allowlist에 private IP가 포함됨 (거부됨): %s", pid) + continue + self._providers[pid] = url.rstrip("/") + if not self._providers: + logger.warning("LLM allowlist가 비어 있습니다 — 외부 egress 전부 차단") + + def resolve(self, provider_id: str) -> str | None: + return self._providers.get(provider_id) + + @staticmethod + def _is_private(host: str) -> bool: + try: + ip = socket.gethostbyname(host) + except OSError: + return True + parts = ip.split(".") + if len(parts) != 4: + return True + a, b = int(parts[0]), int(parts[1]) + if a == 10: + return True + if a == 127: + return True + if a == 169 and b == 254: + return True + if a == 172 and 16 <= b <= 31: + return True + if a == 192 and b == 168: + return True + return False + + +@dataclass +class LLMResult: + corrections: list[dict[str, Any]] + segments: list[Segment] + meta: dict[str, Any] | None = None + + +class LLMCorrector: + """LLM 보정 백엔드 — 설정 기반. 기본 off. + + v0.1 환경(네트워크 제약)에서는 백엔드 호출을 실행하지 않도록 + ``enabled``가 False인 채로 생성된다. 실제 호출은 allowlist 검증 후에만. + """ + + def __init__(self, settings: Settings, *, http_client=None) -> None: + self.settings = settings + self.enabled = settings.llm_backend in ("local", "openai", "external") + self.audit = settings.llm_audit + self._guard = EgressGuard(settings.parse_llm_allowlist()) + self._http = http_client # 주입용 (테스트 mock) + + def correct(self, segments: list[Segment], settings: Settings | None = None) -> LLMResult: + settings = settings or self.settings + # confidence gate: 저신뢰 세그먼트만 + low_conf = [ + s for s in segments if (segment_confidence(s) or 1.0) < settings.llm_confidence_gate + ] + if not low_conf: + return LLMResult(corrections=[], segments=segments, meta={"gated": 0}) + if not self.enabled: + return LLMResult(corrections=[], segments=segments, meta={"enabled": False}) + + provider_id = "llm" if settings.llm_backend == "external" else settings.llm_backend + endpoint = self._guard.resolve(provider_id) + if endpoint is None: + raise EgressDenied(f"LLM provider {provider_id!r}가 allowlist에 없습니다") + if self.audit: + logger.info( + "LLM egress: provider=%s endpoint=%s segments=%d", + provider_id, + endpoint, + len(low_conf), + ) + + # 실제 호출: v0.1에서는 HTTP 클라이언트 주입 시에만 수행 + if self._http is None: + raise EgressDenied( + "LLM HTTP 클라이언트가 구성되지 않았습니다 (v0.1은 외부 LLM 기본 off)" + ) + + response = self._http.post(endpoint, json={"segments": [s.model_dump() for s in low_conf]}) + if response.status_code != 200: + raise EgressDenied(f"LLM 응답 오류: HTTP {response.status_code}") + data = response.json() + corrections = [ + {"provider": provider_id, "segment_index": i} for i in data.get("corrected", []) + ] + return LLMResult(corrections=corrections, segments=segments, meta={"provider": provider_id}) diff --git a/src/luke_scribe/postprocess/pipeline.py b/src/luke_scribe/postprocess/pipeline.py new file mode 100644 index 0000000..ff8ac83 --- /dev/null +++ b/src/luke_scribe/postprocess/pipeline.py @@ -0,0 +1,80 @@ +"""후처리 파이프라인 조율. + +순서 (plan §7/AC-10): +1. glossary — 도메인 용어 원문 표기 복원 (선택, 요청 시) +2. rules — 결정적 정규화 (알려진 오인식 → 표준 용어) +3. LLM 보정 (기본 off, confidence-gated, egress 통제) — 선택 +4. confidence 플래깅 — 저신뢰 세그먼트 표시 + +Eng 리뷰 P21 반영: glossary/rules 교정은 **span-aware**이어야 한다. word +timestamps/SRT/VTT 출력을 무효화하지 않도록, 교정은 세그먼트 텍스트 단위로 +적용하고 타임라인은 보존한다. 원문과 교정문을 함께 기록한다. +""" + +from __future__ import annotations + +from typing import Any + +from ..config import Settings +from ..engine.base import TranscriptionOptions +from ..results.models import Segment +from .confidence import confidence_flags +from .glossary import apply_glossary +from .rules import apply_rules + + +def run_postprocess( + segments: list[Segment], + options: TranscriptionOptions, + settings: Settings, + *, + glossary: dict[str, str] | None = None, + llm_backend=None, +) -> dict[str, Any]: + """후처리 실행. segments(수정본) + warnings + meta 반환. + + ``settings.post_mode``: none | glossary | rules | llm + """ + mode = settings.post_mode if settings.post_enabled else "none" + warnings: list[str] = [] + meta: dict[str, Any] = { + "mode": mode, + "corrections": [], + "raw_text": " ".join(s.text for s in segments), + } + + if mode in ("glossary", "rules", "llm"): + # 1) glossary + if mode in ("glossary", "rules", "llm") and glossary: + applied = apply_glossary(segments, glossary) + corrections = applied["corrections"] + if corrections: + warnings.append(f"glossary: {len(corrections)}건 교정") + meta["corrections"].extend(corrections) + segments = applied["segments"] + + # 2) rules + if mode in ("rules", "llm"): + rule_result = apply_rules(segments) + if rule_result["corrections"]: + warnings.append(f"rules: {len(rule_result['corrections'])}건 정규화") + meta["corrections"].extend(rule_result["corrections"]) + segments = rule_result["segments"] + + # 3) LLM (선택, 기본 off) + if mode == "llm" and llm_backend is not None: + llm_result = llm_backend.correct(segments, settings) + if llm_result["corrections"]: + warnings.append(f"llm: {len(llm_result['corrections'])}건 보정") + meta["corrections"].extend(llm_result["corrections"]) + meta["llm"] = llm_result.get("meta") + segments = llm_result["segments"] + + # 4) confidence 플래깅 + flagged = confidence_flags(segments) + if flagged: + warnings.append(f"confidence: {len(flagged)}개 저신뢰 구간 플래그") + meta["low_confidence_segments"] = flagged + + meta["corrected_text"] = " ".join(s.text.strip() for s in segments if s.text.strip()) + return {"segments": segments, "warnings": warnings, "meta": meta} diff --git a/src/luke_scribe/postprocess/rules.py b/src/luke_scribe/postprocess/rules.py new file mode 100644 index 0000000..1d49d99 --- /dev/null +++ b/src/luke_scribe/postprocess/rules.py @@ -0,0 +1,56 @@ +"""Rules — 결정적 정규화 (알려진 오인식 → 표준 용어). + +약어 대소문자 보정, 공백 정규화, 혼용어 패턴 복원. 모든 변환은 span 기록. +""" + +from __future__ import annotations + +import re +from typing import Any + +from ..results.models import Segment + +# 흔한 오인식 패턴 → 표준 표기 (정규식) +DEFAULT_RULES: list[tuple[re.Pattern, str]] = [ + (re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"), + (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"\bg ?p ?u\b", re.IGNORECASE), "GPU"), + (re.compile(r"\bl ?l ?m\b", re.IGNORECASE), "LLM"), +] + +# 반복 whitespace 축약 +_WS = re.compile(r"\s+") + + +def apply_rules( + segments: list[Segment], rules: list[tuple[re.Pattern, str]] | None = None +) -> dict[str, Any]: + rules = rules or DEFAULT_RULES + corrections: list[dict[str, Any]] = [] + out: list[Segment] = [] + for seg in segments: + text = seg.text + seg_corrections: list[dict[str, Any]] = [] + for pattern, replacement in rules: + new_text, n = pattern.subn(replacement, text) + if n: + seg_corrections.append( + { + "from": pattern.pattern, + "to": replacement, + "segment_index": seg.index, + "count": n, + } + ) + text = new_text + normalized = _WS.sub(" ", text).strip() + if normalized != text: + seg_corrections.append( + {"from": "whitespace", "to": "single-space", "segment_index": seg.index} + ) + text = normalized + if seg_corrections: + corrections.extend(seg_corrections) + out.append(Segment(**{**seg.model_dump(), "text": text})) + return {"segments": out, "corrections": corrections} diff --git a/src/luke_scribe/results/__init__.py b/src/luke_scribe/results/__init__.py new file mode 100644 index 0000000..536e3da --- /dev/null +++ b/src/luke_scribe/results/__init__.py @@ -0,0 +1 @@ +"""결과 스키마·저장·포맷·보관.""" diff --git a/src/luke_scribe/results/formats.py b/src/luke_scribe/results/formats.py new file mode 100644 index 0000000..1e92c7b --- /dev/null +++ b/src/luke_scribe/results/formats.py @@ -0,0 +1,70 @@ +"""출력 포맷 — json / txt / srt / vtt 변환. + +포맷은 enum(고정 확장자)으로만 요청된다 — 사용자 문자열을 경로에 결합하지 +않는다 (Eng 리뷰 P16). +""" + +from __future__ import annotations + +from typing import Literal + +from .models import TranscriptResult + +Format = Literal["json", "txt", "srt", "vtt"] +FORMAT_EXTENSIONS: dict[str, str] = { + "json": ".json", + "txt": ".txt", + "srt": ".srt", + "vtt": ".vtt", +} + +SUPPORTED_FORMATS = set(FORMAT_EXTENSIONS) + + +def render(result: TranscriptResult, fmt: Format) -> str: + if fmt == "json": + import json + + return json.dumps(result.model_dump(), ensure_ascii=False, indent=2) + if fmt == "txt": + return result.text + "\n" + if fmt == "srt": + return _to_srt(result) + if fmt == "vtt": + return _to_vtt(result) + raise ValueError(f"지원하지 않는 포맷: {fmt}") + + +def _fmt_ts_srt(sec: float) -> str: + ms = int(round(sec * 1000)) + h, rem = divmod(ms, 3600_000) + m, rem = divmod(rem, 60_000) + s, ms = divmod(rem, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def _fmt_ts_vtt(sec: float) -> str: + ms = int(round(sec * 1000)) + h, rem = divmod(ms, 3600_000) + m, rem = divmod(rem, 60_000) + s, ms = divmod(rem, 1000) + return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" + + +def _to_srt(result: TranscriptResult) -> str: + lines: list[str] = [] + for i, seg in enumerate(result.segments, start=1): + lines.append(str(i)) + lines.append(f"{_fmt_ts_srt(seg.start)} --> {_fmt_ts_srt(seg.end)}") + lines.append(seg.text.strip()) + lines.append("") + return "\n".join(lines) + + +def _to_vtt(result: TranscriptResult) -> str: + lines = ["WEBVTT", ""] + for seg in result.segments: + lines.append(f"{_fmt_ts_vtt(seg.start)} --> {_fmt_ts_vtt(seg.end)}") + lines.append(seg.text.strip()) + lines.append("") + return "\n".join(lines) diff --git a/src/luke_scribe/results/models.py b/src/luke_scribe/results/models.py new file mode 100644 index 0000000..626d916 --- /dev/null +++ b/src/luke_scribe/results/models.py @@ -0,0 +1,100 @@ +"""TranscriptResult v1 — CLI/API/큐/실시간이 공유하는 정규 결과 스키마. + +설계 계약: 결과 스키마는 v1 동안 필드 의미·자료형을 바꾸지 않는다. +확장 필드는 추가 가능하되 기존 필드는 불변이다. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + +SCHEMA_VERSION = "1.0" + + +class SourceInfo(BaseModel): + name: str + codec: str | None = None + size_bytes: int | None = None + + +class NormalizedAudio(BaseModel): + duration_sec: float = Field(gt=0) + audio_format: str = "pcm_s16le" + sample_rate: int = 16000 + channels: int = 1 + + +class ExecutionInfo(BaseModel): + model: str + device: str + compute_type: str + language_requested: str | None = None + language_detected: str | None = None + language_detection_confidence: float | None = None + model_used: str | None = None + compute_type_used: str | None = None + downgrade_attempts: int = 0 + capability_tier: str | None = None + + +class Timings(BaseModel): + model_load_sec: float | None = None + transcription_sec: float | None = None + ingest_sec: float | None = None + postprocess_sec: float | None = None + rtf: float | None = None + + +class Segment(BaseModel): + index: int = Field(ge=0) + start: float = Field(ge=0) + end: float = Field(ge=0) + text: str = "" + avg_logprob: float | None = None + no_speech_prob: float | None = None + confidence: float | None = None + words: list[dict[str, Any]] | None = None + speaker: str | None = None + + +class ErrorInfo(BaseModel): + code: str + message: str + retryable: bool = False + + +class TranscriptResult(BaseModel): + schema_version: str = SCHEMA_VERSION + status: Literal["completed", "failed", "cancelled"] = "completed" + source: SourceInfo | None = None + normalized_audio: NormalizedAudio | None = None + execution: ExecutionInfo | None = None + timings: Timings = Field(default_factory=Timings) + text: str = "" + segments: list[Segment] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + error: ErrorInfo | None = None + # 확장: 후처리/화자/용어 보존 정보 + postprocessing: dict[str, Any] | None = None + entities: list[dict[str, Any]] | None = None + + @model_validator(mode="after") + def _check_state(self) -> TranscriptResult: + if self.status == "completed": + if self.error is not None: + raise ValueError("completed 결과에 error가 있을 수 없습니다") + elif self.status in ("failed", "cancelled"): + if self.error is None: + self.error = ErrorInfo( + code="transcription_failed" if self.status == "failed" else "cancelled", + message="", + ) + # text = 세그먼트 trim 텍스트를 단일 공백으로 연결 (계약) + if self.status == "completed" and self.segments and not self.text: + self.text = " ".join(s.text.strip() for s in self.segments if s.text.strip()) + return self + + def dict_v1(self) -> dict[str, Any]: + return self.model_dump(exclude_none=False) diff --git a/src/luke_scribe/results/retention.py b/src/luke_scribe/results/retention.py new file mode 100644 index 0000000..8888326 --- /dev/null +++ b/src/luke_scribe/results/retention.py @@ -0,0 +1,82 @@ +"""Retention — 결과 보관 TTL 청소 (plan §3.7e). + +- **터미널 상태(completed/failed/cancelled) Job만** 청소. +- {queued, processing}이 보유한 결과·임시물은 절대 건드리지 않는다 + (보관 경합 방지). +- TTL은 Job 메타의 ``completed_at``(또는 result.json mtime) 기준. +""" + +from __future__ import annotations + +import time +from datetime import UTC, datetime +from pathlib import Path + +from .store import ResultStore + +TERMINAL_META_FIELDS = ("completed_at", "failed_at", "cancelled_at") + + +class RetentionSweeper: + def __init__( + self, store: ResultStore, retention_days: int = 7, *, now: float | None = None + ) -> None: + self.store = store + self.retention_days = retention_days + self._now = now # 테스트용 fake clock + + def sweep(self) -> list[str]: + """만료된 터미널(completed/failed/cancelled) Job 디렉터리 삭제. + + 삭제된 job_id 목록 반환. {queued, processing}은 절대 건드리지 않는다 + (터미널 타임스탬프가 meta.json에 없으면 스킵). + """ + if self.retention_days <= 0: + return [] + now = self._now if self._now is not None else time.time() + cutoff = now - self.retention_days * 86400 + removed: list[str] = [] + for job_id, job_dir in self.store.iter_job_dirs(): + if self._job_stale(job_id, job_dir, cutoff): + self.store.delete_job(job_id) + removed.append(job_id) + return removed + + @staticmethod + def _ts_to_epoch(ts: object) -> float | None: + """ISO 8601 또는 epoch float 타임스탬프를 epoch 초로 변환.""" + if isinstance(ts, (int, float)): + return float(ts) + try: + dt = datetime.fromisoformat(str(ts)) + except (ValueError, TypeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.timestamp() + + def _job_stale(self, job_id: str, job_dir: Path, cutoff: float) -> bool: + """터미널 타임스탬프 기준 만료 판정. 결과/메타 모두 없으면 비터미널로 스킵.""" + meta_path = job_dir / "meta.json" + if meta_path.exists(): + import json + + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except Exception: + meta = {} + for field_name in TERMINAL_META_FIELDS: + ts = meta.get(field_name) + if not ts: + continue + # 터미널 타임스탬프 존재 → 만료 여부 결정 + ts_f = self._ts_to_epoch(ts) + return ts_f is not None and ts_f < cutoff + # 메타가 없으면 result.json mtime 폴백 (완료 경로 호환) + result_path = job_dir / "result.json" + if result_path.exists(): + try: + return result_path.stat().st_mtime < cutoff + except OSError: + return False + return False diff --git a/src/luke_scribe/results/store.py b/src/luke_scribe/results/store.py new file mode 100644 index 0000000..98e6111 --- /dev/null +++ b/src/luke_scribe/results/store.py @@ -0,0 +1,141 @@ +"""ResultStore — 결과 저장 (UUID 키, 경로 트래버설 차단). + +Eng 리뷰 P16/P20 반영: +- 결과 파일은 클라이언트 파일명이 아니라 **서버 생성 UUID 키**로 저장. +- ``format``은 enum으로 고정 확장자에 매핑 (사용자 문자열을 경로에 결합 금지). +- 모든 경로는 store root 하위로 canonicalize 검증, 심볼릭 링크 거부. +- 원자적 쓰기(임시 파일 → rename), 부분 JSON 파일 금지. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import uuid +from pathlib import Path + +from ..errors import OutputWriteError +from ..results.models import TranscriptResult + +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +class ResultStore: + def __init__(self, root: str) -> None: + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + + def _job_dir(self, job_id: str) -> Path: + if not UUID_RE.match(job_id): + raise ValueError(f"유효하지 않은 job id: {job_id!r}") + d = self.root / job_id + # root 하위 canonical 경로 검증 (심볼릭 링크/트래버설 방지) + canonical = d.resolve() + if not canonical.is_relative_to(self.root.resolve()): + raise ValueError(f"경로 탈출 시도: {job_id!r}") + return d + + def _reject_symlinks(self, d: Path) -> None: + if d.is_symlink(): + raise ValueError("심볼릭 링크가 있는 job 디렉터리는 허용하지 않습니다") + + def write_result(self, job_id: str, result: TranscriptResult) -> Path: + d = self._job_dir(job_id) + self._reject_symlinks(d) + d.mkdir(parents=True, exist_ok=True) + payload = json.dumps(result.model_dump(), ensure_ascii=False, indent=2) + tmp = d / f".result-{uuid.uuid4().hex}.tmp" + try: + tmp.write_text(payload, encoding="utf-8") + target = d / "result.json" + os.replace(tmp, target) + except OSError as exc: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise OutputWriteError(f"결과 저장 실패: {exc}") from exc + return target + + def read_result(self, job_id: str) -> TranscriptResult | None: + d = self._job_dir(job_id) + self._reject_symlinks(d) + p = d / "result.json" + if not p.exists(): + return None + return TranscriptResult.model_validate_json(p.read_text(encoding="utf-8")) + + def write_source_metadata(self, job_id: str, meta: dict) -> None: + d = self._job_dir(job_id) + self._reject_symlinks(d) + d.mkdir(parents=True, exist_ok=True) + (d / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") + + def source_path_for(self, job_id: str, source_name: str) -> Path: + """업로드 원본 저장 — 클라이언트 파일명을 **이름으로만** 사용, 확장자만 검증.""" + name = Path(source_name).name # 경로 성분 제거 + if not name or name in (".", ".."): + raise ValueError("잘못된 파일명") + d = self._job_dir(job_id) + d.mkdir(parents=True, exist_ok=True) + return d / name + + def iter_job_dirs(self) -> list[tuple[str, Path]]: + """모든 job 디렉터리 (job_id, dir) — retention sweeper용.""" + out: list[tuple[str, Path]] = [] + for entry in self.root.iterdir(): + if not entry.is_dir(): + continue + if not UUID_RE.match(entry.name): + continue + out.append((entry.name, entry)) + return out + + def iter_terminal_results(self) -> list[tuple[str, Path]]: + """result.json이 있는 job 디렉터리 — 기존 호환용.""" + out: list[tuple[str, Path]] = [] + for job_id, d in self.iter_job_dirs(): + p = d / "result.json" + if p.exists(): + out.append((job_id, p)) + return out + + def delete_job(self, job_id: str) -> None: + d = self._job_dir(job_id) + if d.exists(): + shutil.rmtree(d, ignore_errors=True) + + def delete_derived(self, job_id: str) -> None: + """원본 오디오 + 파생물 삭제 — 결과/메타만 보존 (privacy-first). + + 전사가 끝나면 업로드 원본은 즉시 삭제된다 (plan §3.7e/§6.1). + ``result.json``/``meta.json``은 보관 대상이므로 건드리지 않는다. + """ + d = self._job_dir(job_id) + self._reject_symlinks(d) + if not d.exists(): + return + for p in d.iterdir(): + if p.is_file() and p.name not in ("result.json", "meta.json"): + p.unlink(missing_ok=True) + + +class AtomicFileWriter: + """일반 파일 원자적 쓰기 — CLI --output 용.""" + + @staticmethod + def write(path: str | Path, data: str) -> None: + p = Path(path) + tmp = p.parent / f".{p.name}.{uuid.uuid4().hex}.tmp" + try: + p.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(data, encoding="utf-8") + os.replace(tmp, p) + except OSError as exc: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + raise OutputWriteError(f"출력 파일을 쓸 수 없습니다: {exc}") from exc diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..241b882 --- /dev/null +++ b/tests/conftest.py @@ -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": [], + } diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..e84938b --- /dev/null +++ b/tests/integration/test_api.py @@ -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"] diff --git a/tests/integration/test_worker.py b/tests/integration/test_worker.py new file mode 100644 index 0000000..bfeef03 --- /dev/null +++ b/tests/integration/test_worker.py @@ -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" diff --git a/tests/unit/test_devices.py b/tests/unit/test_devices.py new file mode 100644 index 0000000..c120111 --- /dev/null +++ b/tests/unit/test_devices.py @@ -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") diff --git a/tests/unit/test_engine_owner.py b/tests/unit/test_engine_owner.py new file mode 100644 index 0000000..c7f2899 --- /dev/null +++ b/tests/unit/test_engine_owner.py @@ -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 # 새 시도 없음 diff --git a/tests/unit/test_formats.py b/tests/unit/test_formats.py new file mode 100644 index 0000000..67401c7 --- /dev/null +++ b/tests/unit/test_formats.py @@ -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="", + ) diff --git a/tests/unit/test_ingest.py b/tests/unit/test_ingest.py new file mode 100644 index 0000000..92e3031 --- /dev/null +++ b/tests/unit/test_ingest.py @@ -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() diff --git a/tests/unit/test_jobs.py b/tests/unit/test_jobs.py new file mode 100644 index 0000000..371dd46 --- /dev/null +++ b/tests/unit/test_jobs.py @@ -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" diff --git a/tests/unit/test_keystore.py b/tests/unit/test_keystore.py new file mode 100644 index 0000000..f8efc54 --- /dev/null +++ b/tests/unit/test_keystore.py @@ -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") diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py new file mode 100644 index 0000000..2182d95 --- /dev/null +++ b/tests/unit/test_metrics.py @@ -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 diff --git a/tests/unit/test_postprocess.py b/tests/unit/test_postprocess.py new file mode 100644 index 0000000..084a6f7 --- /dev/null +++ b/tests/unit/test_postprocess.py @@ -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 diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py new file mode 100644 index 0000000..8725e2f --- /dev/null +++ b/tests/unit/test_realtime.py @@ -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"] diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py new file mode 100644 index 0000000..9efc2c1 --- /dev/null +++ b/tests/unit/test_store.py @@ -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") -- 2.54.0 From 0d90845ac69a99637d983968d62421263415d0e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 16:39:25 +0900 Subject: [PATCH 03/21] docs: add Colab GPU test notebook for real-model verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CPU-only dev env verified via mocks; the notebook runs the full real pipeline on Colab Pro T4: clone → ffmpeg/venv install → detect (GPU capability tier) → 127 unit/integration tests → sample TTS (KO+EN tech terms) → real faster-whisper transcription → hotword/postprocess → API smoke → benchmark. --- README.md | 11 ++ notebooks/luke-scribe-colab.ipynb | 175 +++++++++++++++++ scripts/build_colab_notebook.py | 304 ++++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 notebooks/luke-scribe-colab.ipynb create mode 100644 scripts/build_colab_notebook.py diff --git a/README.md b/README.md index 3ce8fa1..55832ea 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,17 @@ docker compose --profile gpu up -d API + Redis + 워커, 공유 스토어 볼륨. `LUKESCRIBE_API_KEYS` 필수. +## Colab 실전 테스트 (GPU) + +이 저장소의 CI/개발 환경은 CPU-only라 모델·ffmpeg는 mock으로만 검증했다. +GPU + 실제 faster-whisper 모델로 실전 검증하려면 **Colab 노트북**을 사용한다 +(Colab Pro T4 GPU 권장 — 터미널에서도 동일 명령 실행 가능): + +- `notebooks/luke-scribe-colab.ipynb` — 클론 → 설치 → `detect`(GPU 감지) → + 테스트 → 샘플 TTS 생성 → **실제 한국어 전사** → hotword/후처리 → API 스모크 → 벤치마크 +- 노트북은 `scripts/build_colab_notebook.py`로 생성/재생성한다 +- private 저장소이면 1번 셀의 `GITEA_TOKEN`에 토큰을 넣는다 + ## 알려진 제한 (v0.1) - 실시간 decode는 `EngineOwner` 스텁 (실제 WS decode는 GPU 환경에서 활성화). diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb new file mode 100644 index 0000000..d6f4bb3 --- /dev/null +++ b/notebooks/luke-scribe-colab.ipynb @@ -0,0 +1,175 @@ +{ + "nbformat": 4, + "nbformat_minor": 0, + "metadata": { + "colab": { + "provenance": [], + "gpuType": "T4" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + }, + "accelerator": "GPU" + }, + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# luke_scribe — Colab 실전 테스트 노트북\n\n> 내부용 로컬 STT 전사 API (faster-whisper, hardware-adaptive, privacy-first)\n\n이 노트북은 CPU-only 개발 환경에서 **mock으로만 검증**했던 것을,\nColab Pro(GPU + 터미널)에서 **실전 검증**하기 위한 것입니다.\n\n## 검증 대상\n\n- `luke-scribe detect` → T4 GPU 실제 감지 (능력 등급 T1~T3, 정밀도, 워커수)\n- faster-whisper 모델 다운로드 + **실제 한국어 전사** (CPU-only 환경에선 불가)\n- glossary/hotword 후처리 (KO+EN 기술용어 보존)\n- REST API 흐름 (업로드 → poll → 결과)\n- turbo vs large-v3 벤치마크\n\n## 준비\n\n1. 런타임 → 런타임 유형 변경 → **T4 GPU** 선택\n2. (Colab Pro) 터미널을 사용해도 동일한 명령을 실행할 수 있습니다\n3. 저장소가 private이면 아래 셀에 **Gitea 토큰** 입력 (Settings → Applications → Generate New Token)\n\n---\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 0) 런타임 확인 — T4 GPU가 활성 상태여야 합니다\n!nvidia-smi\nimport sys\nprint('Python', sys.version.split()[0])\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 1) 저장소 클론\n\n`feat/full-platform` 브랜치를 클론합니다.\n저장소가 private이면 아래 셀의 `GITEA_TOKEN`에 토큰을 입력하세요." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 1) 클론 (private 저장소면 GITEA_TOKEN 입력)\nGITEA_TOKEN = '' # ← 필요 시 입력: https://git.lukehemmin.com/user/settings/applications\n\nif GITEA_TOKEN:\n REPO = f'https://{GITEA_TOKEN}@git.lukehemmin.com/lukehemmin/luke_scribe.git'\nelse:\n REPO = 'https://git.lukehemmin.com/lukehemmin/luke_scribe.git'\n\n!rm -rf luke_scribe\n!git clone -b feat/full-platform {REPO}\nimport os\nos.chdir('/content/luke_scribe')\nprint('클론 완료 →', os.getcwd())\n!git log --oneline -1\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n!ffmpeg -version 2>&1 | head -1\n!ffprobe -version 2>&1 | head -1\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 3) venv + 설치 (GPU 추론은 engine+api만으로 충분 — Colab T4에 CUDA 런타임 내장)\n!python3 -m venv .venv\n!source .venv/bin/activate && pip install -q --upgrade pip\n!source .venv/bin/activate && pip install -q -e '.[engine,api]'\n!source .venv/bin/activate && pip install -q edge-tts # 샘플 음성 생성용\n\n# 이후 셀에서 activate 없이 CLI 사용 가능하게 PATH 등록\nimport os\nos.environ['PATH'] = '/content/luke_scribe/.venv/bin:' + os.environ['PATH']\nprint('설치 완료')\n!luke-scribe --help 2>&1 | head -12\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 3) 하드웨어 감지 — T4 GPU 실제 확인\n\nCPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\nColab T4(16GB VRAM, sm_75)에서는 GPU가 감지되어야 합니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n!luke-scribe detect\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 5) 테스트 스위트\n!source .venv/bin/activate && python -m pytest tests/ -q 2>&1 | tail -5\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 5) 실전 전사 (GPU)\n\n### 5-1) 샘플 오디오 생성\n\nedge-tts(MS TTS)로 **한국어 + 영문 기술용어가 섞인** 샘플을 생성합니다.\n이 텍스트에는 `vLLM`, `Kubernetes`, `GPU` 같은 용어가 포함되어 glossary/hotword 검증에 적합합니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 6) 샘플 오디오 생성 (한국어 + 기술용어, 무료 TTS)\n!mkdir -p samples\n!edge-tts --voice ko-KR-SunHiNeural --text '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.' --write-media samples/colab-ko-en.mp3\n!ffprobe -v error -show_entries format=duration -of json samples/colab-ko-en.mp3\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n!time luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### 5-2) 후처리 검증\n\nglossary(오인식 용어 복원) + hotword(용어 사전 주입) 동작을 확인합니다.\n`--hotword vLLM Kubernetes`를 주면 initial_prompt에 용어가 주입되어 보존률이 올라갑니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 8) hotword 포함 전사 (용어 보존 강화)\n!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 6) REST API 스모크\n\n서버를 백그라운드로 띄우고 **업로드 → poll → 결과(SRT)** 흐름을 검증합니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 9) API 키 생성 (1회 출력)\n!luke-scribe key --create --scopes transcribe,admin --file /content/api_keys.json\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드)\nimport subprocess, time, os, json\nenv = dict(os.environ)\nenv['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nenv['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\nproc = subprocess.Popen(\n ['luke-scribe', 'serve', '--port', '8000'],\n env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,\n)\n# 기동 대기\nimport urllib.request\nfor _ in range(30):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n break\n except Exception:\n time.sleep(1)\nprint('서버 기동 OK')\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 11) 업로드 → poll → 결과\nimport json, time, urllib.request, urllib.error\n\nKEY = json.load(open('/content/api_keys.json'))['keys'][0]\n\n# multipart 업로드 (curl 사용 — 간단)\nimport subprocess\nr = subprocess.run(\n ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n '-H', f'X-API-Key: {KEY}',\n '-F', 'file=@samples/colab-ko-en.mp3',\n '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n capture_output=True, text=True,\n)\njob = json.loads(r.stdout)\nprint('생성:', job)\njob_id = job['job_id']\n\n# 완료까지 poll (워커 없이 in-proc이라 즉시 완료되지는 않음 → 여기선 생성까지만 확인)\nprint('job_id =', job_id)\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### 참고 — 서버에서 실제 전사까지 실행하려면\n\nin-proc 브로커는 큐만 받고 워커가 별도로 소비해야 합니다. 워커 스레드를 띄우거나\n간단하게는 배치 파이프라인을 직접 호출하는 CLI가 더 간단합니다. API 전체 흐름(워커 포함)은\n다음 셀에서 `python`으로 브로커 + 워커를 함께 돌려 확인합니다." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nprint('status:', result.status)\nprint('text:', result.text[:120])\nprint('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 7) 벤치마크 (turbo vs large-v3)\n\nmanifest에 모델 비교 항목이 있으면 실행합니다. 두 모델을 모두 다운로드하므로\n시간이 걸립니다 (turbo ~1.6GB + large-v3 ~3GB)." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n# 간단 manifest 생성\nimport yaml\nmanifest = {\n 'name': 'colab-quick',\n 'language': 'ko',\n 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n 'cases': [\n {'name': 'ko-en-tech', 'audio': 'samples/colab-ko-en.mp3',\n 'expected_entities': ['vLLM', 'Kubernetes', 'GPU']},\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", + "metadata": {}, + "source": "## 8) 문제 해결\n\n### CUDA 라이브러리 오류 (faster-whisper/CTranslate2)\n\nColab T4에는 CUDA 런타임이 있지만, CTranslate2가 pip wheel의 CUDA 라이브러리를\n찾지 못하면 아래를 실행하세요:\n\n```python\nimport os\nos.environ['LD_LIBRARY_PATH'] = (\n '/usr/local/cuda/lib64:' + os.environ.get('LD_LIBRARY_PATH', '')\n)\n```\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + } + ] +} \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py new file mode 100644 index 0000000..b6ca20c --- /dev/null +++ b/scripts/build_colab_notebook.py @@ -0,0 +1,304 @@ +"""Colab 실전 테스트 노트북 생성 스크립트. + +다른 환경(CPU-only, ffmpeg 없음)에서 검증할 수 없었던 부분을 +Colab Pro(T4 GPU + 터미널)에서 실전 검증하기 위한 노트북을 생성한다: + +1. 저장소 클론 (feat/full-platform) +2. 시스템 의존성 (ffmpeg) + venv + luke-scribe 설치 +3. `detect` — T4 GPU 실제 감지 (능력 등급/정밀도/워커수) +4. 단위/통합 테스트 (127개 mock) +5. 샘플 오디오 생성 (한국어+영문 기술용어, edge-tts) +6. 실전 전사 (GPU, faster-whisper 모델 다운로드) + glossary/hotword 검증 +7. API 서버 기동 + curl 스모크 (업로드 → poll → 결과) +8. 벤치마크 (turbo vs large-v3) + +사용법: python scripts/build_colab_notebook.py → notebooks/luke-scribe-colab.ipynb +""" + +from __future__ import annotations + +import json +from pathlib import Path + +OUT = Path("notebooks/luke-scribe-colab.ipynb") + + +def md(source: str) -> dict: + return {"cell_type": "markdown", "metadata": {}, "source": source} + + +def code(source: str) -> dict: + return {"cell_type": "code", "execution_count": None, "metadata": {}, "outputs": [], "source": source} + + +def cells() -> list[dict]: + return [ + md( + "# luke_scribe — Colab 실전 테스트 노트북\n" + "\n" + "> 내부용 로컬 STT 전사 API (faster-whisper, hardware-adaptive, privacy-first)\n" + "\n" + "이 노트북은 CPU-only 개발 환경에서 **mock으로만 검증**했던 것을,\n" + "Colab Pro(GPU + 터미널)에서 **실전 검증**하기 위한 것입니다.\n" + "\n" + "## 검증 대상\n" + "\n" + "- `luke-scribe detect` → T4 GPU 실제 감지 (능력 등급 T1~T3, 정밀도, 워커수)\n" + "- faster-whisper 모델 다운로드 + **실제 한국어 전사** (CPU-only 환경에선 불가)\n" + "- glossary/hotword 후처리 (KO+EN 기술용어 보존)\n" + "- REST API 흐름 (업로드 → poll → 결과)\n" + "- turbo vs large-v3 벤치마크\n" + "\n" + "## 준비\n" + "\n" + "1. 런타임 → 런타임 유형 변경 → **T4 GPU** 선택\n" + "2. (Colab Pro) 터미널을 사용해도 동일한 명령을 실행할 수 있습니다\n" + "3. 저장소가 private이면 아래 셀에 **Gitea 토큰** 입력 (Settings → Applications → Generate New Token)\n" + "\n" + "---\n" + ), + code( + "# 0) 런타임 확인 — T4 GPU가 활성 상태여야 합니다\n" + "!nvidia-smi\n" + "import sys\n" + "print('Python', sys.version.split()[0])\n" + ), + md( + "## 1) 저장소 클론\n" + "\n" + "`feat/full-platform` 브랜치를 클론합니다.\n" + "저장소가 private이면 아래 셀의 `GITEA_TOKEN`에 토큰을 입력하세요." + ), + code( + "# 1) 클론 (private 저장소면 GITEA_TOKEN 입력)\n" + "GITEA_TOKEN = '' # ← 필요 시 입력: https://git.lukehemmin.com/user/settings/applications\n" + "\n" + "if GITEA_TOKEN:\n" + " REPO = f'https://{GITEA_TOKEN}@git.lukehemmin.com/lukehemmin/luke_scribe.git'\n" + "else:\n" + " REPO = 'https://git.lukehemmin.com/lukehemmin/luke_scribe.git'\n" + "\n" + "!rm -rf luke_scribe\n" + "!git clone -b feat/full-platform {REPO}\n" + "import os\n" + "os.chdir('/content/luke_scribe')\n" + "print('클론 완료 →', os.getcwd())\n" + "!git log --oneline -1\n" + ), + md("## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."), + code( + "# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n" + "!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n" + "!ffmpeg -version 2>&1 | head -1\n" + "!ffprobe -version 2>&1 | head -1\n" + ), + code( + "# 3) venv + 설치 (GPU 추론은 engine+api만으로 충분 — Colab T4에 CUDA 런타임 내장)\n" + "!python3 -m venv .venv\n" + "!source .venv/bin/activate && pip install -q --upgrade pip\n" + "!source .venv/bin/activate && pip install -q -e '.[engine,api]'\n" + "!source .venv/bin/activate && pip install -q edge-tts # 샘플 음성 생성용\n" + "\n" + "# 이후 셀에서 activate 없이 CLI 사용 가능하게 PATH 등록\n" + "import os\n" + "os.environ['PATH'] = '/content/luke_scribe/.venv/bin:' + os.environ['PATH']\n" + "print('설치 완료')\n" + "!luke-scribe --help 2>&1 | head -12\n" + ), + md( + "## 3) 하드웨어 감지 — T4 GPU 실제 확인\n" + "\n" + "CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n" + "Colab T4(16GB VRAM, sm_75)에서는 GPU가 감지되어야 합니다." + ), + code( + "# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n" + "!luke-scribe detect\n" + ), + md("## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."), + code( + "# 5) 테스트 스위트\n" + "!source .venv/bin/activate && python -m pytest tests/ -q 2>&1 | tail -5\n" + ), + md( + "## 5) 실전 전사 (GPU)\n\n" + "### 5-1) 샘플 오디오 생성\n\n" + "edge-tts(MS TTS)로 **한국어 + 영문 기술용어가 섞인** 샘플을 생성합니다.\n" + "이 텍스트에는 `vLLM`, `Kubernetes`, `GPU` 같은 용어가 포함되어 glossary/hotword 검증에 적합합니다." + ), + code( + "# 6) 샘플 오디오 생성 (한국어 + 기술용어, 무료 TTS)\n" + "!mkdir -p samples\n" + "!edge-tts --voice ko-KR-SunHiNeural --text '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.' --write-media samples/colab-ko-en.mp3\n" + "!ffprobe -v error -show_entries format=duration -of json samples/colab-ko-en.mp3\n" + ), + code( + "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n" + "# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n" + "!time luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto\n" + ), + md( + "### 5-2) 후처리 검증\n\n" + "glossary(오인식 용어 복원) + hotword(용어 사전 주입) 동작을 확인합니다.\n" + "`--hotword vLLM Kubernetes`를 주면 initial_prompt에 용어가 주입되어 보존률이 올라갑니다." + ), + code( + "# 8) hotword 포함 전사 (용어 보존 강화)\n" + "!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes\n" + ), + md( + "## 6) REST API 스모크\n\n" + "서버를 백그라운드로 띄우고 **업로드 → poll → 결과(SRT)** 흐름을 검증합니다." + ), + code( + "# 9) API 키 생성 (1회 출력)\n" + "!luke-scribe key --create --scopes transcribe,admin --file /content/api_keys.json\n" + ), + code( + "# 10) 서버 기동 (in-proc 큐, 백그라운드)\n" + "import subprocess, time, os, json\n" + "env = dict(os.environ)\n" + "env['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n" + "env['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n" + "proc = subprocess.Popen(\n" + " ['luke-scribe', 'serve', '--port', '8000'],\n" + " env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,\n" + ")\n" + "# 기동 대기\n" + "import urllib.request\n" + "for _ in range(30):\n" + " try:\n" + " urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n" + " break\n" + " except Exception:\n" + " time.sleep(1)\n" + "print('서버 기동 OK')\n" + ), + code( + "# 11) 업로드 → poll → 결과\n" + "import json, time, urllib.request, urllib.error\n" + "\n" + "KEY = json.load(open('/content/api_keys.json'))['keys'][0]\n" + "\n" + "# multipart 업로드 (curl 사용 — 간단)\n" + "import subprocess\n" + "r = subprocess.run(\n" + " ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n" + " '-H', f'X-API-Key: {KEY}',\n" + " '-F', 'file=@samples/colab-ko-en.mp3',\n" + " '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n" + " capture_output=True, text=True,\n" + ")\n" + "job = json.loads(r.stdout)\n" + "print('생성:', job)\n" + "job_id = job['job_id']\n" + "\n" + "# 완료까지 poll (워커 없이 in-proc이라 즉시 완료되지는 않음 → 여기선 생성까지만 확인)\n" + "print('job_id =', job_id)\n" + ), + md( + "### 참고 — 서버에서 실제 전사까지 실행하려면\n\n" + "in-proc 브로커는 큐만 받고 워커가 별도로 소비해야 합니다. 워커 스레드를 띄우거나\n" + "간단하게는 배치 파이프라인을 직접 호출하는 CLI가 더 간단합니다. API 전체 흐름(워커 포함)은\n" + "다음 셀에서 `python`으로 브로커 + 워커를 함께 돌려 확인합니다." + ), + code( + "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n" + "# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n" + "# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\n" + "from luke_scribe.config import Settings\n" + "from luke_scribe.jobqueue.broker import InProcBroker\n" + "from luke_scribe.jobqueue.worker import Worker\n" + "from luke_scribe.jobqueue.jobs import Job\n" + "from luke_scribe.results.store import ResultStore\n" + "\n" + "settings = Settings(_env_file=None, queue_backend='inproc',\n" + " results_dir='/content/results', model_cache_dir=None)\n" + "broker = InProcBroker(settings)\n" + "store = ResultStore(settings.results_dir)\n" + "\n" + "job = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n" + " options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\n" + "broker.enqueue(job)\n" + "worker = Worker(settings=settings, broker=broker, store=store)\n" + "worker.drain()\n" + "\n" + "result = store.read_result(job.id)\n" + "print('status:', result.status)\n" + "print('text:', result.text[:120])\n" + "print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + ), + md( + "## 7) 벤치마크 (turbo vs large-v3)\n\n" + "manifest에 모델 비교 항목이 있으면 실행합니다. 두 모델을 모두 다운로드하므로\n" + "시간이 걸립니다 (turbo ~1.6GB + large-v3 ~3GB)." + ), + code( + "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n" + "# 간단 manifest 생성\n" + "import yaml\n" + "manifest = {\n" + " 'name': 'colab-quick',\n" + " 'language': 'ko',\n" + " 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n" + " 'cases': [\n" + " {'name': 'ko-en-tech', 'audio': 'samples/colab-ko-en.mp3',\n" + " 'expected_entities': ['vLLM', 'Kubernetes', 'GPU']},\n" + " ],\n" + "}\n" + "yaml.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" + ), + md( + "## 8) 문제 해결\n\n" + "### CUDA 라이브러리 오류 (faster-whisper/CTranslate2)\n\n" + "Colab T4에는 CUDA 런타임이 있지만, CTranslate2가 pip wheel의 CUDA 라이브러리를\n" + "찾지 못하면 아래를 실행하세요:\n" + "\n" + "```python\n" + "import os\n" + "os.environ['LD_LIBRARY_PATH'] = (\n" + " '/usr/local/cuda/lib64:' + os.environ.get('LD_LIBRARY_PATH', '')\n" + ")\n" + "```\n" + "\n" + "### 모델 다운로드 실패\n\n" + "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" + "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" + "\n" + "### OOM (16GB VRAM)\n\n" + "- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n" + "- 수동 지정: `--compute-type int8_float16`\n" + "\n" + "### 저장소가 private인데 클론 안 됨\n\n" + "1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + ), + ] + + +def main() -> None: + nb = { + "nbformat": 4, + "nbformat_minor": 0, + "metadata": { + "colab": {"provenance": [], "gpuType": "T4"}, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + }, + "language_info": {"name": "python", "version": "3.11"}, + "accelerator": "GPU", + }, + "cells": cells(), + } + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(nb, ensure_ascii=False, indent=1), encoding="utf-8") + n_code = sum(1 for c in nb["cells"] if c["cell_type"] == "code") + print(f"✅ {OUT} 생성 (셀 {len(nb['cells'])}개, 코드 {n_code}개)") + + +if __name__ == "__main__": + main() -- 2.54.0 From f3857346300054d73b29817dc7b2f8fe3d52dabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 16:55:21 +0900 Subject: [PATCH 04/21] =?UTF-8?q?fix:=20colab=20notebook=20=E2=80=94=20use?= =?UTF-8?q?=20system=20pip=20instead=20of=20venv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colab 'python3 -m venv' fails with ensurepip error (no .venv created, so every subsequent cell hit 'command not found'). Switch to system pip (Colab standard) and run the API server via nohup background with log fallback diagnostics. --- notebooks/luke-scribe-colab.ipynb | 6 ++--- scripts/build_colab_notebook.py | 40 +++++++++++++------------------ 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index d6f4bb3..7238c57 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -59,7 +59,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 3) venv + 설치 (GPU 추론은 engine+api만으로 충분 — Colab T4에 CUDA 런타임 내장)\n!python3 -m venv .venv\n!source .venv/bin/activate && pip install -q --upgrade pip\n!source .venv/bin/activate && pip install -q -e '.[engine,api]'\n!source .venv/bin/activate && pip install -q edge-tts # 샘플 음성 생성용\n\n# 이후 셀에서 activate 없이 CLI 사용 가능하게 PATH 등록\nimport os\nos.environ['PATH'] = '/content/luke_scribe/.venv/bin:' + os.environ['PATH']\nprint('설치 완료')\n!luke-scribe --help 2>&1 | head -12\n" + "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\nprint('설치 완료')\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" }, { "cell_type": "markdown", @@ -83,7 +83,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 5) 테스트 스위트\n!source .venv/bin/activate && python -m pytest tests/ -q 2>&1 | tail -5\n" + "source": "# 5) 테스트 스위트\n!python -m pytest tests/ -q 2>&1 | tail -5\n" }, { "cell_type": "markdown", @@ -133,7 +133,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드)\nimport subprocess, time, os, json\nenv = dict(os.environ)\nenv['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nenv['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\nproc = subprocess.Popen(\n ['luke-scribe', 'serve', '--port', '8000'],\n env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,\n)\n# 기동 대기\nimport urllib.request\nfor _ in range(30):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n break\n except Exception:\n time.sleep(1)\nprint('서버 기동 OK')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\nimport time, urllib.request\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nprint('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\nif not ok:\n print(open('/content/logs/server.log').read()[-1500:])\n" }, { "cell_type": "code", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index b6ca20c..5c0fa03 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -93,17 +93,12 @@ def cells() -> list[dict]: "!ffprobe -version 2>&1 | head -1\n" ), code( - "# 3) venv + 설치 (GPU 추론은 engine+api만으로 충분 — Colab T4에 CUDA 런타임 내장)\n" - "!python3 -m venv .venv\n" - "!source .venv/bin/activate && pip install -q --upgrade pip\n" - "!source .venv/bin/activate && pip install -q -e '.[engine,api]'\n" - "!source .venv/bin/activate && pip install -q edge-tts # 샘플 음성 생성용\n" - "\n" - "# 이후 셀에서 activate 없이 CLI 사용 가능하게 PATH 등록\n" - "import os\n" - "os.environ['PATH'] = '/content/luke_scribe/.venv/bin:' + os.environ['PATH']\n" + "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n" + "!pip install -q --upgrade pip\n" + "!pip install -q -e '.[engine,api]'\n" + "!pip install -q edge-tts # 샘플 음성 생성용\n" "print('설치 완료')\n" - "!luke-scribe --help 2>&1 | head -12\n" + "!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" ), md( "## 3) 하드웨어 감지 — T4 GPU 실제 확인\n" @@ -118,7 +113,7 @@ def cells() -> list[dict]: md("## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."), code( "# 5) 테스트 스위트\n" - "!source .venv/bin/activate && python -m pytest tests/ -q 2>&1 | tail -5\n" + "!python -m pytest tests/ -q 2>&1 | tail -5\n" ), md( "## 5) 실전 전사 (GPU)\n\n" @@ -155,24 +150,21 @@ def cells() -> list[dict]: "!luke-scribe key --create --scopes transcribe,admin --file /content/api_keys.json\n" ), code( - "# 10) 서버 기동 (in-proc 큐, 백그라운드)\n" - "import subprocess, time, os, json\n" - "env = dict(os.environ)\n" - "env['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n" - "env['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n" - "proc = subprocess.Popen(\n" - " ['luke-scribe', 'serve', '--port', '8000'],\n" - " env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,\n" - ")\n" - "# 기동 대기\n" - "import urllib.request\n" - "for _ in range(30):\n" + "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n" + "!mkdir -p /content/logs\n" + "!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n" + "import time, urllib.request\n" + "ok = False\n" + "for _ in range(40):\n" " try:\n" " urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n" + " ok = True\n" " break\n" " except Exception:\n" " time.sleep(1)\n" - "print('서버 기동 OK')\n" + "print('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\n" + "if not ok:\n" + " print(open('/content/logs/server.log').read()[-1500:])\n" ), code( "# 11) 업로드 → poll → 결과\n" -- 2.54.0 From f6e5ae662f3ac48c37aa34f1772148b3f092c386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:02:53 +0900 Subject: [PATCH 05/21] =?UTF-8?q?fix:=20colab=20notebook=20=E2=80=94=20ide?= =?UTF-8?q?mpotent=20clone=20(pull=20if=20repo=20exists)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell 1 now detects an existing /content/luke_scribe/.git and runs git fetch + checkout feat/full-platform + pull --ff-only instead of rm -rf + clone, so re-running the notebook updates instead of wiping local changes. --- notebooks/luke-scribe-colab.ipynb | 2 +- scripts/build_colab_notebook.py | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 7238c57..5b4bb35 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -40,7 +40,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 1) 클론 (private 저장소면 GITEA_TOKEN 입력)\nGITEA_TOKEN = '' # ← 필요 시 입력: https://git.lukehemmin.com/user/settings/applications\n\nif GITEA_TOKEN:\n REPO = f'https://{GITEA_TOKEN}@git.lukehemmin.com/lukehemmin/luke_scribe.git'\nelse:\n REPO = 'https://git.lukehemmin.com/lukehemmin/luke_scribe.git'\n\n!rm -rf luke_scribe\n!git clone -b feat/full-platform {REPO}\nimport os\nos.chdir('/content/luke_scribe')\nprint('클론 완료 →', os.getcwd())\n!git log --oneline -1\n" + "source": "# 1) 클론 또는 업데이트 (private 저장소면 GITEA_TOKEN 입력)\nGITEA_TOKEN = '' # ← 필요 시 입력: https://git.lukehemmin.com/user/settings/applications\n\nif GITEA_TOKEN:\n REPO = f'https://{GITEA_TOKEN}@git.lukehemmin.com/lukehemmin/luke_scribe.git'\nelse:\n REPO = 'https://git.lukehemmin.com/lukehemmin/luke_scribe.git'\n\nimport os, subprocess\n\nif os.path.isdir('/content/luke_scribe/.git'):\n # 이미 클론된 레포 → 최신 브랜치로 갱신 (pull)\n print('기존 레포 감지 → pull로 갱신')\n subprocess.run(['git', 'fetch', 'origin'], cwd='/content/luke_scribe', check=True)\n subprocess.run(['git', 'checkout', 'feat/full-platform'], cwd='/content/luke_scribe', check=True)\n subprocess.run(['git', 'pull', '--ff-only', 'origin', 'feat/full-platform'], cwd='/content/luke_scribe', check=True)\nelse:\n # 최초 실행 → 클론\n subprocess.run(['git', 'clone', '-b', 'feat/full-platform', REPO], cwd='/content', check=True)\n\nos.chdir('/content/luke_scribe')\nprint('작업 디렉터리 →', os.getcwd())\n!git log --oneline -1\n" }, { "cell_type": "markdown", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 5c0fa03..3ac50ba 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -70,7 +70,7 @@ def cells() -> list[dict]: "저장소가 private이면 아래 셀의 `GITEA_TOKEN`에 토큰을 입력하세요." ), code( - "# 1) 클론 (private 저장소면 GITEA_TOKEN 입력)\n" + "# 1) 클론 또는 업데이트 (private 저장소면 GITEA_TOKEN 입력)\n" "GITEA_TOKEN = '' # ← 필요 시 입력: https://git.lukehemmin.com/user/settings/applications\n" "\n" "if GITEA_TOKEN:\n" @@ -78,11 +78,20 @@ def cells() -> list[dict]: "else:\n" " REPO = 'https://git.lukehemmin.com/lukehemmin/luke_scribe.git'\n" "\n" - "!rm -rf luke_scribe\n" - "!git clone -b feat/full-platform {REPO}\n" - "import os\n" + "import os, subprocess\n" + "\n" + "if os.path.isdir('/content/luke_scribe/.git'):\n" + " # 이미 클론된 레포 → 최신 브랜치로 갱신 (pull)\n" + " print('기존 레포 감지 → pull로 갱신')\n" + " subprocess.run(['git', 'fetch', 'origin'], cwd='/content/luke_scribe', check=True)\n" + " subprocess.run(['git', 'checkout', 'feat/full-platform'], cwd='/content/luke_scribe', check=True)\n" + " subprocess.run(['git', 'pull', '--ff-only', 'origin', 'feat/full-platform'], cwd='/content/luke_scribe', check=True)\n" + "else:\n" + " # 최초 실행 → 클론\n" + " subprocess.run(['git', 'clone', '-b', 'feat/full-platform', REPO], cwd='/content', check=True)\n" + "\n" "os.chdir('/content/luke_scribe')\n" - "print('클론 완료 →', os.getcwd())\n" + "print('작업 디렉터리 →', os.getcwd())\n" "!git log --oneline -1\n" ), md("## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."), -- 2.54.0 From fb936e1953930402bfb4c75b5e20b895892172e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:07:23 +0900 Subject: [PATCH 06/21] =?UTF-8?q?fix:=20colab=20notebook=20=E2=80=94=20CUD?= =?UTF-8?q?A=2013=20runtime=20+=20raw=20key=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colab now ships CUDA 13.0 (driver 580); CTranslate2 wheels are CUDA 12 so GPU init fails with 'unsupported device cuda:0'. Install nvidia-cublas-cu12/nvidia-cudnn-cu12 and set LD_LIBRARY_PATH in the install cell; transcription cell reports failures cleanly. API smoke: api_keys.json stores only digests (raw key shown once), so the notebook now captures RAW_KEY at creation and uses it for upload instead of reading the digest file (fixes 401). --- notebooks/luke-scribe-colab.ipynb | 10 ++-- scripts/build_colab_notebook.py | 79 ++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 5b4bb35..d3ccdfb 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -59,7 +59,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\nprint('설치 완료')\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" + "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고 LD_LIBRARY_PATH에 추가한다.\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, nvidia.cublas.lib, nvidia.cudnn.lib\nos.environ['LD_LIBRARY_PATH'] = (\n os.path.dirname(nvidia.cublas.lib.__file__) + ':' +\n os.path.dirname(nvidia.cudnn.lib.__file__) + ':' +\n os.environ.get('LD_LIBRARY_PATH', '')\n)\nprint('설치 완료')\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" }, { "cell_type": "markdown", @@ -102,7 +102,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n!time luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto\n" + "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 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" }, { "cell_type": "markdown", @@ -126,7 +126,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 9) API 키 생성 (1회 출력)\n!luke-scribe key --create --scopes transcribe,admin --file /content/api_keys.json\n" + "source": "# 9) API 키 생성 — raw 키는 1회만 출력되므로 여기서 캡처한다\nimport subprocess, json, os\nr = subprocess.run(\n ['luke-scribe', 'key', '--create', '--scopes', 'transcribe,admin', '--file', '/content/api_keys.json'],\n capture_output=True, text=True,\n)\ncreated = json.loads(r.stdout)\nRAW_KEY = created['key'] # 이후 셀에서 사용\nprint('key_id:', created['key_id'], '| scopes:', created['scopes'])\nprint('raw 키 캡처 완료 (표시 안 함)')\n" }, { "cell_type": "code", @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 11) 업로드 → poll → 결과\nimport json, time, urllib.request, urllib.error\n\nKEY = json.load(open('/content/api_keys.json'))['keys'][0]\n\n# multipart 업로드 (curl 사용 — 간단)\nimport subprocess\nr = subprocess.run(\n ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n '-H', f'X-API-Key: {KEY}',\n '-F', 'file=@samples/colab-ko-en.mp3',\n '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n capture_output=True, text=True,\n)\njob = json.loads(r.stdout)\nprint('생성:', job)\njob_id = job['job_id']\n\n# 완료까지 poll (워커 없이 in-proc이라 즉시 완료되지는 않음 → 여기선 생성까지만 확인)\nprint('job_id =', job_id)\n" + "source": "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\nimport json, time, urllib.request, urllib.error, subprocess\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\n# multipart 업로드 (curl 사용 — 간단)\nr = subprocess.run(\n ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n '-H', f'X-API-Key: {RAW_KEY}',\n '-F', 'file=@samples/colab-ko-en.mp3',\n '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n capture_output=True, text=True,\n)\njob = json.loads(r.stdout)\nprint('생성:', job)\njob_id = job.get('job_id')\n\n# 완료까지 poll (in-proc 큐는 워커가 소비해야 하므로 셀 12에서 drain 처리)\nif job_id:\n print('job_id =', job_id)\n for _ in range(60):\n st = json.loads(urllib.request.urlopen(\n urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n headers={'X-API-Key': RAW_KEY})\n ).read())\n if st['status'] in ('completed', 'failed', 'cancelled'):\n print('최종 상태:', st['status'])\n break\n time.sleep(2)\n else:\n print('60초 내 미완료 — 셀 12(워커 drain) 실행 후 다시 확인')\nelse:\n print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n" }, { "cell_type": "markdown", @@ -169,7 +169,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### CUDA 라이브러리 오류 (faster-whisper/CTranslate2)\n\nColab T4에는 CUDA 런타임이 있지만, CTranslate2가 pip wheel의 CUDA 라이브러리를\n찾지 못하면 아래를 실행하세요:\n\n```python\nimport os\nos.environ['LD_LIBRARY_PATH'] = (\n '/usr/local/cuda/lib64:' + os.environ.get('LD_LIBRARY_PATH', '')\n)\n```\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\nColab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\nCTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 3ac50ba..a91a84e 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -106,6 +106,16 @@ def cells() -> list[dict]: "!pip install -q --upgrade pip\n" "!pip install -q -e '.[engine,api]'\n" "!pip install -q edge-tts # 샘플 음성 생성용\n" + "\n" + "# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n" + "# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고 LD_LIBRARY_PATH에 추가한다.\n" + "!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\n" + "import os, nvidia.cublas.lib, nvidia.cudnn.lib\n" + "os.environ['LD_LIBRARY_PATH'] = (\n" + " os.path.dirname(nvidia.cublas.lib.__file__) + ':' +\n" + " os.path.dirname(nvidia.cudnn.lib.__file__) + ':' +\n" + " os.environ.get('LD_LIBRARY_PATH', '')\n" + ")\n" "print('설치 완료')\n" "!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" ), @@ -139,7 +149,14 @@ def cells() -> list[dict]: code( "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n" "# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n" - "!time luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto\n" + "# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 CPU로 폴백합니다.\n" + "import subprocess, json\n" + "r = subprocess.run(\n" + " ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n" + " capture_output=True, text=True,\n" + ")\n" + "print(r.stdout[-2500:] if r.stdout else '')\n" + "print(r.stderr[-800:] if r.stderr else '')\n" ), md( "### 5-2) 후처리 검증\n\n" @@ -155,8 +172,16 @@ def cells() -> list[dict]: "서버를 백그라운드로 띄우고 **업로드 → poll → 결과(SRT)** 흐름을 검증합니다." ), code( - "# 9) API 키 생성 (1회 출력)\n" - "!luke-scribe key --create --scopes transcribe,admin --file /content/api_keys.json\n" + "# 9) API 키 생성 — raw 키는 1회만 출력되므로 여기서 캡처한다\n" + "import subprocess, json, os\n" + "r = subprocess.run(\n" + " ['luke-scribe', 'key', '--create', '--scopes', 'transcribe,admin', '--file', '/content/api_keys.json'],\n" + " capture_output=True, text=True,\n" + ")\n" + "created = json.loads(r.stdout)\n" + "RAW_KEY = created['key'] # 이후 셀에서 사용\n" + "print('key_id:', created['key_id'], '| scopes:', created['scopes'])\n" + "print('raw 키 캡처 완료 (표시 안 함)')\n" ), code( "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n" @@ -176,26 +201,39 @@ def cells() -> list[dict]: " print(open('/content/logs/server.log').read()[-1500:])\n" ), code( - "# 11) 업로드 → poll → 결과\n" - "import json, time, urllib.request, urllib.error\n" + "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n" + "import json, time, urllib.request, urllib.error, subprocess\n" "\n" - "KEY = json.load(open('/content/api_keys.json'))['keys'][0]\n" + "assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n" "\n" "# multipart 업로드 (curl 사용 — 간단)\n" - "import subprocess\n" "r = subprocess.run(\n" " ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n" - " '-H', f'X-API-Key: {KEY}',\n" + " '-H', f'X-API-Key: {RAW_KEY}',\n" " '-F', 'file=@samples/colab-ko-en.mp3',\n" " '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n" " capture_output=True, text=True,\n" ")\n" "job = json.loads(r.stdout)\n" "print('생성:', job)\n" - "job_id = job['job_id']\n" + "job_id = job.get('job_id')\n" "\n" - "# 완료까지 poll (워커 없이 in-proc이라 즉시 완료되지는 않음 → 여기선 생성까지만 확인)\n" - "print('job_id =', job_id)\n" + "# 완료까지 poll (in-proc 큐는 워커가 소비해야 하므로 셀 12에서 drain 처리)\n" + "if job_id:\n" + " print('job_id =', job_id)\n" + " for _ in range(60):\n" + " st = json.loads(urllib.request.urlopen(\n" + " urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n" + " headers={'X-API-Key': RAW_KEY})\n" + " ).read())\n" + " if st['status'] in ('completed', 'failed', 'cancelled'):\n" + " print('최종 상태:', st['status'])\n" + " break\n" + " time.sleep(2)\n" + " else:\n" + " print('60초 내 미완료 — 셀 12(워커 drain) 실행 후 다시 확인')\n" + "else:\n" + " print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n" ), md( "### 참고 — 서버에서 실제 전사까지 실행하려면\n\n" @@ -254,21 +292,18 @@ def cells() -> list[dict]: ), md( "## 8) 문제 해결\n\n" - "### CUDA 라이브러리 오류 (faster-whisper/CTranslate2)\n\n" - "Colab T4에는 CUDA 런타임이 있지만, CTranslate2가 pip wheel의 CUDA 라이브러리를\n" - "찾지 못하면 아래를 실행하세요:\n" - "\n" - "```python\n" - "import os\n" - "os.environ['LD_LIBRARY_PATH'] = (\n" - " '/usr/local/cuda/lib64:' + os.environ.get('LD_LIBRARY_PATH', '')\n" - ")\n" - "```\n" - "\n" + "### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\n" + "Colab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\n" + "CTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n" + "설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n" + "그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n" "### 모델 다운로드 실패\n\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" "\n" + "### API 키 인증 실패 (401)\n\n" + "`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n" + "(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n" "### OOM (16GB VRAM)\n\n" "- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n" "- 수동 지정: `--compute-type int8_float16`\n" -- 2.54.0 From 236ca0acf4c963cb64ec89de1b4dca91b71e9f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:11:14 +0900 Subject: [PATCH 07/21] =?UTF-8?q?fix:=20colab=20notebook=20=E2=80=94=20loc?= =?UTF-8?q?ate=20cuDNN/cuBLAS=20.so=20via=20find?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nvidia-cudnn-cu12/cublas-cu12 are namespace packages with no __file__, so os.path.dirname() raised TypeError and the install cell aborted. Locate libcublas.so/libcudnn.so under site-packages with find and build LD_LIBRARY_PATH from those. Also add a CTranslate2 GPU check cell (ctranslate2.get_cuda_device_count) right after install so GPU usability is confirmed before transcription. --- notebooks/luke-scribe-colab.ipynb | 11 ++++++++-- scripts/build_colab_notebook.py | 36 +++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index d3ccdfb..5b82682 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -59,12 +59,19 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고 LD_LIBRARY_PATH에 추가한다.\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, nvidia.cublas.lib, nvidia.cudnn.lib\nos.environ['LD_LIBRARY_PATH'] = (\n os.path.dirname(nvidia.cublas.lib.__file__) + ':' +\n os.path.dirname(nvidia.cudnn.lib.__file__) + ':' +\n os.environ.get('LD_LIBRARY_PATH', '')\n)\nprint('설치 완료')\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" + "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# 4) CTranslate2 GPU 검증 — unsupported device cuda:0 해결 여부 확인\nimport ctranslate2\nprint('ctranslate2', ctranslate2.__version__)\nprint('CUDA device count:', ctranslate2.get_cuda_device_count())\nif ctranslate2.get_cuda_device_count() > 0:\n print('GPU 사용 가능 ✅ — 이후 전사는 GPU로 실행됩니다')\nelse:\n print('GPU 사용 불가 — CPU 폴백 필요 (--device cpu --compute-type int8)')\n" }, { "cell_type": "markdown", "metadata": {}, - "source": "## 3) 하드웨어 감지 — T4 GPU 실제 확인\n\nCPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\nColab T4(16GB VRAM, sm_75)에서는 GPU가 감지되어야 합니다." + "source": "## 3) 하드웨어 감지 — GPU 실제 확인\n\nCPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\nColab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다." }, { "cell_type": "code", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index a91a84e..a279209 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -108,22 +108,40 @@ def cells() -> list[dict]: "!pip install -q edge-tts # 샘플 음성 생성용\n" "\n" "# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n" - "# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고 LD_LIBRARY_PATH에 추가한다.\n" + "# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n" + "# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n" "!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\n" - "import os, nvidia.cublas.lib, nvidia.cudnn.lib\n" - "os.environ['LD_LIBRARY_PATH'] = (\n" - " os.path.dirname(nvidia.cublas.lib.__file__) + ':' +\n" - " os.path.dirname(nvidia.cudnn.lib.__file__) + ':' +\n" - " os.environ.get('LD_LIBRARY_PATH', '')\n" - ")\n" + "import os, site, subprocess\n" + "lib_dirs = set()\n" + "for lib in ('libcublas.so', 'libcudnn.so'):\n" + " out = subprocess.run(\n" + " ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n" + " capture_output=True, text=True,\n" + " ).stdout\n" + " for line in out.splitlines():\n" + " d = os.path.dirname(line)\n" + " if d:\n" + " lib_dirs.add(d)\n" + "os.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\n" + "print('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\n" "print('설치 완료')\n" "!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" ), + code( + "# 4) CTranslate2 GPU 검증 — unsupported device cuda:0 해결 여부 확인\n" + "import ctranslate2\n" + "print('ctranslate2', ctranslate2.__version__)\n" + "print('CUDA device count:', ctranslate2.get_cuda_device_count())\n" + "if ctranslate2.get_cuda_device_count() > 0:\n" + " print('GPU 사용 가능 ✅ — 이후 전사는 GPU로 실행됩니다')\n" + "else:\n" + " print('GPU 사용 불가 — CPU 폴백 필요 (--device cpu --compute-type int8)')\n" + ), md( - "## 3) 하드웨어 감지 — T4 GPU 실제 확인\n" + "## 3) 하드웨어 감지 — GPU 실제 확인\n" "\n" "CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n" - "Colab T4(16GB VRAM, sm_75)에서는 GPU가 감지되어야 합니다." + "Colab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다." ), code( "# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n" -- 2.54.0 From 741bce9fc63e4cd69dea47e1d1b49b7db46560d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:20:12 +0900 Subject: [PATCH 08/21] fix: split 'cuda:N' into device+device_index for CTranslate2 Colab A100 run exposed a GPU-only bug: DeviceManager returns selected_device='cuda:0' and the engine passed it verbatim to faster-whisper, but CTranslate2 only accepts device='cuda' with a separate device_index arg -> 'unsupported device cuda:0' on every GPU transcription. Fix: FasterWhisperEngine._split_device() splits 'cuda:N' -> ('cuda', N) and passes device_index to WhisperModel. Notebook: server cell now pkills stale servers (old process holds port 8000 and 401s on new keys since KeyStore loads at startup), sets LUKESCRIBE_API_KEY_FILE explicitly, and verifies auth with RAW_KEY before proceeding; worker cell guards read_result() is None. + 4 unit tests (device split contract), 131 tests pass, ruff clean. --- notebooks/luke-scribe-colab.ipynb | 8 +- scripts/build_colab_notebook.py | 90 ++++++++++++++----- .../engine/faster_whisper_engine.py | 21 ++++- tests/unit/test_engine_faster_whisper.py | 80 +++++++++++++++++ 4 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 tests/unit/test_engine_faster_whisper.py diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 5b82682..ea116f1 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -109,7 +109,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 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 폴백 안내가 출력됩니다.\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" }, { "cell_type": "markdown", @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\nimport time, urllib.request\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nprint('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\nif not ok:\n print(open('/content/logs/server.log').read()[-1500:])\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" }, { "cell_type": "code", @@ -159,7 +159,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nprint('status:', result.status)\nprint('text:', result.text[:120])\nprint('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n print('job 오류:', getattr(state, 'error', None) or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" }, { "cell_type": "markdown", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\nColab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\nCTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index a279209..6ab8289 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -28,7 +28,13 @@ def md(source: str) -> dict: def code(source: str) -> dict: - return {"cell_type": "code", "execution_count": None, "metadata": {}, "outputs": [], "source": source} + return { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": source, + } def cells() -> list[dict]: @@ -94,7 +100,9 @@ def cells() -> list[dict]: "print('작업 디렉터리 →', os.getcwd())\n" "!git log --oneline -1\n" ), - md("## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다."), + md( + "## 2) 시스템 의존성 + 설치\n\nffmpeg(오디오 정규화) 설치 후 venv에 luke-scribe를 설치합니다." + ), code( "# 2) 시스템 패키지 (ffmpeg — ffprobe/정규화에 필수)\n" "!apt-get update -qq && apt-get install -y -qq ffmpeg >/dev/null\n" @@ -143,15 +151,11 @@ def cells() -> list[dict]: "CPU-only 환경에서는 `T0 / cpu / int8`이 나왔지만,\n" "Colab GPU(A100 80GB / T4 16GB)에서는 GPU가 감지되어야 합니다." ), - code( - "# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n" - "!luke-scribe detect\n" - ), - md("## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다."), - code( - "# 5) 테스트 스위트\n" - "!python -m pytest tests/ -q 2>&1 | tail -5\n" + code("# 4) detect — GPU 감지 / 능력 등급 / 정밀도 / 워커수\n!luke-scribe detect\n"), + md( + "## 4) 단위/통합 테스트 (127개)\n\nmock 기반 테스트가 GPU 환경에서도 전부 통과하는지 확인합니다." ), + code("# 5) 테스트 스위트\n!python -m pytest tests/ -q 2>&1 | tail -5\n"), md( "## 5) 실전 전사 (GPU)\n\n" "### 5-1) 샘플 오디오 생성\n\n" @@ -167,7 +171,8 @@ def cells() -> list[dict]: code( "# 7) 실전 전사 — GPU 자동 감지 + 모델 다운로드 (large-v3-turbo)\n" "# 첫 실행 시 Hugging Face에서 모델을 다운로드합니다 (turbo ≈ 1.6GB, 1~2분)\n" - "# CUDA 13 환경에서 CTranslate2가 GPU를 못 열면 자동으로 CPU로 폴백합니다.\n" + "# v0.1: 'cuda:N' device를 분리해 CTranslate2 계약에 맞게 전달하도록 수정됨\n" + "# (기존: unsupported device cuda:0). 그래도 실패하면 CPU 폴백 안내가 출력됩니다.\n" "import subprocess, json\n" "r = subprocess.run(\n" " ['luke-scribe', 'transcribe', 'samples/colab-ko-en.mp3', '--language', 'ko', '--device', 'auto'],\n" @@ -202,10 +207,23 @@ def cells() -> list[dict]: "print('raw 키 캡처 완료 (표시 안 함)')\n" ), code( - "# 10) 서버 기동 (in-proc 큐, 백그라운드 — Colab에서는 %%bash --bg 또는 nohup 사용)\n" + "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n" + "# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n" + "# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\n" + "import subprocess, os, time, urllib.request, urllib.error\n" + "\n" + "assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n" + "\n" + "subprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\n" + "time.sleep(2)\n" + "\n" + "# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n" + "os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n" + "os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n" + "\n" "!mkdir -p /content/logs\n" "!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n" - "import time, urllib.request\n" + "\n" "ok = False\n" "for _ in range(40):\n" " try:\n" @@ -214,9 +232,20 @@ def cells() -> list[dict]: " break\n" " except Exception:\n" " time.sleep(1)\n" - "print('서버 기동 OK' if ok else '서버 기동 실패 — 로그:')\n" "if not ok:\n" + " print('서버 기동 실패 — 로그:')\n" " print(open('/content/logs/server.log').read()[-1500:])\n" + " raise SystemExit('서버 기동 실패')\n" + "\n" + "# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n" + "req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n" + "try:\n" + " resp = urllib.request.urlopen(req, timeout=5)\n" + " print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\n" + "except urllib.error.HTTPError as e:\n" + " print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n" + " print(open('/content/logs/server.log').read()[-1500:])\n" + " raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" ), code( "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n" @@ -229,7 +258,7 @@ def cells() -> list[dict]: " ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n" " '-H', f'X-API-Key: {RAW_KEY}',\n" " '-F', 'file=@samples/colab-ko-en.mp3',\n" - " '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n" + ' \'-F\', \'options={"language":"ko","formats":["json","srt"]}\'],\n' " capture_output=True, text=True,\n" ")\n" "job = json.loads(r.stdout)\n" @@ -281,9 +310,16 @@ def cells() -> list[dict]: "worker.drain()\n" "\n" "result = store.read_result(job.id)\n" - "print('status:', result.status)\n" - "print('text:', result.text[:120])\n" - "print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + "if result is None:\n" + " # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n" + " state = broker.get(job.id)\n" + " print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n" + " if state is not None:\n" + " print('job 오류:', getattr(state, 'error', None) or '없음')\n" + "else:\n" + " print('status:', result.status)\n" + " print('text:', result.text[:120])\n" + " print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" ), md( "## 7) 벤치마크 (turbo vs large-v3)\n\n" @@ -310,11 +346,19 @@ def cells() -> list[dict]: ), md( "## 8) 문제 해결\n\n" - "### CUDA 13 환경에서 `unsupported device cuda:0` (faster-whisper/CTranslate2)\n\n" - "Colab이 CUDA 13.0(드라이버 580)으로 올라가면서, CUDA 12용으로 빌드된\n" - "CTranslate2 wheel이 런타임 라이브러리(cuBLAS/cuDNN)를 찾지 못하는 이슈가 발생합니다.\n" - "설치 셀 3에서 CUDA 12 런타임을 pip로 설치하고 `LD_LIBRARY_PATH`를 잡아 해결합니다.\n" - "그래도 안 되면 CPU로 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n" + "### 전사 실패: `unsupported device cuda:0`\n\n" + '**근본 원인**: v0.1 엔진이 `device="cuda:0"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\n' + 'CTranslate2는 `device="cuda"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n' + "→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n" + "참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n" + "런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n" + "`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n" + "그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n" + "### API 키 인증 실패 (401)\n\n" + "1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n" + " (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n" + "2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n" + " 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n" "### 모델 다운로드 실패\n\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" diff --git a/src/luke_scribe/engine/faster_whisper_engine.py b/src/luke_scribe/engine/faster_whisper_engine.py index a9585cf..48de03a 100644 --- a/src/luke_scribe/engine/faster_whisper_engine.py +++ b/src/luke_scribe/engine/faster_whisper_engine.py @@ -86,10 +86,15 @@ class FasterWhisperEngine(TranscriptionEngine): with self._lock: model = self._models.get(model_key) if model is None: + # CTranslate2는 device 문자열로 "cuda"만 허용한다 ("cuda:0" 형태는 + # "unsupported device cuda:0" 오류). DeviceManager가 내려주는 + # "cuda:N"을 device + device_index로 분리해 전달한다. + device_name, device_index = self._split_device(options.device) try: model = WhisperModel( options.model, - device=options.device, + device=device_name, + device_index=device_index, compute_type=options.compute_type, download_root=self._download_root(), ) @@ -128,6 +133,20 @@ class FasterWhisperEngine(TranscriptionEngine): wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False)) return TranscriptionOutcome(wrapped, info=info) + @staticmethod + def _split_device(device: str) -> tuple[str, int]: + """'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다. + + 'cpu'/'cuda' 같은 인덱스 없는 값은 그대로 (device, 0)을 반환한다. + """ + if device.startswith("cuda") and ":" in device: + name, _, idx = device.partition(":") + try: + return name, int(idx) + except ValueError: + pass + return device, 0 + def _download_root(self) -> str | None: from ..config import get_settings diff --git a/tests/unit/test_engine_faster_whisper.py b/tests/unit/test_engine_faster_whisper.py new file mode 100644 index 0000000..d27feeb --- /dev/null +++ b/tests/unit/test_engine_faster_whisper.py @@ -0,0 +1,80 @@ +"""FasterWhisperEngine 단위 테스트 — CTranslate2 호출 계약 (device 분리). + +faster-whisper/CTranslate2는 ``device`` 문자열로 "cuda"만 허용하며 인덱스는 +별도 ``device_index`` 인자로 받는다. DeviceManager가 내려주는 "cuda:N"을 +제대로 분리해 전달하는지 mock으로 검증한다 (GPU 환경에서만 재현되는 버그 +— Colab 실전 테스트에서 ``unsupported device cuda:0``으로 확인됨). +""" + +from __future__ import annotations + +import sys +import types + +from luke_scribe.engine.base import TranscriptionOptions +from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine + + +class FakeWhisperModel: + """faster_whisper.WhisperModel 대체 — 생성 인자만 기록한다.""" + + calls: list[dict] = [] + + def __init__(self, *args, **kwargs) -> None: + self.kwargs = kwargs + self.__class__.calls.append(kwargs) + + def transcribe(self, audio_path, **kwargs): + return iter([]), {} + + +def _install_fake(monkeypatch) -> None: + mod = types.ModuleType("faster_whisper") + mod.WhisperModel = FakeWhisperModel + monkeypatch.setitem(sys.modules, "faster_whisper", mod) + FakeWhisperModel.calls.clear() + + +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_cuda_device_index_split(monkeypatch): + """'cuda:0' → device='cuda', device_index=0 (Colab A100 첫 GPU).""" + _install_fake(monkeypatch) + FasterWhisperEngine().transcribe("/tmp/x.wav", _opts()) + kwargs = FakeWhisperModel.calls[-1] + assert kwargs["device"] == "cuda" + assert kwargs["device_index"] == 0 + assert kwargs["compute_type"] == "float16" + + +def test_cuda_second_device_index(monkeypatch): + """'cuda:1' → device='cuda', device_index=1.""" + _install_fake(monkeypatch) + FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cuda:1")) + kwargs = FakeWhisperModel.calls[-1] + assert kwargs["device"] == "cuda" + assert kwargs["device_index"] == 1 + + +def test_cpu_passthrough(monkeypatch): + """'cpu' → 그대로 device='cpu', device_index=0.""" + _install_fake(monkeypatch) + FasterWhisperEngine().transcribe("/tmp/x.wav", _opts(device="cpu", compute_type="int8")) + kwargs = FakeWhisperModel.calls[-1] + assert kwargs["device"] == "cpu" + assert kwargs["device_index"] == 0 + assert kwargs["compute_type"] == "int8" + + +def test_split_device_helper(): + assert FasterWhisperEngine._split_device("cuda:0") == ("cuda", 0) + assert FasterWhisperEngine._split_device("cuda:3") == ("cuda", 3) + assert FasterWhisperEngine._split_device("cpu") == ("cpu", 0) + assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0) + # 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit) + assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0) -- 2.54.0 From 20777386fe8f709aa5e3e676e94ed7cfde041dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:32:18 +0900 Subject: [PATCH 09/21] fix: normalize faster-whisper segments to dicts + in-proc auto-worker Colab run 3 (A100) surfaced three GPU/API-path bugs mocks couldn't catch: 1. faster-whisper yields namedtuple Segments, but batch/bench consume them as dicts (.get) -> AttributeError 'Segment' has no attribute 'get' on every real transcription. Engine now normalizes segments to dicts (_to_dict_segments) at the boundary. 2. API TranscribeOptions carries engine-irrelevant keys (formats, timestamps, glossary_id, post_correction, diarize); worker's TranscriptionOptions(**job.options) crashed with TypeError. Worker now filters job.options to TranscriptionOptions.__slots__. 3. in-proc server never consumed its own queue (jobs stayed queued forever). Added opt-in Settings.auto_worker (default off): lifespan starts a daemon Worker thread for inproc backend, stopped on shutdown. Notebook enables it via LUKESCRIBE_AUTO_WORKER=true so the API upload -> completed flow works end to end. Notebook: bench manifest now uses clips schema (audio_path/duration_sec/ entities); cell 22 reads error_message/error_code; upload poll window raised to 4min (first-run model download). + 5 tests (namedtuple/dict segments, API-style options, auto_worker on/off); 136 tests pass, ruff clean. --- notebooks/luke-scribe-colab.ipynb | 10 +-- scripts/build_colab_notebook.py | 23 ++++--- src/luke_scribe/api/app.py | 22 +++++++ src/luke_scribe/config.py | 3 + .../engine/faster_whisper_engine.py | 38 ++++++++++- src/luke_scribe/jobqueue/worker.py | 11 +++- tests/integration/test_api.py | 28 +++++++++ tests/integration/test_worker.py | 36 +++++++++++ tests/unit/test_engine_faster_whisper.py | 63 ++++++++++++++++++- 9 files changed, 218 insertions(+), 16 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index ea116f1..530e034 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -140,14 +140,14 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\nimport json, time, urllib.request, urllib.error, subprocess\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\n# multipart 업로드 (curl 사용 — 간단)\nr = subprocess.run(\n ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n '-H', f'X-API-Key: {RAW_KEY}',\n '-F', 'file=@samples/colab-ko-en.mp3',\n '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n capture_output=True, text=True,\n)\njob = json.loads(r.stdout)\nprint('생성:', job)\njob_id = job.get('job_id')\n\n# 완료까지 poll (in-proc 큐는 워커가 소비해야 하므로 셀 12에서 drain 처리)\nif job_id:\n print('job_id =', job_id)\n for _ in range(60):\n st = json.loads(urllib.request.urlopen(\n urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n headers={'X-API-Key': RAW_KEY})\n ).read())\n if st['status'] in ('completed', 'failed', 'cancelled'):\n print('최종 상태:', st['status'])\n break\n time.sleep(2)\n else:\n print('60초 내 미완료 — 셀 12(워커 drain) 실행 후 다시 확인')\nelse:\n print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n" + "source": "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\nimport json, time, urllib.request, urllib.error, subprocess\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\n# multipart 업로드 (curl 사용 — 간단)\nr = subprocess.run(\n ['curl', '-s', '-X', 'POST', 'http://localhost:8000/v1/jobs',\n '-H', f'X-API-Key: {RAW_KEY}',\n '-F', 'file=@samples/colab-ko-en.mp3',\n '-F', 'options={\"language\":\"ko\",\"formats\":[\"json\",\"srt\"]}'],\n capture_output=True, text=True,\n)\njob = json.loads(r.stdout)\nprint('생성:', job)\njob_id = job.get('job_id')\n\n# 완료까지 poll (auto-worker가 서버 프로세스에서 큐를 소비 — 첫 실행은\n# 모델 다운로드 ~1.6GB 포함이라 최대 4분까지 대기)\nif job_id:\n print('job_id =', job_id)\n for _ in range(120):\n st = json.loads(urllib.request.urlopen(\n urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n headers={'X-API-Key': RAW_KEY})\n ).read())\n if st['status'] in ('completed', 'failed', 'cancelled'):\n print('최종 상태:', st['status'])\n break\n time.sleep(2)\n else:\n print('폴링 60초 초과 — 서버 로그 확인: /content/logs/server.log')\nelse:\n print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n" }, { "cell_type": "markdown", @@ -159,7 +159,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n print('job 오류:', getattr(state, 'error', None) or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n err = getattr(state, 'error_message', None) or getattr(state, 'error_code', None)\n print('job 오류:', err or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" }, { "cell_type": "markdown", @@ -171,12 +171,12 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n# 간단 manifest 생성\nimport yaml\nmanifest = {\n 'name': 'colab-quick',\n 'language': 'ko',\n 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n 'cases': [\n {'name': 'ko-en-tech', 'audio': 'samples/colab-ko-en.mp3',\n 'expected_entities': ['vLLM', 'Kubernetes', 'GPU']},\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# 간단 manifest 생성\nimport yaml\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 {'name': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n 'duration_sec': 10.5, 'entities': ['vLLM', 'Kubernetes', 'GPU']},\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", "metadata": {}, - "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 6ab8289..1ceff61 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -220,6 +220,8 @@ def cells() -> list[dict]: "# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n" "os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n" "os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n" + "# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\n" + "os.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n" "\n" "!mkdir -p /content/logs\n" "!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n" @@ -265,10 +267,11 @@ def cells() -> list[dict]: "print('생성:', job)\n" "job_id = job.get('job_id')\n" "\n" - "# 완료까지 poll (in-proc 큐는 워커가 소비해야 하므로 셀 12에서 drain 처리)\n" + "# 완료까지 poll (auto-worker가 서버 프로세스에서 큐를 소비 — 첫 실행은\n" + "# 모델 다운로드 ~1.6GB 포함이라 최대 4분까지 대기)\n" "if job_id:\n" " print('job_id =', job_id)\n" - " for _ in range(60):\n" + " for _ in range(120):\n" " st = json.loads(urllib.request.urlopen(\n" " urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n" " headers={'X-API-Key': RAW_KEY})\n" @@ -278,7 +281,7 @@ def cells() -> list[dict]: " break\n" " time.sleep(2)\n" " else:\n" - " print('60초 내 미완료 — 셀 12(워커 drain) 실행 후 다시 확인')\n" + " print('폴링 60초 초과 — 서버 로그 확인: /content/logs/server.log')\n" "else:\n" " print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n" ), @@ -315,7 +318,8 @@ def cells() -> list[dict]: " state = broker.get(job.id)\n" " print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n" " if state is not None:\n" - " print('job 오류:', getattr(state, 'error', None) or '없음')\n" + " err = getattr(state, 'error_message', None) or getattr(state, 'error_code', None)\n" + " print('job 오류:', err or '없음')\n" "else:\n" " print('status:', result.status)\n" " print('text:', result.text[:120])\n" @@ -332,11 +336,12 @@ def cells() -> list[dict]: "import yaml\n" "manifest = {\n" " 'name': 'colab-quick',\n" + " 'dataset_version': '1.0',\n" " 'language': 'ko',\n" " 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n" - " 'cases': [\n" - " {'name': 'ko-en-tech', 'audio': 'samples/colab-ko-en.mp3',\n" - " 'expected_entities': ['vLLM', 'Kubernetes', 'GPU']},\n" + " 'clips': [\n" + " {'name': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n" + " 'duration_sec': 10.5, 'entities': ['vLLM', 'Kubernetes', 'GPU']},\n" " ],\n" "}\n" "yaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n" @@ -359,6 +364,10 @@ def cells() -> list[dict]: " (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n" "2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n" " 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n" + "### 업로드 후 job이 queued에 머무는 경우\n\n" + "in-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n" + "셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n" + "업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n" "### 모델 다운로드 실패\n\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" diff --git a/src/luke_scribe/api/app.py b/src/luke_scribe/api/app.py index 2aadf21..91577bf 100644 --- a/src/luke_scribe/api/app.py +++ b/src/luke_scribe/api/app.py @@ -9,6 +9,7 @@ Lifespan (plan §3.10a/§3.5): from __future__ import annotations import logging +import threading from contextlib import asynccontextmanager from fastapi import FastAPI, Request @@ -63,6 +64,23 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.session_guard = _SessionGuard(settings) + # in-proc 백엔드 + auto_worker: 같은 프로세스 워커 스레드가 큐를 소비 + # (dev/Colab에서 별도 워커 프로세스 없이 업로드 → 완료 흐름이 가능하게 함) + inproc_worker = None + if settings.queue_backend == "inproc" and settings.auto_worker: + from ..jobqueue.worker import Worker + + inproc_worker = Worker( + settings=settings, + broker=broker, + store=store, + owner=owner, + worker_id="api-inproc", + ) + threading.Thread(target=inproc_worker.run_forever, daemon=True).start() + logger.info("in-proc auto-worker 스레드 시작 (queue_backend=inproc, auto_worker=true)") + app.state.inproc_worker = inproc_worker + # 모델 프로비저닝 (선택) — 설정된 경우에만 model_cache = settings.model_cache_dir if model_cache: @@ -99,6 +117,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: tunnel.stop() except Exception: pass + if inproc_worker is not None: + # stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후 + # 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전) + inproc_worker.stop() owner.unload_all() app = FastAPI( diff --git a/src/luke_scribe/config.py b/src/luke_scribe/config.py index 5aa7168..dbc18dd 100644 --- a/src/luke_scribe/config.py +++ b/src/luke_scribe/config.py @@ -58,6 +58,9 @@ class Settings(BaseSettings): job_timeout_hours: float = 4.0 job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진 + # ── 워커 ── + auto_worker: bool = False # in-proc 백엔드: 서버 프로세스가 큐를 소비 (dev/Colab) + # ── 입력 상한 ── max_duration_sec: int = 14400 # 4h max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB diff --git a/src/luke_scribe/engine/faster_whisper_engine.py b/src/luke_scribe/engine/faster_whisper_engine.py index 48de03a..31a8be0 100644 --- a/src/luke_scribe/engine/faster_whisper_engine.py +++ b/src/luke_scribe/engine/faster_whisper_engine.py @@ -130,9 +130,45 @@ class FasterWhisperEngine(TranscriptionEngine): except Exception as exc: raise TranscriptionFailed(f"전사 중 오류: {exc}") from exc - wrapped = _CancellableSegmentIterator(segments_iter, should_cancel or (lambda: False)) + wrapped = _CancellableSegmentIterator( + self._to_dict_segments(segments_iter), should_cancel or (lambda: False) + ) return TranscriptionOutcome(wrapped, info=info) + @staticmethod + def _to_dict_segments(segments): + """faster-whisper Segment(namedtuple) → dict로 정규화. + + 다운스트림(배치 파이프라인/벤치/실시간)은 dict 계약을 쓴다 (mock과 동일). + GPU 실전에서만 재현되는 버그: 'Segment' object has no attribute 'get'. + """ + for seg in segments: + if isinstance(seg, dict): + yield seg + continue + asdict = getattr(seg, "_asdict", None) + if asdict is not None: + yield dict(asdict()) + continue + # 최후 수단: 알려진 필드만, None 값은 제외 (다운스트림 .get(key, default)가 + # key 존재+None으로 default를 무시하지 않도록) + yield { + k: v + for k in ( + "id", + "seek", + "start", + "end", + "text", + "tokens", + "temperature", + "avg_logprob", + "compression_ratio", + "no_speech_prob", + ) + if (v := getattr(seg, k, None)) is not None + } + @staticmethod def _split_device(device: str) -> tuple[str, int]: """'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다. diff --git a/src/luke_scribe/jobqueue/worker.py b/src/luke_scribe/jobqueue/worker.py index 61d54ea..d7db075 100644 --- a/src/luke_scribe/jobqueue/worker.py +++ b/src/luke_scribe/jobqueue/worker.py @@ -100,7 +100,16 @@ class Worker: self._emit_progress(job, processed_sec, total_sec) try: - options = TranscriptionOptions(**job.options) + # API의 TranscribeOptions에는 엔진과 무관한 키가 포함될 수 있다 + # (formats/timestamps/glossary_id/post_correction/diarize 등) — + # 엔진 계약 필드만 골라 전달한다. + options = TranscriptionOptions( + **{ + k: v + for k, v in (job.options or {}).items() + if k in TranscriptionOptions.__slots__ + } + ) result = pipeline.run(job, options, progress_cb=progress_cb) # 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게) self.store.write_result(job.id, result) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index e84938b..54c7bd1 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -44,6 +44,34 @@ class TestHealth: assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류 +class TestAutoWorker: + def test_auto_worker_off_by_default(self, client: TestClient): + """기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전).""" + assert client.app.state.inproc_worker is None + + def test_auto_worker_started_when_enabled(self, tmp_path): + """auto_worker=true + inproc → lifespan이 워커 스레드를 시작하고 종료 시 정지.""" + from luke_scribe.api.app import create_app + from luke_scribe.config import Settings + + settings = 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, + tunnel="none", + auto_worker=True, + ) + app = create_app(settings) + with TestClient(app) as c: + w = c.app.state.inproc_worker + assert w is not None + assert not w._stop.is_set() + # lifespan 종료 → 워커 정지 요청 + assert w._stop.is_set() + + class TestAuth: def test_missing_key_rejected(self, client: TestClient): r = client.get("/v1/jobs") diff --git a/tests/integration/test_worker.py b/tests/integration/test_worker.py index bfeef03..80ae55c 100644 --- a/tests/integration/test_worker.py +++ b/tests/integration/test_worker.py @@ -30,6 +30,42 @@ def _job(**kw) -> Job: class TestWorkerLifecycle: + def test_api_style_options_filtered(self, settings, tmp_path): + """API TranscribeOptions(엔진 무관 키 포함) → 워커가 엔진 필드만 골라 처리. + + Colab 실전에서 잡이 계속 실패한 원인: job.options에 formats/timestamps/ + diarize 등이 포함돼 TranscriptionOptions(**job.options)가 TypeError를 냄. + """ + broker = InProcBroker(settings) + store = ResultStore(str(tmp_path / "results")) + worker = Worker( + settings=settings, + broker=broker, + store=store, + owner=FakeEngineOwner(), + ingestor=FakeIngestor(), + ) + job = _job( + options={ + "language": "ko", + "device": "auto", + "timestamps": True, + "formats": ["json", "srt"], + "word_timestamps": False, + "vad": True, + "hotwords": [], + "glossary_id": None, + "post_correction": None, + "diarize": False, + } + ) + broker.enqueue(job) + worker.drain() + assert broker.get(job.id).status == JobStatus.COMPLETED + result = store.read_result(job.id) + assert result is not None + assert result.status == "completed" + def test_complete_flow(self, settings, tmp_path): """enqueue → worker 처리 → completed + 결과 저장 + 콜백.""" broker = InProcBroker(settings) diff --git a/tests/unit/test_engine_faster_whisper.py b/tests/unit/test_engine_faster_whisper.py index d27feeb..081ca38 100644 --- a/tests/unit/test_engine_faster_whisper.py +++ b/tests/unit/test_engine_faster_whisper.py @@ -16,16 +16,17 @@ from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine class FakeWhisperModel: - """faster_whisper.WhisperModel 대체 — 생성 인자만 기록한다.""" + """faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트를 기록한다.""" calls: list[dict] = [] + segments: list = [] # transcribe()가 yield할 세그먼트 (기본: namedtuple) def __init__(self, *args, **kwargs) -> None: self.kwargs = kwargs self.__class__.calls.append(kwargs) def transcribe(self, audio_path, **kwargs): - return iter([]), {} + return iter(list(self.__class__.segments)), {} def _install_fake(monkeypatch) -> None: @@ -33,6 +34,7 @@ def _install_fake(monkeypatch) -> None: mod.WhisperModel = FakeWhisperModel monkeypatch.setitem(sys.modules, "faster_whisper", mod) FakeWhisperModel.calls.clear() + FakeWhisperModel.segments = [] def _opts(**kw) -> TranscriptionOptions: @@ -78,3 +80,60 @@ def test_split_device_helper(): assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0) # 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit) assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0) + + +def test_namedtuple_segments_normalized_to_dicts(monkeypatch): + """faster-whisper Segment(namedtuple) → dict 정규화 (GPU 실전 버그).""" + from collections import namedtuple + + _install_fake(monkeypatch) + Seg = namedtuple( + "Segment", + [ + "id", + "seek", + "start", + "end", + "text", + "tokens", + "temperature", + "avg_logprob", + "compression_ratio", + "no_speech_prob", + ], + ) + FakeWhisperModel.segments = [ + Seg( + id=0, + seek=0, + start=0.0, + end=2.5, + text="오늘 vLLM을 배포합니다.", + tokens=[1, 2], + temperature=0.0, + avg_logprob=-0.2, + compression_ratio=1.0, + no_speech_prob=0.01, + ) + ] + outcome = FasterWhisperEngine().transcribe( + "/tmp/x.wav", _opts(device="cpu", compute_type="int8") + ) + segs = list(outcome.segments) + assert len(segs) == 1 + seg = segs[0] + # dict 계약: .get() 사용 가능 (파이프라인이 이걸로 접근) + assert seg.get("text") == "오늘 vLLM을 배포합니다." + assert seg.get("end") == 2.5 + assert seg.get("avg_logprob") == -0.2 + + +def test_dict_segments_passthrough(monkeypatch): + """이미 dict인 세그먼트는 그대로 (mock 계약과 호환).""" + _install_fake(monkeypatch) + FakeWhisperModel.segments = [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}] + outcome = FasterWhisperEngine().transcribe( + "/tmp/x.wav", _opts(device="cpu", compute_type="int8") + ) + segs = list(outcome.segments) + assert segs == [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}] -- 2.54.0 From be5f5054101a7731477b632b13fc4f105f890a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:39:15 +0900 Subject: [PATCH 10/21] fix: normalize faster-whisper TranscriptionInfo to dict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colab run 4 (A100): same namedtuple bug as segments — faster-whisper returns TranscriptionInfo (namedtuple) but batch.py reads outcome['info'].get('language') -> AttributeError 'TranscriptionInfo' object has no attribute 'get' on every real transcription, failing the CLI, API auto-worker job, worker drain, and bench clip alike. FasterWhisperEngine now normalizes info to a dict at the boundary (_to_dict_info: _asdict -> dataclasses.asdict -> known-field fallback). + 2 unit tests (namedtuple/dict info); 138 tests pass, ruff clean. --- .../engine/faster_whisper_engine.py | 28 ++++++++++++++- tests/unit/test_engine_faster_whisper.py | 36 +++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/luke_scribe/engine/faster_whisper_engine.py b/src/luke_scribe/engine/faster_whisper_engine.py index 31a8be0..0d2a914 100644 --- a/src/luke_scribe/engine/faster_whisper_engine.py +++ b/src/luke_scribe/engine/faster_whisper_engine.py @@ -133,7 +133,7 @@ class FasterWhisperEngine(TranscriptionEngine): wrapped = _CancellableSegmentIterator( self._to_dict_segments(segments_iter), should_cancel or (lambda: False) ) - return TranscriptionOutcome(wrapped, info=info) + return TranscriptionOutcome(wrapped, info=self._to_dict_info(info)) @staticmethod def _to_dict_segments(segments): @@ -169,6 +169,32 @@ class FasterWhisperEngine(TranscriptionEngine): if (v := getattr(seg, k, None)) is not None } + @staticmethod + def _to_dict_info(info): + """faster-whisper TranscriptionInfo(namedtuple) → dict. + + 다운스트림(배치 파이프라인)은 info를 dict 계약으로 접근한다 + (``info.get("language")``) — GPU 실전에서만 재현되는 버그: + 'TranscriptionInfo' object has no attribute 'get'. + """ + if info is None or isinstance(info, dict): + return info + asdict = getattr(info, "_asdict", None) + if asdict is not None: + return dict(asdict()) + try: + import dataclasses + + if dataclasses.is_dataclass(info): + return dataclasses.asdict(info) + except Exception: + pass + return { + k: v + for k in ("language", "language_probability", "duration", "duration_after_vad") + if (v := getattr(info, k, None)) is not None + } + @staticmethod def _split_device(device: str) -> tuple[str, int]: """'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다. diff --git a/tests/unit/test_engine_faster_whisper.py b/tests/unit/test_engine_faster_whisper.py index 081ca38..5ca618f 100644 --- a/tests/unit/test_engine_faster_whisper.py +++ b/tests/unit/test_engine_faster_whisper.py @@ -16,17 +16,18 @@ from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine class FakeWhisperModel: - """faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트를 기록한다.""" + """faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트/info를 기록한다.""" calls: list[dict] = [] segments: list = [] # transcribe()가 yield할 세그먼트 (기본: namedtuple) + info: object = None # 기본: TranscriptionInfo(namedtuple) 흉내 def __init__(self, *args, **kwargs) -> None: self.kwargs = kwargs self.__class__.calls.append(kwargs) def transcribe(self, audio_path, **kwargs): - return iter(list(self.__class__.segments)), {} + return iter(list(self.__class__.segments)), self.__class__.info def _install_fake(monkeypatch) -> None: @@ -35,6 +36,7 @@ def _install_fake(monkeypatch) -> None: monkeypatch.setitem(sys.modules, "faster_whisper", mod) FakeWhisperModel.calls.clear() FakeWhisperModel.segments = [] + FakeWhisperModel.info = None def _opts(**kw) -> TranscriptionOptions: @@ -137,3 +139,33 @@ def test_dict_segments_passthrough(monkeypatch): ) segs = list(outcome.segments) assert segs == [{"index": 0, "start": 0.0, "end": 1.0, "text": "x"}] + + +def test_namedtuple_info_normalized_to_dict(monkeypatch): + """faster-whisper TranscriptionInfo(namedtuple) → dict (GPU 실전 버그).""" + from collections import namedtuple + + _install_fake(monkeypatch) + Info = namedtuple( + "TranscriptionInfo", + ["language", "language_probability", "duration", "duration_after_vad"], + ) + FakeWhisperModel.info = Info( + language="ko", language_probability=0.99, duration=10.464, duration_after_vad=9.088 + ) + outcome = FasterWhisperEngine().transcribe( + "/tmp/x.wav", _opts(device="cpu", compute_type="int8") + ) + # dict 계약: .get() 사용 가능 (batch.py가 이걸로 접근) + assert outcome.info.get("language") == "ko" + assert outcome.info.get("duration") == 10.464 + + +def test_dict_info_passthrough(monkeypatch): + """이미 dict인 info는 그대로 (mock 계약과 호환).""" + _install_fake(monkeypatch) + FakeWhisperModel.info = {"language": "ko"} + outcome = FasterWhisperEngine().transcribe( + "/tmp/x.wav", _opts(device="cpu", compute_type="int8") + ) + assert outcome.info == {"language": "ko"} -- 2.54.0 From 457c67df2ce88652c21c373fabdb9457fb2ac676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 17:40:08 +0900 Subject: [PATCH 11/21] =?UTF-8?q?fix:=20colab=20bench=20manifest=20?= =?UTF-8?q?=E2=80=94=20entities=20must=20be=20dicts=20with=20char=20spans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench clip failed in Colab because entity_retention calls ent.get() but the manifest used plain strings -> AttributeError -> clip counted as failure (failure_rate 1.0). Notebook now builds entities as {canonical, surface, start_char, end_char} from the reference text and writes a reference file for meaningful metrics. --- notebooks/luke-scribe-colab.ipynb | 2 +- scripts/build_colab_notebook.py | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 530e034..d0f026c 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -171,7 +171,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n# 간단 manifest 생성\nimport yaml\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 {'name': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n 'duration_sec': 10.5, 'entities': ['vLLM', 'Kubernetes', 'GPU']},\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 '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", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 1ceff61..a453b6b 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -332,16 +332,31 @@ def cells() -> list[dict]: ), code( "# 13) 벤치마크 — 샘플 manifest 사용 (선택, 시간 소요)\n" - "# 간단 manifest 생성\n" + "# entities는 {canonical, surface, start_char, end_char} dict여야 한다\n" + "# (문자열이면 entity_retention이 .get() 호출에 실패해 clip이 실패 처리됨)\n" "import yaml\n" + "\n" + "REF_TEXT = '오늘은 vLLM 서버를 Kubernetes 클러스터에 배포하는 방법을 설명합니다. GPU 가속 추론으로 대기 시간을 줄일 수 있습니다.'\n" + "with open('/content/reference.txt', 'w', encoding='utf-8') as f:\n" + " f.write(REF_TEXT)\n" + "\n" + "entities = []\n" + "for 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" + "\n" "manifest = {\n" " 'name': 'colab-quick',\n" " 'dataset_version': '1.0',\n" " 'language': 'ko',\n" " 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n" " 'clips': [\n" - " {'name': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n" - " 'duration_sec': 10.5, 'entities': ['vLLM', 'Kubernetes', 'GPU']},\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" "}\n" "yaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n" -- 2.54.0 From 0ed2ea6160f9de3ffe0d4b48622a0f61c656223d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 19:41:16 +0900 Subject: [PATCH 12/21] feat: colab notebook - cloudflare tunnel for external dashboard access Server cell sets LUKESCRIBE_TUNNEL=cloudflare so the app built-in CloudflareTunnel (trycloudflare quick tunnel) starts alongside the server. After health+auth checks the cell polls server.log for the trycloudflare URL, prints it with /docs dashboard and /health links, and verifies external reachability. cloudflared binary is downloaded in the install cell (graceful skip on failure). Troubleshooting section documents the temporary/public nature of the link. --- notebooks/luke-scribe-colab.ipynb | 6 ++--- scripts/build_colab_notebook.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index d0f026c..c3c3c61 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -59,7 +59,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" + "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n\n# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" }, { "cell_type": "code", @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nimport re\nTUNNEL_URL = None\nfor _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n except Exception as exc:\n print(' 외부 접속 검증 실패:', exc)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 (cloudflared 미설치/연결 실패) — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index a453b6b..999c5c8 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -133,6 +133,10 @@ def cells() -> list[dict]: "os.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\n" "print('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\n" "print('설치 완료')\n" + "\n" + "# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n" + "!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n" + "!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n" "!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" ), code( @@ -222,6 +226,8 @@ def cells() -> list[dict]: "os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n" "# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\n" "os.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n" + "# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\n" + "os.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n" "\n" "!mkdir -p /content/logs\n" "!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n" @@ -248,6 +254,32 @@ def cells() -> list[dict]: " print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n" " print(open('/content/logs/server.log').read()[-1500:])\n" " raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" + "\n" + "# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\n" + "import re\n" + "TUNNEL_URL = None\n" + "for _ in range(60):\n" + " log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n" + " m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n" + " if m:\n" + " TUNNEL_URL = m.group(0)\n" + " break\n" + " time.sleep(2)\n" + "\n" + "if TUNNEL_URL:\n" + " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" + " print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n" + " print(' 상태 (health):', TUNNEL_URL + '/health')\n" + " try:\n" + " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" + " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" + " except Exception as exc:\n" + " print(' 외부 접속 검증 실패:', exc)\n" + " print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\n" + "else:\n" + " print('터널 URL 미감지 (cloudflared 미설치/연결 실패) — 서버 로그:')\n" + " print(open('/content/logs/server.log').read()[-1200:])\n" + " print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" ), code( "# 11) 업로드 → poll → 결과 (RAW_KEY는 셀 9에서 캡처된 값)\n" @@ -383,6 +415,13 @@ def cells() -> list[dict]: "in-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n" "셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n" "업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n" + "### Cloudflare 터널로 외부에서 접속 (선택)\n\n" + "서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n" + "- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n" + "- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n" + "- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n" + "- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n" + "- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n" "### 모델 다운로드 실패\n\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" -- 2.54.0 From 44b72116533ee7cfb6d5ec76387ce57f9442acc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 19:42:14 +0900 Subject: [PATCH 13/21] refine: colab tunnel cell - skip poll when cloudflared missing, softer verify message Reviewer feedback: guard the 120s URL poll with shutil.which(cloudflared) so the failure case returns immediately; merge import re into the cell top import; note Cloudflare browser-check interstitials can fail urllib verification even when the link works in a real browser. --- notebooks/luke-scribe-colab.ipynb | 2 +- scripts/build_colab_notebook.py | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index c3c3c61..75038ab 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nimport re\nTUNNEL_URL = None\nfor _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n except Exception as exc:\n print(' 외부 접속 검증 실패:', exc)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 (cloudflared 미설치/연결 실패) — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n except Exception as exc:\n print(' 외부 접속 검증 실패 (브라우저에선 열릴 수 있음):', exc)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 999c5c8..abec94a 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -214,7 +214,7 @@ def cells() -> list[dict]: "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n" "# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n" "# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\n" - "import subprocess, os, time, urllib.request, urllib.error\n" + "import subprocess, os, re, shutil, time, urllib.request, urllib.error\n" "\n" "assert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n" "\n" @@ -256,15 +256,17 @@ def cells() -> list[dict]: " raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n" "\n" "# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\n" - "import re\n" "TUNNEL_URL = None\n" - "for _ in range(60):\n" - " log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n" - " m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n" - " if m:\n" - " TUNNEL_URL = m.group(0)\n" - " break\n" - " time.sleep(2)\n" + "if shutil.which('cloudflared'):\n" + " for _ in range(60):\n" + " log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n" + " m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n" + " if m:\n" + " TUNNEL_URL = m.group(0)\n" + " break\n" + " time.sleep(2)\n" + "else:\n" + " print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n" "\n" "if TUNNEL_URL:\n" " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" @@ -274,10 +276,10 @@ def cells() -> list[dict]: " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" " except Exception as exc:\n" - " print(' 외부 접속 검증 실패:', exc)\n" + " print(' 외부 접속 검증 실패 (브라우저에선 열릴 수 있음):', exc)\n" " print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\n" "else:\n" - " print('터널 URL 미감지 (cloudflared 미설치/연결 실패) — 서버 로그:')\n" + " print('터널 URL 미감지 — 서버 로그:')\n" " print(open('/content/logs/server.log').read()[-1200:])\n" " print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" ), -- 2.54.0 From 5b8576e2a37016eb544391363ffaadd3ac9060be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 20:48:08 +0900 Subject: [PATCH 14/21] fix: colab notebook - register src on kernel sys.path Colab run 5 failed at cell 12 (import luke_scribe.config) because hatchling editable installs write a .pth pointing at src/ that the interpreter only reads at startup; a long-running Colab kernel started before pip install -e never sees it. Subprocesses (CLI) work, but kernel-side imports fail. Fix: install cell now walks up to the repo root and inserts src/ into the kernel sys.path, verifying with import luke_scribe.config. Cell 12 has a defensive guard of the same kind. Tunnel external verification now retries up to 6x2s for DNS propagation. Troubleshooting section documents the issue. --- notebooks/luke-scribe-colab.ipynb | 8 +++---- scripts/build_colab_notebook.py | 40 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 75038ab..b418e67 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -59,7 +59,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n\n# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" + "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n\n# 실행 중인 Colab 커널은 새 .pth 파일을 읽지 못한다 (pip editable 설치는\n# 인터프리터 시작 시에만 반영). → 커널 sys.path에 src/를 직접 등록해\n# luke_scribe import를 보장한다 (서브프로세스 CLI는 .pth를 읽으므로 무관).\nimport sys, os as _os\n_root = _os.getcwd()\nwhile not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n _root = _os.path.dirname(_root)\nsys.path.insert(0, _os.path.join(_root, 'src'))\nimport luke_scribe.config as _cfg\nprint('패키지 임포트 OK →', _cfg.__file__)\n\n# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" }, { "cell_type": "code", @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n except Exception as exc:\n print(' 외부 접속 검증 실패 (브라우저에선 열릴 수 있음):', exc)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n ext_ok = False\n for _ in range(6):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception:\n time.sleep(2)\n if not ext_ok:\n print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요)')\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", @@ -159,7 +159,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n err = getattr(state, 'error_message', None) or getattr(state, 'error_code', None)\n print('job 오류:', err or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" + "source": "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\n# 커널 sys.path 보강 — editable 설치는 인터프리터 시작 시에만 반영되므로\n# 실행 중인 커널은 src/를 직접 등록해야 한다 (셀 3에서도 처리하지만 방어적 가드)\nimport sys, os as _os\nif not any(_os.path.isfile(_os.path.join(p, 'luke_scribe', 'config.py')) for p in sys.path):\n _root = _os.getcwd()\n while not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n _root = _os.path.dirname(_root)\n sys.path.insert(0, _os.path.join(_root, 'src'))\nfrom luke_scribe.config import Settings\nfrom luke_scribe.jobqueue.broker import InProcBroker\nfrom luke_scribe.jobqueue.worker import Worker\nfrom luke_scribe.jobqueue.jobs import Job\nfrom luke_scribe.results.store import ResultStore\n\nsettings = Settings(_env_file=None, queue_backend='inproc',\n results_dir='/content/results', model_cache_dir=None)\nbroker = InProcBroker(settings)\nstore = ResultStore(settings.results_dir)\n\njob = Job(type='file', lane='batch', source_path='samples/colab-ko-en.mp3',\n options={'model': 'large-v3-turbo', 'language': 'ko', 'device': 'auto'})\nbroker.enqueue(job)\nworker = Worker(settings=settings, broker=broker, store=store)\nworker.drain()\n\nresult = store.read_result(job.id)\nif result is None:\n # 전사 실패(예: GPU 초기화 실패) 시 결과가 저장되지 않음 — 상태/오류 출력\n state = broker.get(job.id)\n print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n if state is not None:\n err = getattr(state, 'error_message', None) or getattr(state, 'error_code', None)\n print('job 오류:', err or '없음')\nelse:\n print('status:', result.status)\n print('text:', result.text[:120])\n print('device:', result.execution.device, '| ct:', result.execution.compute_type, '| rtf:', result.timings.rtf)\n" }, { "cell_type": "markdown", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\nColab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index abec94a..9167c71 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -134,6 +134,17 @@ def cells() -> list[dict]: "print('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\n" "print('설치 완료')\n" "\n" + "# 실행 중인 Colab 커널은 새 .pth 파일을 읽지 못한다 (pip editable 설치는\n" + "# 인터프리터 시작 시에만 반영). → 커널 sys.path에 src/를 직접 등록해\n" + "# luke_scribe import를 보장한다 (서브프로세스 CLI는 .pth를 읽으므로 무관).\n" + "import sys, os as _os\n" + "_root = _os.getcwd()\n" + "while not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n" + " _root = _os.path.dirname(_root)\n" + "sys.path.insert(0, _os.path.join(_root, 'src'))\n" + "import luke_scribe.config as _cfg\n" + "print('패키지 임포트 OK →', _cfg.__file__)\n" + "\n" "# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n" "!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n" "!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n" @@ -272,11 +283,18 @@ def cells() -> list[dict]: " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" " print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n" " print(' 상태 (health):', TUNNEL_URL + '/health')\n" - " try:\n" - " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" - " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" - " except Exception as exc:\n" - " print(' 외부 접속 검증 실패 (브라우저에선 열릴 수 있음):', exc)\n" + " # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n" + " ext_ok = False\n" + " for _ in range(6):\n" + " try:\n" + " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" + " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" + " ext_ok = True\n" + " break\n" + " except Exception:\n" + " time.sleep(2)\n" + " if not ext_ok:\n" + " print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요)')\n" " print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\n" "else:\n" " print('터널 URL 미감지 — 서버 로그:')\n" @@ -329,6 +347,14 @@ def cells() -> list[dict]: "# 12) 브로커 + 워커 포함 전체 흐름 (in-proc)\n" "# 서버의 in-proc 브로커에 enqueue된 job을 같은 프로세스의 워커가 소비하는 구조는\n" "# 프로세스 분리 필요 → 여기서는 클라이언트에서 직접 Worker.drain() 호출로 검증\n" + "# 커널 sys.path 보강 — editable 설치는 인터프리터 시작 시에만 반영되므로\n" + "# 실행 중인 커널은 src/를 직접 등록해야 한다 (셀 3에서도 처리하지만 방어적 가드)\n" + "import sys, os as _os\n" + "if not any(_os.path.isfile(_os.path.join(p, 'luke_scribe', 'config.py')) for p in sys.path):\n" + " _root = _os.getcwd()\n" + " while not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n" + " _root = _os.path.dirname(_root)\n" + " sys.path.insert(0, _os.path.join(_root, 'src'))\n" "from luke_scribe.config import Settings\n" "from luke_scribe.jobqueue.broker import InProcBroker\n" "from luke_scribe.jobqueue.worker import Worker\n" @@ -424,6 +450,10 @@ def cells() -> list[dict]: "- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n" "- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n" "- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n" + "### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\n" + "Colab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n" + "(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n" + "등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n" "### 모델 다운로드 실패\n\n" "- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n" "- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n" -- 2.54.0 From 96b53f889c55b6744045d7646b3773ba677465ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 20:49:36 +0900 Subject: [PATCH 15/21] refine: notebook - tolerant import verify, keep last err in tunnel check Reviewer feedback: wrap the install-cell import verification in try/except so a walk-up failure cannot crash the cell (previously always-succeeding), and keep the last exception detail in the tunnel external-verification failure message. --- notebooks/luke-scribe-colab.ipynb | 4 ++-- scripts/build_colab_notebook.py | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index b418e67..674ab04 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -59,7 +59,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n\n# 실행 중인 Colab 커널은 새 .pth 파일을 읽지 못한다 (pip editable 설치는\n# 인터프리터 시작 시에만 반영). → 커널 sys.path에 src/를 직접 등록해\n# luke_scribe import를 보장한다 (서브프로세스 CLI는 .pth를 읽으므로 무관).\nimport sys, os as _os\n_root = _os.getcwd()\nwhile not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n _root = _os.path.dirname(_root)\nsys.path.insert(0, _os.path.join(_root, 'src'))\nimport luke_scribe.config as _cfg\nprint('패키지 임포트 OK →', _cfg.__file__)\n\n# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" + "source": "# 3) 설치 — Colab은 시스템 pip가 표준 (venv는 Colab에서 ensurepip 오류로 실패할 수 있음)\n!pip install -q --upgrade pip\n!pip install -q -e '.[engine,api]'\n!pip install -q edge-tts # 샘플 음성 생성용\n\n# CUDA 13(Colab)에서는 CTranslate2 wheel(CUDA 12용)이 런타임 라이브러리를 못 찾음.\n# CUDA 12 런타임(cuBLAS/cuDNN)을 pip로 설치하고, 실제 .so 위치를 find로 찾아\n# LD_LIBRARY_PATH에 추가한다 (nvidia-*-cu12는 namespace package라 __file__이 없음).\n!pip install -q nvidia-cublas-cu12 nvidia-cudnn-cu12\nimport os, site, subprocess\nlib_dirs = set()\nfor lib in ('libcublas.so', 'libcudnn.so'):\n out = subprocess.run(\n ['bash', '-c', f'find {site.getsitepackages()[0]} -name \"{lib}*\" 2>/dev/null | head -3'],\n capture_output=True, text=True,\n ).stdout\n for line in out.splitlines():\n d = os.path.dirname(line)\n if d:\n lib_dirs.add(d)\nos.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs) + ':' + os.environ.get('LD_LIBRARY_PATH', '')\nprint('LD_LIBRARY_PATH:', os.environ['LD_LIBRARY_PATH'])\nprint('설치 완료')\n\n# 실행 중인 Colab 커널은 새 .pth 파일을 읽지 못한다 (pip editable 설치는\n# 인터프리터 시작 시에만 반영). → 커널 sys.path에 src/를 직접 등록해\n# luke_scribe import를 보장한다 (서브프로세스 CLI는 .pth를 읽으므로 무관).\nimport sys, os as _os\n_root = _os.getcwd()\nwhile not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n _root = _os.path.dirname(_root)\nsys.path.insert(0, _os.path.join(_root, 'src'))\ntry:\n import luke_scribe.config as _cfg\n print('패키지 임포트 OK →', _cfg.__file__)\nexcept ImportError as _e:\n print('경고: luke_scribe import 실패 —', _e)\n print(' 셀 1(클론)이 실행됐는지, 저장소가 src/luke_scribe 구조인지 확인하세요')\n\n# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n!chmod +x /usr/local/bin/cloudflared 2>/dev/null; cloudflared --version 2>&1 | head -1 || echo 'cloudflared 미설치 — 터널 생략'\n!which luke-scribe && luke-scribe --help 2>&1 | head -8\n" }, { "cell_type": "code", @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n ext_ok = False\n for _ in range(6):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception:\n time.sleep(2)\n if not ext_ok:\n print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요)')\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n ext_ok = False\n _last_err = None\n for _ in range(6):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok:\n print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요):', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 9167c71..f1fd9df 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -142,8 +142,12 @@ def cells() -> list[dict]: "while not _os.path.isdir(_os.path.join(_root, 'src', 'luke_scribe')) and _root != _os.path.dirname(_root):\n" " _root = _os.path.dirname(_root)\n" "sys.path.insert(0, _os.path.join(_root, 'src'))\n" - "import luke_scribe.config as _cfg\n" - "print('패키지 임포트 OK →', _cfg.__file__)\n" + "try:\n" + " import luke_scribe.config as _cfg\n" + " print('패키지 임포트 OK →', _cfg.__file__)\n" + "except ImportError as _e:\n" + " print('경고: luke_scribe import 실패 —', _e)\n" + " print(' 셀 1(클론)이 실행됐는지, 저장소가 src/luke_scribe 구조인지 확인하세요')\n" "\n" "# Cloudflare 터널용 cloudflared 바이너리 (실패해도 진행 — 터널 없이 로컬 사용 가능)\n" "!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O /usr/local/bin/cloudflared || echo 'cloudflared 다운로드 실패 — 터널 없이 계속합니다'\n" @@ -285,16 +289,18 @@ def cells() -> list[dict]: " print(' 상태 (health):', TUNNEL_URL + '/health')\n" " # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n" " ext_ok = False\n" + " _last_err = None\n" " for _ in range(6):\n" " try:\n" " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" " ext_ok = True\n" " break\n" - " except Exception:\n" + " except Exception as _e:\n" + " _last_err = _e\n" " time.sleep(2)\n" " if not ext_ok:\n" - " print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요)')\n" + " print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요):', _last_err)\n" " print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\n" "else:\n" " print('터널 URL 미감지 — 서버 로그:')\n" -- 2.54.0 From e34322cc73185b93e8a26da28746d272536c898f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 20:57:37 +0900 Subject: [PATCH 16/21] feat: colab notebook - DNS diagnosis + DoH-bypass tunnel verification Colab VM DNS fails to resolve fresh trycloudflare hostnames (Name or service not known) while the tunnel itself works from any real browser. Verification now: 1) diagnoses VM DNS via socket.gethostbyname, 2) uses urllib when DNS resolves, 3) falls back to curl --doh-url (Cloudflare DNS over HTTPS) to bypass the VM resolver, 4) otherwise prints the docs URL with an explicit note that a minted URL means the tunnel is already connected. Troubleshooting section documents the DNS limitation. --- notebooks/luke-scribe-colab.ipynb | 4 +-- scripts/build_colab_notebook.py | 52 ++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 674ab04..137b884 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n ext_ok = False\n _last_err = None\n for _ in range(6):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok:\n print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요):', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # ── 외부 접속 검증 ──\n # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n import socket\n _host = TUNNEL_URL.split('//')[1].split('/')[0]\n _dns_ip = None\n try:\n _dns_ip = socket.gethostbyname(_host)\n except socket.gaierror as _de:\n print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n\n ext_ok = False\n _last_err = None\n if _dns_ip:\n for _ in range(6):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok and shutil.which('curl'):\n # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n for _ in range(6):\n _c = subprocess.run(\n ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '15',\n '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n capture_output=True, text=True,\n )\n if _c.stdout.strip() == '200':\n print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n ext_ok = True\n break\n _last_err = _c.stdout.strip() or _c.stderr.strip() or _last_err\n time.sleep(2)\n if not ext_ok:\n print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\nColab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n- **자동 검증이 실패해도 터널은 정상일 수 있습니다.** Colab VM의 DNS가 새 trycloudflare 호스트를\n 해석하지 못하면 `Name or service not known`이 나지만, URL 발급 자체가 이미 edge↔서버 연결을 의미합니다.\n 검증 셀은 Cloudflare DoH(`--doh-url`) 우회로 다시 시도하며, 안 되면 브라우저/폰에서 직접 확인하세요.\n\n### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\nColab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index f1fd9df..d40f5de 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -287,20 +287,47 @@ def cells() -> list[dict]: " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" " print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n" " print(' 상태 (health):', TUNNEL_URL + '/health')\n" - " # DNS 전파 지연 대비 재시도 (새 trycloudflare 호스트는 초반에 못 풀릴 수 있음)\n" + " # ── 외부 접속 검증 ──\n" + " # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n" + " # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n" + " # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n" + " import socket\n" + " _host = TUNNEL_URL.split('//')[1].split('/')[0]\n" + " _dns_ip = None\n" + " try:\n" + " _dns_ip = socket.gethostbyname(_host)\n" + " except socket.gaierror as _de:\n" + " print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n" + "\n" " ext_ok = False\n" " _last_err = None\n" - " for _ in range(6):\n" - " try:\n" - " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" - " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" - " ext_ok = True\n" - " break\n" - " except Exception as _e:\n" - " _last_err = _e\n" + " if _dns_ip:\n" + " for _ in range(6):\n" + " try:\n" + " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" + " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" + " ext_ok = True\n" + " break\n" + " except Exception as _e:\n" + " _last_err = _e\n" + " time.sleep(2)\n" + " if not ext_ok and shutil.which('curl'):\n" + " # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n" + " for _ in range(6):\n" + " _c = subprocess.run(\n" + " ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '15',\n" + " '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n" + " capture_output=True, text=True,\n" + " )\n" + " if _c.stdout.strip() == '200':\n" + " print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n" + " ext_ok = True\n" + " break\n" + " _last_err = _c.stdout.strip() or _c.stderr.strip() or _last_err\n" " time.sleep(2)\n" " if not ext_ok:\n" - " print(' 외부 접속 검증 실패 (DNS 전파 지연이거나 Cloudflare 브라우저 체크 — 브라우저에서 직접 확인하세요):', _last_err)\n" + " print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n" + " print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n" " print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\n" "else:\n" " print('터널 URL 미감지 — 서버 로그:')\n" @@ -455,7 +482,10 @@ def cells() -> list[dict]: "- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n" "- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n" "- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n" - "- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n\n" + "- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n" + "- **자동 검증이 실패해도 터널은 정상일 수 있습니다.** Colab VM의 DNS가 새 trycloudflare 호스트를\n" + " 해석하지 못하면 `Name or service not known`이 나지만, URL 발급 자체가 이미 edge↔서버 연결을 의미합니다.\n" + " 검증 셀은 Cloudflare DoH(`--doh-url`) 우회로 다시 시도하며, 안 되면 브라우저/폰에서 직접 확인하세요.\n\n" "### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\n" "Colab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n" "(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n" -- 2.54.0 From f171ce49926f6b7d0247c00a57641dff3db96ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 20:58:44 +0900 Subject: [PATCH 17/21] refine: tunnel verify - tighter retries, stderr-first on 000, announce DoH fallback Reviewer feedback: cut worst-case stall from ~200s to ~60s (urllib 2x, DoH 4x with max-time 10); when curl reports 000 (connect failure) prefer stderr for the diagnostic; print an explicit note when falling back to DoH after a resolved-DNS urllib failure. --- notebooks/luke-scribe-colab.ipynb | 2 +- scripts/build_colab_notebook.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 137b884..311af09 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # ── 외부 접속 검증 ──\n # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n import socket\n _host = TUNNEL_URL.split('//')[1].split('/')[0]\n _dns_ip = None\n try:\n _dns_ip = socket.gethostbyname(_host)\n except socket.gaierror as _de:\n print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n\n ext_ok = False\n _last_err = None\n if _dns_ip:\n for _ in range(6):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok and shutil.which('curl'):\n # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n for _ in range(6):\n _c = subprocess.run(\n ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '15',\n '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n capture_output=True, text=True,\n )\n if _c.stdout.strip() == '200':\n print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n ext_ok = True\n break\n _last_err = _c.stdout.strip() or _c.stderr.strip() or _last_err\n time.sleep(2)\n if not ext_ok:\n print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # ── 외부 접속 검증 ──\n # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n import socket\n _host = TUNNEL_URL.split('//')[1].split('/')[0]\n _dns_ip = None\n try:\n _dns_ip = socket.gethostbyname(_host)\n except socket.gaierror as _de:\n print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n\n ext_ok = False\n _last_err = None\n if _dns_ip:\n # DNS가 풀리면 터널도 이미 연결된 상태 — 2회면 충분\n for _ in range(2):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok and shutil.which('curl'):\n if _dns_ip:\n print(' (시스템 DNS 경로 실패 — DoH로 우회 검증 시도)')\n # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n for _ in range(4):\n _c = subprocess.run(\n ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10',\n '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n capture_output=True, text=True,\n )\n _code = _c.stdout.strip()\n if _code == '200':\n print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n ext_ok = True\n break\n # '000'은 접속 실패 — stderr가 더 설명적\n if _code == '000':\n _last_err = _c.stderr.strip() or _last_err\n else:\n _last_err = _code or _c.stderr.strip() or _last_err\n time.sleep(2)\n if not ext_ok:\n print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index d40f5de..0311cb1 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -302,7 +302,8 @@ def cells() -> list[dict]: " ext_ok = False\n" " _last_err = None\n" " if _dns_ip:\n" - " for _ in range(6):\n" + " # DNS가 풀리면 터널도 이미 연결된 상태 — 2회면 충분\n" + " for _ in range(2):\n" " try:\n" " ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n" " print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n" @@ -312,18 +313,25 @@ def cells() -> list[dict]: " _last_err = _e\n" " time.sleep(2)\n" " if not ext_ok and shutil.which('curl'):\n" + " if _dns_ip:\n" + " print(' (시스템 DNS 경로 실패 — DoH로 우회 검증 시도)')\n" " # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n" - " for _ in range(6):\n" + " for _ in range(4):\n" " _c = subprocess.run(\n" - " ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '15',\n" + " ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10',\n" " '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n" " capture_output=True, text=True,\n" " )\n" - " if _c.stdout.strip() == '200':\n" + " _code = _c.stdout.strip()\n" + " if _code == '200':\n" " print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n" " ext_ok = True\n" " break\n" - " _last_err = _c.stdout.strip() or _c.stderr.strip() or _last_err\n" + " # '000'은 접속 실패 — stderr가 더 설명적\n" + " if _code == '000':\n" + " _last_err = _c.stderr.strip() or _last_err\n" + " else:\n" + " _last_err = _code or _c.stderr.strip() or _last_err\n" " time.sleep(2)\n" " if not ext_ok:\n" " print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n" -- 2.54.0 From 6bb89eb51fdb01b1d0b756444d6072c64ab87d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 21:06:42 +0900 Subject: [PATCH 18/21] fix: solve vLLM->BLM misrecognition via glossary postprocessing The bench and CLI kept emitting BLM for vLLM. Three layers: 1. rules.py: add default rule BLM -> vLLM (word-boundary, case-insensitive) so the default post_mode=rules path restores it everywhere. 2. benchmark/runner.py: the bench never ran postprocessing - it measured raw engine text, so entity retention (66.7%) never reflected rules/glossary. _transcribe_clip now builds Segments and runs run_postprocess(settings, glossary) before metrics; manifest top-level glossary {pattern: replacement} is supported and recorded in run_config (post_mode/glossary). 3. glossary plumbing: CLI transcribe gains --glossary KEY=VALUE (repeatable, parsed + validated); BatchPipeline.run accepts glossary= and passes it to run_postprocess; the in-proc worker forwards job.options.glossary or post_correction. Notebook: transcribe cells pass --glossary BLM=vLLM, bench manifest carries glossary, postprocess section documents rules/glossary/hotword. + 9 unit tests (BLM rule, boundary/case behavior, bench postprocess + glossary entity retention). 147 tests pass, ruff clean. --- notebooks/luke-scribe-colab.ipynb | 8 +- scripts/build_colab_notebook.py | 17 +++- src/luke_scribe/benchmark/runner.py | 45 +++++++-- src/luke_scribe/cli.py | 13 ++- src/luke_scribe/jobqueue/worker.py | 6 +- src/luke_scribe/pipeline/batch.py | 6 +- src/luke_scribe/postprocess/rules.py | 2 + tests/unit/test_benchmark.py | 143 +++++++++++++++++++++++++++ tests/unit/test_postprocess.py | 19 ++++ 9 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_benchmark.py diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 311af09..30e6425 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -109,19 +109,19 @@ "execution_count": null, "metadata": {}, "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", "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", "execution_count": null, "metadata": {}, "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", @@ -171,7 +171,7 @@ "execution_count": null, "metadata": {}, "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", diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index 0311cb1..ab05e66 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -192,9 +192,12 @@ def cells() -> list[dict]: "# 첫 실행 시 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'으로 도메인 용어를 명시적으로 보강할 수 있다.\n" "import subprocess, json\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" ")\n" "print(r.stdout[-2500:] if r.stdout else '')\n" @@ -202,12 +205,14 @@ def cells() -> list[dict]: ), md( "### 5-2) 후처리 검증\n\n" - "glossary(오인식 용어 복원) + hotword(용어 사전 주입) 동작을 확인합니다.\n" - "`--hotword vLLM Kubernetes`를 주면 initial_prompt에 용어가 주입되어 보존률이 올라갑니다." + "후처리(rules/glossary) + hotword(용어 사전 주입) 동작을 확인합니다.\n" + "- **rules (기본)**: `BLM → vLLM`, `v l l m → vLLM` 같은 흔한 오인식을 결정적으로 복원.\n" + "- **glossary**: `--glossary '오인식=표준'`으로 도메인 용어를 명시적으로 보강 (반복 가능).\n" + "- **hotword**: `--hotword vLLM Kubernetes` → initial_prompt 주입으로 보존률 향상." ), code( - "# 8) hotword 포함 전사 (용어 보존 강화)\n" - "!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes\n" + "# 8) hotword + glossary 포함 전사 (용어 보존 강화)\n" + "!luke-scribe transcribe samples/colab-ko-en.mp3 --language ko --device auto --hotword vLLM --hotword Kubernetes --glossary BLM=vLLM\n" ), md( "## 6) REST API 스모크\n\n" @@ -454,6 +459,8 @@ def cells() -> list[dict]: " '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" diff --git a/src/luke_scribe/benchmark/runner.py b/src/luke_scribe/benchmark/runner.py index 32387e6..71133a5 100644 --- a/src/luke_scribe/benchmark/runner.py +++ b/src/luke_scribe/benchmark/runner.py @@ -56,6 +56,8 @@ def run_benchmark( from ..errors import InvalidInput raise InvalidInput("manifest에 clips가 없습니다") + # manifest 최상위 glossary: {오인식 패턴: 표준 표기} — 벤치도 후처리를 적용한다 + glossary = data.get("glossary") or None report: dict = { "report_version": REPORT_VERSION, @@ -71,6 +73,8 @@ def run_benchmark( "beam_size": 5, "temperature": 0.0, "vad_filter": True, + "post_mode": settings.post_mode if settings.post_enabled else "none", + "glossary": glossary or {}, }, "models": [], "decision": {"status": "pending", "default_model": None, "reasons": []}, @@ -81,7 +85,9 @@ def run_benchmark( owner = EngineOwner.get(settings) model_results = {} 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 report["models"].append(agg["summary"]) @@ -102,7 +108,9 @@ def run_benchmark( 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 options = TranscriptionOptions( @@ -114,7 +122,7 @@ def _run_model(owner, clips, model, device, compute_type, repeats, hotwords, rep ) # warm-up (비평가 클립 1회) clip0 = clips[0] - _transcribe_clip(owner, options, clip0) + _transcribe_clip(owner, options, clip0, settings, glossary) rtf_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 for _ in range(repeats): try: - result = _transcribe_clip(owner, options, clip) + result = _transcribe_clip(owner, options, clip, settings, glossary) m = clip_metrics(ref_text, result["text"], entities) clip_rtfs.append(result["rtf"]) 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} -def _transcribe_clip(owner, options, clip) -> dict: - """클립 전사 — 세그먼트 소비 + text/rtf 반환.""" +def _transcribe_clip(owner, options, clip, settings, glossary) -> dict: + """클립 전사 — 후처리(glossary/rules) 적용 + text/rtf 반환. + + 벤치 지표는 원시 전사가 아니라 사용자가 실제로 받는 후처리 결과를 측정해야 + 한다 (vLLM→BLM 같은 오인식은 rules/glossary에서 복원된다). + """ from ..engine.owner import InferenceRequest + from ..postprocess.pipeline import run_postprocess + from ..results.models import Segment audio_path = clip.get("audio_path") if not audio_path: @@ -196,11 +210,22 @@ def _transcribe_clip(owner, options, clip) -> dict: t0 = time.time() req = InferenceRequest(audio_path=audio_path, options=options, lane="batch") outcome = owner.transcribe(req) - texts = [] - for seg in outcome["segments"]: - texts.append(seg.get("text", "")) + segments: list[Segment] = [] + for idx, seg in enumerate(outcome["segments"]): + 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 - 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: diff --git a/src/luke_scribe/cli.py b/src/luke_scribe/cli.py index 2fb038f..c1fbc00 100644 --- a/src/luke_scribe/cli.py +++ b/src/luke_scribe/cli.py @@ -86,6 +86,9 @@ def transcribe( ), vad: bool = typer.Option(True, "--vad/--no-vad"), 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)"), force: bool = typer.Option(False, "--force", help="기존 출력 파일 overwrite"), word_timestamps: bool = typer.Option(False, "--word-timestamps"), @@ -113,9 +116,17 @@ def transcribe( hotwords=hotword, 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: 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: if output is not None: failed = TranscriptResult( diff --git a/src/luke_scribe/jobqueue/worker.py b/src/luke_scribe/jobqueue/worker.py index d7db075..b3622b2 100644 --- a/src/luke_scribe/jobqueue/worker.py +++ b/src/luke_scribe/jobqueue/worker.py @@ -110,7 +110,11 @@ class Worker: 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로 전이 가능하게) self.store.write_result(job.id, result) current = self._transition(job, JobStatus.COMPLETED) diff --git a/src/luke_scribe/pipeline/batch.py b/src/luke_scribe/pipeline/batch.py index 61824c9..657909e 100644 --- a/src/luke_scribe/pipeline/batch.py +++ b/src/luke_scribe/pipeline/batch.py @@ -54,9 +54,13 @@ class BatchPipeline: *, progress_cb: Callable[[float, float], None] | None = None, source_name: str | None = None, + glossary: dict[str, str] | None = None, ) -> TranscriptResult: """job(Job) 또는 source(Path)를 받아 전사. + Args: + glossary: {오인식 패턴: 표준 표기} — run_postprocess에 전달. + Returns: completed TranscriptResult (후처리 포함). """ @@ -116,7 +120,7 @@ class BatchPipeline: # 4) 후처리 (glossary/rules/LLM/confidence) 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 text = " ".join(s.text.strip() for s in post_result["segments"] if s.text.strip()) diff --git a/src/luke_scribe/postprocess/rules.py b/src/luke_scribe/postprocess/rules.py index 1d49d99..b936128 100644 --- a/src/luke_scribe/postprocess/rules.py +++ b/src/luke_scribe/postprocess/rules.py @@ -13,6 +13,8 @@ from ..results.models import Segment # 흔한 오인식 패턴 → 표준 표기 (정규식) DEFAULT_RULES: list[tuple[re.Pattern, str]] = [ (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"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"), (re.compile(r"\bg ?p ?u\b", re.IGNORECASE), "GPU"), diff --git a/tests/unit/test_benchmark.py b/tests/unit/test_benchmark.py new file mode 100644 index 0000000..af354b0 --- /dev/null +++ b/tests/unit/test_benchmark.py @@ -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 diff --git a/tests/unit/test_postprocess.py b/tests/unit/test_postprocess.py index 084a6f7..0caaef7 100644 --- a/tests/unit/test_postprocess.py +++ b/tests/unit/test_postprocess.py @@ -43,6 +43,25 @@ class TestRules: out = apply_rules(segs) 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): segs = _segments(["오늘 API 서버"]) out = apply_rules(segs) -- 2.54.0 From 440e947d47198e47a549dfaf72d630c25e449f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 21:07:56 +0900 Subject: [PATCH 19/21] refine: glossary - reject empty keys, guard non-dict, schema field Reviewer feedback: (1) CLI --glossary rejects empty key/value so an empty pattern cannot corrupt text; (2) worker guards job.options glossary/post_correction with isinstance dict; (3) TranscribeOptions gains a dedicated glossary dict field; (4) rules.py documents the word-boundary/Hangul adjacency limitation. --- src/luke_scribe/api/schemas.py | 1 + src/luke_scribe/cli.py | 6 +++++- src/luke_scribe/jobqueue/worker.py | 6 ++++-- src/luke_scribe/postprocess/rules.py | 2 ++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/luke_scribe/api/schemas.py b/src/luke_scribe/api/schemas.py index 80a88b0..d70d2a6 100644 --- a/src/luke_scribe/api/schemas.py +++ b/src/luke_scribe/api/schemas.py @@ -23,6 +23,7 @@ class TranscribeOptions(BaseModel): hotwords: list[str] = Field(default_factory=list) vad: bool = True glossary_id: str | None = None + glossary: dict[str, str] | None = None # {오인식 패턴: 표준 표기} — 후처리 glossary post_correction: dict[str, Any] | None = None diarize: bool = False diff --git a/src/luke_scribe/cli.py b/src/luke_scribe/cli.py index c1fbc00..6488970 100644 --- a/src/luke_scribe/cli.py +++ b/src/luke_scribe/cli.py @@ -121,7 +121,11 @@ def transcribe( if "=" not in item: _fail(EXIT_INPUT, f"--glossary는 'KEY=VALUE' 형식이어야 합니다: {item}") key, _, value = item.partition("=") - glossary_dict[key.strip()] = value.strip() + key, value = key.strip(), value.strip() + if not key or not value: + # 빈 패턴(re.escape(''))은 모든 위치에 매칭돼 텍스트를 망가뜨린다 + _fail(EXIT_INPUT, f"--glossary 키/값이 비어 있으면 안 됩니다: {item}") + glossary_dict[key] = value try: pipeline = BatchPipeline(settings=settings, token=token) result = pipeline.run( diff --git a/src/luke_scribe/jobqueue/worker.py b/src/luke_scribe/jobqueue/worker.py index b3622b2..de915da 100644 --- a/src/luke_scribe/jobqueue/worker.py +++ b/src/luke_scribe/jobqueue/worker.py @@ -110,10 +110,12 @@ class Worker: if k in TranscriptionOptions.__slots__ } ) - # API 옵션의 glossary/post_correction(dict)을 후처리 glossary로 전달 - glossary = (job.options or {}).get("glossary") or (job.options or {}).get( + # API 옵션의 glossary/post_correction(dict)을 후처리 glossary로 전달. + # pydantic 스키마를 거치지 않은 직접 생성 Job은 비-dict일 수 있어 방어. + raw_glossary = (job.options or {}).get("glossary") or (job.options or {}).get( "post_correction" ) + glossary = raw_glossary if isinstance(raw_glossary, dict) else None result = pipeline.run(job, options, progress_cb=progress_cb, glossary=glossary) # 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게) self.store.write_result(job.id, result) diff --git a/src/luke_scribe/postprocess/rules.py b/src/luke_scribe/postprocess/rules.py index b936128..e1834fa 100644 --- a/src/luke_scribe/postprocess/rules.py +++ b/src/luke_scribe/postprocess/rules.py @@ -14,6 +14,8 @@ from ..results.models import Segment DEFAULT_RULES: list[tuple[re.Pattern, str]] = [ (re.compile(r"\bv ?l ?l ?m\b", re.IGNORECASE), "vLLM"), # 흔한 오인식: vLLM → BLM (GPU 실전에서 재현). 기술 STT 도메인 전제로 복원. + # 주의: \b 경계는 유니코드 \w 기준이라 'BLM은'(공백 없음)은 교정하지 않는다. + # faster-whisper 출력은 어절 단위 공백 분리("BLM 서버를")라 실제로는 충분하다. (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"\bf ?a ?s ?t ?a ?p ?i\b", re.IGNORECASE), "FastAPI"), -- 2.54.0 From 3dfa6605034cb3875581748d8fec561a6b14342e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=ED=98=B8=20Sangho=20Park?= Date: Wed, 12 Aug 2026 21:41:42 +0900 Subject: [PATCH 20/21] feat: dashboard UI + key API + realtime decode Full HTML dashboard (dark theme, vanilla JS, no external deps) served at / and /dashboard with five tabs: system status (admin), file upload-to-transcribe with progress/result/downloads, job history with cancel/result modal, realtime mic demo over the existing WebSocket, and API key create/list. Backend: POST/GET /v1/keys (admin; raw key returned once, digest-only storage); KeyStore.list_keys(); EngineOwner.emit_hypothesis is now a real implementation (PCM16 chunk -> WAV -> realtime-lane decode with the single GPU lock) instead of a stub. Notebook: tunnel cell links /dashboard and smoke-checks the HTML. + 6 tests (dashboard public HTML, key create/list/scope, WAV header, emit_hypothesis cleanup). 153 tests pass, ruff clean, JS syntax verified with node --check. --- notebooks/luke-scribe-colab.ipynb | 4 +- scripts/build_colab_notebook.py | 13 +- src/luke_scribe/api/app.py | 4 +- src/luke_scribe/api/deps.py | 7 + src/luke_scribe/api/routes/dashboard.py | 22 + src/luke_scribe/api/routes/keys.py | 35 + src/luke_scribe/api/schemas.py | 4 + src/luke_scribe/api/static/dashboard.html | 854 ++++++++++++++++++++++ src/luke_scribe/engine/owner.py | 75 +- tests/integration/test_api.py | 38 + tests/unit/test_engine_owner.py | 68 ++ 11 files changed, 1111 insertions(+), 13 deletions(-) create mode 100644 src/luke_scribe/api/routes/dashboard.py create mode 100644 src/luke_scribe/api/routes/keys.py create mode 100644 src/luke_scribe/api/static/dashboard.html diff --git a/notebooks/luke-scribe-colab.ipynb b/notebooks/luke-scribe-colab.ipynb index 30e6425..721c16a 100644 --- a/notebooks/luke-scribe-colab.ipynb +++ b/notebooks/luke-scribe-colab.ipynb @@ -140,7 +140,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # ── 외부 접속 검증 ──\n # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n import socket\n _host = TUNNEL_URL.split('//')[1].split('/')[0]\n _dns_ip = None\n try:\n _dns_ip = socket.gethostbyname(_host)\n except socket.gaierror as _de:\n print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n\n ext_ok = False\n _last_err = None\n if _dns_ip:\n # DNS가 풀리면 터널도 이미 연결된 상태 — 2회면 충분\n for _ in range(2):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok and shutil.which('curl'):\n if _dns_ip:\n print(' (시스템 DNS 경로 실패 — DoH로 우회 검증 시도)')\n # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n for _ in range(4):\n _c = subprocess.run(\n ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10',\n '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n capture_output=True, text=True,\n )\n _code = _c.stdout.strip()\n if _code == '200':\n print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n ext_ok = True\n break\n # '000'은 접속 실패 — stderr가 더 설명적\n if _code == '000':\n _last_err = _c.stderr.strip() or _last_err\n else:\n _last_err = _code or _c.stderr.strip() or _last_err\n time.sleep(2)\n if not ext_ok:\n print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" + "source": "# 10) 서버 기동 (in-proc 큐, 백그라운드 — nohup)\n# 이전 실행에서 남은 서버가 포트 8000을 점유하면 새 키를 모른 채 401이 난다.\n# → 먼저 기존 서버를 모두 종료하고, 키 파일 경로를 명시해 재시작한다.\nimport subprocess, os, re, shutil, time, urllib.request, urllib.error\n\nassert 'RAW_KEY' in globals(), '셀 9(키 생성)를 먼저 실행하세요'\n\nsubprocess.run(['pkill', '-f', 'luke-scribe serve'], capture_output=True)\ntime.sleep(2)\n\n# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\nos.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\nos.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\nos.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n# Cloudflare Quick Tunnel — 외부 접속 링크 발급 (cloudflared는 셀 3에서 설치)\nos.environ['LUKESCRIBE_TUNNEL'] = 'cloudflare'\n\n!mkdir -p /content/logs\n!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\n\nok = False\nfor _ in range(40):\n try:\n urllib.request.urlopen('http://localhost:8000/health', timeout=1)\n ok = True\n break\n except Exception:\n time.sleep(1)\nif not ok:\n print('서버 기동 실패 — 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('서버 기동 실패')\n\n# 대시보드 HTML 스모크 (공개 — 키 불필요)\ntry:\n d = urllib.request.urlopen('http://localhost:8000/dashboard', timeout=5)\n print('대시보드 HTML:', d.status, len(d.read()), 'bytes')\nexcept Exception as exc:\n print('대시보드 로드 실패:', exc)\n\n# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\nreq = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\ntry:\n resp = urllib.request.urlopen(req, timeout=5)\n print('서버 기동 OK + 키 인증 OK (HTTP', resp.status, ')')\nexcept urllib.error.HTTPError as e:\n print('키 인증 실패 (HTTP', e.code, ') — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1500:])\n raise SystemExit('키 인증 실패 — RAW_KEY 캡처/키 파일 확인')\n\n# ── Cloudflare 터널 URL 캡처 + 외부 접속 검증 (서버 로그의 trycloudflare URL) ──\nTUNNEL_URL = None\nif shutil.which('cloudflared'):\n for _ in range(60):\n log = open('/content/logs/server.log', encoding='utf-8', errors='replace').read()\n m = re.search(r'https://[a-z0-9-]+\\.trycloudflare\\.com', log)\n if m:\n TUNNEL_URL = m.group(0)\n break\n time.sleep(2)\nelse:\n print('cloudflared 미설치 (셀 3 다운로드 실패) — 터널 생략, 로컬(8000) 계속 사용')\n\nif TUNNEL_URL:\n print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n print(' 대시보드:', TUNNEL_URL + '/dashboard')\n print(' API Docs (Swagger):', TUNNEL_URL + '/docs')\n print(' 상태 (health):', TUNNEL_URL + '/health')\n # ── 외부 접속 검증 ──\n # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n # (실측: Name or service not known — 브라우저/폰에선 정상 접속).\n # 순서: ① urllib(시스템 DNS) ② 실패 시 DoH(Cloudflare DNS)로 우회 검증\n import socket\n _host = TUNNEL_URL.split('//')[1].split('/')[0]\n _dns_ip = None\n try:\n _dns_ip = socket.gethostbyname(_host)\n except socket.gaierror as _de:\n print(' (VM DNS 해석 실패:', _de, '— DoH로 우회 검증 시도)')\n\n ext_ok = False\n _last_err = None\n if _dns_ip:\n # DNS가 풀리면 터널도 이미 연결된 상태 — 2회면 충분\n for _ in range(2):\n try:\n ext = urllib.request.urlopen(TUNNEL_URL + '/health', timeout=15)\n print(' 외부 접속 검증 OK (HTTP', ext.status, ')')\n ext_ok = True\n break\n except Exception as _e:\n _last_err = _e\n time.sleep(2)\n if not ext_ok and shutil.which('curl'):\n if _dns_ip:\n print(' (시스템 DNS 경로 실패 — DoH로 우회 검증 시도)')\n # VM DNS 우회: Cloudflare DoH로 호스트 해석 → 터널 직접 접속 (외부와 동일 경로)\n for _ in range(4):\n _c = subprocess.run(\n ['curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10',\n '--doh-url', 'https://cloudflare-dns.com/dns-query', TUNNEL_URL + '/health'],\n capture_output=True, text=True,\n )\n _code = _c.stdout.strip()\n if _code == '200':\n print(' 외부 접속 검증 OK (DoH 우회, HTTP 200)')\n ext_ok = True\n break\n # '000'은 접속 실패 — stderr가 더 설명적\n if _code == '000':\n _last_err = _c.stderr.strip() or _last_err\n else:\n _last_err = _code or _c.stderr.strip() or _last_err\n time.sleep(2)\n if not ext_ok:\n print(' 자동 검증 불가 — 브라우저에서 직접 열어보세요:', TUNNEL_URL + '/docs')\n print(' (URL이 발급됐다는 건 Cloudflare edge↔서버 터널이 이미 연결된 상태입니다. 상세:', _last_err)\n print(' ※ 임시 링크 — Colab 세션 종료 시 닫힘. API 호출에는 RAW_KEY(X-API-Key) 필요.')\nelse:\n print('터널 URL 미감지 — 서버 로그:')\n print(open('/content/logs/server.log').read()[-1200:])\n print('로컬(8000)에서 계속 사용할 수 있습니다.')\n" }, { "cell_type": "code", @@ -176,7 +176,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n- **자동 검증이 실패해도 터널은 정상일 수 있습니다.** Colab VM의 DNS가 새 trycloudflare 호스트를\n 해석하지 못하면 `Name or service not known`이 나지만, URL 발급 자체가 이미 edge↔서버 연결을 의미합니다.\n 검증 셀은 Cloudflare DoH(`--doh-url`) 우회로 다시 시도하며, 안 되면 브라우저/폰에서 직접 확인하세요.\n\n### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\nColab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" + "source": "## 8) 문제 해결\n\n### 전사 실패: `unsupported device cuda:0`\n\n**근본 원인**: v0.1 엔진이 `device=\"cuda:0\"`(인덱스 포함)를 그대로 CTranslate2에 전달했는데,\nCTranslate2는 `device=\"cuda\"`만 허용하고 인덱스는 별도 `device_index` 인자로 받습니다.\n→ 엔진에서 `device`/`device_index`를 분리하도록 **수정 완료** (이 브랜치 최신 커밋 포함).\n\n참고: Colab의 CUDA 13(드라이버 580)에서는 CUDA 12용 CTranslate2 wheel이\n런타임 라이브러리(cuBLAS/cuDNN)를 못 찾을 수 있어, 설치 셀 3에서\n`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` + `LD_LIBRARY_PATH`를 설정합니다.\n그래도 실패하면 CPU 폴백: `--device cpu --compute-type int8` (느리지만 확실).\n\n### API 키 인증 실패 (401)\n\n1. `api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\n### 업로드 후 job이 queued에 머무는 경우\n\nin-proc 백엔드는 별도 워커 프로세스가 없으면 큐를 소비하지 못합니다.\n셀 10이 `LUKESCRIBE_AUTO_WORKER=true`로 서버를 띄우면 서버 내부 워커 스레드가\n업로드 → 완료까지 처리합니다 (v0.1에서 추가된 옵트인 기능).\n\n### Cloudflare 터널로 외부에서 접속 (선택)\n\n서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n- `/dashboard`(전용 대시보드 UI)와 `/docs`(Swagger), `/health`는 별도 키 없이 열리지만,\n **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다. 대시보드 상단에 키를 저장하면 사용됩니다.\n- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n- **자동 검증이 실패해도 터널은 정상일 수 있습니다.** Colab VM의 DNS가 새 trycloudflare 호스트를\n 해석하지 못하면 `Name or service not known`이 나지만, URL 발급 자체가 이미 edge↔서버 연결을 의미합니다.\n 검증 셀은 Cloudflare DoH(`--doh-url`) 우회로 다시 시도하며, 안 되면 브라우저/폰에서 직접 확인하세요.\n\n### `No module named 'luke_scribe'` (셀 12에서 import 실패)\n\nColab 커널은 실행 중에는 pip editable 설치가 만든 .pth를 읽지 못합니다\n(인터프리터 시작 시에만 반영). 설치 셀 3이 커널 sys.path에 `src/`를 직접\n등록해 해결했습니다 — 이 셀만 다시 실행하려면 설치 셀 3(또는 전체)을 재실행하세요.\n\n### 모델 다운로드 실패\n\n- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n\n### API 키 인증 실패 (401)\n\n`api_keys.json`에는 **다이제스트만** 저장되고 raw 키는 생성 시 1회만 출력됩니다\n(보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401이 납니다.\n\n### OOM (16GB VRAM)\n\n- EngineOwner가 자동으로 정밀도를 강등합니다 (float16 → int8_float16 → int8 → CPU)\n- 수동 지정: `--compute-type int8_float16`\n\n### 저장소가 private인데 클론 안 됨\n\n1번 셀의 `GITEA_TOKEN`에 토큰을 입력하고 런타임 → **Restart session** 후 다시 실행하세요.\n" } ] } \ No newline at end of file diff --git a/scripts/build_colab_notebook.py b/scripts/build_colab_notebook.py index ab05e66..45d6f06 100644 --- a/scripts/build_colab_notebook.py +++ b/scripts/build_colab_notebook.py @@ -265,6 +265,13 @@ def cells() -> list[dict]: " print(open('/content/logs/server.log').read()[-1500:])\n" " raise SystemExit('서버 기동 실패')\n" "\n" + "# 대시보드 HTML 스모크 (공개 — 키 불필요)\n" + "try:\n" + " d = urllib.request.urlopen('http://localhost:8000/dashboard', timeout=5)\n" + " print('대시보드 HTML:', d.status, len(d.read()), 'bytes')\n" + "except Exception as exc:\n" + " print('대시보드 로드 실패:', exc)\n" + "\n" "# 키 인증 검증 — RAW_KEY로 /v1/jobs 호출 → 200이어야 함 (401이면 종료)\n" "req = urllib.request.Request('http://localhost:8000/v1/jobs', headers={'X-API-Key': RAW_KEY})\n" "try:\n" @@ -290,7 +297,8 @@ def cells() -> list[dict]: "\n" "if TUNNEL_URL:\n" " print('🌐 Cloudflare 터널 (외부 접속):', TUNNEL_URL)\n" - " print(' 대시보드 (API Docs):', TUNNEL_URL + '/docs')\n" + " print(' 대시보드:', TUNNEL_URL + '/dashboard')\n" + " print(' API Docs (Swagger):', TUNNEL_URL + '/docs')\n" " print(' 상태 (health):', TUNNEL_URL + '/health')\n" " # ── 외부 접속 검증 ──\n" " # Colab VM의 DNS가 새 trycloudflare 호스트를 해석 못 하는 경우가 있다\n" @@ -494,7 +502,8 @@ def cells() -> list[dict]: "### Cloudflare 터널로 외부에서 접속 (선택)\n\n" "서버 셀 10이 완료되면 `https://xxx.trycloudflare.com` 외부 접속 링크가 출력됩니다.\n" "- **임시 링크** — Colab 세션(서버 프로세스)이 살아 있는 동안만 유효하며, 세션 종료/재시작 시 닫힙니다.\n" - "- `/docs`(Swagger 대시보드)와 `/health`는 별도 키 없이 열리지만, **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다.\n" + "- `/dashboard`(전용 대시보드 UI)와 `/docs`(Swagger), `/health`는 별도 키 없이 열리지만,\n" + " **실제 API 호출은 `X-API-Key`(RAW_KEY)가 필요**합니다. 대시보드 상단에 키를 저장하면 사용됩니다.\n" "- cloudflared는 셀 3에서 설치됩니다. 다운로드 실패 시 터널 없이 로컬(8000)에서 계속 사용할 수 있습니다.\n" "- 터널은 `http://localhost:8000`을 고정으로 바라봅니다. 다른 포트로 서버를 띄우면 터널이 연결되지 않습니다.\n" "- trycloudflare는 **인증 없는 Quick Tunnel**이라 URL만 알면 누구나 접근 가능합니다. 민감 데이터는 올리지 마세요.\n" diff --git a/src/luke_scribe/api/app.py b/src/luke_scribe/api/app.py index 91577bf..6874718 100644 --- a/src/luke_scribe/api/app.py +++ b/src/luke_scribe/api/app.py @@ -28,7 +28,7 @@ from ..errors import ( ) from ..results.store import ResultStore from .deps import KeyStore -from .routes import admin, jobs, stream +from .routes import admin, dashboard, jobs, keys, stream logger = logging.getLogger("luke_scribe.api") @@ -130,9 +130,11 @@ def create_app(settings: Settings | None = None) -> FastAPI: lifespan=lifespan, ) + app.include_router(dashboard.router) app.include_router(admin.router) app.include_router(jobs.router) app.include_router(stream.router) + app.include_router(keys.router) # ── 오류 → HTTP 매핑 (LukeScribeError envelope) ── @app.exception_handler(AuthError) diff --git a/src/luke_scribe/api/deps.py b/src/luke_scribe/api/deps.py index 860b6f0..7b9a13a 100644 --- a/src/luke_scribe/api/deps.py +++ b/src/luke_scribe/api/deps.py @@ -73,6 +73,13 @@ class KeyStore: return Principal(key_id=key_id, scopes=entry["scopes"]) raise AuthError("유효하지 않은 API 키") + def list_keys(self) -> list[dict]: + """키 ID/스코프 목록 (raw 키는 절대 노출하지 않음).""" + return [ + {"id": key_id, "scopes": sorted(entry["scopes"])} + for key_id, entry in sorted(self._keys.items()) + ] + def create_key(self, scopes: list[str] | None = None, *, save_path: str | None = None) -> dict: """새 키 생성 — raw 키는 1회만 반환하고 다이제스트만 저장.""" raw = f"luke-{secrets.token_urlsafe(32)}" diff --git a/src/luke_scribe/api/routes/dashboard.py b/src/luke_scribe/api/routes/dashboard.py new file mode 100644 index 0000000..5940d5e --- /dev/null +++ b/src/luke_scribe/api/routes/dashboard.py @@ -0,0 +1,22 @@ +"""대시보드 정적 페이지 라우트. + +HTML 자체는 공개로 제공하고, API 호출은 브라우저가 사용자 입력 키 +(X-API-Key)를 헤더로 보낸다 — /docs(Swagger)와 같은 인증 모델. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import FileResponse + +router = APIRouter(tags=["dashboard"]) + +_DASHBOARD = Path(__file__).resolve().parent.parent / "static" / "dashboard.html" + + +@router.get("/", include_in_schema=False) +@router.get("/dashboard", include_in_schema=False) +async def dashboard() -> FileResponse: + return FileResponse(_DASHBOARD, media_type="text/html; charset=utf-8") diff --git a/src/luke_scribe/api/routes/keys.py b/src/luke_scribe/api/routes/keys.py new file mode 100644 index 0000000..d45a2cb --- /dev/null +++ b/src/luke_scribe/api/routes/keys.py @@ -0,0 +1,35 @@ +"""API 키 라우트 — 생성/목록 (admin 스코프). + +- ``POST /v1/keys``: 새 키 생성 — raw 키는 **응답에서 1회만** 노출하고 + 다이제스트만 저장한다 (대시보드/CLI 공통 계약). +- ``GET /v1/keys``: 키 ID/스코프 목록 (raw 키는 노출하지 않음). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from ..deps import Principal, require_scope +from ..schemas import KeyCreateRequest + +router = APIRouter(prefix="/v1/keys", tags=["keys"]) + + +@router.post("", status_code=201) +async def create_key( + request: Request, + body: KeyCreateRequest, + principal: Principal = Depends(require_scope("admin")), +) -> dict: + keystore = request.app.state.keystore + created = keystore.create_key(body.scopes) + return created + + +@router.get("") +async def list_keys( + request: Request, + principal: Principal = Depends(require_scope("admin")), +) -> dict: + keystore = request.app.state.keystore + return {"keys": keystore.list_keys()} diff --git a/src/luke_scribe/api/schemas.py b/src/luke_scribe/api/schemas.py index d70d2a6..746d2cb 100644 --- a/src/luke_scribe/api/schemas.py +++ b/src/luke_scribe/api/schemas.py @@ -28,6 +28,10 @@ class TranscribeOptions(BaseModel): diarize: bool = False +class KeyCreateRequest(BaseModel): + scopes: list[str] = Field(default_factory=lambda: ["transcribe"]) + + class JobCreateResponse(BaseModel): job_id: str status: str diff --git a/src/luke_scribe/api/static/dashboard.html b/src/luke_scribe/api/static/dashboard.html new file mode 100644 index 0000000..0920664 --- /dev/null +++ b/src/luke_scribe/api/static/dashboard.html @@ -0,0 +1,854 @@ + + + + + +luke_scribe 대시보드 + + + +
+
luke_scribe v0.1 · 대시보드
+
+ 연결 확인 중… + +
+ + +
+
+ +
+ + + +
+
+

🖥️시스템 상태

+
+
연결 상태
+
능력 등급
+
GPU
+
VRAM
+
컴퓨트 타입
+
워커
+
큐 깊이
+
모델
+
+

+
+
+

📊장치 상세

+

+    
+
+ + +
+
+

🎙️파일 전사

+
+
🎧 파일을 끌어다 놓거나 클릭하여 선택
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ + +
+ +
+ +
+ + +
+
+

📋작업 히스토리 3초 자동 갱신

+
+ + + +
상태파일job id진행률작업
+
+
+
+ + +
+
+

실시간 마이크 전사 (WebSocket)

+

마이크 오디오를 16kHz PCM으로 보내 서버가 실시간 가설/확정 텍스트를 반환합니다. 첫 가설은 모델 로드로 지연될 수 있습니다.

+ + +
+
+
+
+
+
+ + +
+
+

🔑키 생성 (admin 스코프 필요)

+
+ + +
+
+ +
+
+

🗝️키 목록

+ + + +
key id스코프
+
+
+ +
privacy-first · 모든 처리는 로컬/자체 서버에서 수행됩니다
+
+ + + +
+ + + + diff --git a/src/luke_scribe/engine/owner.py b/src/luke_scribe/engine/owner.py index dda5fd4..088819d 100644 --- a/src/luke_scribe/engine/owner.py +++ b/src/luke_scribe/engine/owner.py @@ -18,6 +18,9 @@ EngineOwner**가 모든 CT2 모델과 VRAM을 소유하고, API·배치 워커 from __future__ import annotations +import os +import struct +import tempfile import threading from dataclasses import dataclass, field @@ -31,6 +34,28 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"] MAX_DOWNGRADES = 2 +def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes: + """PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용.""" + n = len(data) + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", + 36 + n, + b"WAVE", + b"fmt ", + 16, + 1, # PCM + 1, # mono + sample_rate, + sample_rate * 2, # byte rate + 2, # block align + 16, # bits per sample + b"data", + n, + ) + return header + data + + @dataclass class InferenceRequest: audio_path: str @@ -149,16 +174,50 @@ class EngineOwner: return profiles def emit_hypothesis(self, pcm_chunk: bytes) -> dict: - """실시간 레인 가설 생성 (v0.1 스텁). + """실시간 레인 가설 생성 — PCM16(16kHz mono) → WAV → realtime lane decode. - 실제 decode는 오디오 청크를 임시 WAV로 이어붙인 뒤 ``transcribe()``를 - 호출한다. v0.1 mock 환경(모델 미탑재)에서는 빈 가설을 반환하며, - GPU 환경에서 실전 decode는 이 진입점으로 통일된다 (§3.9a — 단일 GPU 락). + 청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를 + 반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던 + 실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다. """ - if self._stats.get("realtime_decode_ready"): - # 실전 구현: 청크 WAV → transcribe(lane=realtime) → segments - pass - return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000} + from ..results.models import Segment + + wav = _pcm16_to_wav(pcm_chunk) + fd, path = tempfile.mkstemp(suffix=".wav") + try: + with os.fdopen(fd, "wb") as f: + f.write(wav) + req = InferenceRequest( + audio_path=path, + options=TranscriptionOptions( + model=self.settings.model_rt, + language=self.settings.language, + device=self.settings.device, + compute_type=self.settings.compute_type, + vad=False, # 실시간 레인: 짧은 청크에서 VAD 무음 제거 방지 + beam_size=5, + ), + lane="realtime", + ) + outcome = self.transcribe(req) + segments: list[Segment] = [] + for i, seg in enumerate(outcome["segments"]): + segments.append( + Segment( + index=i, + start=float(seg.get("start", 0.0)), + end=float(seg.get("end", 0.0)), + text=seg.get("text", ""), + avg_logprob=seg.get("avg_logprob"), + no_speech_prob=seg.get("no_speech_prob"), + ) + ) + return {"segments": segments, "audio_sec": len(pcm_chunk) / 2 / 16000} + finally: + try: + os.unlink(path) + except OSError: + pass def unload_all(self) -> None: self._engine.unload_all() diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 54c7bd1..39e5412 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -44,6 +44,44 @@ class TestHealth: assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류 +class TestDashboard: + def test_dashboard_html_public(self, client: TestClient): + """대시보드 HTML은 공개 — 인증은 클라이언트에서 API 키 입력.""" + for path in ("/", "/dashboard"): + r = client.get(path) + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert "luke_scribe" in r.text + assert "실시간" in r.text # 전체 기능 포함 + + +class TestKeys: + def test_create_and_list_key(self, client: TestClient): + headers = {"X-API-Key": "key-admin"} + r = client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers) + assert r.status_code == 201, r.text + body = r.json() + assert body["key"].startswith("luke-") + assert body["key_id"].startswith("k-") + # raw 키는 1회만 노출 — 목록에는 다이제스트 ID만 + r2 = client.get("/v1/keys", headers=headers) + keys = r2.json()["keys"] + assert body["key_id"] in [k["id"] for k in keys] + assert all("key" not in k for k in keys) + + def test_admin_scope_required(self, client: TestClient): + headers = {"X-API-Key": "key-transcribe"} + assert ( + client.post("/v1/keys", json={"scopes": ["transcribe"]}, headers=headers).status_code + == 403 + ) + assert client.get("/v1/keys", headers=headers).status_code == 403 + + def test_no_auth_401(self, client: TestClient): + assert client.get("/v1/keys").status_code == 401 + assert client.post("/v1/keys", json={"scopes": []}).status_code == 401 + + class TestAutoWorker: def test_auto_worker_off_by_default(self, client: TestClient): """기본(False)에서는 워커 스레드를 띄우지 않는다 (테스트/프로덕션 안전).""" diff --git a/tests/unit/test_engine_owner.py b/tests/unit/test_engine_owner.py index c7f2899..9e3e9ce 100644 --- a/tests/unit/test_engine_owner.py +++ b/tests/unit/test_engine_owner.py @@ -34,6 +34,74 @@ def _opts(**kw) -> TranscriptionOptions: return TranscriptionOptions(**kw) +class _SegFakeEngine: + """emit_hypothesis 테스트용 — WAV 경로 기록 + 세그먼트 반환.""" + + def __init__(self) -> None: + self.path = None + + def transcribe(self, audio_path, options, should_cancel=None, download_progress=None): + self.path = audio_path + segs = [ + { + "start": 0.0, + "end": 1.2, + "text": "안녕하세요", + "avg_logprob": -0.2, + "no_speech_prob": 0.01, + } + ] + return type("O", (), {"segments": iter(segs), "info": {"language": "ko"}})() + + def unload_all(self): + pass + + +def _bare_owner(engine) -> EngineOwner: + import threading + + from luke_scribe.config import Settings + + owner = EngineOwner.__new__(EngineOwner) + owner.settings = Settings(_env_file=None) + owner._engine = engine + owner._lock = threading.Lock() + owner._realtime_priority = threading.Lock() + owner._stats = {"jobs": 0, "downgrades": 0, "oom": 0} + return owner + + +def test_pcm16_to_wav_header(): + import struct + + from luke_scribe.engine.owner import _pcm16_to_wav + + wav = _pcm16_to_wav(b"\x00\x00" * 8000) # 1초 (16kHz mono) + assert wav[:4] == b"RIFF" + assert wav[8:12] == b"WAVE" + assert wav[12:16] == b"fmt " + sr = struct.unpack(" Date: Wed, 12 Aug 2026 21:44:26 +0900 Subject: [PATCH 21/21] refine: dashboard review fixes - echo removal, model escape, RMS gate, fd leak, mic cleanup Reviewer feedback: remove AudioContext destination echo, escape model names, add RMS gate to skip silence, close mkstemp fd, clean up mic on WS close. Tests updated for the RMS gate. --- src/luke_scribe/api/routes/jobs.py | 1 + src/luke_scribe/api/schemas.py | 1 + src/luke_scribe/api/static/dashboard.html | 14 +++++++--- src/luke_scribe/engine/owner.py | 33 ++++++++++++++++++++++- tests/integration/test_api.py | 4 ++- tests/unit/test_engine_owner.py | 14 +++++++++- 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/luke_scribe/api/routes/jobs.py b/src/luke_scribe/api/routes/jobs.py index 2983b94..78069bb 100644 --- a/src/luke_scribe/api/routes/jobs.py +++ b/src/luke_scribe/api/routes/jobs.py @@ -168,6 +168,7 @@ def _to_status(job: Job) -> JobStatusResponse: return JobStatusResponse( job_id=job.id, status=job.status.value, + source_name=job.source_name, queue_position=job.queue_position, progress=job.progress, processed_sec=job.processed_sec, diff --git a/src/luke_scribe/api/schemas.py b/src/luke_scribe/api/schemas.py index 746d2cb..738f020 100644 --- a/src/luke_scribe/api/schemas.py +++ b/src/luke_scribe/api/schemas.py @@ -41,6 +41,7 @@ class JobCreateResponse(BaseModel): class JobStatusResponse(BaseModel): job_id: str status: str + source_name: str | None = None queue_position: int | None = None jobs_ahead: int | None = None progress: float | None = None diff --git a/src/luke_scribe/api/static/dashboard.html b/src/luke_scribe/api/static/dashboard.html index 0920664..93a1bb0 100644 --- a/src/luke_scribe/api/static/dashboard.html +++ b/src/luke_scribe/api/static/dashboard.html @@ -509,7 +509,7 @@ async function loadSystem() { $("#s-ct").textContent = b.compute_type_used || "—"; $("#s-workers").textContent = b.workers ?? "—"; $("#s-queue").textContent = b.queue_depth ?? "—"; - $("#s-model").innerHTML = (b.models || []).join("
"); + $("#s-model").innerHTML = (b.models || []).map(esc).join("
"); $("#sys-detail").textContent = JSON.stringify(b, null, 2); } catch (e) { $("#sys-hint").textContent = "⚠ " + e.message; @@ -758,8 +758,15 @@ $("#rt-start").addEventListener("click", async () => { } }; ws.onclose = () => { - if (rt && !rt.stopped) setRtStatus("연결 종료", "err"); $("#rt-wave").classList.remove("live"); + if (rt && !rt.stopped) { + setRtStatus("연결 종료", "err"); + // 예기치 않은 종료 시 마이크/오디오 컨텍스트 정리 + if (rt.stream) rt.stream.getTracks().forEach((t) => t.stop()); + if (rt.ctx) rt.ctx.close().catch(() => {}); + $("#rt-start").disabled = false; + $("#rt-stop").disabled = true; + } }; ws.onerror = () => setRtStatus("WebSocket 오류", "err"); @@ -781,7 +788,8 @@ $("#rt-start").addEventListener("click", async () => { if (ws.readyState === WebSocket.OPEN) ws.send(toPCM16(d)); }; src.connect(proc); - proc.connect(ctx.destination); + // proc.connect(ctx.destination) 의도적으로 생략 — 마이크 오디오를 스피커로 + // 재생하면 에코 피드백이 생긴다 (ScriptProcessor는 연결 없이도 동작). } catch (e) { setRtStatus("오디오 캡처 실패: " + e.message, "err"); stopRt(); diff --git a/src/luke_scribe/engine/owner.py b/src/luke_scribe/engine/owner.py index 088819d..ac21f29 100644 --- a/src/luke_scribe/engine/owner.py +++ b/src/luke_scribe/engine/owner.py @@ -34,6 +34,27 @@ DOWNGRADE_CHAIN_GPU = ["float16", "int8_float16", "int8"] MAX_DOWNGRADES = 2 +# 무음 청크 decode 생략 임계 (int16 RMS, ~-36dBFS) +SILENCE_RMS_THRESHOLD = 500.0 + + +def _rms16(data: bytes) -> float: + """PCM16 바이트의 RMS 레벨 (int16 단위, 0~32767).""" + import array + + if not data: + return 0.0 + usable = data[: len(data) - (len(data) % 2)] + samples = array.array("h") + samples.frombytes(usable) + if not samples: + return 0.0 + s = 0.0 + for v in samples: + s += v * v + return (s / len(samples)) ** 0.5 + + def _pcm16_to_wav(data: bytes, sample_rate: int = 16000) -> bytes: """PCM16(mono) 바이트 → WAV(RIFF) 컨테이너. 실시간 청크 decode용.""" n = len(data) @@ -179,13 +200,23 @@ class EngineOwner: 청크 단위로 ``transcribe(lane="realtime")``를 호출해 세그먼트를 반환한다 (§3.9a — 단일 GPU 락, 실시간 우선 채널). v0.1 스텁이었던 실전 구현으로: 첫 가설은 모델 로드(다운로드)가 필요할 수 있다. + 무음 청크(RMS < 임계)는 decode 없이 빈 가설을 반환해 Whisper의 + 무음 할루시네이션이 LocalAgreement를 통해 확정되는 것을 막는다. """ from ..results.models import Segment + if _rms16(pcm_chunk) < SILENCE_RMS_THRESHOLD: + return {"segments": [], "audio_sec": len(pcm_chunk) / 2 / 16000} + wav = _pcm16_to_wav(pcm_chunk) fd, path = tempfile.mkstemp(suffix=".wav") try: - with os.fdopen(fd, "wb") as f: + try: + f = os.fdopen(fd, "wb") + except Exception: + os.close(fd) # fdopen 실패 시 fd 누수 방지 + raise + with f: f.write(wav) req = InferenceRequest( audio_path=path, diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 39e5412..f71e66c 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -147,7 +147,9 @@ class TestJobs: # 조회 r = client.get(f"/v1/jobs/{job_id}", headers=headers) assert r.status_code == 200 - assert r.json()["status"] == "queued" + body = r.json() + assert body["status"] == "queued" + assert body["source_name"] == "meeting.mp3" # 대시보드 파일 컬럼용 # 결과는 아직 없음 r = client.get(f"/v1/jobs/{job_id}/result", headers=headers) diff --git a/tests/unit/test_engine_owner.py b/tests/unit/test_engine_owner.py index 9e3e9ce..463b917 100644 --- a/tests/unit/test_engine_owner.py +++ b/tests/unit/test_engine_owner.py @@ -87,12 +87,24 @@ def test_pcm16_to_wav_header(): assert struct.unpack("