feat: integrate OCI agent streaming and web voice client
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
107
src/voice_assistant/agent/oci_connection.py
Normal file
107
src/voice_assistant/agent/oci_connection.py
Normal 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
|
||||
|
||||
85
src/voice_assistant/agent/text_chunker.py
Normal file
85
src/voice_assistant/agent/text_chunker.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user