feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -126,6 +126,9 @@ DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테
- **`run_turn_stream(ctx, engine, *, log_hook=None)`** (4~8, 기본 UX 경로):
게이트웨이 SSE 원시 라인을 받아 `token | done | safety | error`로 재방출.
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 스캔하고, 발견 시 `safety` 이벤트 + 안전 대체로 종결한다.
세션 라우트는 client 응답과 결정론 상태를 먼저 영속화하고 `done`을 방출한다. fast-loop evaluator는
백그라운드 태스크로 실행해 같은 learner turn의 normalized 평가 row를 교체 저장하며, 평가 기반 코칭 충전도
이 태스크가 한 번만 적용한다. 따라서 보이지 않는 평가 지연이 다음 학습자 전송을 잠그지 않는다.
`TurnContext`/`TurnResult`/`StreamEvent` dataclass가 파이프라인을 관통한다. `EvalHook`/`LogHook`
`Callable[[TurnContext, str], Awaitable[...]]` 타입으로 주입된다.
@ -300,7 +303,7 @@ RBAC×AIView로 차단된다. 이 모듈은 평가 신호만 산출한다.
### 2.8 음성 캐스케이드 — `app/services/voice.py` + `app/routes/voice.py`
OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
- STT: `/audio/transcriptions` (gpt-4o-transcribe → 404 시 whisper-1 폴백), 언어 힌트 `ko`.
- TTS: `/audio/speech` (gpt-4o-mini-tts → tts-1 폴백). 음성 선택 우선순위는 명시 query preset →
@ -309,12 +312,16 @@ OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은
OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다.
dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와
`/voice/ws` 바인딩이 같은 테이블을 사용하게 한다.
- 로컬 개발에서 `VIGNETTE_VOICE_TTS_PROVIDER=higgs`이면 P1만 loopback Higgs 서버로 합성한다.
`scripts/higgs-tts-server.py`는 설치된 `higgs-audio-v3-tts-4b`를 다운로드 없이 한 번만 GPU에 올리고,
저장소의 무참조 synthetic seed만 reference로 쓴다. `/health`는 model/load time/reference policy를,
`/tts`는 24kHz WAV와 provider/model 헤더를 반환한다. 이 경로는 dev 전용이며 non-dev 설정은 fail-closed다.
프론트는 마이크 클릭과 텍스트 발화 전송 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고,
TTS Blob을 Web Audio buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시
`<audio>` 재생으로 fallback한다.
- 텍스트 턴의 AI 내담자 응답은 인증된 `POST /voice/speech``session_id`/`turn_seq`로 소유 회기를 다시
로드하고, 이미 저장된 client-visible 내담자 응답만 MP3로 합성한다. 브라우저가 임의 문장을 보내는 유료
TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/OpenAI TTS 어댑터를 공유한다.
로드하고, 이미 저장된 client-visible 내담자 응답만 선택 provider의 오디오로 합성한다. 브라우저가 임의
문장을 보내는 TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/TTS 어댑터를 공유한다.
- 키 없으면 명확히 degraded(`is_available()=False`, `VoiceUnavailable`). 라우트가 503/WS close로 변환.
`/voice/ws` WebSocket 캐스케이드(`routes/voice.py`):
@ -362,7 +369,8 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
학습자 발화 turn(speaker=`counselor`, 원문 `text` + `text_masked`) + 내담자 응답 turn(speaker=`client`)
순차 append → 상태 갱신. `EngineError`는 503으로 변환.
- `POST /sessions/{id}/stream` — SSE 경로(`run_turn_stream`). token/done/ping/error를 흘리고,
done 시점에 학습자 발화 + 누적 내담자 응답을 영속화. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
done 시점에 학습자 발화 + 누적 내담자 응답 + 상태를 먼저 영속화하고 즉시 완료한다. fast-loop 평가는
응답 경로 밖에서 같은 learner turn에 사후 저장한다. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
- `POST /sessions/{id}/live-coach` — 방금 완료된 상담자 발화를 워크북 요약/RAG/evaluator fast-loop 신호와 대조해
라이브 코칭 카드 1개를 반환하고 `app.live_coach_events``use/-1`로 저장한다. 저장 payload는 마스킹
excerpt와 코칭 구조화 JSON이며, 잔여 기회가 없으면 409로 차단한다.
@ -550,9 +558,9 @@ HTTP error mapping을 유지한다.
## 3. 엔진 게이트웨이 — `apps/api/engine_gateway/gateway.py`
컨테이너 밖(호스트)에서 도는 **별도 서비스**. `claude -p`(Opus 4.8) 상주 멀티턴 풀을 흡수한다.
회기당 1 `EngineSession` = `claude -p` 프로세스 1개 상주 → 페르소나 system 프롬프트 고정 + 발화마다
stdin 주입(턴 간 컨텍스트 유지 + prompt caching 재사용).
컨테이너 밖(호스트)에서 도는 **별도 서비스**. Claude CLI 상주 멀티턴 풀과 Anthropic API,
Codex CLI, Agy CLI의 모델 탐색·실행 차이를 흡수한다. Claude CLI는 회기당 1 `EngineSession` 프로세스를
상주시켜 페르소나 system 프롬프트와 prompt caching을 유지한다. 나머지 공급자는 격리된 stateless 호출로 실행한다.
실행:
```
@ -570,11 +578,20 @@ cd apps/api && uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
- `POST /v1/stream``turn_stream()`이 assistant 텍스트 델타를 즉시 yield → SSE
`event: token` / `event: done`(provider/model/cost 메타) / `event: error` 프레이밍.
- `session_id`가 살아있으면 풀 재사용, 아니면 1회성(ephemeral) 세션 생성 후 close(`_resolve_session`).
- **공급자 capability** `GET /v1/capabilities` — Codex app-server `model/list`, `agy models`, Anthropic
`GET /v1/models` 또는 Claude CLI 공식 alias를 공통 `EngineModelOption`으로 정규화한다. 60초 캐시와
관리자 강제 새로고침을 지원한다.
- `GET /ready``/health`(얕은 프로세스 liveness)와 달리 실제 1턴 생성을 시도해
"설치됐지만 미인증" CLI 상태를 학습자 도달 전에 잡는다(TTL 캐시).
선택한 provider/model/reasoning_effort의 "설치됐지만 미인증" 상태를 학습자 도달 전에 잡는다(TTL 캐시).
provider 라우팅 모드는 `ENGINE_MODE`(`app/config.py`)로 선택: `claude_api`(기본, Anthropic Messages API
직결) / `claude_cli`(로컬 상주 풀) / `openai`(폴백/평가 보조) / `solar`(국내, PII 민감구간 inference_geo:kr).
provider 라우팅 모드는 `ENGINE_MODE`(`app/config.py`) 또는 DB의 관리자 설정으로 선택한다.
실행 가능 공급자는 `claude_cli`, `claude_api`, `codex_cli`, `agy_cli`다. Codex 기본은
`gpt-5.6-terra` / Medium, Agy 기본은 `gemini-3.6-flash-high` / High다. `openai``solar`
기존 계약값을 보존하지만 실행 어댑터가 없어 capability에서 unavailable로 fail-closed한다.
`AdminEngineConfigResponse`는 provider·URL·model에 `reasoning_effort`를 더해 DB에 저장한다. PATCH는
제안된 게이트웨이에서 capability를 강제 재조회한 뒤 실제 목록에 없는 모델·추론 강도, 미인증 공급자,
접속 불가 URL을 422로 거부하고 저장 성공 후 `EngineClient` 런타임 설정을 함께 교체한다.
> 포트 주의: 게이트웨이 docstring 예시는 `:9099`이고, `app/config.py`의 bare Settings fallback은
> legacy compose 서비스명 기반 `http://engine:8100`이다. 현재 지원 compose/local runtime은
@ -602,7 +619,7 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin |
| `/admin` | Admin(운영 홈) | admin |
| `/admin/ai` | AdminAi(AI 엔진 설정, 기간별 DB 비용·토큰·계량 커버리지, provider/model 원장, 평가 캐시 효율) | admin |
| `/admin/ai` | AdminAi(AI 공급자·실시간 모델·추론 강도 드롭다운, 기간별 DB 비용·토큰·계량 커버리지, provider/model 원장, 평가 캐시 효율) | admin |
| `/admin/users` | Admin(사용자 관리) | admin |
| `/admin/access` | Admin(접근 권한) | admin |
| `/admin/tickets` | Admin(운영 티켓 처리 큐: 접수, 필터, 카테고리 큐, 우선순위, 수동 상태 변경 감사) | admin |
@ -612,6 +629,13 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
- `App.tsx`는 모든 역할 페이지를 `lazy()`로 로드하고 공통 `Suspense` 부트 경계를 사용한다. 페이지별 순수 표시 계산은
`pages/*/model.ts`, 브라우저 음성 캡처는 `pages/session/voiceCapture.ts`, 페르소나 이름·난도·아바타 팔레트는
`lib/personaViewModel.ts`가 소유해 라우트 컴포넌트의 API/상태/렌더 책임과 분리한다.
- Pages 배포 전환 중 열린 탭이 삭제된 lazy 청크를 요청하면 `lib/chunkRecovery.ts`가 Vite
`vite:preloadError`를 받아 현재 경로에서 문서 재로드를 1회만 수행한다. 정상 라우트 렌더 뒤 재시도 표식을
지우고, 같은 경로의 연속 실패는 무한 재로드하지 않고 `RouteErrorBoundary`의 수동 복구 액션으로 넘긴다.
- 최초 `/auth/me` 복원은 요청별 5초 상한과 짧은 3회 재시도를 둔다. 재부팅 직후 API가 늦게 올라와도
세션을 즉시 로그아웃 처리하지 않으며, 끝내 연결되지 않으면 무한 `불러오는 중…` 대신 같은 HttpOnly
세션으로 다시 확인할 수 있는 `AuthRestoreGate` 복구 화면을 표시한다. `401`만 정상 로그아웃 상태로
확정하고 네트워크/5xx 실패는 권한 상실과 구분한다.
- API 클라이언트 `src/lib/api.ts`:
- `apiFetch`/`api`(get/post/put/del) — 표준 JSON, 에러는 `ApiError`로 정규화, 항상 `credentials:"include"`.
- SSE: `openSessionStream(sessionId, text, handlers)``fetch` 스트림을 직접 라인 파싱해
@ -769,18 +793,42 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는
`/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다.
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
- SPA 라우트 전환은 `App.tsx``pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 또한 브라우저
`history.scrollRestoration``manual`로 두고 `pageshow`에서도 다시 복원해, 재부팅 뒤 기존 관리자 탭을
되살릴 때 이전 `scrollY` 때문에 sticky 셸만 보이고 본문이 화면 위로 밀리는 빈 화면을 막는다.
- SPA 라우트 전환은 `App.tsx``pathname` 변경마다 문서 스크롤을 맨 위로 복원하고 브라우저
`history.scrollRestoration``manual`로 둔다. 실제 세로 스크롤 소유자는 document가 아니라
`.vg-main`이므로 `AppShell`이 mount·pathname 변경·`pageshow`마다 내부 `scrollTop/scrollLeft`
직접 0으로 되돌린다. 재부팅 뒤 기존 관리자 탭이나 긴 관리자 하위 페이지에서 이동한 화면에 sticky 셸만
남고 본문이 화면 밖에 머무르는 빈 화면을 막는다.
- 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의
`cohort_ids`, `active_sessions`, `created_at``/admin/tickets` 응답의 `summary`, `by_status`,
`by_priority` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로 정규화하고 `관리자 데이터 진단`
패널에 깨진 필드와 asset/path를 표시한다. 앱 route-level error boundary는 관리자 컴포넌트 바깥에서
터진 렌더 예외도 full white page 대신 진단 화면으로 노출하고, 본문 0-height 상태도 route-scoped
diagnostic panel로 표시해 운영자가 원인 없이 빈칸만 보지 않게 한다.
- 관리자 DOM/CSS는 광고 차단기의 generic cosmetic selector와 충돌하는 `ad-*` 접두사를 쓰지 않고
`vgops-*` namespace를 쓴다. EasyList에는 `.ad-root``.ad-section`이 실제 광고 숨김 규칙으로
등록되어 있어, 옛 접두사는 API가 모두 정상이어도 관리자 본문 전체를 `display:none`으로 만들었다.
관리자 루트와 진단 marker는 class명이 아닌 `data-vignette-admin-*` 속성으로 식별한다.
- 이 계약은 관례가 아니라 빌드 게이트다. `scripts/check-cosmetic-filter-safety.mjs`가 프로덕션 TS/TSX/CSS와
`index.html`의 class/id 후보를 정적으로 검사하고, 광고 의미의 위험 namespace를 발견하면 `build`
`lint`를 실패시킨다. 검사기는 내장된 위험/안전 fixture를 매번 먼저 실행해 탐지 로직이 무력화된 채
통과하지 못하게 한다. 공개 관리자 E2E는 동일 EasyList 규칙 아래 5경로의 실제 픽셀 가시성을 별도로
검증하므로 정적 검사와 런타임 검사가 서로 다른 실패면을 막는다.
- `index.html`은 React module 실행 자체가 실패하는 경우도 full white page로 두지 않는다. 부트스트랩 watchdog은
3.5초/8초 시점에 body/root visible content와 관리자 본문 marker를 검사하고, 실패 시 `Vignette 화면 진단`
패널을 React root 바깥 body에 직접 추가해 path, asset, bodyText, visibleNodes, global error를 표시한다.
패널을 React root 바깥 body에 직접 추가해 path, asset, bodyText, visibleNodes, mainScrollTop, global error를
표시한다. visible node와 관리자 marker는 `.vg-main` viewport 교차, rect, computed display/visibility/opacity를
함께 검사하므로 화면 밖 DOM이나 광고 차단기에 숨은 DOM을 정상 픽셀로 오인하지 않는다.
- 공개 런타임은 관리자·인증 제어면(`environment=prod`, `db=true`)과 AI 엔진 readiness를 별도 게이트로
감시한다. 엔진만 실패하면 `start-public-runtime.ps1`가 살아 있는 API·웹·cloudflared를 유지하고 엔진만
복구한다. 부팅 작업도 엔진이 늦더라도 관리자·인증 API가 준비되면 성공으로 끝내며 degraded 엔진은 별도
경고로 남긴다. 따라서 엔진 probe 실패가 API 재시작과 관리자 세션 복원 공백으로 전파되지 않는다.
- 설정 기반 슈퍼 관리자/관리자 이메일이 로그인하거나 기존 세션을 복원하면 유효한 관리자 접근권을
`app.app_user.admin_access`에도 영속화한다. primary role은 learner/teacher로 유지할 수 있지만,
재기동 뒤 환경설정 판정만으로 권한을 복원하지 않으며 기본 역할 사이드바에도 `운영 콘솔` 링크를 노출한다.
관리자 작업면에서는 `navRole="admin"`으로 전체 관리자 5경로를 항상 표시한다.
- OAuth 로그인에서 이메일 기반 기존 사용자와 provider external id를 연결할 때 nullable
`admin_access` SQL 인자는 명시적으로 `boolean` 캐스팅한다. non-dev의 managed-user 저장 실패는
메모리 폴백으로 숨기지 않고 원래 DB 예외를 서버 로그에 남긴 뒤 503으로 닫는다.
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
@ -845,13 +893,15 @@ POST /auth/dev-login {email, role: learner|teacher|admin, display_name}
→ __Host-vignette_sid 쿠키 발급(웹은 Login.tsx에 dev-login 경로 존재)
```
### 7.2 엔진 게이트웨이 (AI 턴 생성, ENGINE_MODE=claude_cli)
### 7.2 엔진 게이트웨이 (AI 턴 생성, 다중 공급자)
```powershell
cd D:\workspace\vignette\apps\api
python -m uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
# API의 ENGINE_URL을 게이트웨이 포트로 맞춘다(예: http://127.0.0.1:9099)
```
`claude_api`는 게이트웨이 프로세스에 `ANTHROPIC_API_KEY`, CLI 모드는 해당 호스트에 로그인된
`claude`/`codex`/`agy` 실행 파일이 필요하다. 관리자 화면의 목록 조회가 unavailable이면 저장도 차단된다.
게이트웨이가 없으면 `/health``engine:false`이고 턴 생성은 실패하지만, UI/네비/로그인/페르소나/
세션생성(in-memory)은 동작한다.