세션 메모리와 비언어 이벤트 저장

This commit is contained in:
Yun Chan 2026-06-28 20:12:50 +09:00
parent e8e08935ed
commit 50fa4ad432
12 changed files with 2848 additions and 1277 deletions

File diff suppressed because it is too large Load diff

View file

@ -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