Files
alvaro 4b0108ecf0 feat(vocero): add linked content and TALKFIRST greetings (#2)
Opening a report or podcast link now establishes an authenticated starting reference for the conversation. Users can select other accessible content or compare reports while retaining the original link, and TALKFIRST greetings can name the verified report or podcast.

Release notes:

- Added authenticated report and episode link resolution with bounded lookups and unavailable-content handling
- Added explicit conversation selection without restricting searches or changing the reference during comparisons
- Added contextual TALKFIRST greetings through both voice-proxy and HTTP reasoning paths
- Preserved backend permission checks, conversation isolation, cancellation, and the existing speech protocol

Reviewed-on: AquaPortal/s2s#2
2026-10-08 09:34:16 -04:00

387 lines
17 KiB
Python

"""Authenticated MCP orchestration around the unchanged speech wire protocol."""
import asyncio
import json
from dataclasses import dataclass, field
from time import monotonic
from typing import Any
import websockets
from fastapi import WebSocket, WebSocketDisconnect
from starlette.websockets import WebSocketState
from websockets.asyncio.client import ClientConnection
from websockets.typing import Subprotocol
from vocero_api.core import config as settings
from vocero_api.core.logging import logger
from vocero_api.core.urls import _proxy_upstream_url
from vocero_api.models.mcp import IntegrationError
from vocero_api.services import content_context, conversations, mcp_tools
_TEXT_DELTA = {
"response.text.delta",
"response.output_text.delta",
"response.audio_transcript.delta",
"response.output_audio_transcript.delta",
}
_TEXT_DONE = {
"response.text.done",
"response.output_text.done",
"response.audio_transcript.done",
"response.output_audio_transcript.done",
}
_AUDIO = {"response.audio.delta", "response.output_audio.delta", "response.audio.done", "response.output_audio.done"}
@dataclass
class ResponseBuffer:
generation: int
text: str = ""
segment: str = ""
frames: list[str | bytes] = field(default_factory=list)
size: int = 0
plain: bool = False
def collect(self, raw: str | bytes, event: dict[str, Any]) -> None:
kind = event.get("type")
if kind in _TEXT_DELTA:
self.segment += event.get("delta", "")
elif kind in _TEXT_DONE:
text = event.get("transcript") or event.get("text") or self.segment
if isinstance(text, str) and text:
self.text = (self.text + " " + text).strip()
self.segment = ""
candidate = (self.text + self.segment).lstrip()
self.plain = bool(
candidate
and "<" not in candidate
and not candidate.startswith(">")
and "VOCERO_MCP" not in candidate.upper()
and not "VOCERO_MCP".startswith(candidate[:10].upper())
)
self.frames.append(raw)
self.size += len(raw)
if self.size > 8_000_000 or len(candidate) > 64000:
raise IntegrationError(502, "La respuesta de voz excedió el tamaño permitido.")
class McpRelay:
def __init__(
self, browser: WebSocket, upstream: ClientConnection, conversation: conversations.Conversation
) -> None:
self.browser = browser
self.upstream = upstream
self.conversation = conversation
self.turn = mcp_tools.ToolTurn(conversation, conversation.generation)
self.responses: dict[str, ResponseBuffer] = {}
self.finished: set[str] = set()
self.active_id = ""
self.accept_response = True
self.upstream_lock = asyncio.Lock()
self.browser_lock = asyncio.Lock()
self.tool_tasks: set[asyncio.Task[None]] = set()
self.base_instructions = settings.S2S_PRIMARY_PROMPT or ""
def instructions_event(self) -> str:
return json.dumps(
{
"type": "session.update",
"session": {"instructions": mcp_tools.instructions(self.base_instructions, self.conversation)},
},
ensure_ascii=False,
)
async def send_browser(self, raw: str | bytes) -> None:
async with self.browser_lock:
try:
if isinstance(raw, bytes):
await self.browser.send_bytes(raw)
else:
await self.browser.send_text(raw)
except (RuntimeError, WebSocketDisconnect):
raise asyncio.CancelledError from None
async def browser_event(self, event: dict[str, Any]) -> None:
await self.send_browser(json.dumps(event, ensure_ascii=False))
async def send_upstream(self, raw: str | bytes) -> None:
async with self.upstream_lock:
await self.upstream.send(raw)
def new_turn(self, *, speaking: bool = False) -> None:
self.turn = mcp_tools.ToolTurn(self.conversation, self.conversation.interrupt())
self.accept_response = not speaking
async def flush(self, buffer: ResponseBuffer) -> None:
for frame in buffer.frames:
await self.send_browser(frame)
buffer.frames.clear()
buffer.size = 0
async def tool_call(self, name: str, arguments: dict[str, Any], turn: mcp_tools.ToolTurn) -> None:
try:
await self.browser_event({"type": "vocero.tool.started", "name": name})
previous_content = self.conversation.current_content
result = await turn.call(name, arguments)
async with self.upstream_lock:
turn.require_current()
if previous_content != self.conversation.current_content:
await self.upstream.send(self.instructions_event())
await self.upstream.send(
json.dumps(
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": mcp_tools.result_text(name, result)}],
},
},
ensure_ascii=False,
)
)
turn.require_current()
await self.upstream.send(json.dumps({"type": "response.create"}))
except IntegrationError as exc:
if turn.generation != self.conversation.generation:
raise asyncio.CancelledError from None
await self.browser_event({"type": "error", "error": {"code": "mcp_error", "message": exc.message}})
await self.browser_event({"type": "vocero.tool.failed"})
if exc.status == 401:
conversations.store.remove(self.conversation.id)
except websockets.ConnectionClosed:
pass
except Exception as exc:
logger.warning("mcp_relay_tool_failed error_type=%s", type(exc).__name__)
await self.browser_event({"type": "error", "error": {"message": "No se pudo completar la consulta."}})
await self.browser_event({"type": "vocero.tool.failed"})
finally:
if self.conversation.pending is asyncio.current_task():
self.conversation.pending = None
async def client_to_speech(self) -> None:
while True:
self.conversation.require_active()
message = await self.browser.receive()
if message["type"] == "websocket.disconnect":
return
if message.get("bytes") is not None:
await self.send_upstream(message["bytes"])
continue
raw = message.get("text")
if raw is None:
continue
try:
event = json.loads(raw)
except ValueError:
await self.send_upstream(raw)
continue
if isinstance(event, dict):
kind = event.get("type")
if settings.S2S_REASONING_TYPE == "PROXY":
if kind == "response.cancel":
self.new_turn()
item = event.get("item")
if kind == "conversation.item.create" and isinstance(item, dict) and item.get("role") == "user":
self.new_turn()
session = event.get("session")
if kind == "session.update" and isinstance(session, dict):
# Browser instructions also carry the current session's RAG notes.
self.base_instructions = str(session.get("instructions") or settings.S2S_PRIMARY_PROMPT or "")
session["instructions"] = mcp_tools.instructions(self.base_instructions, self.conversation)
raw = json.dumps(event, ensure_ascii=False)
await self.send_upstream(raw)
async def speech_to_client(self) -> None:
async for raw in self.upstream:
self.conversation.require_active()
if settings.S2S_REASONING_TYPE != "PROXY":
await self.send_browser(raw)
continue
if isinstance(raw, bytes):
buffer = self.responses.get(self.active_id)
if buffer is None:
await self.send_browser(raw)
elif buffer.generation == self.conversation.generation:
buffer.collect(raw, {})
if buffer.plain:
await self.flush(buffer)
continue
try:
event = json.loads(raw)
except ValueError:
await self.send_browser(raw)
continue
if not isinstance(event, dict):
await self.send_browser(raw)
continue
kind = event.get("type", "")
if kind == "input_audio_buffer.speech_started":
self.new_turn(speaking=True)
if kind == "conversation.item.input_audio_transcription.completed":
self.accept_response = True
response = event.get("response")
response = response if isinstance(response, dict) else {}
response_id = event.get("response_id") or response.get("id") or self.active_id
if kind == "response.created":
if len(self.responses) >= 8:
raise IntegrationError(502, "El servicio de voz envió demasiadas respuestas simultáneas.")
self.active_id = str(response_id)
self.responses.setdefault(
self.active_id, ResponseBuffer(self.conversation.generation if self.accept_response else -1)
)
if response.get("output") is None:
response["output"] = []
await self.browser_event(event)
continue
if response_id in self.finished and kind.startswith("response."):
continue
protected = kind in _TEXT_DELTA | _TEXT_DONE | _AUDIO or kind in {
"response.content_part.added",
"response.content_part.done",
"response.output_item.added",
"response.output_item.done",
"response.done",
}
if not protected:
await self.send_browser(raw)
continue
# Some compatible speech servers emit text before response.created.
response_id = str(response_id or "unlabelled")
buffer = self.responses.setdefault(
response_id, ResponseBuffer(self.conversation.generation if self.accept_response else -1)
)
if kind == "response.done":
self.responses.pop(response_id, None)
self.finished.add(response_id)
if len(self.finished) > 256:
self.finished = {response_id}
if self.active_id == response_id:
self.active_id = ""
full_text = (
" ".join(
str(part.get("transcript") or part.get("text") or "")
for item in response.get("output") or []
if isinstance(item, dict)
for part in item.get("content") or []
if isinstance(part, dict)
).strip()
or (buffer.text + buffer.segment).strip()
)
chosen = None
error = None
if (
buffer.generation == self.conversation.generation
and response.get("status", "completed") == "completed"
):
try:
chosen = mcp_tools.selection(full_text)
except IntegrationError as exc:
error = exc
stale = buffer.generation != self.conversation.generation
interrupted_marker = response.get("status", "completed") != "completed" and (
not buffer.plain or "VOCERO_MCP" in full_text.upper()
)
if chosen or error or stale or interrupted_marker:
response["output"] = []
await self.browser_event({"type": "response.done", "response": response})
if chosen:
if self.conversation.pending is not None:
raise IntegrationError(502, "El servicio de voz solicitó consultas simultáneas.")
task = asyncio.create_task(self.tool_call(*chosen, self.turn))
self.conversation.pending = task
self.tool_tasks.add(task)
task.add_done_callback(self.tool_tasks.discard)
if error:
await self.browser_event({"type": "error", "error": {"message": error.message}})
await self.browser_event({"type": "vocero.tool.failed"})
else:
await self.flush(buffer)
if response.get("output") is None:
response["output"] = []
await self.browser_event(event)
if self.conversation.greeting_pending and response.get("status", "completed") == "completed":
self.conversation.greeting_pending = False
await self.send_upstream(self.instructions_event())
continue
if buffer.generation == self.conversation.generation:
buffer.collect(raw, event)
if buffer.plain:
await self.flush(buffer)
async def realtime_mcp_proxy(ws: WebSocket) -> None:
conversation: conversations.Conversation | None = None
claimed = False
tasks: list[asyncio.Task[None]] = []
relay: McpRelay | None = None
try:
conversation = conversations.resolve(ws)
if not settings.S2S_PROXY_TARGET:
raise IntegrationError(503, "El proxy de voz no está configurado.")
if conversation.socket_attached:
raise IntegrationError(409, "Esta conversación ya tiene una conexión de voz.")
conversation.socket_attached = True
claimed = True
await ws.accept()
if settings.S2S_REASONING_TYPE == "PROXY":
conversation.catalog = await content_context.catalog(conversation.backend_token)
conversation.require_active()
# Only the existing speech protocols cross the upstream boundary.
protocols = [
Subprotocol(part.strip())
for part in ws.headers.get("sec-websocket-protocol", "").split(",")
if part.strip()
]
async with websockets.connect(
_proxy_upstream_url(ws), subprotocols=protocols or None, open_timeout=15, max_size=8_000_000
) as upstream:
relay = McpRelay(ws, upstream, conversation)
async def expired_or_closed() -> None:
try:
await asyncio.wait_for(
conversation.closed.wait(), timeout=max(0, conversation.expires_at - monotonic())
)
except TimeoutError:
await relay.browser_event(
{"type": "error", "error": {"message": "La conversación expiró. Inicia una nueva sesión."}}
)
tasks = [
asyncio.create_task(relay.client_to_speech()),
asyncio.create_task(relay.speech_to_client()),
asyncio.create_task(expired_or_closed()),
]
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
task.result()
except IntegrationError as exc:
if exc.status == 401 and claimed and conversation is not None:
conversations.store.remove(conversation.id)
if ws.application_state == WebSocketState.CONNECTING:
await ws.accept()
await ws.send_json({"type": "error", "error": {"code": "mcp_error", "message": exc.message}})
except (WebSocketDisconnect, websockets.ConnectionClosed):
pass
except (OSError, TimeoutError):
await ws.send_json({"type": "error", "error": {"message": "No se pudo conectar con el servicio de voz."}})
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if relay is not None:
pending_tools = list(relay.tool_tasks)
for task in pending_tools:
task.cancel()
await asyncio.gather(*pending_tools, return_exceptions=True)
if claimed and conversation is not None:
pending = conversation.pending
conversations.store.remove(conversation.id)
if pending is not None:
await asyncio.gather(pending, return_exceptions=True)
try:
await ws.close()
except (RuntimeError, WebSocketDisconnect):
pass