feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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)은 동작한다.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue