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
This commit is contained in:
@@ -10,11 +10,14 @@ from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from jsonschema import Draft202012Validator, SchemaError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.core.logging import logger
|
||||
from vocero_api.models.content import SelectContentRequest
|
||||
from vocero_api.models.mcp import IntegrationError
|
||||
from vocero_api.providers import mcp
|
||||
from vocero_api.services import content_context
|
||||
from vocero_api.services.conversations import Conversation
|
||||
|
||||
CALL_PREFIX = "<<<VOCERO_MCP_CALL:"
|
||||
@@ -50,10 +53,10 @@ def instructions(base: str, conversation: Conversation) -> str:
|
||||
"You can make successive calls to resolve a report, read details, or request a page. "
|
||||
"If a selection is ambiguous, ask the user to choose. get_report selections appear inside result. "
|
||||
"For pagination reuse filters with next_offset. Do not fetch every page unnecessarily. "
|
||||
"There is no automatic current podcast yet; resolve a podcast ID with list_podcasts. "
|
||||
"Do not fall back to public URLs or web search for denied AquaPortal content. "
|
||||
"If a tool fails, explain that limitation; do not fabricate its result.\n"
|
||||
"When selecting a tool, output ONLY this marker as the entire response, with valid JSON arguments:\n"
|
||||
+ content_context.instructions(conversation)
|
||||
+ "When selecting a tool, output ONLY this marker as the entire response, with valid JSON arguments:\n"
|
||||
'<<<VOCERO_MCP_CALL:NAME=tool_name,ARGUMENTS={"request":{"filter":"value"}}>>>\n'
|
||||
"Use the exact tool name and complete parameters schema below, including optional filters and nested request. "
|
||||
"The marker is an internal signal, never spoken. After receiving data, either select another tool "
|
||||
@@ -85,7 +88,13 @@ class ToolTurn:
|
||||
|
||||
async def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
self.require_current()
|
||||
signature = json.dumps([name, arguments], sort_keys=True)
|
||||
content = self.conversation.current_content
|
||||
context_key = (
|
||||
(content.report.id if content.report else None, content.podcast.id if content.podcast else None)
|
||||
if name in {content_context.SELECT_CONTENT.name, "get_podcast", "read_podcast_transcript"}
|
||||
else None
|
||||
)
|
||||
signature = json.dumps([name, arguments, context_key], sort_keys=True)
|
||||
if (
|
||||
self.attempts >= settings.S2S_MCP_MAX_CALLS_PER_TURN
|
||||
or signature in self.seen
|
||||
@@ -97,7 +106,7 @@ class ToolTurn:
|
||||
self.attempts += 1
|
||||
self.seen.add(signature)
|
||||
definition = next((tool for tool in self.conversation.catalog if tool.name == name), None)
|
||||
if definition is None or name not in mcp.ALLOWED_TOOLS:
|
||||
if definition is None or name not in mcp.ALLOWED_TOOLS | {content_context.SELECT_CONTENT.name}:
|
||||
return {"error": "Unknown tool. Use a name from the catalog."}
|
||||
try:
|
||||
validator = Draft202012Validator(definition.parameters)
|
||||
@@ -107,7 +116,25 @@ class ToolTurn:
|
||||
}
|
||||
except SchemaError:
|
||||
raise IntegrationError(502, "El catálogo de herramientas no es válido.") from None
|
||||
result = await mcp.execute(self.conversation.backend_token, name, arguments)
|
||||
selected_content = None
|
||||
if name == content_context.SELECT_CONTENT.name:
|
||||
try:
|
||||
request = SelectContentRequest.model_validate(arguments).request
|
||||
except ValidationError:
|
||||
return {"error": "Select a report or podcast using a non-empty exact ID."}
|
||||
selected_content = await content_context.resolve(self.conversation.backend_token, request.kind, request.id)
|
||||
result = (
|
||||
{"error": "This content is unavailable. The current selection has not changed."}
|
||||
if selected_content.unavailable
|
||||
else {"data": {"current_selection": selected_content.model_dump(mode="json")}}
|
||||
)
|
||||
elif name in {"get_podcast", "read_podcast_transcript"}:
|
||||
podcast = self.conversation.current_content.podcast
|
||||
result = await mcp.execute(
|
||||
self.conversation.backend_token, name, arguments, podcast_id=podcast.id if podcast else None
|
||||
)
|
||||
else:
|
||||
result = await mcp.execute(self.conversation.backend_token, name, arguments)
|
||||
self.require_current()
|
||||
size = len(json.dumps(result, ensure_ascii=False))
|
||||
if self.result_chars + size > 96000:
|
||||
@@ -115,5 +142,7 @@ class ToolTurn:
|
||||
"error": "The turn's content limit was reached. Answer from existing results or ask for a narrower query."
|
||||
}
|
||||
self.result_chars += size
|
||||
if selected_content is not None and not selected_content.unavailable:
|
||||
self.conversation.current_content = selected_content
|
||||
logger.info("mcp_tool_completed tool=%s user_id=%s result_chars=%d", name, self.conversation.profile.id, size)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user