음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -5,7 +5,7 @@ Client sends JSON controls plus binary audio chunks:
Server emits:
ready -> state(listening) -> state(thinking) -> transcript -> reply
-> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
-> state(speaking) -> binary audio chunks -> tts_end -> state(idle)
When voice is not configured, the route reports degraded state and closes
cleanly instead of crashing.
@ -23,11 +23,16 @@ from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from .. import session_persistence, turn_runtime
from ..auth_sessions import get_session, user_has_consent
from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete
from ..config import settings
from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..persona_repository import (
PersonaVoiceMap,
get_catalog_persona,
get_persona_voice_map,
get_session_voice_map,
)
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, orchestrator, state_machine
from ..services import voice as voice_svc
@ -72,6 +77,8 @@ async def voice_ws(websocket: WebSocket) -> None:
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
if principal.role != Role.LEARNER and principal.super_admin:
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
@ -418,6 +425,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
session = await get_session(raw_cookie)
if session is None:
return None
if session.account_status != "approved":
return None
try:
role = Role(session.role)
@ -427,10 +436,14 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
return Principal(
user_id=session.user_id,
role=role,
admin_access=session.admin_access,
super_admin=session.super_admin,
account_status=session.account_status,
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
consent_at=session.consent_at,
profile_completed_at=session.profile_completed_at,
)
@ -447,7 +460,11 @@ async def _bind_session(
sess, err = await _load_voice_session(session_id, principal)
if sess is None:
return None, None, err or f"unknown session {session_id}", {}
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
vp = await _resolve_session_voice(
session_id=session_id,
persona_code=sess.persona.code,
explicit_preset=explicit_preset,
)
return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
# persona_code session creation is local-dev only. Production uses REST start.
@ -457,6 +474,11 @@ async def _bind_session(
persona_code = qp.get("persona_code")
if not persona_code:
return None, None, "session_id or persona_code query required", {}
if (
principal.profile_completed_at is None
and not await user_onboarding_complete(principal.user_id)
):
return None, None, "onboarding_required", {}
if principal.consent_at is None and not await user_has_consent(principal.user_id):
return None, None, "consent_required", {}
try:
@ -494,7 +516,12 @@ async def _bind_session(
session_source = "runtime"
else:
store.put(sess)
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
vp = await _resolve_catalog_voice(
persona_id=catalog_persona.persona_id,
version=catalog_persona.version,
persona_code=card.code,
explicit_preset=explicit_preset,
)
degraded_reasons: list[str] = []
if catalog_persona.degraded:
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
@ -509,6 +536,54 @@ async def _bind_session(
return sess.session_id, vp, None, bind_meta
async def _resolve_session_voice(
*,
session_id: str,
persona_code: str,
explicit_preset: str | None,
) -> VoicePreset:
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
if explicit_preset:
return fallback
try:
voice_map = await get_session_voice_map(session_id)
except Exception:
return fallback
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
async def _resolve_catalog_voice(
*,
persona_id: str | None,
version: int | None,
persona_code: str,
explicit_preset: str | None,
) -> VoicePreset:
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
if explicit_preset:
return fallback
try:
voice_map = await get_persona_voice_map(persona_id=persona_id, version=version)
except Exception:
return fallback
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
def _voice_from_map(
voice_map: PersonaVoiceMap | None,
*,
persona_code: str,
) -> VoicePreset | None:
if voice_map is None:
return None
return voice_svc.resolve_voice_from_map(
provider=voice_map.provider,
voice_id=voice_map.voice_id,
base_params=voice_map.base_params,
persona_code=persona_code,
)
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
"""Map the browser audio format to upload metadata."""
f = (fmt or "webm").lower().lstrip(".")