feat: integrate OCI agent streaming and web voice client

This commit is contained in:
2026-10-13 19:49:27 +00:00
parent 088473a0c8
commit 19a415daae
19 changed files with 2034 additions and 23 deletions

View File

@@ -10,7 +10,6 @@ from __future__ import annotations
import json
from typing import Any
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import aclosing
@@ -19,6 +18,7 @@ import oci
from voice_assistant.agent.adapter import AgentAdapter
from voice_assistant.agent.oci_stream import stream_sse_events
from voice_assistant.agent.oci_connection import open_oci_stream
def extract_text_delta(raw_data: str) -> str | None:
@@ -205,9 +205,8 @@ class OCIAgentAdapter(AgentAdapter):
self.model_id,
)
response = await asyncio.to_thread(
self.client.chat,
details,
response = await open_oci_stream(
lambda: self.client.chat(details)
)
if response.status != 200:
@@ -256,4 +255,3 @@ class OCIAgentAdapter(AgentAdapter):
)
return response_text

View File

@@ -0,0 +1,107 @@
"""
Lifecycle management for blocking OCI HTTP connection setup.
Ensures that responses arriving after coroutine cancellation
are closed instead of being abandoned.
"""
from __future__ import annotations
import asyncio
import logging
import threading
from typing import Any, Callable
logger = logging.getLogger(__name__)
def _close_response(response: Any) -> None:
"""Close the SSE response returned by the OCI SDK."""
stream = getattr(response, "data", None)
if stream is not None:
close = getattr(stream, "close", None)
if callable(close):
close()
async def open_oci_stream(
open_connection: Callable[[], Any],
) -> Any:
"""
Open a blocking OCI connection with explicit response ownership.
The worker retains ownership until the async caller claims
the response. Cancellation closes any unclaimed response.
A blocking SDK call may continue after cancellation.
"""
ownership_lock = threading.Lock()
abandoned = False
claimed = False
response = None
def worker() -> Any:
nonlocal response
opened = open_connection()
with ownership_lock:
if abandoned:
should_close = True
else:
response = opened
should_close = False
if should_close:
try:
_close_response(opened)
except Exception:
logger.exception(
"Failed to close abandoned OCI response."
)
return opened
task = asyncio.create_task(
asyncio.to_thread(worker)
)
try:
opened = await asyncio.shield(task)
with ownership_lock:
if abandoned:
raise RuntimeError(
"OCI connection was abandoned."
)
claimed = True
return opened
except asyncio.CancelledError:
with ownership_lock:
abandoned = True
if not claimed:
unclaimed = response
response = None
else:
unclaimed = None
if unclaimed is not None:
try:
await asyncio.to_thread(
_close_response,
unclaimed,
)
except Exception:
logger.exception(
"Failed to close unclaimed OCI response."
)
raise

View File

@@ -0,0 +1,85 @@
"""
Incremental text segmentation for streaming voice synthesis.
Converts LLM text deltas into speech-ready segments while
preserving word boundaries and punctuation.
"""
from __future__ import annotations
class TextChunker:
"""
Accumulate text deltas and emit segments suitable for TTS.
Segments are emitted on sentence-ending punctuation or
at a configurable maximum length.
"""
def __init__(
self,
min_chars: int = 20,
max_chars: int = 160,
) -> None:
if min_chars < 1:
raise ValueError("min_chars must be positive.")
if max_chars < min_chars:
raise ValueError(
"max_chars must be greater than or equal to min_chars."
)
self.min_chars = min_chars
self.max_chars = max_chars
self._buffer = ""
def feed(self, delta: str) -> list[str]:
"""Accept a text delta and return completed segments."""
self._buffer += delta
segments: list[str] = []
while len(self._buffer) >= self.min_chars:
boundary = self._find_boundary()
if boundary is None:
break
segment = self._buffer[:boundary].strip()
self._buffer = self._buffer[boundary:]
if segment:
segments.append(segment)
return segments
def _find_boundary(self) -> int | None:
"""Find a sentence boundary or safe length boundary."""
for index, char in enumerate(self._buffer):
if char in ".!?":
boundary = index + 1
if boundary >= self.min_chars:
return boundary
if len(self._buffer) < self.max_chars:
return None
# Prefer breaking at a word boundary.
boundary = self._buffer.rfind(
" ",
0,
self.max_chars + 1,
)
if boundary >= self.min_chars:
return boundary + 1
return self.max_chars
def flush(self) -> str | None:
"""Return any remaining text after generation completes."""
remaining = self._buffer.strip()
self._buffer = ""
return remaining or None