refactor: formaliza contratos de streaming e ciclo de vida dos provedores
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
from .adapter import AgentAdapter
|
||||
from .adapter import AgentAdapter, StreamingAgentAdapter
|
||||
from .mock import MockAgentAdapter
|
||||
|
||||
__all__ = [
|
||||
"AgentAdapter",
|
||||
"StreamingAgentAdapter",
|
||||
"MockAgentAdapter",
|
||||
]
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Agent processing, optional streaming capability, and session cleanup."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
|
||||
|
||||
class AgentAdapter(ABC):
|
||||
@@ -9,6 +13,45 @@ class AgentAdapter(ABC):
|
||||
turn_id: int,
|
||||
text: str,
|
||||
) -> str:
|
||||
"""Process the final text of a turn and return the response."""
|
||||
"""Return a complete response, propagating cancellation and errors."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release session-owned resources after execution has stopped.
|
||||
|
||||
Override when the adapter retains connections or other resources.
|
||||
Repeated calls must be safe; cleanup errors must propagate so the
|
||||
application can report them and continue cleaning other providers.
|
||||
Resource-free adapters inherit this no-op implementation.
|
||||
"""
|
||||
|
||||
|
||||
class StreamingAgentAdapter(AgentAdapter):
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
session_id: str,
|
||||
turn_id: int,
|
||||
text: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Yield ordered text deltas, preserving spaces between fragments.
|
||||
|
||||
The consumer owns the generator and closes it on early exit.
|
||||
Implementations must release stream-owned resources in finally
|
||||
blocks and propagate cancellation and provider errors.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def process(
|
||||
self,
|
||||
session_id: str,
|
||||
turn_id: int,
|
||||
text: str,
|
||||
) -> str:
|
||||
"""Offer complete-response processing for streaming implementations."""
|
||||
async with aclosing(self.stream(session_id, turn_id, text)) as deltas:
|
||||
parts = [delta async for delta in deltas]
|
||||
response = "".join(parts).strip()
|
||||
if not response:
|
||||
raise RuntimeError("Agent returned an empty streaming response.")
|
||||
return response
|
||||
|
||||
@@ -16,7 +16,7 @@ Does not manage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
@@ -24,7 +24,7 @@ from langchain_core.messages import HumanMessage
|
||||
from langchain_oci import ChatOCIGenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
from voice_assistant.agent.adapter import AgentAdapter
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """
|
||||
Você é o AgentVoice, um assistente pessoal por voz. Que responde pelo nome de Roberta.
|
||||
@@ -41,7 +41,7 @@ de ferramentas que não foram executadas.
|
||||
"""
|
||||
|
||||
|
||||
class DeepAgentsAdapter(AgentAdapter):
|
||||
class DeepAgentsAdapter(StreamingAgentAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
region: str,
|
||||
@@ -133,7 +133,7 @@ class DeepAgentsAdapter(AgentAdapter):
|
||||
session_id: str,
|
||||
turn_id: int,
|
||||
text: str,
|
||||
) -> AsyncIterator[str]:
|
||||
) -> AsyncGenerator[str, None]:
|
||||
if self._closed:
|
||||
raise RuntimeError("Deep Agent is closed.")
|
||||
|
||||
@@ -169,5 +169,5 @@ class DeepAgentsAdapter(AgentAdapter):
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
await self.model.aclose()
|
||||
self._closed = True
|
||||
|
||||
@@ -7,16 +7,17 @@ chat inference using the GenericChatRequest format.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
|
||||
import oci
|
||||
|
||||
from voice_assistant.agent.adapter import AgentAdapter
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
from voice_assistant.agent.oci_stream import stream_sse_events
|
||||
from voice_assistant.agent.oci_connection import open_oci_stream
|
||||
|
||||
@@ -91,7 +92,7 @@ def extract_text_delta(raw_data: str) -> str | None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OCIAgentAdapter(AgentAdapter):
|
||||
class OCIAgentAdapter(StreamingAgentAdapter):
|
||||
"""
|
||||
OCI Generative AI streaming Agent.
|
||||
|
||||
@@ -135,6 +136,7 @@ class OCIAgentAdapter(AgentAdapter):
|
||||
self.temperature = temperature
|
||||
self.max_completion_tokens = max_completion_tokens
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._closed = False
|
||||
|
||||
signer = (
|
||||
oci.auth.signers
|
||||
@@ -184,7 +186,7 @@ class OCIAgentAdapter(AgentAdapter):
|
||||
session_id: str,
|
||||
turn_id: int,
|
||||
text: str,
|
||||
) -> AsyncIterator[str]:
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Stream OCI Generative AI text deltas.
|
||||
|
||||
@@ -192,6 +194,9 @@ class OCIAgentAdapter(AgentAdapter):
|
||||
event loop. SSE events are consumed through the
|
||||
asynchronous bridge.
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("OCI Agent is closed.")
|
||||
|
||||
if not text.strip():
|
||||
raise ValueError("Agent input text cannot be empty.")
|
||||
|
||||
@@ -255,3 +260,11 @@ class OCIAgentAdapter(AgentAdapter):
|
||||
)
|
||||
|
||||
return response_text
|
||||
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release the OCI HTTP session after turn execution has stopped."""
|
||||
if self._closed:
|
||||
return
|
||||
await asyncio.to_thread(self.client.base_client.session.close)
|
||||
self._closed = True
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import aclosing
|
||||
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
from voice_assistant.application.events import (
|
||||
AgentReply,
|
||||
AgentTextDelta,
|
||||
@@ -245,7 +246,7 @@ class VoiceSession:
|
||||
await self._respond(turn_id, event.text)
|
||||
|
||||
async def _respond(self, turn_id: int, text: str) -> None:
|
||||
if callable(getattr(self._pipeline.agent, "stream", None)):
|
||||
if isinstance(self._pipeline.agent, StreamingAgentAdapter):
|
||||
async with aclosing(self._pipeline.stream_turn_response(
|
||||
session_id=self.session_id, turn_id=turn_id, text=text,
|
||||
)) as response_events:
|
||||
@@ -263,8 +264,11 @@ class VoiceSession:
|
||||
session_id=self.session_id, turn_id=turn_id, text=text,
|
||||
)
|
||||
await self._emit(AgentReply(self.session_id, turn_id, result.response_text))
|
||||
async for audio in self._pipeline.synthesize_response(result.response_text):
|
||||
await self._emit(SpeechChunk(self.session_id, turn_id, audio))
|
||||
async with aclosing(
|
||||
self._pipeline.synthesize_response(result.response_text)
|
||||
) as audio_stream:
|
||||
async for audio in audio_stream:
|
||||
await self._emit(SpeechChunk(self.session_id, turn_id, audio))
|
||||
self._pipeline.on_tts_complete(turn_id)
|
||||
|
||||
async def _finalize_turn(self, turn_id: int, stt: STTAdapter) -> None:
|
||||
@@ -404,9 +408,14 @@ class VoiceSession:
|
||||
self._pipeline.close_session()
|
||||
except Exception:
|
||||
logger.exception("Failed to close voice session. session_id=%s", self.session_id)
|
||||
close_agent = getattr(self._pipeline.agent, "aclose", None)
|
||||
if callable(close_agent):
|
||||
for name, provider in (
|
||||
("Agent", self._pipeline.agent),
|
||||
("TTS", self._pipeline.tts),
|
||||
):
|
||||
try:
|
||||
await close_agent()
|
||||
await provider.aclose()
|
||||
except Exception:
|
||||
logger.exception("Failed to close Agent provider. session_id=%s", self.session_id)
|
||||
logger.exception(
|
||||
"Failed to close %s provider. session_id=%s",
|
||||
name, self.session_id,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from voice_assistant.domain.session import SessionState
|
||||
from voice_assistant.providers import (
|
||||
create_agent_adapter, create_stt_adapter, create_tts_adapter,
|
||||
)
|
||||
from voice_assistant.tts.adapter import TTSAdapter
|
||||
from voice_assistant.turn_manager import TurnManager
|
||||
from voice_assistant.voice_pipeline import VoicePipeline
|
||||
|
||||
@@ -96,6 +97,7 @@ async def create_voice_session(websocket: WebSocket, session_id: str | None) ->
|
||||
"""Compose providers at the channel boundary, injecting application ports."""
|
||||
state = SessionState(session_id=session_id) if session_id is not None else SessionState()
|
||||
agent = create_agent_adapter(config=app_config, catalog=provider_catalog)
|
||||
tts: TTSAdapter | None = None
|
||||
try:
|
||||
tts = create_tts_adapter(config=app_config, catalog=provider_catalog)
|
||||
pipeline = VoicePipeline(
|
||||
@@ -104,12 +106,15 @@ async def create_voice_session(websocket: WebSocket, session_id: str | None) ->
|
||||
except BaseException:
|
||||
# A partially composed application has not yet acquired ownership.
|
||||
with anyio.CancelScope(shield=True):
|
||||
close_agent = getattr(agent, "aclose", None)
|
||||
if callable(close_agent):
|
||||
for name, provider in (("Agent", agent), ("TTS", tts)):
|
||||
if provider is None:
|
||||
continue
|
||||
try:
|
||||
await close_agent()
|
||||
await provider.aclose()
|
||||
except Exception:
|
||||
logger.exception("Failed to close Agent after session composition failure.")
|
||||
logger.exception(
|
||||
"Failed to close %s after session composition failure.", name,
|
||||
)
|
||||
raise
|
||||
return VoiceSession(
|
||||
pipeline=pipeline,
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
"""Speech synthesis streaming and session cleanup contract."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
class TTSAdapter(ABC):
|
||||
@abstractmethod
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Convert text into audio chunks."""
|
||||
def synthesize(self, text: str) -> AsyncGenerator[bytes, None]:
|
||||
"""Return an audio generator consumed directly with async for.
|
||||
|
||||
The caller closes it on early exit. Implementations must release
|
||||
stream-owned resources in finally blocks and propagate cancellation
|
||||
and provider errors. The caller does not await synthesize itself.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release retained resources after synthesis has stopped.
|
||||
|
||||
Override when resources are retained between synthesis calls.
|
||||
Repeated calls must be safe and cleanup errors must propagate.
|
||||
Adapters owning resources only inside synthesize inherit this no-op.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from .adapter import TTSAdapter
|
||||
|
||||
@@ -10,7 +10,7 @@ class MockTTSAdapter(TTSAdapter):
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
) -> AsyncIterator[bytes]:
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
self.calls.append(text)
|
||||
|
||||
yield b"\x01\x02\x03\x04"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -27,7 +27,7 @@ class VoxCPMTTSAdapter(TTSAdapter):
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
) -> AsyncIterator[bytes]:
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
if not text:
|
||||
raise ValueError("Text cannot be empty.")
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ from contextlib import aclosing
|
||||
|
||||
from voice_assistant.agent.text_chunker import TextChunker
|
||||
from voice_assistant.turn_manager import TurnState
|
||||
from voice_assistant.agent.adapter import AgentAdapter
|
||||
from voice_assistant.agent.adapter import AgentAdapter, StreamingAgentAdapter
|
||||
from voice_assistant.domain.session import SessionState
|
||||
from voice_assistant.turn_manager import Turn, TurnManager
|
||||
from voice_assistant.tts.adapter import TTSAdapter
|
||||
@@ -181,10 +181,9 @@ class VoicePipeline:
|
||||
The TTS adapter remains responsible for provider-specific
|
||||
synthesis and audio streaming behavior.
|
||||
"""
|
||||
async for audio_chunk in self.tts.synthesize(
|
||||
response_text,
|
||||
):
|
||||
yield audio_chunk
|
||||
async with aclosing(self.tts.synthesize(response_text)) as audio_stream:
|
||||
async for audio_chunk in audio_stream:
|
||||
yield audio_chunk
|
||||
|
||||
|
||||
async def stream_turn_response(
|
||||
@@ -211,9 +210,9 @@ class VoicePipeline:
|
||||
f"Cannot stream response while turn is {turn.state.value}."
|
||||
)
|
||||
|
||||
stream_method = getattr(self.agent, "stream", None)
|
||||
agent = self.agent
|
||||
|
||||
if not callable(stream_method):
|
||||
if not isinstance(agent, StreamingAgentAdapter):
|
||||
raise RuntimeError(
|
||||
"Configured Agent does not support streaming."
|
||||
)
|
||||
@@ -230,7 +229,7 @@ class VoicePipeline:
|
||||
parts: list[str] = []
|
||||
|
||||
async with aclosing(
|
||||
stream_method(
|
||||
agent.stream(
|
||||
session_id=session_id,
|
||||
turn_id=turn_id,
|
||||
text=text,
|
||||
|
||||
Reference in New Issue
Block a user