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

@ -158,14 +158,26 @@ _PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
async def voice_health() -> JSONResponse:
"""Return voice service readiness."""
available = voice_service.is_available()
stt_available = voice_service.stt_available()
tts_available = voice_service.tts_available()
tts_provider = voice_service.tts_provider()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_available": stt_available,
"tts_available": tts_available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"tts_model": (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
),
"tts_provider": tts_provider,
"reason": None if available else "OPENAI_API_KEY is not configured",
"reason": (
None
if available
else "STT 또는 TTS provider가 준비되지 않았습니다"
),
}
return JSONResponse(body, status_code=200 if available else 503)
@ -192,12 +204,6 @@ async def voice_speech(
access_error = await _practice_access_error(learner)
if access_error is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error)
if not voice_service.is_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OPENAI_API_KEY is not configured",
)
sess, err = await turn_runtime.load_owned_session(
body.session_id,
learner,
@ -224,6 +230,11 @@ async def voice_speech(
persona_code=sess.persona.code,
explicit_preset=None,
)
if not voice_service.tts_available(voice_preset):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="TTS provider is not configured",
)
try:
chunks = [
chunk.audio
@ -248,11 +259,11 @@ async def voice_speech(
)
return Response(
content=audio,
media_type="audio/mpeg",
media_type=voice_service.tts_media_type_for_voice(voice_preset),
headers={
"Cache-Control": "no-store",
"X-Vignette-TTS-Model": voice_svc.TTS_MODEL,
"X-Vignette-TTS-Provider": voice_service.tts_provider(),
"X-Vignette-TTS-Model": voice_service.tts_model_for_voice(voice_preset),
"X-Vignette-TTS-Provider": voice_service.tts_provider_for_voice(voice_preset),
},
)
@ -314,7 +325,7 @@ async def voice_ws(websocket: WebSocket) -> None:
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"tts_provider": voice_service.tts_provider(),
"tts_provider": voice_service.tts_provider_for_voice(voice_preset),
"state": "idle",
**bind_meta,
},
@ -751,7 +762,7 @@ async def _run_turn_and_speak(
"type": "state",
"state": "speaking",
"voice": context.voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
"tts_provider": voice_service.tts_provider_for_voice(context.voice_preset),
},
)
try:
@ -787,15 +798,35 @@ async def _load_voice_session(
def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None:
"""Return the persisted client-visible reply for one completed turn."""
for turn in reversed(sess.turns):
if (
turn.turn_seq == turn_seq
and turn.speaker == "client"
and turn.is_visible_to("client")
):
text = (turn.text_masked or turn.text).strip()
return text or None
"""완료된 상담 턴 번호에 대응하는 client-visible 응답을 반환한다.
degraded 인메모리 미러는 상담자/내담자 쌍이 같은 논리 turn_seq를 쓰지만,
DB의 app.turns.seq는 발화마다 1 증가한다. DB 스냅샷에서 논리 1턴을
그대로 seq=1 찾으면 상담자 발화만 잡혀 TTS가 404 되므로 저장 형태를
명시적으로 구분한다.
"""
visible_clients = [
turn
for turn in sess.turns
if turn.speaker == "client" and turn.is_visible_to("client")
]
counselor_sequences = {
turn.turn_seq for turn in sess.turns if turn.speaker == "counselor"
}
paired_sequences = counselor_sequences.intersection(
turn.turn_seq for turn in visible_clients
)
if paired_sequences or not counselor_sequences:
match = next(
(turn for turn in reversed(visible_clients) if turn.turn_seq == turn_seq),
None,
)
else:
index = turn_seq - 1
match = visible_clients[index] if 0 <= index < len(visible_clients) else None
if match is not None:
text = (match.text_masked or match.text).strip()
return text or None
return None