전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import json
import hashlib
import time
from dataclasses import dataclass, field as dataclass_field
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status
@ -49,6 +50,42 @@ class VoiceSpeechRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=80)
turn_seq: int = Field(ge=1)
@dataclass(frozen=True, slots=True)
class VoiceSessionContext:
session_id: str
principal: Principal
voice_preset: VoicePreset
@dataclass(frozen=True, slots=True)
class VoiceProsody:
audio_ref: str | None = None
duration_s: float | None = None
silence_ms: int | None = None
speech_rate: float | None = None
barge_in: bool | None = None
provider_events: list[dict[str, object]] = dataclass_field(default_factory=list)
@dataclass(frozen=True, slots=True)
class VoiceTurnInput:
learner_text: str
prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
@dataclass(frozen=True, slots=True)
class VoiceAudioInput:
audio: bytes
fmt: str | None
sample_rate: int | None = None
channels: int | None = None
sample_width: int | None = None
audio_started_at: float | None = None
audio_ended_at: float | None = None
prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
# WebSocket close codes.
WS_CLOSE_DEGRADED = 1011
WS_CLOSE_BAD_REQUEST = 1008
@ -112,6 +149,8 @@ def _is_turn_persistence_unavailable(exc: Exception) -> bool:
return False
detail = str(exc.detail or "")
return "turn append" in detail and "persistence unavailable" in detail
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
@ -132,7 +171,9 @@ async def voice_health() -> JSONResponse:
@router.post("/speech")
async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) -> Response:
async def voice_speech(
body: VoiceSpeechRequest, principal: CurrentPrincipal
) -> Response:
"""Synthesize the persisted client reply for a completed text turn.
The browser sends only session/turn identifiers. The server reloads the
@ -167,7 +208,9 @@ async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) ->
turn_runtime.SessionAccessError.FORBIDDEN: status.HTTP_403_FORBIDDEN,
turn_runtime.SessionAccessError.ENDED: status.HTTP_409_CONFLICT,
}.get(err, status.HTTP_404_NOT_FOUND)
raise HTTPException(status_code=status_code, detail=f"voice session {err or 'not_found'}")
raise HTTPException(
status_code=status_code, detail=f"voice session {err or 'not_found'}"
)
text = _client_turn_text_for_speech(sess, body.turn_seq)
if text is None:
@ -222,13 +265,17 @@ async def voice_ws(websocket: WebSocket) -> None:
# Authenticate the same server-side browser session used by REST routes.
principal = await _principal_from_websocket(websocket)
if principal is None:
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
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.can_access_role(Role.LEARNER):
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_send_json(
websocket, {"type": "error", "detail": "only learners can use voice"}
)
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
@ -244,7 +291,9 @@ async def voice_ws(websocket: WebSocket) -> None:
websocket,
{
"type": "degraded",
"reason": bind_meta.get("degraded_reason", "voice session binding degraded"),
"reason": bind_meta.get(
"degraded_reason", "voice session binding degraded"
),
**bind_meta,
},
)
@ -294,12 +343,17 @@ async def voice_ws(websocket: WebSocket) -> None:
receiving = True
audio_started_at = time.monotonic()
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
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; please send a shorter utterance"},
{
"type": "error",
"detail": "audio too large; please send a shorter utterance",
},
)
audio_buf.clear()
receiving = False
@ -312,7 +366,9 @@ async def voice_ws(websocket: WebSocket) -> None:
try:
ctrl = json.loads(text)
except (json.JSONDecodeError, TypeError):
await _safe_send_json(websocket, {"type": "error", "detail": "invalid control json"})
await _safe_send_json(
websocket, {"type": "error", "detail": "invalid control json"}
)
continue
ctype = ctrl.get("type")
@ -324,30 +380,44 @@ async def voice_ws(websocket: WebSocket) -> None:
audio_channels = _safe_int(ctrl.get("channels"))
audio_sample_width = _safe_int(ctrl.get("sample_width"))
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
await _safe_send_json(
websocket, {"type": "state", "state": "listening"}
)
elif ctype == "audio_end":
receiving = False
audio_ended_at = time.monotonic()
silence_ms = _safe_int(ctrl.get("silence_ms"))
if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
if (
silence_ms is None
and last_audio_end_at is not None
and audio_started_at is not None
):
silence_ms = max(
0, int((audio_started_at - last_audio_end_at) * 1000)
)
end_format = _safe_str(ctrl.get("format")) or audio_format
await _handle_utterance(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=end_format,
sample_rate=_safe_int(ctrl.get("sample_rate")) or audio_sample_rate,
channels=_safe_int(ctrl.get("channels")) or audio_channels,
sample_width=_safe_int(ctrl.get("sample_width")) or audio_sample_width,
audio_started_at=audio_started_at,
audio_ended_at=audio_ended_at,
silence_ms=silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=_safe_provider_events(ctrl.get("provider_events")),
VoiceSessionContext(session_id, principal, voice_preset),
VoiceAudioInput(
audio=bytes(audio_buf),
fmt=end_format,
sample_rate=_safe_int(ctrl.get("sample_rate"))
or audio_sample_rate,
channels=_safe_int(ctrl.get("channels")) or audio_channels,
sample_width=_safe_int(ctrl.get("sample_width"))
or audio_sample_width,
audio_started_at=audio_started_at,
audio_ended_at=audio_ended_at,
prosody=VoiceProsody(
silence_ms=silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=_safe_provider_events(
ctrl.get("provider_events")
),
),
),
)
last_audio_end_at = audio_ended_at
audio_started_at = None
@ -365,10 +435,8 @@ async def voice_ws(websocket: WebSocket) -> None:
if learner_text:
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
VoiceSessionContext(session_id, principal, voice_preset),
VoiceTurnInput(learner_text=learner_text),
)
elif ctype == "stt_result":
@ -412,7 +480,9 @@ async def voice_ws(websocket: WebSocket) -> None:
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
else:
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"voice ws error: {e}"}
)
finally:
await _safe_close(websocket)
@ -431,7 +501,11 @@ async def _handle_stt_result_control(
learner_text = str(ctrl.get("text") or "").strip()
transcript_final = _safe_bool(ctrl.get("final"))
silence_ms = _safe_int(ctrl.get("silence_ms"))
if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
if (
silence_ms is None
and last_audio_end_at is not None
and audio_started_at is not None
):
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
provider_events = _safe_provider_events(ctrl.get("provider_events"))
decision = voice_svc.assess_end_of_turn(
@ -456,52 +530,49 @@ async def _handle_stt_result_control(
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
{
"type": "transcript",
"text": learner_text,
"final": True,
"speaker": "counselor",
},
)
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
silence_ms=decision.silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=provider_events,
VoiceSessionContext(session_id, principal, voice_preset),
VoiceTurnInput(
learner_text=learner_text,
prosody=VoiceProsody(
duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
silence_ms=decision.silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=provider_events,
),
),
)
async def _handle_utterance(
websocket: WebSocket,
*,
session_id: str,
principal: Principal,
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
sample_rate: int | None = None,
channels: int | None = None,
sample_width: int | None = None,
audio_started_at: float | None = None,
audio_ended_at: float | None = None,
silence_ms: int | None = None,
barge_in: bool | None = None,
provider_events: list[dict[str, object]] | None = None,
context: VoiceSessionContext,
utterance: VoiceAudioInput,
) -> None:
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
if not utterance.audio:
await _safe_send_json(
websocket, {"type": "transcript", "text": "", "final": True}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# STT begins after the learner stops speaking.
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
upload_audio, upload_fmt = _normalize_audio_upload(
audio,
fmt=fmt,
sample_rate=sample_rate,
channels=channels,
sample_width=sample_width,
utterance.audio,
fmt=utterance.fmt,
sample_rate=utterance.sample_rate,
channels=utterance.channels,
sample_width=utterance.sample_width,
)
filename, content_type = _audio_meta(upload_fmt)
try:
@ -513,18 +584,30 @@ async def _handle_utterance(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"STT failed: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"STT failed: {e}"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
learner_text = stt.text
audio_ref = _voice_audio_ref(upload_audio, upload_fmt)
duration_s = stt.duration or _elapsed_seconds(audio_started_at, audio_ended_at)
duration_s = stt.duration or _elapsed_seconds(
utterance.audio_started_at, utterance.audio_ended_at
)
speech_rate = _estimate_speech_rate(learner_text, duration_s)
provider_events = _merge_provider_events(provider_events, getattr(stt, "provider_events", []))
provider_events = _merge_provider_events(
utterance.prosody.provider_events,
getattr(stt, "provider_events", []),
)
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
{
"type": "transcript",
"text": learner_text,
"final": True,
"speaker": "counselor",
},
)
if not learner_text:
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@ -532,47 +615,54 @@ async def _handle_utterance(
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
audio_ref=audio_ref,
silence_ms=silence_ms,
speech_rate=speech_rate,
barge_in=barge_in,
provider_events=provider_events,
context,
VoiceTurnInput(
learner_text=learner_text,
prosody=VoiceProsody(
audio_ref=audio_ref,
duration_s=duration_s,
silence_ms=utterance.prosody.silence_ms,
speech_rate=speech_rate,
barge_in=utterance.prosody.barge_in,
provider_events=provider_events,
),
),
)
async def _run_turn_and_speak(
websocket: WebSocket,
*,
session_id: str,
principal: Principal,
voice_preset: VoicePreset,
learner_text: str,
audio_ref: str | None = None,
duration_s: float | None = None,
silence_ms: int | None = None,
speech_rate: float | None = None,
barge_in: bool | None = None,
provider_events: list[dict[str, object]] | None = None,
context: VoiceSessionContext,
turn: VoiceTurnInput,
) -> None:
"""Run one counseling turn and stream synthesized client speech."""
learner_text = turn.learner_text
prosody = turn.prosody
speech_rate = prosody.speech_rate
if speech_rate is None:
speech_rate = _estimate_speech_rate(learner_text, duration_s)
sess, err = await _load_voice_session(session_id, principal)
speech_rate = _estimate_speech_rate(learner_text, prosody.duration_s)
sess, err = await _load_voice_session(context.session_id, context.principal)
if sess is None:
await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"})
await _safe_send_json(
websocket, {"type": "error", "detail": err or "session not found or ended"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
from . import sessions as session_routes
if session_routes.session_time_over(sess):
await _safe_send_json(
websocket,
{"type": "error", "detail": "session_time_over"},
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = await session_routes.ensure_recall_context(sess)
kb_cues = session_routes._KB_CUES_CACHE.get(session_id) or []
kb_cues = session_routes.cached_kb_cues(context.session_id)
ctx = orchestrator.prepare_turn(
session_id=session_id,
session_id=context.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
@ -599,7 +689,9 @@ async def _run_turn_and_speak(
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"engine unavailable: {e}"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
@ -617,11 +709,11 @@ async def _run_turn_and_speak(
stage=turn_runtime.stage_label(ctx.state_after.stage),
text=learner_text,
text_masked=ctx.learner_text_masked,
audio_ref=audio_ref,
silence_ms=silence_ms,
audio_ref=prosody.audio_ref,
silence_ms=prosody.silence_ms,
speech_rate=speech_rate,
barge_in=barge_in,
provider_events=provider_events or [],
barge_in=prosody.barge_in,
provider_events=prosody.provider_events,
evaluation=result.evaluation,
),
)
@ -640,6 +732,11 @@ async def _run_turn_and_speak(
"crisis_kind": result.crisis_kind,
"crisis_resource": result.crisis_resource,
"conversation_stopped": result.conversation_stopped,
"progress": session_routes.build_session_progress(
result.state_after,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
).model_dump(),
},
)
@ -653,13 +750,13 @@ async def _run_turn_and_speak(
{
"type": "state",
"state": "speaking",
"voice": voice_preset.openai_voice,
"voice": context.voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
},
)
try:
n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset):
async for ck in voice_service.synthesize_stream(reply, context.voice_preset):
# 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출).
await _safe_send_bytes(websocket, ck.audio)
n += 1
@ -667,7 +764,9 @@ async def _run_turn_and_speak(
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 failed: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"TTS failed: {e}"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@ -732,9 +831,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
async def _practice_access_error(principal: Principal) -> str | None:
if (
principal.profile_completed_at is None
and not await user_onboarding_complete(principal.user_id)
if principal.profile_completed_at is None and not await user_onboarding_complete(
principal.user_id
):
return "onboarding_required"
if principal.consent_at is None and not await user_has_consent(principal.user_id):
@ -763,7 +861,12 @@ async def _bind_session(
persona_code=sess.persona.code,
explicit_preset=explicit_preset,
)
return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
return (
session_id,
vp,
None,
{"degraded": False, "persona_catalog_source": "session"},
)
# persona_code session creation is local-dev only. Production uses REST start.
if settings.environment != "dev":
@ -815,9 +918,13 @@ async def _bind_session(
)
degraded_reasons: list[str] = []
if catalog_persona.degraded:
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
degraded_reasons.append(
"카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다"
)
if session_source == "runtime":
degraded_reasons.append("세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다")
degraded_reasons.append(
"세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다"
)
bind_meta = {
"degraded": bool(degraded_reasons),
"degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None,
@ -906,7 +1013,9 @@ def _normalize_audio_upload(
raise ValueError("pcm sample_width must be 2 bytes")
return _wav_from_pcm16(
audio,
sample_rate=_bounded_int(sample_rate, default=48000, minimum=8000, maximum=96000),
sample_rate=_bounded_int(
sample_rate, default=48000, minimum=8000, maximum=96000
),
channels=_bounded_int(channels, default=1, minimum=1, maximum=2),
), "wav"