feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,437 @@
"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹.
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 STT orchestrator 상담 1
내담자 텍스트 TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS) 역방향으로 흘린다.
캐스케이드(설계 §5.2 음성 오브 4상태 listeningthinkingspeakingidle):
[클라] audio_start(JSON) 바이너리 오디오 청크들 audio_end(JSON)
[서버] state(listening) STT transcript(JSON) state(thinking)
orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드)
reply(JSON, 내담자 텍스트 + stage/openness) state(speaking)
[tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N tts_end(JSON) state(idle)
프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS):
- 클라서버 텍스트 = JSON 제어({"type": ...}); 클라서버 바이너리 = 오디오 청크
- 서버클라 텍스트 = JSON 이벤트; 서버클라 바이너리 = TTS 오디오 청크
- TTS 바이너리 청크 *직전* 메타 JSON(tts_chunk: seq, rms) 보내 프론트가 짝짓는다.
음성 미설정(OPENAI_API_KEY 없음): GET /voice/health 503 degraded,
WS 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지.
"""
from __future__ import annotations
import json
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from ..engine_client import EngineError, engine_client
from ..services import memory, orchestrator, persona
from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
from ..store import TurnRecord, store
router = APIRouter(prefix="/voice", tags=["voice"])
# WS close 코드(섹션별 의미 명시)
WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애
WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등)
# 한 발화당 누적 오디오 상한(메모리 방어, ~10MB)
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
# ════════════════════════════════════════════════════════════════════════════
# 헬스 — 음성 가용성(키 설정) 노출
# ════════════════════════════════════════════════════════════════════════════
@router.get("/health")
async def voice_health() -> JSONResponse:
"""음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성)."""
available = voice_service.is_available()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"reason": None if available else "OPENAI_API_KEY 미설정",
}
return JSONResponse(body, status_code=200 if available else 503)
# ════════════════════════════════════════════════════════════════════════════
# WebSocket — 실시간 음성 캐스케이드
# ════════════════════════════════════════════════════════════════════════════
@router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None:
"""음성 실시간 턴 캐스케이드.
쿼리: ?session_id=<hex> (없으면 persona_code 일회용 in-proc 세션 생성 시연용)
오디오 in(바이너리) STT 상담 1 TTS out(바이너리) + 립싱크 힌트.
"""
await websocket.accept()
# 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지)
if not voice_service.is_available():
await _safe_send_json(
websocket,
{"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"},
)
await _safe_close(websocket, WS_CLOSE_DEGRADED)
return
# 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성
session_id, voice_preset, err = _bind_session(websocket)
if err is not None:
await _safe_send_json(websocket, {"type": "error", "detail": err})
await _safe_close(websocket, WS_CLOSE_BAD_REQUEST)
return
assert session_id is not None and voice_preset is not None
await _safe_send_json(
websocket,
{
"type": "ready",
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"state": "idle",
},
)
audio_buf = bytearray()
receiving = False
try:
while True:
msg = await websocket.receive()
mtype = msg.get("type")
if mtype == "websocket.disconnect":
break
# ── 바이너리 = 오디오 청크 누적 ──
if msg.get("bytes") is not None:
if not receiving:
# audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작
receiving = True
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
audio_buf.extend(msg["bytes"])
if len(audio_buf) > _MAX_AUDIO_BYTES:
await _safe_send_json(
websocket,
{"type": "error", "detail": "audio too large — 발화를 짧게 끊어 주세요"},
)
audio_buf.clear()
receiving = False
continue
# ── 텍스트 = JSON 제어 ──
text = msg.get("text")
if text is None:
continue
try:
ctrl = json.loads(text)
except (json.JSONDecodeError, TypeError):
await _safe_send_json(websocket, {"type": "error", "detail": "invalid control json"})
continue
ctype = ctrl.get("type")
if ctype == "audio_start":
receiving = True
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
elif ctype == "audio_end":
receiving = False
await _handle_utterance(
websocket,
session_id=session_id,
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=ctrl.get("format"),
)
audio_buf.clear()
elif ctype == "text_turn":
# 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴.
receiving = False
audio_buf.clear()
learner_text = (ctrl.get("text") or "").strip()
if learner_text:
await _run_turn_and_speak(
websocket,
session_id=session_id,
voice_preset=voice_preset,
learner_text=learner_text,
)
elif ctype == "ping":
await _safe_send_json(websocket, {"type": "pong"})
elif ctype == "close":
break
except WebSocketDisconnect:
pass
except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지)
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
finally:
await _safe_close(websocket)
# ════════════════════════════════════════════════════════════════════════════
# 발화 1건 처리 — STT → 턴 → TTS
# ════════════════════════════════════════════════════════════════════════════
async def _handle_utterance(
websocket: WebSocket,
*,
session_id: str,
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
) -> None:
"""오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드."""
if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 1) STT (thinking 진입)
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
filename, content_type = _audio_meta(fmt)
try:
stt = await voice_service.transcribe(
audio, filename=filename, content_type=content_type
)
except VoiceUnavailable as e:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"STT 실패: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
learner_text = stt.text
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
)
if not learner_text:
# 무음/인식 실패 — 턴 진행 안 함
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
await _run_turn_and_speak(
websocket,
session_id=session_id,
voice_preset=voice_preset,
learner_text=learner_text,
)
async def _run_turn_and_speak(
websocket: WebSocket,
*,
session_id: str,
voice_preset: VoicePreset,
learner_text: str,
) -> None:
"""상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송."""
sess = store.get(session_id)
if sess is None or sess.ended:
await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = memory.RecallContext()
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text=learner_text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
)
assert ctx.state_after is not None
# 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=learner_text,
text_masked=ctx.learner_text_masked,
),
)
# 2) 내담자 AI 1턴(동기 — 음성은 TTS 전 전체 텍스트가 필요)
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
except EngineError as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
reply = result.client_reply or ""
# 내담자 응답 로깅 + 상태 체크포인트
if reply:
store.append_turn(
session_id,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
stage=result.stage,
text=reply,
text_masked=reply,
),
)
store.update_state(session_id, result.state_after)
# 내담자 텍스트 이벤트(설계 §5.3 자막 — partial 없이 final)
await _safe_send_json(
websocket,
{
"type": "reply",
"text": reply,
"speaker": "client",
"stage": result.stage,
"effective_openness": round(result.effective_openness, 4),
"turn_seq": result.turn_seq,
"safety_flagged": result.safety_flagged,
"crisis_kind": result.crisis_kind,
},
)
if not reply:
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오
await _safe_send_json(
websocket,
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
)
try:
n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset):
# 메타 먼저(프론트가 직후 바이너리와 짝지음) — 설계 §4.3 RMS 1채널
await _safe_send_json(
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
)
await _safe_send_bytes(websocket, ck.audio)
n += 1
await _safe_send_json(websocket, {"type": "tts_end", "chunks": n})
except VoiceUnavailable as e:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"TTS 실패: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
# ════════════════════════════════════════════════════════════════════════════
# 세션 바인딩 / 메타 헬퍼
# ════════════════════════════════════════════════════════════════════════════
def _bind_session(
websocket: WebSocket,
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]:
"""쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석.
우선순위:
?session_id=<hex> 기존 세션(REST 시작된) 음성 부착
?persona_code=P1[&preset=] in-proc 시연 세션 생성(DB off 폴백)
반환 (session_id, voice_preset, error).
"""
qp = websocket.query_params
explicit_preset = qp.get("preset")
session_id = qp.get("session_id")
if session_id:
sess = store.get(session_id)
if sess is None:
return None, None, f"unknown session {session_id}"
if sess.ended:
return None, None, "session already ended"
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
return session_id, vp, None
# persona_code 로 시연 세션 생성(REST 미경유 음성 단독 데모)
persona_code = qp.get("persona_code")
if not persona_code:
return None, None, "session_id 또는 persona_code 쿼리 필요"
card = persona.get_seed_persona(persona_code)
if card is None:
return None, None, f"unknown persona {persona_code}"
from ..services import state_machine
st = state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
)
sess = store.create(
learner_id="dev-learner-voice",
persona=card,
theory_mode="humanistic",
state=st,
session_no=1,
)
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
return sess.session_id, vp, None
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
"""클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus."""
f = (fmt or "webm").lower().lstrip(".")
table = {
"webm": ("audio.webm", "audio/webm"),
"ogg": ("audio.ogg", "audio/ogg"),
"opus": ("audio.ogg", "audio/ogg"),
"wav": ("audio.wav", "audio/wav"),
"mp3": ("audio.mp3", "audio/mpeg"),
"mp4": ("audio.mp4", "audio/mp4"),
"m4a": ("audio.m4a", "audio/mp4"),
"pcm": ("audio.wav", "audio/wav"),
}
return table.get(f, ("audio.webm", "audio/webm"))
# ── 안전 송수신(연결 끊김 시 조용히 무시) ───────────────────────────────────
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
if websocket.client_state != WebSocketState.CONNECTED:
return
try:
await websocket.send_text(json.dumps(payload, ensure_ascii=False))
except Exception:
pass
async def _safe_send_bytes(websocket: WebSocket, data: bytes) -> None:
if websocket.client_state != WebSocketState.CONNECTED:
return
try:
await websocket.send_bytes(data)
except Exception:
pass
async def _safe_close(websocket: WebSocket, code: int = 1000) -> None:
if websocket.client_state == WebSocketState.DISCONNECTED:
return
try:
await websocket.close(code=code)
except Exception:
pass
__all__ = ["router"]