222 lines
7.1 KiB
Python
222 lines
7.1 KiB
Python
"""LiveKit LLM adapter that streams from the internal chat engine."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
from livekit.agents import llm
|
|
from livekit.agents.llm import ChatChunk, ChoiceDelta
|
|
|
|
from chat_engine_client import ChatEngineClient
|
|
|
|
logger = logging.getLogger("voice-agent")
|
|
FILLER = "Let me check that in the book."
|
|
APOLOGY = "I lost my place for a moment. Please ask that again."
|
|
|
|
|
|
def parse_sse_line(line):
|
|
text = line.decode("utf-8").strip() if isinstance(line, bytes) else str(line).strip()
|
|
if not text.startswith("data:"):
|
|
return None
|
|
return json.loads(text[5:].strip())
|
|
|
|
|
|
tts_sanitizer_hits = 0
|
|
_COMPLETE_MARKER = re.compile(r"\[\[[^\]]{0,24}\]\]")
|
|
_OPEN_MARKER = re.compile(r"\[\[[^\]]{0,24}$")
|
|
|
|
|
|
def sanitize_for_tts(text):
|
|
"""Strip leaked source markers, then leftover markdown, before speech."""
|
|
global tts_sanitizer_hits
|
|
cleaned = re.sub(r"```.*?```", " ", text or "", flags=re.S)
|
|
cleaned, complete_hits = _COMPLETE_MARKER.subn(" ", cleaned)
|
|
cleaned, open_hits = _OPEN_MARKER.subn(" ", cleaned)
|
|
tts_sanitizer_hits += complete_hits + open_hits
|
|
cleaned = re.sub(r"[*_#>`\[\]]", "", cleaned)
|
|
cleaned = re.sub(r"(?m)^\s*-\s+", "", cleaned)
|
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
return cleaned
|
|
|
|
|
|
def filler_due(started_at, now, text_sent, filler_sent, tool_seen, filler_after_ms):
|
|
if not tool_seen or text_sent or filler_sent:
|
|
return False
|
|
return (now - started_at) * 1000 >= filler_after_ms
|
|
|
|
|
|
def validate_metadata(data):
|
|
if not isinstance(data, dict) or not data.get("sessionId"):
|
|
raise ValueError("room metadata is missing sessionId")
|
|
return data
|
|
|
|
|
|
def parse_metadata(raw):
|
|
if not raw:
|
|
return None
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not data.get("sessionId"):
|
|
return None
|
|
return data
|
|
|
|
|
|
def truncate_spoken(text, playback_position, chars_per_second=14):
|
|
if playback_position is None:
|
|
return text
|
|
count = max(0, int(playback_position * chars_per_second))
|
|
return (text or "")[:count]
|
|
|
|
|
|
class EngineLLM(llm.LLM):
|
|
def __init__(self, client, session_id, filler_after_ms=800):
|
|
super().__init__()
|
|
self.client = client
|
|
self.session_id = session_id
|
|
self.filler_after_ms = filler_after_ms
|
|
self.last_text = ""
|
|
|
|
@property
|
|
def model(self):
|
|
return "chat-engine"
|
|
|
|
@property
|
|
def provider(self):
|
|
return "talkingbooklet"
|
|
|
|
def chat(self, *, chat_ctx, tools=None, conn_options=None, parallel_tool_calls=None, tool_choice=None, extra_kwargs=None):
|
|
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
|
|
|
|
if conn_options is None:
|
|
conn_options = DEFAULT_API_CONNECT_OPTIONS
|
|
if tools is None:
|
|
tools = []
|
|
return EngineLLMStream(
|
|
self,
|
|
chat_ctx=chat_ctx,
|
|
tools=tools,
|
|
conn_options=conn_options,
|
|
)
|
|
|
|
|
|
class EngineLLMStream(llm.LLMStream):
|
|
async def _run(self) -> None:
|
|
user_text = latest_user_text(self._chat_ctx)
|
|
if not user_text:
|
|
return
|
|
started = time.perf_counter()
|
|
text_sent = False
|
|
filler_sent = False
|
|
tool_seen = False
|
|
produced = []
|
|
attempt = 0
|
|
while attempt < 2:
|
|
attempt += 1
|
|
try:
|
|
async for event in aiter_events(self._llm.client, self._llm.session_id, user_text):
|
|
event_type = event.get("type")
|
|
if event_type == "tool_start":
|
|
tool_seen = True
|
|
if filler_due(started, time.perf_counter(), text_sent, filler_sent, True, self._llm.filler_after_ms):
|
|
filler_sent = True
|
|
self._send(FILLER)
|
|
continue
|
|
if event_type == "text_delta":
|
|
piece = sanitize_for_tts(event.get("text") or "")
|
|
if not piece:
|
|
continue
|
|
text_sent = True
|
|
produced.append(piece)
|
|
self._send(piece)
|
|
if attempt == 1:
|
|
self._llm.last_text = "".join(produced)
|
|
elif event_type == "error":
|
|
if event.get("retryable") and attempt == 1 and not text_sent:
|
|
break
|
|
self._send(APOLOGY)
|
|
return
|
|
elif event_type == "final":
|
|
elapsed = int((time.perf_counter() - started) * 1000)
|
|
logger.info(
|
|
"sessionId=%s engine_ttft_ms=%s interrupted=%s",
|
|
self._llm.session_id,
|
|
elapsed,
|
|
bool(event.get("interrupted")),
|
|
)
|
|
self._llm.last_text = "".join(produced)
|
|
return
|
|
else:
|
|
self._llm.last_text = "".join(produced)
|
|
return
|
|
except asyncio.CancelledError:
|
|
self._llm.client.close()
|
|
raise
|
|
self._send(APOLOGY)
|
|
|
|
def _send(self, text):
|
|
self._event_ch.send_nowait(
|
|
ChatChunk(
|
|
id=str(uuid.uuid4()),
|
|
delta=ChoiceDelta(role="assistant", content=text),
|
|
)
|
|
)
|
|
|
|
|
|
def latest_user_text(chat_ctx):
|
|
items = getattr(chat_ctx, "items", None) or getattr(chat_ctx, "messages", None) or []
|
|
for item in reversed(list(items)):
|
|
role = getattr(item, "role", None)
|
|
if role == "user":
|
|
text = getattr(item, "text_content", None)
|
|
if text:
|
|
return text.strip()
|
|
content = getattr(item, "content", "")
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
return ""
|
|
|
|
|
|
async def aiter_events(client, session_id, text):
|
|
loop = asyncio.get_running_loop()
|
|
queue = asyncio.Queue()
|
|
|
|
def worker():
|
|
try:
|
|
for event in client.iter_events(session_id, text):
|
|
loop.call_soon_threadsafe(queue.put_nowait, ("event", event))
|
|
except Exception as error:
|
|
loop.call_soon_threadsafe(queue.put_nowait, ("error", error))
|
|
finally:
|
|
loop.call_soon_threadsafe(queue.put_nowait, ("end", None))
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
while True:
|
|
kind, payload = await queue.get()
|
|
if kind == "end":
|
|
return
|
|
if kind == "error":
|
|
raise payload
|
|
yield payload
|
|
|
|
|
|
def on_playback_interrupted(client, session_id, full_text, playback_position):
|
|
spoken = truncate_spoken(full_text, playback_position)
|
|
client.close()
|
|
if spoken:
|
|
client.mark_interrupted(session_id, spoken)
|
|
return spoken
|
|
|
|
|
|
def engine_client_from_env():
|
|
return ChatEngineClient(
|
|
base_url=os.environ["CHAT_ENGINE_URL"],
|
|
token=os.environ["CHAT_ENGINE_TOKEN"],
|
|
)
|