Merge pull request 'Play a filler ack during slow voice decisions and cache KB search rows' (#2) from voice-latency-llm-tts-improvements into main
deploy / deploy (push) Canceled after 27h22m42s
deploy / deploy (push) Canceled after 27h22m42s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -8,11 +8,12 @@ import math
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from threading import Lock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from services.shared.core import Role, new_id, utc_now_iso
|
from services.shared.core import Role, new_id, utc_now_iso
|
||||||
from services.shared.db import get_session
|
from services.shared.db import get_session
|
||||||
@@ -1909,13 +1910,61 @@ def _article_snippet(article: KBArticleRow, limit: int = 240) -> str:
|
|||||||
return f"{raw[: limit - 3]}..."
|
return f"{raw[: limit - 3]}..."
|
||||||
|
|
||||||
|
|
||||||
def _kb_search(session, text: str, *, language: str | None = None) -> list[KBArticleRow]:
|
_KB_ROWS_CACHE: dict[str, tuple[tuple[int, int, str, int], list[KBArticleRow]]] = {}
|
||||||
if not str(text or "").strip():
|
_KB_ROWS_CACHE_LOCK = Lock()
|
||||||
return []
|
|
||||||
|
|
||||||
|
def _kb_rows_fingerprint(session, language: str | None) -> tuple[int, int, str, int]:
|
||||||
|
# A cheap aggregate (row content lengths, computed server-side - no
|
||||||
|
# title/body/tags_json actually transferred) that changes on any
|
||||||
|
# insert, update, or delete, so the cache below can never serve stale
|
||||||
|
# results. count/max_id/max_updated_at alone aren't sufficient: ids can
|
||||||
|
# be reused after a row is deleted and updated_at has 1-second
|
||||||
|
# resolution, so two different row sets can otherwise share a
|
||||||
|
# fingerprint if they happen to be written within the same second.
|
||||||
|
content_length = func.length(KBArticleRow.title) + func.length(KBArticleRow.body) + func.length(
|
||||||
|
KBArticleRow.tags_json
|
||||||
|
)
|
||||||
|
stmt = select(
|
||||||
|
func.count(KBArticleRow.id),
|
||||||
|
func.max(KBArticleRow.id),
|
||||||
|
func.max(KBArticleRow.updated_at),
|
||||||
|
func.sum(content_length),
|
||||||
|
)
|
||||||
|
if language is not None:
|
||||||
|
stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language))
|
||||||
|
count, max_id, max_updated_at, total_length = session.execute(stmt).one()
|
||||||
|
return (int(count or 0), int(max_id or 0), str(max_updated_at or ""), int(total_length or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_kb_rows_cached(session, language: str | None) -> list[KBArticleRow]:
|
||||||
|
# Every voice/chat turn re-runs KB search, and this table rarely changes
|
||||||
|
# mid-call, so avoid re-fetching + re-transferring the whole table from
|
||||||
|
# the DB (title/body/tags_json for every article) when nothing changed.
|
||||||
|
cache_key = normalize_kb_language(language) if language is not None else "__all__"
|
||||||
|
fingerprint = _kb_rows_fingerprint(session, language)
|
||||||
|
|
||||||
|
with _KB_ROWS_CACHE_LOCK:
|
||||||
|
cached = _KB_ROWS_CACHE.get(cache_key)
|
||||||
|
if cached is not None and cached[0] == fingerprint:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
stmt = select(KBArticleRow).order_by(KBArticleRow.id.desc())
|
stmt = select(KBArticleRow).order_by(KBArticleRow.id.desc())
|
||||||
if language is not None:
|
if language is not None:
|
||||||
stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language))
|
stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language))
|
||||||
rows = session.execute(stmt).scalars().all()
|
rows = list(session.execute(stmt).scalars().all())
|
||||||
|
for row in rows:
|
||||||
|
session.expunge(row)
|
||||||
|
|
||||||
|
with _KB_ROWS_CACHE_LOCK:
|
||||||
|
_KB_ROWS_CACHE[cache_key] = (fingerprint, rows)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _kb_search(session, text: str, *, language: str | None = None) -> list[KBArticleRow]:
|
||||||
|
if not str(text or "").strip():
|
||||||
|
return []
|
||||||
|
rows = _load_kb_rows_cached(session, language)
|
||||||
return search_kb_rows(rows, text, limit=_ai_max_kb_results())
|
return search_kb_rows(rows, text, limit=_ai_max_kb_results())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ class AudioSocketMediaRuntime:
|
|||||||
self._partial_asr_min_ms = 320
|
self._partial_asr_min_ms = 320
|
||||||
self._immediate_ack_min_ms = 700
|
self._immediate_ack_min_ms = 700
|
||||||
self._v2_ack_post_gap_seconds = 0.10
|
self._v2_ack_post_gap_seconds = 0.10
|
||||||
|
self._v1_ack_wait_seconds = 0.6
|
||||||
self._partial_poll_interval_seconds = 0.20
|
self._partial_poll_interval_seconds = 0.20
|
||||||
self._streaming_asr_partial_poll_enabled = (
|
self._streaming_asr_partial_poll_enabled = (
|
||||||
str(os.getenv("AI_VOICE_V2_STREAMING_ASR_PARTIAL_POLL_ENABLED", "0")).strip().lower()
|
str(os.getenv("AI_VOICE_V2_STREAMING_ASR_PARTIAL_POLL_ENABLED", "0")).strip().lower()
|
||||||
@@ -1619,7 +1620,18 @@ class AudioSocketMediaRuntime:
|
|||||||
early_plan_decision = await self._take_early_plan_decision(actor, timeout_seconds=0.12)
|
early_plan_decision = await self._take_early_plan_decision(actor, timeout_seconds=0.12)
|
||||||
decision = early_plan_decision if early_plan_decision is not None else await decision_task
|
decision = early_plan_decision if early_plan_decision is not None else await decision_task
|
||||||
else:
|
else:
|
||||||
decision = await decision_task
|
try:
|
||||||
|
decision = await asyncio.wait_for(asyncio.shield(decision_task), timeout=self._v1_ack_wait_seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if not actor.early_ack_started:
|
||||||
|
await self._emit_early_ack(
|
||||||
|
actor,
|
||||||
|
language=transcription.language or actor.registration.language,
|
||||||
|
metadata=metadata,
|
||||||
|
ack_source="v1_decision_timeout",
|
||||||
|
ack_kind="unknown",
|
||||||
|
)
|
||||||
|
decision = await decision_task
|
||||||
handoff_task: asyncio.Task | None = None
|
handoff_task: asyncio.Task | None = None
|
||||||
if decision.needs_handoff:
|
if decision.needs_handoff:
|
||||||
await self._set_actor_state(actor, "handoff_requested", decision.handoff_reason)
|
await self._set_actor_state(actor, "handoff_requested", decision.handoff_reason)
|
||||||
|
|||||||
@@ -187,6 +187,103 @@ def test_media_runtime_streams_greeting_and_turn():
|
|||||||
assert errors == []
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_runtime_plays_filler_ack_when_v1_decision_is_slow():
|
||||||
|
registrations: dict[str, MediaRegistration] = {}
|
||||||
|
delivered: list[tuple[str, str, bool]] = []
|
||||||
|
|
||||||
|
media_uuid = str(uuid.uuid4())
|
||||||
|
registrations[media_uuid] = MediaRegistration(
|
||||||
|
voice_session_id="avs_media_runtime_slow_v1",
|
||||||
|
call_id="call_media_runtime_slow_v1",
|
||||||
|
interaction_id="int_media_runtime_slow_v1",
|
||||||
|
ai_session_id="ais_media_runtime_slow_v1",
|
||||||
|
language="ru",
|
||||||
|
media_uuid=media_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _slow_process_turn(session_id, transcript_text, language, barge_in, metadata):
|
||||||
|
del transcript_text, barge_in, metadata
|
||||||
|
time.sleep(0.9)
|
||||||
|
return VoiceAITurnDecisionOut(
|
||||||
|
language=language or "ru",
|
||||||
|
intent="answer",
|
||||||
|
reply_text="reply",
|
||||||
|
confidence=0.9,
|
||||||
|
needs_handoff=False,
|
||||||
|
handoff_reason=None,
|
||||||
|
case_action="keep_open",
|
||||||
|
kb_refs=[],
|
||||||
|
summary_text="reply ready",
|
||||||
|
model="stub-voice",
|
||||||
|
latency_ms=1,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = AudioSocketMediaRuntime(
|
||||||
|
enabled=True,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=0,
|
||||||
|
frame_ms=20,
|
||||||
|
idle_timeout_seconds=2.0,
|
||||||
|
registration_wait_timeout_seconds=0.5,
|
||||||
|
min_speech_ms=40,
|
||||||
|
trailing_silence_ms=40,
|
||||||
|
max_turn_ms=400,
|
||||||
|
asr_provider=_StubASRProvider(),
|
||||||
|
tts_provider=_StubTTSProvider(),
|
||||||
|
load_registration_by_media_uuid=lambda value: registrations.get(value),
|
||||||
|
mark_media_connected=lambda session_id, value: None,
|
||||||
|
mark_media_ended=lambda session_id, reason: None,
|
||||||
|
touch_media_frame=lambda session_id: None,
|
||||||
|
set_state=lambda session_id, state, handoff_reason, metadata: None,
|
||||||
|
get_pending_greeting=lambda session_id: "greeting" if session_id == "avs_media_runtime_slow_v1" else None,
|
||||||
|
mark_reply_delivered=lambda session_id, text, is_greeting: delivered.append((session_id, text, is_greeting)),
|
||||||
|
plan_reply=lambda session_id, text, metadata, kind: None,
|
||||||
|
process_turn=_slow_process_turn,
|
||||||
|
request_handoff=lambda session_id, customer_request_text, decision: None,
|
||||||
|
handle_media_error=lambda session_id, message, metadata: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _scenario() -> None:
|
||||||
|
await runtime.start()
|
||||||
|
port = runtime._server.sockets[0].getsockname()[1]
|
||||||
|
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||||
|
writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
packet_type, _ = await read_packet(reader, timeout=2.0)
|
||||||
|
assert packet_type == AUDIO_SOCKET_PACKET_PCM16
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
|
||||||
|
speech_frame = (1000).to_bytes(2, "little", signed=True) * 160
|
||||||
|
silence_frame = b"\x00\x00" * 160
|
||||||
|
for _ in range(2):
|
||||||
|
writer.write(encode_audio_packet(speech_frame))
|
||||||
|
for _ in range(2):
|
||||||
|
writer.write(encode_audio_packet(silence_frame))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
packet_type, _ = await read_packet(reader, timeout=2.0)
|
||||||
|
assert packet_type == AUDIO_SOCKET_PACKET_PCM16
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
if len(delivered) >= 3:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
asyncio.run(_scenario())
|
||||||
|
|
||||||
|
assert delivered[0] == ("avs_media_runtime_slow_v1", "greeting", True)
|
||||||
|
assert delivered[1] == ("avs_media_runtime_slow_v1", "Секунду.", False)
|
||||||
|
assert delivered[2] == ("avs_media_runtime_slow_v1", "reply", False)
|
||||||
|
|
||||||
|
|
||||||
def test_media_runtime_speaks_technical_fallback_when_asr_transcribe_fails():
|
def test_media_runtime_speaks_technical_fallback_when_asr_transcribe_fails():
|
||||||
registrations: dict[str, MediaRegistration] = {}
|
registrations: dict[str, MediaRegistration] = {}
|
||||||
delivered: list[tuple[str, str, bool]] = []
|
delivered: list[tuple[str, str, bool]] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user