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.
This commit is contained in:
@@ -140,14 +140,14 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"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",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"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",
|
"cell_type": "markdown",
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"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",
|
"cell_type": "markdown",
|
||||||
@@ -171,12 +171,12 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"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",
|
"cell_type": "markdown",
|
||||||
"metadata": {},
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -220,6 +220,8 @@ def cells() -> list[dict]:
|
|||||||
"# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n"
|
"# 서버가 읽을 키 파일/큐를 명시 (cwd 의존 제거)\n"
|
||||||
"os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n"
|
"os.environ['LUKESCRIBE_API_KEY_FILE'] = '/content/api_keys.json'\n"
|
||||||
"os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n"
|
"os.environ['LUKESCRIBE_QUEUE_BACKEND'] = 'inproc'\n"
|
||||||
|
"# in-proc 서버가 자체 워커 스레드로 큐를 소비 (업로드 → 완료까지 API 단독 처리)\n"
|
||||||
|
"os.environ['LUKESCRIBE_AUTO_WORKER'] = 'true'\n"
|
||||||
"\n"
|
"\n"
|
||||||
"!mkdir -p /content/logs\n"
|
"!mkdir -p /content/logs\n"
|
||||||
"!nohup luke-scribe serve --port 8000 > /content/logs/server.log 2>&1 &\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"
|
"print('생성:', job)\n"
|
||||||
"job_id = job.get('job_id')\n"
|
"job_id = job.get('job_id')\n"
|
||||||
"\n"
|
"\n"
|
||||||
"# 완료까지 poll (in-proc 큐는 워커가 소비해야 하므로 셀 12에서 drain 처리)\n"
|
"# 완료까지 poll (auto-worker가 서버 프로세스에서 큐를 소비 — 첫 실행은\n"
|
||||||
|
"# 모델 다운로드 ~1.6GB 포함이라 최대 4분까지 대기)\n"
|
||||||
"if job_id:\n"
|
"if job_id:\n"
|
||||||
" print('job_id =', 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"
|
" st = json.loads(urllib.request.urlopen(\n"
|
||||||
" urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n"
|
" urllib.request.Request(f'http://localhost:8000/v1/jobs/{job_id}',\n"
|
||||||
" headers={'X-API-Key': RAW_KEY})\n"
|
" headers={'X-API-Key': RAW_KEY})\n"
|
||||||
@@ -278,7 +281,7 @@ def cells() -> list[dict]:
|
|||||||
" break\n"
|
" break\n"
|
||||||
" time.sleep(2)\n"
|
" time.sleep(2)\n"
|
||||||
" else:\n"
|
" else:\n"
|
||||||
" print('60초 내 미완료 — 셀 12(워커 drain) 실행 후 다시 확인')\n"
|
" print('폴링 60초 초과 — 서버 로그 확인: /content/logs/server.log')\n"
|
||||||
"else:\n"
|
"else:\n"
|
||||||
" print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n"
|
" print('업로드 실패 — 서버 로그 확인: /content/logs/server.log')\n"
|
||||||
),
|
),
|
||||||
@@ -315,7 +318,8 @@ def cells() -> list[dict]:
|
|||||||
" state = broker.get(job.id)\n"
|
" state = broker.get(job.id)\n"
|
||||||
" print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n"
|
" print('결과 없음 — job 상태:', getattr(state, 'status', 'unknown'))\n"
|
||||||
" if state is not None:\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"
|
"else:\n"
|
||||||
" print('status:', result.status)\n"
|
" print('status:', result.status)\n"
|
||||||
" print('text:', result.text[:120])\n"
|
" print('text:', result.text[:120])\n"
|
||||||
@@ -332,11 +336,12 @@ def cells() -> list[dict]:
|
|||||||
"import yaml\n"
|
"import yaml\n"
|
||||||
"manifest = {\n"
|
"manifest = {\n"
|
||||||
" 'name': 'colab-quick',\n"
|
" 'name': 'colab-quick',\n"
|
||||||
|
" 'dataset_version': '1.0',\n"
|
||||||
" 'language': 'ko',\n"
|
" 'language': 'ko',\n"
|
||||||
" 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n"
|
" 'targets': {'entity_preservation': 0.95, 'cer': 0.15},\n"
|
||||||
" 'cases': [\n"
|
" 'clips': [\n"
|
||||||
" {'name': 'ko-en-tech', 'audio': 'samples/colab-ko-en.mp3',\n"
|
" {'name': 'ko-en-tech', 'audio_path': 'samples/colab-ko-en.mp3',\n"
|
||||||
" 'expected_entities': ['vLLM', 'Kubernetes', 'GPU']},\n"
|
" 'duration_sec': 10.5, 'entities': ['vLLM', 'Kubernetes', 'GPU']},\n"
|
||||||
" ],\n"
|
" ],\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"yaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n"
|
"yaml.safe_dump(manifest, open('/content/manifest.yaml', 'w'))\n"
|
||||||
@@ -359,6 +364,10 @@ def cells() -> list[dict]:
|
|||||||
" (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n"
|
" (보안 설계). 셀 9에서 `RAW_KEY`를 캡처했는지 확인하세요 — 파일에서 키를 읽으면 401.\n"
|
||||||
"2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n"
|
"2. **이전 실행에서 남은 서버**가 포트 8000을 점유하면 새 키를 모른 채 401이 납니다.\n"
|
||||||
" 셀 10이 시작 시 `pkill`로 기존 서버를 종료하고 키 인증(HTTP 200)까지 검증합니다.\n\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"
|
"### 모델 다운로드 실패\n\n"
|
||||||
"- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n"
|
"- Hugging Face 연결 필요. 재시도: `LUKESCRIBE_MODEL_DOWNLOAD_RETRIES=3`\n"
|
||||||
"- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n"
|
"- 특정 모델만: `--model large-v3-turbo` (기본) / `--model large-v3`\n"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Lifespan (plan §3.10a/§3.5):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -63,6 +64,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
app.state.session_guard = _SessionGuard(settings)
|
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
|
model_cache = settings.model_cache_dir
|
||||||
if model_cache:
|
if model_cache:
|
||||||
@@ -99,6 +117,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
tunnel.stop()
|
tunnel.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if inproc_worker is not None:
|
||||||
|
# stop()은 다음 루프 반복에서 반영 — 진행 중 job은 끝까지 완료 후
|
||||||
|
# 스레드가 종료된다 (daemon + 프로세스 종료 흐름에서는 안전)
|
||||||
|
inproc_worker.stop()
|
||||||
owner.unload_all()
|
owner.unload_all()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class Settings(BaseSettings):
|
|||||||
job_timeout_hours: float = 4.0
|
job_timeout_hours: float = 4.0
|
||||||
job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진
|
job_timeout_margin_rtf: float = 2.0 # duration × RTF 추정 시 마진
|
||||||
|
|
||||||
|
# ── 워커 ──
|
||||||
|
auto_worker: bool = False # in-proc 백엔드: 서버 프로세스가 큐를 소비 (dev/Colab)
|
||||||
|
|
||||||
# ── 입력 상한 ──
|
# ── 입력 상한 ──
|
||||||
max_duration_sec: int = 14400 # 4h
|
max_duration_sec: int = 14400 # 4h
|
||||||
max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
|
max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2GB
|
||||||
|
|||||||
@@ -130,9 +130,45 @@ class FasterWhisperEngine(TranscriptionEngine):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise TranscriptionFailed(f"전사 중 오류: {exc}") from 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)
|
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
|
@staticmethod
|
||||||
def _split_device(device: str) -> tuple[str, int]:
|
def _split_device(device: str) -> tuple[str, int]:
|
||||||
"""'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다.
|
"""'cuda:N' → ('cuda', N). CTranslate2는 device='cuda'만 허용하므로 분리한다.
|
||||||
|
|||||||
@@ -100,7 +100,16 @@ class Worker:
|
|||||||
self._emit_progress(job, processed_sec, total_sec)
|
self._emit_progress(job, processed_sec, total_sec)
|
||||||
|
|
||||||
try:
|
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)
|
result = pipeline.run(job, options, progress_cb=progress_cb)
|
||||||
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
|
# 결과를 먼저 영속화한 뒤 상태 전이 (실패 시 FAILED로 전이 가능하게)
|
||||||
self.store.write_result(job.id, result)
|
self.store.write_result(job.id, result)
|
||||||
|
|||||||
@@ -44,6 +44,34 @@ class TestHealth:
|
|||||||
assert body["model_ready"] is False # 모델 캐시 없음 → 프로비저닝 보류
|
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:
|
class TestAuth:
|
||||||
def test_missing_key_rejected(self, client: TestClient):
|
def test_missing_key_rejected(self, client: TestClient):
|
||||||
r = client.get("/v1/jobs")
|
r = client.get("/v1/jobs")
|
||||||
|
|||||||
@@ -30,6 +30,42 @@ def _job(**kw) -> Job:
|
|||||||
|
|
||||||
|
|
||||||
class TestWorkerLifecycle:
|
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):
|
def test_complete_flow(self, settings, tmp_path):
|
||||||
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
|
"""enqueue → worker 처리 → completed + 결과 저장 + 콜백."""
|
||||||
broker = InProcBroker(settings)
|
broker = InProcBroker(settings)
|
||||||
|
|||||||
@@ -16,16 +16,17 @@ from luke_scribe.engine.faster_whisper_engine import FasterWhisperEngine
|
|||||||
|
|
||||||
|
|
||||||
class FakeWhisperModel:
|
class FakeWhisperModel:
|
||||||
"""faster_whisper.WhisperModel 대체 — 생성 인자만 기록한다."""
|
"""faster_whisper.WhisperModel 대체 — 생성 인자/세그먼트를 기록한다."""
|
||||||
|
|
||||||
calls: list[dict] = []
|
calls: list[dict] = []
|
||||||
|
segments: list = [] # transcribe()가 yield할 세그먼트 (기본: namedtuple)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
self.__class__.calls.append(kwargs)
|
self.__class__.calls.append(kwargs)
|
||||||
|
|
||||||
def transcribe(self, audio_path, **kwargs):
|
def transcribe(self, audio_path, **kwargs):
|
||||||
return iter([]), {}
|
return iter(list(self.__class__.segments)), {}
|
||||||
|
|
||||||
|
|
||||||
def _install_fake(monkeypatch) -> None:
|
def _install_fake(monkeypatch) -> None:
|
||||||
@@ -33,6 +34,7 @@ def _install_fake(monkeypatch) -> None:
|
|||||||
mod.WhisperModel = FakeWhisperModel
|
mod.WhisperModel = FakeWhisperModel
|
||||||
monkeypatch.setitem(sys.modules, "faster_whisper", mod)
|
monkeypatch.setitem(sys.modules, "faster_whisper", mod)
|
||||||
FakeWhisperModel.calls.clear()
|
FakeWhisperModel.calls.clear()
|
||||||
|
FakeWhisperModel.segments = []
|
||||||
|
|
||||||
|
|
||||||
def _opts(**kw) -> TranscriptionOptions:
|
def _opts(**kw) -> TranscriptionOptions:
|
||||||
@@ -78,3 +80,60 @@ def test_split_device_helper():
|
|||||||
assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0)
|
assert FasterWhisperEngine._split_device("cuda") == ("cuda", 0)
|
||||||
# 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit)
|
# 파싱 불가 인덱스는 그대로 전달 (모델 로드 시 명시적 오류로 fail-explicit)
|
||||||
assert FasterWhisperEngine._split_device("cuda:xx") == ("cuda:xx", 0)
|
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"}]
|
||||||
|
|||||||
Reference in New Issue
Block a user