로컬 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:
Yun Chan 2026-08-08 01:39:58 +09:00
parent 16e791e044
commit 05aa7b312e
9 changed files with 252 additions and 30 deletions

View file

@ -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=

View file

@ -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 한국어 미지원 보강 |

View file

@ -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 — 아직 미실행)

View file

@ -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,

View 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)는 이 결정과 무관하게 그대로 남아 있다.

View file

@ -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>

View file

@ -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 이하면 비활성 |

View file

@ -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

View file

@ -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):