세션 메모리와 비언어 이벤트 저장
This commit is contained in:
parent
e8e08935ed
commit
50fa4ad432
12 changed files with 2848 additions and 1277 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -48,6 +48,56 @@ WS_CLOSE_UNAUTHORIZED = 1008
|
|||
|
||||
# Per-utterance audio cap to avoid unbounded memory growth.
|
||||
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
|
||||
_PROVIDER_EVENT_MAX_ITEMS = 12
|
||||
_PROVIDER_EVENT_MAX_STRING = 80
|
||||
_PROVIDER_EVENT_ALLOWED_KEYS = {
|
||||
"category",
|
||||
"event_type",
|
||||
"type",
|
||||
"kind",
|
||||
"label",
|
||||
"source",
|
||||
"provider",
|
||||
"start_ms",
|
||||
"end_ms",
|
||||
"duration_ms",
|
||||
"confidence",
|
||||
"score",
|
||||
"is_final",
|
||||
}
|
||||
_PROVIDER_EVENT_TAXONOMY = {
|
||||
"barge_in": ("barge_in", "turn_taking"),
|
||||
"interrupt": ("barge_in", "turn_taking"),
|
||||
"interruption": ("barge_in", "turn_taking"),
|
||||
"overlap": ("barge_in", "turn_taking"),
|
||||
"sigh": ("sigh", "paralinguistic"),
|
||||
"sighing": ("sigh", "paralinguistic"),
|
||||
"sob": ("cry", "paralinguistic"),
|
||||
"cry": ("cry", "paralinguistic"),
|
||||
"crying": ("cry", "paralinguistic"),
|
||||
"weep": ("cry", "paralinguistic"),
|
||||
"laugh": ("laugh", "paralinguistic"),
|
||||
"laughter": ("laugh", "paralinguistic"),
|
||||
"breath": ("breath", "paralinguistic"),
|
||||
"breathing": ("breath", "paralinguistic"),
|
||||
"voice_activity": ("voice_activity", "speech_activity"),
|
||||
"vad": ("voice_activity", "speech_activity"),
|
||||
"speech_start": ("speech_start", "speech_activity"),
|
||||
"speech_end": ("speech_end", "speech_activity"),
|
||||
"speech_final": ("speech_final", "speech_activity"),
|
||||
"silence": ("silence", "timing"),
|
||||
"pause": ("silence", "timing"),
|
||||
"long_pause": ("silence", "timing"),
|
||||
"speech_rate": ("speech_rate", "prosody"),
|
||||
"fast_speech": ("speech_rate", "prosody"),
|
||||
"slow_speech": ("speech_rate", "prosody"),
|
||||
"pitch": ("pitch", "prosody"),
|
||||
"intonation": ("intonation", "prosody"),
|
||||
"prosody": ("prosody", "prosody"),
|
||||
"noise": ("background_noise", "audio_quality"),
|
||||
"background_noise": ("background_noise", "audio_quality"),
|
||||
}
|
||||
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
|
|
@ -187,6 +237,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
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")),
|
||||
)
|
||||
last_audio_end_at = audio_ended_at
|
||||
audio_started_at = None
|
||||
|
|
@ -232,6 +283,7 @@ async def _handle_utterance(
|
|||
audio_ended_at: float | None = None,
|
||||
silence_ms: int | None = None,
|
||||
barge_in: bool | None = None,
|
||||
provider_events: list[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
|
||||
if not audio:
|
||||
|
|
@ -259,6 +311,7 @@ async def _handle_utterance(
|
|||
audio_ref = _voice_audio_ref(audio, fmt)
|
||||
duration_s = stt.duration or _elapsed_seconds(audio_started_at, audio_ended_at)
|
||||
speech_rate = _estimate_speech_rate(learner_text, duration_s)
|
||||
provider_events = _merge_provider_events(provider_events, getattr(stt, "provider_events", []))
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
|
||||
|
|
@ -277,6 +330,7 @@ async def _handle_utterance(
|
|||
silence_ms=silence_ms,
|
||||
speech_rate=speech_rate,
|
||||
barge_in=barge_in,
|
||||
provider_events=provider_events,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -291,6 +345,7 @@ async def _run_turn_and_speak(
|
|||
silence_ms: int | None = None,
|
||||
speech_rate: float | None = None,
|
||||
barge_in: bool | None = None,
|
||||
provider_events: list[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
"""Run one counseling turn and stream synthesized client speech."""
|
||||
sess, err = await _load_voice_session(session_id, principal)
|
||||
|
|
@ -351,6 +406,7 @@ async def _run_turn_and_speak(
|
|||
silence_ms=silence_ms,
|
||||
speech_rate=speech_rate,
|
||||
barge_in=barge_in,
|
||||
provider_events=provider_events or [],
|
||||
evaluation=result.evaluation,
|
||||
),
|
||||
)
|
||||
|
|
@ -646,6 +702,71 @@ def _safe_bool(value: object) -> bool | None:
|
|||
return bool(value)
|
||||
|
||||
|
||||
def _provider_event_slug(value: object) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
chars = []
|
||||
previous_underscore = False
|
||||
for char in text:
|
||||
if char.isalnum():
|
||||
chars.append(char)
|
||||
previous_underscore = False
|
||||
elif not previous_underscore:
|
||||
chars.append("_")
|
||||
previous_underscore = True
|
||||
return "".join(chars).strip("_")[:_PROVIDER_EVENT_MAX_STRING]
|
||||
|
||||
|
||||
def _provider_event_taxonomy(event: dict[str, object]) -> tuple[str, str]:
|
||||
for field in _PROVIDER_EVENT_TYPE_FIELDS:
|
||||
slug = _provider_event_slug(event.get(field))
|
||||
if not slug:
|
||||
continue
|
||||
canonical = _PROVIDER_EVENT_TAXONOMY.get(slug)
|
||||
if canonical:
|
||||
return canonical
|
||||
return slug, "unknown"
|
||||
return "", ""
|
||||
|
||||
|
||||
def _safe_provider_events(value: object) -> list[dict[str, object]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
events: list[dict[str, object]] = []
|
||||
for item in value[:_PROVIDER_EVENT_MAX_ITEMS]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
safe: dict[str, object] = {}
|
||||
for key in _PROVIDER_EVENT_ALLOWED_KEYS:
|
||||
raw = item.get(key)
|
||||
if isinstance(raw, bool):
|
||||
safe[key] = raw
|
||||
elif isinstance(raw, (int, float)):
|
||||
safe[key] = raw
|
||||
elif isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if text:
|
||||
safe[key] = text[:_PROVIDER_EVENT_MAX_STRING]
|
||||
if safe:
|
||||
event_type, category = _provider_event_taxonomy(safe)
|
||||
if event_type:
|
||||
safe["event_type"] = event_type
|
||||
if category:
|
||||
safe["category"] = category
|
||||
events.append(safe)
|
||||
return events
|
||||
|
||||
|
||||
def _merge_provider_events(*values: object) -> list[dict[str, object]]:
|
||||
merged: list[dict[str, object]] = []
|
||||
for value in values:
|
||||
merged.extend(_safe_provider_events(value))
|
||||
if len(merged) >= _PROVIDER_EVENT_MAX_ITEMS:
|
||||
return merged[:_PROVIDER_EVENT_MAX_ITEMS]
|
||||
return merged
|
||||
|
||||
|
||||
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
|
||||
if websocket.client_state != WebSocketState.CONNECTED:
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue