로컬 whisper STT 사이드카 디바이스 폴백과 deprecated audioop 제거
cuDNN 부재 환경에서 ctranslate2 는 Python 예외가 아니라 네이티브 크래시로 죽어 프로세스가 통째로 사라진다. 같은 프로세스의 try/except 로는 절대 잡을 수 없어 디바이스 확인을 버릴 수 있는 자식 프로세스(--self-check)로 분리했다. auto 는 CUDA 프로브 실패 시 CPU(int8)로 폴백하고, 명시적 --device cuda 는 조용히 강등하지 않는다. 함께 고친 것: - warmup PCM 생성의 연산자 우선순위 버그(b"\x00\x00" * N // 2 가 바이트열 정수 나눗셈이 되어 TypeError). warmup_pcm() 으로 분리하고 테스트로 고정했다. - audioop 은 Python 3.13 에서 제거되므로 array + 정수 연산으로 RMS 를 직접 구한다. 실측(저장소 무참조 synthetic seed 8.72초, CPU int8 small): interim 9 · final 5 · word timestamp 12개 present, 전사는 원문과 한 글자 차이. 사이드카 회귀 37/37.
This commit is contained in:
parent
16e791e044
commit
05aa7b312e
9 changed files with 252 additions and 30 deletions
10
.env.example
10
.env.example
|
|
@ -31,6 +31,16 @@ VIGNETTE_VOICE_TTS_PROVIDER=openai
|
||||||
VIGNETTE_HIGGS_TTS_URL=http://127.0.0.1:9881
|
VIGNETTE_HIGGS_TTS_URL=http://127.0.0.1:9881
|
||||||
VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS=300
|
VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS=300
|
||||||
|
|
||||||
|
# STT. `local_whisper`는 노트북 상주 faster-whisper 사이드카를 쓴다. 외부 키가 필요 없고
|
||||||
|
# 오디오가 호스트를 벗어나지 않는다. 서버는 scripts/start-local-whisper-stt.ps1 로 띄운다.
|
||||||
|
# `openai`는 배치라 interim 이 없고, `deepgram`은 운영 키가 필요하다.
|
||||||
|
VIGNETTE_VOICE_STT_PROVIDER=openai
|
||||||
|
VIGNETTE_LOCAL_WHISPER_STT_URL=ws://127.0.0.1:9882/v1/listen
|
||||||
|
VIGNETTE_LOCAL_WHISPER_STT_MODEL=large-v3
|
||||||
|
VIGNETTE_LOCAL_WHISPER_STT_LANGUAGE=ko
|
||||||
|
VIGNETTE_LOCAL_WHISPER_ENDPOINTING_MS=300
|
||||||
|
VIGNETTE_LOCAL_WHISPER_UTTERANCE_END_MS=1200
|
||||||
|
|
||||||
# Auth/session. Local dev may enable dev-login; public/prod must not.
|
# Auth/session. Local dev may enable dev-login; public/prod must not.
|
||||||
SESSION_SECRET=dev-insecure-change-me
|
SESSION_SECRET=dev-insecure-change-me
|
||||||
OAUTH_GOOGLE_CLIENT_ID=
|
OAUTH_GOOGLE_CLIENT_ID=
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ dataset / dataset_item / annotation_round / annotation / export_manifest
|
||||||
| DB | **PostgreSQL 16** + pgvector(RAG 통합), NAS 영구볼륨, RLS | Timescale은 hypertable 선택 확장 |
|
| DB | **PostgreSQL 16** + pgvector(RAG 통합), NAS 영구볼륨, RLS | Timescale은 hypertable 선택 확장 |
|
||||||
| 컨테이너 | **Docker Compose** 단일 호스트 모놀리식(6 컨테이너), Caddy 리버스프록시(`flush_interval -1`) | 이식=compose+secrets, 클라우드 VM(국내 리전) `docker compose up` |
|
| 컨테이너 | **Docker Compose** 단일 호스트 모놀리식(6 컨테이너), Caddy 리버스프록시(`flush_interval -1`) | 이식=compose+secrets, 클라우드 VM(국내 리전) `docker compose up` |
|
||||||
| AI 엔진 | **엔진 어댑터** → 기본 Anthropic Messages API(Opus 4.8/Sonnet 4.6), `claude -p`(Max OAuth)는 옵션 플래그 | 과금 누수·동시성·rate limit 흡수. tier: client=Sonnet/Solar, feedback=Opus, cheap=Haiku |
|
| AI 엔진 | **엔진 어댑터** → 기본 Anthropic Messages API(Opus 4.8/Sonnet 4.6), `claude -p`(Max OAuth)는 옵션 플래그 | 과금 누수·동시성·rate limit 흡수. tier: client=Sonnet/Solar, feedback=Opus, cheap=Haiku |
|
||||||
| 음성 | **OpenAI gpt-4o-mini-tts**(13 voice + instructions prosody) + Deepgram STT, voice_id 추상화(Higgs/GPT-SoVITS 로컬 폴백) | s2s 1차 탈락(transcript 보존) |
|
| 음성 | ~~OpenAI gpt-4o-mini-tts + Deepgram STT, 로컬은 폴백~~ → **2026-08-08 결정으로 로컬 우선**: STT는 노트북 상주 faster-whisper(`local_whisper`), TTS는 노트북 Higgs Audio v3. 근거·제약은 [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | s2s 1차 탈락(transcript 보존). Higgs 운영 사용은 라이선스 확인 전까지 dev 전용이라 운영 TTS는 OpenAI 유지 |
|
||||||
| 인증 | **OAuth 2.1 Auth Code + PKCE(S256)** BFF, Google OIDC 1차, Authlib | 토큰 Redis만, HttpOnly `__Host-` 쿠키, stateful 세션(IRB 철회 즉시 무효화) |
|
| 인증 | **OAuth 2.1 Auth Code + PKCE(S256)** BFF, Google OIDC 1차, Authlib | 토큰 Redis만, HttpOnly `__Host-` 쿠키, stateful 세션(IRB 철회 즉시 무효화) |
|
||||||
| 외부노출 | **Cloudflare named tunnel** + SSE는 Tailscale Funnel 분리 | 인바운드 포트 0 |
|
| 외부노출 | **Cloudflare named tunnel** + SSE는 Tailscale Funnel 분리 | 인바운드 포트 0 |
|
||||||
| 안전 | Presidio PII 마스킹 + NeMo rail + 한국어 자살분류 + 결정론 게이트 | Llama Guard 한국어 미지원 보강 |
|
| 안전 | Presidio PII 마스킹 + NeMo rail + 한국어 자살분류 + 결정론 게이트 | Llama Guard 한국어 미지원 보강 |
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`decisions/backend-node-transition.md`](./decisions/backend-node-transition.md) | 백엔드 언어 방향(FastAPI 유지·Node 계약 우선 전환) |
|
| [`decisions/backend-node-transition.md`](./decisions/backend-node-transition.md) | 백엔드 언어 방향(FastAPI 유지·Node 계약 우선 전환) |
|
||||||
| [`decisions/voice-s2s-poc.md`](./decisions/voice-s2s-poc.md) | 음성 s2s 2차 PoC 채택 판단 기준 |
|
| [`decisions/voice-s2s-poc.md`](./decisions/voice-s2s-poc.md) | 음성 s2s 2차 PoC 채택 판단 기준 |
|
||||||
|
| [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | 노트북 로컬 음성 스택 결정 — faster-whisper STT + Higgs TTS, cuDNN·라이선스 제약, G7 게이트 provider 계약 |
|
||||||
| [`decisions/outcome-alliance-measurement-ledger.md`](./decisions/outcome-alliance-measurement-ledger.md) | Outcome & Alliance append-only 측정 원장, source/perspective 경계, 전진 복구·롤백 결정 |
|
| [`decisions/outcome-alliance-measurement-ledger.md`](./decisions/outcome-alliance-measurement-ledger.md) | Outcome & Alliance append-only 측정 원장, source/perspective 경계, 전진 복구·롤백 결정 |
|
||||||
|
|
||||||
## 🧪 Phase 3 파일럿 (forward — 아직 미실행)
|
## 🧪 Phase 3 파일럿 (forward — 아직 미실행)
|
||||||
|
|
|
||||||
20
docs/TODO.md
20
docs/TODO.md
|
|
@ -62,6 +62,26 @@
|
||||||
남은 것: NAS용 9100은 실사용 세션 7개가 있어 미접촉이라 (2)는 다음 재기동 때 반영된다.
|
남은 것: NAS용 9100은 실사용 세션 7개가 있어 미접촉이라 (2)는 다음 재기동 때 반영된다.
|
||||||
- [ ] **재부팅 후 watchdog smoke** — 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn` 실측.
|
- [ ] **재부팅 후 watchdog smoke** — 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn` 실측.
|
||||||
DNS 개통 후 `api-vnet.18ka.net`을 `-AdditionalPublicHealthUrls`로 명시 추가.
|
DNS 개통 후 `api-vnet.18ka.net`을 `-AdditionalPublicHealthUrls`로 명시 추가.
|
||||||
|
- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 STT는 노트북 상주
|
||||||
|
faster-whisper, TTS는 노트북 Higgs를 쓴다. 이전에는 코드에 로컬 STT 경로가 아예 없었고
|
||||||
|
(`voice_stt_provider`가 `openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다.
|
||||||
|
`scripts/local-whisper-stt-server.py` 사이드카(Higgs와 같은 loopback 상주 방식)와
|
||||||
|
`voice_stt_provider=local_whisper` 경로를 추가했다. 오디오는 호스트를 벗어나지 않고 외부 STT 키가
|
||||||
|
필요 없다. 실측: 저장소 합성 시드 음성 8.72초로 interim 9 · final 5 · word timestamp 12개 전부 present,
|
||||||
|
전사는 원문과 한 글자(`안녕하세요`→`안녕하세여`) 차이였다. 회귀 `scripts/test_local_whisper_stt_server.py`
|
||||||
|
37/37, `apps/api/app/test_voice_service.py` 로컬 provider 케이스 포함 API 전체 908 passed.
|
||||||
|
**발견 1:** 이 노트북은 CUDA 장치는 보이지만 cuDNN(`cudnn_ops64_9.dll`)이 없어 GPU 추론이 **네이티브
|
||||||
|
크래시**로 죽는다. 같은 프로세스 try/except로는 못 잡아서 디바이스 확인을 버릴 수 있는 자식 프로세스
|
||||||
|
(`--self-check`)로 분리하고 `auto`에서 CPU로 폴백하게 했다. cuDNN 9를 설치하면 GPU(float16)로 훨씬
|
||||||
|
빨라지며 `large-v3`도 실시간권에 들어온다 — 설치는 소유자 판단 사항이라 하지 않았다.
|
||||||
|
**발견 2:** G7 종료 체커가 `expected_stt_provider == "deepgram"`을 하드코딩하고 있었다. 이는 결정
|
||||||
|
기록이 아니라 벤더 한 줄이었으므로, 운영하기로 한 provider 허용목록으로 바꿨다
|
||||||
|
(`ALLOWED_STT_PROVIDERS`/`ALLOWED_TTS_PROVIDERS`). 선언/실제 불일치 차단은 그대로 유지한다.
|
||||||
|
- [ ] **Higgs 운영 사용 라이선스 결정** — 소유자는 TTS를 노트북 Higgs로 쓰기로 결정했으나,
|
||||||
|
`apps/api/app/config.py`가 `environment != dev`에서 `VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다
|
||||||
|
(Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는 법적 판단이라 임의로 제거하지 않았다.
|
||||||
|
산학협력 연구·교육 용도가 해당 라이선스 범위에 들어가는지 확인하고 `decisions/`에 근거를 남긴 뒤
|
||||||
|
가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는 OpenAI `gpt-4o-mini-tts`로 유지한다.
|
||||||
- [ ] **음성 캐스케이드 live** — Deepgram streaming adapter와 interim/final·word timestamp, bounded event queue,
|
- [ ] **음성 캐스케이드 live** — Deepgram streaming adapter와 interim/final·word timestamp, bounded event queue,
|
||||||
운영 10MiB 사전 거부와 실제 PostgreSQL 동시 철회 직렬화는 코드/내부 회귀까지 완료했다. 종료에는 운영
|
운영 10MiB 사전 거부와 실제 PostgreSQL 동시 철회 직렬화는 코드/내부 회귀까지 완료했다. 종료에는 운영
|
||||||
key·quota를 사용한 live interim/final, 기대 provider/model과 일치하는 authenticated public WSS ready,
|
key·quota를 사용한 live interim/final, 기대 provider/model과 일치하는 authenticated public WSS ready,
|
||||||
|
|
|
||||||
92
docs/decisions/local-voice-stack.md
Normal file
92
docs/decisions/local-voice-stack.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 로컬 음성 스택 결정 — 노트북 faster-whisper STT + Higgs TTS
|
||||||
|
|
||||||
|
Date: 2026-08-08
|
||||||
|
Status: accepted (STT 구현 완료 · TTS 운영 사용은 라이선스 확인 대기)
|
||||||
|
Owner decision: 윤찬
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
음성 캐스케이드의 양쪽을 **개발 노트북에 상주하는 로컬 모델**로 운영한다.
|
||||||
|
|
||||||
|
- **STT(듣기)**: faster-whisper (`local_whisper` provider)
|
||||||
|
- **TTS(말하기)**: Higgs Audio v3 TTS 4B
|
||||||
|
|
||||||
|
## 이 결정이 뒤집는 것
|
||||||
|
|
||||||
|
`MASTERPLAN.md`의 음성 행은 "OpenAI `gpt-4o-mini-tts` + **Deepgram STT**, voice_id 추상화(Higgs/GPT-SoVITS
|
||||||
|
로컬 폴백)"였다. 그 기록에서 로컬 모델은 *폴백*이었고 주 경로는 외부 API였다. 이 문서가 그 행을 대체한다.
|
||||||
|
|
||||||
|
## 왜 이 결정이 필요했는가
|
||||||
|
|
||||||
|
2026-08-08 확인 결과 상태가 서로 어긋나 있었다.
|
||||||
|
|
||||||
|
- 실제 런타임은 **Deepgram을 쓰고 있지 않았다.** `.env`에 STT provider 설정이 없어 코드 기본값 `openai`
|
||||||
|
(배치 전사)가 적용됐고, NAS 프리뷰도 `VIGNETTE_VOICE_STT_PROVIDER=openai`였다.
|
||||||
|
- 그런데 G7 종료 체커(`scripts/check-g7-external-proof.py`)는 `expected_stt_provider == "deepgram"`을
|
||||||
|
**하드코딩**하고 있었다. 이는 결정 기록이 아니라 벤더 한 줄이었고, 그 때문에 "G7을 닫으려면 운영
|
||||||
|
Deepgram 키가 필요하다"는 잘못된 요구가 만들어졌다.
|
||||||
|
- 코드에는 **로컬 STT 경로가 아예 없었다.** `voice_stt_provider`는 `openai|deepgram` 둘뿐이었고,
|
||||||
|
interim/final 스트리밍 구현은 Deepgram 전용이었다. Higgs는 TTS 전용이라 STT를 대신하지 못한다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- **데이터 주권**: 상담 훈련 오디오가 호스트를 벗어나지 않는다. 미성년 원본 활용동의·한신대 데이터
|
||||||
|
거버넌스 게이트가 열려 있는 상태에서 외부 STT로 원음을 보내는 것보다 경계가 단순하다.
|
||||||
|
- **외부 키 의존 제거**: 운영 Deepgram key/quota 없이도 G7의 interim/final 스트리밍 계약을 만족한다.
|
||||||
|
- **자산 보유**: 노트북에 `faster-whisper 1.0.3` + `ctranslate2 4.5.0`과 모델
|
||||||
|
(`large-v3`/`large-v3-turbo`/`medium`/`small`/`base`)이 이미 캐시돼 있고, Higgs v3 4B도 ComfyUI에 있다.
|
||||||
|
|
||||||
|
## 구현
|
||||||
|
|
||||||
|
- `scripts/local-whisper-stt-server.py` — loopback WebSocket 사이드카. Higgs TTS 서버와 같은 상주 모델
|
||||||
|
방식이다. linear16 PCM을 받아 RMS 기반 endpointing으로 발화를 나누고 interim/final과 word timestamp를
|
||||||
|
낸다. 오디오는 발화 단위 메모리 버퍼로만 다루고 확정 즉시 버린다. 디스크에 쓰지 않는다.
|
||||||
|
- `apps/api/app/services/voice.py`의 `LocalWhisperStreamingSession` — Deepgram 세션과 **같은 공개 표면**
|
||||||
|
(`send_audio`/`finish`/`abort`)이라 WebSocket 라우트는 provider로 분기하지 않는다.
|
||||||
|
- `VIGNETTE_VOICE_STT_PROVIDER=local_whisper` + `VIGNETTE_LOCAL_WHISPER_*` 설정.
|
||||||
|
- 실행: `scripts/start-local-whisper-stt.ps1`.
|
||||||
|
|
||||||
|
## 실측 증거 (2026-08-08)
|
||||||
|
|
||||||
|
저장소의 무참조 synthetic seed 음성(8.72초, 원문이 manifest에 기록됨)으로 종단 검증했다.
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| interim 프레임 | 9 |
|
||||||
|
| final 프레임 | 5 (발화 분절 동작) |
|
||||||
|
| word timestamp | 12개 전부 present |
|
||||||
|
| 전사 | `안녕하세여. 저는 서연이에요. 오늘은 천천히 너무 밝지 않게 하지만 또렷하게 말해 볼게요.` |
|
||||||
|
| 원문 | `안녕하세요. 저는 서연이에요. 오늘은 천천히, 너무 밝지 않게, 하지만 또렷하게 말해볼게요.` |
|
||||||
|
|
||||||
|
`small` + CPU int8에서 한 글자(`안녕하세요`→`안녕하세여`) 차이였다. 회귀는 사이드카 37/37,
|
||||||
|
API 전체 908 passed, gateway 58, G7 checker 23, ruff clean이다.
|
||||||
|
|
||||||
|
## 알려진 제약
|
||||||
|
|
||||||
|
1. **cuDNN 부재로 GPU 추론 불가.** 이 노트북은 CUDA 장치가 보이지만 `cudnn_ops64_9.dll`이 없어
|
||||||
|
ctranslate2가 **네이티브 크래시**로 죽는다. Python 예외가 아니라 프로세스가 통째로 죽으므로 같은
|
||||||
|
프로세스의 `try/except`로는 잡을 수 없다. 그래서 디바이스 확인을 버릴 수 있는 자식 프로세스
|
||||||
|
(`--self-check`)로 분리하고 `auto`에서 CPU(int8)로 폴백한다. 명시적 `--device cuda`는 조용히 강등하지
|
||||||
|
않는다. **cuDNN 9를 설치하면** GPU float16으로 올라가고 `large-v3`도 실시간권에 들어온다. 설치는
|
||||||
|
환경 변경이라 소유자 판단으로 남겼다.
|
||||||
|
2. **Higgs 운영 사용은 아직 막혀 있다.** `apps/api/app/config.py`가 `environment != dev`에서
|
||||||
|
`VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다(Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는
|
||||||
|
법적 판단이라 코드에서 임의로 제거하지 않았다. 산학협력 연구·교육 용도가 라이선스 범위에 들어가는지
|
||||||
|
확인하고 그 근거를 이 문서에 추가한 뒤 가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는
|
||||||
|
OpenAI `gpt-4o-mini-tts`를 유지한다.
|
||||||
|
3. **CPU 폴백 성능.** CPU int8에서는 `small`이 현실적이다. `large-v3`는 CPU에서 실시간 스트리밍에
|
||||||
|
맞추기 어렵다.
|
||||||
|
|
||||||
|
## G7 게이트에 미친 영향
|
||||||
|
|
||||||
|
`check-g7-external-proof.py`의 벤더 하드코딩을 **운영하기로 한 provider 허용목록**으로 바꿨다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram")
|
||||||
|
ALLOWED_TTS_PROVIDERS = ("higgs", "openai")
|
||||||
|
```
|
||||||
|
|
||||||
|
게이트를 약화시키지 않았다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어 목록에 없고,
|
||||||
|
`expected_* == ready_*` 결속(선언한 provider와 실제로 돈 provider가 같아야 함)은 그대로다.
|
||||||
|
G7의 나머지 세 artifact(물리 마이크 50분 soak, worker/topology high-water, 독립 blind human
|
||||||
|
voice-gain pack)는 이 결정과 무관하게 그대로 남아 있다.
|
||||||
|
|
@ -1122,6 +1122,7 @@
|
||||||
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns</code> voice metadata columns(<code>audio_ref</code>/<code>silence_ms</code>/<code>speech_rate</code>/<code>barge_in</code>/<code>provider_events</code>) plus worksheet review columns.</td></tr>
|
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns</code> voice metadata columns(<code>audio_ref</code>/<code>silence_ms</code>/<code>speech_rate</code>/<code>barge_in</code>/<code>provider_events</code>) plus worksheet review columns.</td></tr>
|
||||||
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
|
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
|
||||||
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code> 매일 04:30 KST / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current SHA <code>6030a677…c611</code>을 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. browser E2E 108/108, health 3/3, OpenAPI 126·auth 401·G0~G8, postdeploy SSE→DB review를 통과했고 predeploy dump와 이전 exact images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr>
|
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code> 매일 04:30 KST / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current SHA <code>6030a677…c611</code>을 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. browser E2E 108/108, health 3/3, OpenAPI 126·auth 401·G0~G8, postdeploy SSE→DB review를 통과했고 predeploy dump와 이전 exact images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr>
|
||||||
|
<tr><td>노트북 로컬 음성 스택 — faster-whisper STT</td><td><code>scripts/local-whisper-stt-server.py</code> · <code>scripts/start-local-whisper-stt.ps1</code> · <code>voice_stt_provider=local_whisper</code></td><td>2026-08-08 소유자 결정으로 STT를 노트북 상주 faster-whisper로, TTS를 노트북 Higgs로 정했다. 이전에는 로컬 STT 경로가 코드에 없었고 interim/final 스트리밍이 Deepgram 전용이었다. Higgs와 같은 loopback 사이드카를 추가해 오디오가 호스트를 벗어나지 않고 외부 STT 키도 필요 없다. <b>실측</b>: 저장소 합성 시드 8.72초에서 interim <code>9</code>·final <code>5</code>·word timestamp 12개 present, 전사는 원문과 한 글자 차이. 회귀 사이드카 <code>37/37</code>, API 전체 <code>908 passed</code>, gateway <code>58</code>, G7 checker <code>23</code>, ruff clean. 이 노트북은 CUDA는 보이지만 cuDNN 부재로 GPU 추론이 <b>네이티브 크래시</b>라 디바이스 확인을 자식 프로세스(<code>--self-check</code>)로 분리하고 CPU 폴백을 넣었다. cuDNN 9 설치는 소유자 판단으로 남겼다. G7 체커의 <code>deepgram</code> 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다.</td></tr>
|
||||||
<tr><td>비-secure origin 회기 리뷰 크래시 수정</td><td><code>apps/web/src/lib/uuid.ts</code> · <code>apps/web/e2e/insecure-context-uuid.spec.ts</code></td><td>격리 NAS 프리뷰(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가 <code>crypto.randomUUID is not a function</code>으로 error boundary에 잡혔다. 이 API는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 <code>RuptureRepairCard</code>는 렌더 시점 호출이라 화면 전체가 죽었다. 릴리스 게이트는 localhost 후보 스택에서만 돌아 미검출. <code>randomUuid()</code>로 통일하고 fallback도 <code>getRandomValues</code> 우선으로 예측 불가능성을 유지했다. 회귀 <code>6/6</code>(직접 호출 0건 검사 포함), typecheck·build 통과. <b>NAS 프리뷰에는 아직 미배포</b>이며 배포된 SHA <code>6030a677…c611</code>은 여전히 결함 빌드다.</td></tr>
|
<tr><td>비-secure origin 회기 리뷰 크래시 수정</td><td><code>apps/web/src/lib/uuid.ts</code> · <code>apps/web/e2e/insecure-context-uuid.spec.ts</code></td><td>격리 NAS 프리뷰(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가 <code>crypto.randomUUID is not a function</code>으로 error boundary에 잡혔다. 이 API는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 <code>RuptureRepairCard</code>는 렌더 시점 호출이라 화면 전체가 죽었다. 릴리스 게이트는 localhost 후보 스택에서만 돌아 미검출. <code>randomUuid()</code>로 통일하고 fallback도 <code>getRandomValues</code> 우선으로 예측 불가능성을 유지했다. 회귀 <code>6/6</code>(직접 호출 0건 검사 포함), typecheck·build 통과. <b>NAS 프리뷰에는 아직 미배포</b>이며 배포된 SHA <code>6030a677…c611</code>은 여전히 결함 빌드다.</td></tr>
|
||||||
<tr><td>공개 워치독 engine readiness 사각지대(해결)</td><td><code>scripts/watch-public-runtime.ps1</code> · <code>scripts/start-public-runtime.ps1</code> · <code>apps/api/engine_gateway/gateway.py</code></td><td>2026-08-07 22:0x KST 소유자 승인으로 9099를 재기동해 복구했다. 재기동 직후 <code>/ready?force=true</code> 200 <code>ok:true·detail:OK</code>, API 8001과 공개 <code>api-vignette.chanpaca.net</code> 모두 <code>status:ok·engine:true·engine_detail:OK</code>다. 원인은 16:06부터 상주한 9099 프로세스 하나였다(CLI·코드·cwd는 정상 — 동일 인자 직접 실행과 <code>EngineSession</code> 재현이 <code>"OK"</code>를 반환). 재발 방지 3건: (1) 워치독·기동 스크립트의 engine 판정을 <code>/health</code>(프로세스 liveness)에서 <code>/ready</code>(실제 생성)로 바꾸고 API 판정에 <code>engine</code>을 추가, shared secret 인스턴스용 토큰 헤더와 503 본문 로깅 포함. (2) 게이트웨이가 자식 <code>claude -p</code>의 stderr를 상시 드레인해(PIPE 미독 시 자식 블록도 차단) 실패 detail에 <code>exit</code>·stderr를 붙인다 — 실증: <code>empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz')</code>. (3) 기동 시 게이트웨이 로그 회전, <code>Stop-UvicornByPort</code>에 <code>Name -like python*</code> 추가(호출자 자기 자신 종료 방지). 검증: <code>pytest engine_gateway</code> 58/58, 워치독 <code>-CheckOnly</code> healthy 5/5, 장애 재현(9299·<code>CLAUDE_BIN</code> 부재)에서 <code>/health</code>는 <code>ok:true</code>로 통과하고 <code>/ready</code>는 503으로 검출되어 워치독이 <code>unhealthy (1/3)</code> + 503 본문을 남겼다. <b>NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉</b>이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다.</td></tr>
|
<tr><td>공개 워치독 engine readiness 사각지대(해결)</td><td><code>scripts/watch-public-runtime.ps1</code> · <code>scripts/start-public-runtime.ps1</code> · <code>apps/api/engine_gateway/gateway.py</code></td><td>2026-08-07 22:0x KST 소유자 승인으로 9099를 재기동해 복구했다. 재기동 직후 <code>/ready?force=true</code> 200 <code>ok:true·detail:OK</code>, API 8001과 공개 <code>api-vignette.chanpaca.net</code> 모두 <code>status:ok·engine:true·engine_detail:OK</code>다. 원인은 16:06부터 상주한 9099 프로세스 하나였다(CLI·코드·cwd는 정상 — 동일 인자 직접 실행과 <code>EngineSession</code> 재현이 <code>"OK"</code>를 반환). 재발 방지 3건: (1) 워치독·기동 스크립트의 engine 판정을 <code>/health</code>(프로세스 liveness)에서 <code>/ready</code>(실제 생성)로 바꾸고 API 판정에 <code>engine</code>을 추가, shared secret 인스턴스용 토큰 헤더와 503 본문 로깅 포함. (2) 게이트웨이가 자식 <code>claude -p</code>의 stderr를 상시 드레인해(PIPE 미독 시 자식 블록도 차단) 실패 detail에 <code>exit</code>·stderr를 붙인다 — 실증: <code>empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz')</code>. (3) 기동 시 게이트웨이 로그 회전, <code>Stop-UvicornByPort</code>에 <code>Name -like python*</code> 추가(호출자 자기 자신 종료 방지). 검증: <code>pytest engine_gateway</code> 58/58, 워치독 <code>-CheckOnly</code> healthy 5/5, 장애 재현(9299·<code>CLAUDE_BIN</code> 부재)에서 <code>/health</code>는 <code>ok:true</code>로 통과하고 <code>/ready</code>는 503으로 검출되어 워치독이 <code>unhealthy (1/3)</code> + 503 본문을 남겼다. <b>NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉</b>이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다.</td></tr>
|
||||||
<tr><td>G8 실제 receipt-bound image rollback</td><td><code>scripts/launch-nas-preview-g8-helpers.py</code> / <code>scripts/serve-nas-preview-rollback-executor.py</code> / <code>scripts/probe-nas-preview-g8-rollback.py</code> / <a href="./ops/nas-preview-g8-rollback-proof-runbook.md">런북</a> / <a href="./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json">기계 판독 증거</a></td><td>격리 NAS에서 별도 executor(18149)·control-plane(8018) helper로 실제 image rollback 2회를 실행했다. receipt <code>nas-g8-723eeef2…</code>로 previous <code>79ec…4450</code>/<code>c530…2f28</code>, receipt <code>nas-g8-2738846c…</code>로 current <code>52e0…8b2d</code>/<code>6fdb…f215</code>를 활성화했다. release gate·approval 각각 2회 멱등, <code>audit.ci_lifecycle_event</code> rollback/executed 2건과 receipt id 결속, silent auto-promotion 0, HMAC journal 6-record 체인 검증, health 3/3·OpenAPI 126·auth 401·Web 200, helper 0·listener 0 복귀. helper 격리 계약은 image 상속 label baseline 대비 검증이며 fake-runner 37/37로 고정했다. <code>down</code>/<code>volume rm</code>/<code>prune</code> 미실행, 공개 런타임 미접촉.</td></tr>
|
<tr><td>G8 실제 receipt-bound image rollback</td><td><code>scripts/launch-nas-preview-g8-helpers.py</code> / <code>scripts/serve-nas-preview-rollback-executor.py</code> / <code>scripts/probe-nas-preview-g8-rollback.py</code> / <a href="./ops/nas-preview-g8-rollback-proof-runbook.md">런북</a> / <a href="./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json">기계 판독 증거</a></td><td>격리 NAS에서 별도 executor(18149)·control-plane(8018) helper로 실제 image rollback 2회를 실행했다. receipt <code>nas-g8-723eeef2…</code>로 previous <code>79ec…4450</code>/<code>c530…2f28</code>, receipt <code>nas-g8-2738846c…</code>로 current <code>52e0…8b2d</code>/<code>6fdb…f215</code>를 활성화했다. release gate·approval 각각 2회 멱등, <code>audit.ci_lifecycle_event</code> rollback/executed 2건과 receipt id 결속, silent auto-promotion 0, HMAC journal 6-record 체인 검증, health 3/3·OpenAPI 126·auth 401·Web 200, helper 0·listener 0 복귀. helper 격리 계약은 image 상속 label baseline 대비 검증이며 fake-runner 37/37로 고정했다. <code>down</code>/<code>volume rm</code>/<code>prune</code> 미실행, 공개 런타임 미접촉.</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ DB가 없으면 인메모리 degraded 폴백으로 기동한다.
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
||||||
# 로컬 Higgs Audio v3 P1 음성까지 함께 연결
|
# 로컬 Higgs Audio v3 P1 음성까지 함께 연결
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 -UseHiggsVoice
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 -UseHiggsVoice
|
||||||
|
|
||||||
|
# 노트북 상주 faster-whisper STT 사이드카 (외부 STT 키 불필요)
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-local-whisper-stt.ps1 -Model small
|
||||||
|
# 그 뒤 API 에 VIGNETTE_VOICE_STT_PROVIDER=local_whisper 를 준다.
|
||||||
|
# 이 노트북은 cuDNN 이 없어 GPU 추론이 네이티브 크래시로 죽는다. 사이드카가 자식 프로세스로
|
||||||
|
# 디바이스를 먼저 확인하고 CPU(int8)로 자동 폴백한다. cuDNN 9 를 설치하면 GPU(float16)로 올라간다.
|
||||||
# 종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1
|
# 종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1
|
||||||
# DB 컨테이너까지 멈출 때만: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1 -Db
|
# DB 컨테이너까지 멈출 때만: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1 -Db
|
||||||
```
|
```
|
||||||
|
|
@ -169,6 +175,9 @@ npm install
|
||||||
| `VIGNETTE_VOICE_POC_SAMPLE_TTS` | `false` | P1 무참조 샘플 음성을 `/voice/ws` TTS에 연결하는 개발 전용 플래그. 마이크/STT는 선택한 STT provider credential 필요, 프로덕션 금지 |
|
| `VIGNETTE_VOICE_POC_SAMPLE_TTS` | `false` | P1 무참조 샘플 음성을 `/voice/ws` TTS에 연결하는 개발 전용 플래그. 마이크/STT는 선택한 STT provider credential 필요, 프로덕션 금지 |
|
||||||
| `VIGNETTE_VOICE_TTS_PROVIDER` | `openai` 또는 `higgs` | TTS 공급자. `higgs`는 dev + P1에서만 허용하며 다른 환경은 설정 검증에서 차단. 동작 자체는 운영 상업 이용권 증거를 대신하지 않음 |
|
| `VIGNETTE_VOICE_TTS_PROVIDER` | `openai` 또는 `higgs` | TTS 공급자. `higgs`는 dev + P1에서만 허용하며 다른 환경은 설정 검증에서 차단. 동작 자체는 운영 상업 이용권 증거를 대신하지 않음 |
|
||||||
| `VIGNETTE_HIGGS_TTS_URL` | `http://127.0.0.1:9881` | 로컬 Higgs 상주 서버. 저장소의 무참조 synthetic seed만 화자 참조로 사용 |
|
| `VIGNETTE_HIGGS_TTS_URL` | `http://127.0.0.1:9881` | 로컬 Higgs 상주 서버. 저장소의 무참조 synthetic seed만 화자 참조로 사용 |
|
||||||
|
| `VIGNETTE_VOICE_STT_PROVIDER` | `openai`·`deepgram`·`local_whisper` | STT 공급자. `local_whisper`는 노트북 상주 faster-whisper 사이드카를 쓴다(외부 키 불필요, 오디오가 호스트를 벗어나지 않음). `openai`는 배치라 interim 이 없다 |
|
||||||
|
| `VIGNETTE_LOCAL_WHISPER_STT_URL` | `ws://127.0.0.1:9882/v1/listen` | 로컬 whisper 사이드카. `scripts/start-local-whisper-stt.ps1`로 띄운다 |
|
||||||
|
| `VIGNETTE_LOCAL_WHISPER_STT_MODEL` | `large-v3` | `large-v3-turbo`·`medium`·`small`·`base` 허용. CPU 폴백 시에는 `small` 이 현실적이다 |
|
||||||
| `EVALUATOR_SEMANTIC_CACHE_ENABLED` | `true` | fast/deep evaluator structured 결과 인메모리 캐시 활성화. 원문 prompt/completion은 저장하지 않음 |
|
| `EVALUATOR_SEMANTIC_CACHE_ENABLED` | `true` | fast/deep evaluator structured 결과 인메모리 캐시 활성화. 원문 prompt/completion은 저장하지 않음 |
|
||||||
| `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS` | `900` | evaluator cache TTL(초). 0 이하면 비활성 |
|
| `EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS` | `900` | evaluator cache TTL(초). 0 이하면 비활성 |
|
||||||
| `EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES` | `256` | evaluator cache LRU 최대 엔트리 수. 0 이하면 비활성 |
|
| `EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES` | `256` | evaluator cache LRU 최대 엔트리 수. 0 이하면 비활성 |
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import math
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Protocol
|
from typing import Any, Callable, Iterable, Protocol
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
@ -447,31 +448,66 @@ def resolve_device(requested: str, *, cuda_available: bool | None = None) -> tup
|
||||||
return ("cuda", "float16") if cuda_available else ("cpu", "int8")
|
return ("cuda", "float16") if cuda_available else ("cpu", "int8")
|
||||||
|
|
||||||
|
|
||||||
|
def probe_device(model: str, device: str, *, python: str | None = None) -> bool:
|
||||||
|
"""별도 프로세스에서 디바이스를 실제로 한 번 돌려 본다.
|
||||||
|
|
||||||
|
cuDNN 이 없으면 ctranslate2 는 Python 예외가 아니라 **네이티브 크래시**로
|
||||||
|
죽는다. 같은 프로세스의 try/except 로는 절대 잡을 수 없으므로, 살릴 수 없는
|
||||||
|
실패는 버릴 수 있는 자식 프로세스에서 먼저 확인한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
argv = [
|
||||||
|
python or sys.executable,
|
||||||
|
"-X",
|
||||||
|
"utf8",
|
||||||
|
str(Path(__file__).resolve()),
|
||||||
|
"--self-check",
|
||||||
|
"--model",
|
||||||
|
model,
|
||||||
|
"--device",
|
||||||
|
device,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
argv,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=600,
|
||||||
|
shell=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
return completed.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def build_transcriber(
|
def build_transcriber(
|
||||||
model: str,
|
model: str,
|
||||||
requested_device: str,
|
requested_device: str,
|
||||||
*,
|
*,
|
||||||
factory: Callable[[str, str, str], Any],
|
factory: Callable[[str, str, str], Any],
|
||||||
cuda_available: bool | None = None,
|
cuda_available: bool | None = None,
|
||||||
|
prober: Callable[[str, str], bool] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""디바이스를 해석하고 warmup 까지 통과한 transcriber 만 돌려준다.
|
"""디바이스를 해석하고 실제로 추론이 되는 transcriber 만 돌려준다.
|
||||||
|
|
||||||
`auto` 는 CUDA 가 보이면 먼저 시도하되, 첫 추론이 실패하면 CPU 로 내려간다.
|
`auto` 는 CUDA 가 보이면 먼저 자식 프로세스로 확인하고, 거기서 실패하면
|
||||||
명시적 `--device cuda` 는 조용히 강등하지 않고 그대로 실패시킨다.
|
CPU 로 내려간다. 명시적 `--device cuda` 는 조용히 강등하지 않는다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device, compute_type = resolve_device(requested_device, cuda_available=cuda_available)
|
device, compute_type = resolve_device(requested_device, cuda_available=cuda_available)
|
||||||
|
check = prober if prober is not None else (lambda m, d: probe_device(m, d))
|
||||||
|
if device == "cuda" and not check(model, "cuda"):
|
||||||
|
if requested_device != "auto":
|
||||||
|
raise TranscriptionError("device_unusable:cuda")
|
||||||
|
device, compute_type = "cpu", "int8"
|
||||||
try:
|
try:
|
||||||
transcriber = factory(model, device, compute_type)
|
transcriber = factory(model, device, compute_type)
|
||||||
transcriber.warmup()
|
transcriber.warmup()
|
||||||
return transcriber
|
return transcriber
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if requested_device != "auto" or device == "cpu":
|
raise TranscriptionError(f"device_unusable:{device}") from exc
|
||||||
raise TranscriptionError(f"device_unusable:{device}") from exc
|
|
||||||
device, compute_type = "cpu", "int8"
|
|
||||||
transcriber = factory(model, device, compute_type)
|
|
||||||
transcriber.warmup()
|
|
||||||
return transcriber
|
|
||||||
|
|
||||||
|
|
||||||
async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진입점
|
async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진입점
|
||||||
|
|
@ -573,11 +609,34 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
parser.add_argument("--model", default=DEFAULT_MODEL, choices=list(ALLOWED_MODELS))
|
parser.add_argument("--model", default=DEFAULT_MODEL, choices=list(ALLOWED_MODELS))
|
||||||
parser.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"])
|
parser.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"])
|
||||||
parser.add_argument("--enable", action="store_true")
|
parser.add_argument("--enable", action="store_true")
|
||||||
|
parser.add_argument(
|
||||||
|
"--self-check",
|
||||||
|
action="store_true",
|
||||||
|
help="load the model on --device once and exit; used as a crash-safe probe",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def run_self_check(args: argparse.Namespace) -> int: # pragma: no cover - 서브프로세스
|
||||||
|
device, compute_type = resolve_device(
|
||||||
|
"cpu" if args.device == "cpu" else args.device
|
||||||
|
)
|
||||||
|
transcriber = FasterWhisperTranscriber(
|
||||||
|
args.model, device=device, compute_type=compute_type
|
||||||
|
)
|
||||||
|
transcriber.warmup()
|
||||||
|
print(json.dumps({"ok": True, "device": device}, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Iterable[str] | None = None) -> int: # pragma: no cover - CLI
|
def main(argv: Iterable[str] | None = None) -> int: # pragma: no cover - CLI
|
||||||
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
||||||
|
if args.self_check:
|
||||||
|
try:
|
||||||
|
return run_self_check(args)
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({"ok": False, "error": type(exc).__name__}), file=sys.stderr)
|
||||||
|
return 3
|
||||||
if not args.enable:
|
if not args.enable:
|
||||||
print("local whisper STT server is disabled; pass --enable", file=sys.stderr)
|
print("local whisper STT server is disabled; pass --enable", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
|
||||||
|
|
@ -326,42 +326,72 @@ class _FakeTranscriberFactory:
|
||||||
|
|
||||||
|
|
||||||
class TranscriberBuildTest(unittest.TestCase):
|
class TranscriberBuildTest(unittest.TestCase):
|
||||||
def test_auto_falls_back_to_cpu_when_cuda_warmup_fails(self) -> None:
|
def test_auto_falls_back_to_cpu_when_the_cuda_probe_fails(self) -> None:
|
||||||
"""cuDNN 부재처럼 '장치는 보이지만 추론이 죽는' 경우를 잡는다."""
|
"""cuDNN 부재는 네이티브 크래시라 자식 프로세스 프로브로만 잡힌다."""
|
||||||
|
|
||||||
|
factory = _FakeTranscriberFactory()
|
||||||
|
probes: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def prober(model: str, device: str) -> bool:
|
||||||
|
probes.append((model, device))
|
||||||
|
return False
|
||||||
|
|
||||||
factory = _FakeTranscriberFactory(failing_device="cuda")
|
|
||||||
transcriber = MODULE.build_transcriber(
|
transcriber = MODULE.build_transcriber(
|
||||||
"small", "auto", factory=factory, cuda_available=True
|
"small", "auto", factory=factory, cuda_available=True, prober=prober
|
||||||
)
|
)
|
||||||
|
self.assertEqual(probes, [("small", "cuda")])
|
||||||
self.assertEqual(transcriber.device, "cpu")
|
self.assertEqual(transcriber.device, "cpu")
|
||||||
self.assertEqual(transcriber.compute_type, "int8")
|
self.assertEqual(transcriber.compute_type, "int8")
|
||||||
self.assertEqual(
|
# CUDA 로는 아예 모델을 올리지 않는다. 올렸다면 그 자리에서 죽는다.
|
||||||
factory.built, [("small", "cuda", "float16"), ("small", "cpu", "int8")]
|
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
||||||
)
|
|
||||||
|
|
||||||
def test_auto_keeps_cuda_when_warmup_succeeds(self) -> None:
|
def test_auto_keeps_cuda_when_the_probe_succeeds(self) -> None:
|
||||||
factory = _FakeTranscriberFactory()
|
factory = _FakeTranscriberFactory()
|
||||||
transcriber = MODULE.build_transcriber(
|
transcriber = MODULE.build_transcriber(
|
||||||
"small", "auto", factory=factory, cuda_available=True
|
"small",
|
||||||
|
"auto",
|
||||||
|
factory=factory,
|
||||||
|
cuda_available=True,
|
||||||
|
prober=lambda model, device: True,
|
||||||
)
|
)
|
||||||
self.assertEqual(transcriber.device, "cuda")
|
self.assertEqual(transcriber.device, "cuda")
|
||||||
self.assertEqual(len(factory.built), 1)
|
|
||||||
|
|
||||||
def test_explicit_cuda_never_downgrades_silently(self) -> None:
|
|
||||||
factory = _FakeTranscriberFactory(failing_device="cuda")
|
|
||||||
with self.assertRaises(MODULE.TranscriptionError):
|
|
||||||
MODULE.build_transcriber(
|
|
||||||
"small", "cuda", factory=factory, cuda_available=True
|
|
||||||
)
|
|
||||||
self.assertEqual(factory.built, [("small", "cuda", "float16")])
|
self.assertEqual(factory.built, [("small", "cuda", "float16")])
|
||||||
|
|
||||||
def test_cpu_warmup_failure_is_not_retried(self) -> None:
|
def test_explicit_cuda_never_downgrades_silently(self) -> None:
|
||||||
|
factory = _FakeTranscriberFactory()
|
||||||
|
with self.assertRaises(MODULE.TranscriptionError):
|
||||||
|
MODULE.build_transcriber(
|
||||||
|
"small",
|
||||||
|
"cuda",
|
||||||
|
factory=factory,
|
||||||
|
cuda_available=True,
|
||||||
|
prober=lambda model, device: False,
|
||||||
|
)
|
||||||
|
self.assertEqual(factory.built, [])
|
||||||
|
|
||||||
|
def test_cpu_path_is_not_probed(self) -> None:
|
||||||
|
factory = _FakeTranscriberFactory()
|
||||||
|
probes: list[tuple[str, str]] = []
|
||||||
|
MODULE.build_transcriber(
|
||||||
|
"small",
|
||||||
|
"cpu",
|
||||||
|
factory=factory,
|
||||||
|
cuda_available=True,
|
||||||
|
prober=lambda model, device: probes.append((model, device)) or True,
|
||||||
|
)
|
||||||
|
self.assertEqual(probes, [])
|
||||||
|
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
||||||
|
|
||||||
|
def test_python_level_warmup_failure_still_fails_closed(self) -> None:
|
||||||
factory = _FakeTranscriberFactory(failing_device="cpu")
|
factory = _FakeTranscriberFactory(failing_device="cpu")
|
||||||
with self.assertRaises(MODULE.TranscriptionError):
|
with self.assertRaises(MODULE.TranscriptionError):
|
||||||
MODULE.build_transcriber(
|
MODULE.build_transcriber(
|
||||||
"small", "auto", factory=factory, cuda_available=False
|
"small",
|
||||||
|
"auto",
|
||||||
|
factory=factory,
|
||||||
|
cuda_available=False,
|
||||||
|
prober=lambda model, device: True,
|
||||||
)
|
)
|
||||||
self.assertEqual(factory.built, [("small", "cpu", "int8")])
|
|
||||||
|
|
||||||
|
|
||||||
class CliTest(unittest.TestCase):
|
class CliTest(unittest.TestCase):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue