전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue