Files
vocero-client/vocero-api/vocero_api/api/routes/caller.py
T
alvaro 96895996d9 feat(mcp): connect authenticated AquaPortal tools to Vocero (#1)
Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged.

Release notes:

- Added browser-bound conversations that keep backend credentials server-side and isolate callers.
- Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results.
- Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection.
- Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup.
- Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints.
- Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage.

Reviewed-on: AquaPortal/s2s#1
2026-10-08 08:49:39 -04:00

294 lines
13 KiB
Python

from __future__ import annotations
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from vocero_api.api.responses import validated_json_response
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.models.responses import ReportsResponse, UserProfileResponse
from vocero_api.providers import caller as providers_caller
from vocero_api.services import context as services_context
router = APIRouter()
@router.get("/api/user/phone", response_model=UserProfileResponse)
async def user_by_phone(phoneNumber: str) -> JSONResponse:
"""HTTP passthrough: fetches caller identity / profile from the external
info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw
JSON to the browser.
The proxy NEVER inspects this payload nor injects it into the realtime
WebSocket session. It is only used by:
- the browser UI to render a caller name header (#ident-user-name)
- the /api/reasoning HTTP body user_context field (browser's choice).
When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can
silently skip identity loading (backward compatible with generic deploys).
When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone
POST with the caller phone and (if a bearer token is extracted) forwards
it as Authorization: Bearer on the subsequent profile GET.
"""
if settings.S2S_MCP_URL:
raise HTTPException(409, "Usa la sesión de conversación para consultar AquaPortal.")
if not settings.VOCERO_USER_PHONE_AUTH_URL:
raise HTTPException(
status_code=503,
detail="VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.",
)
phone = (phoneNumber or "").strip()
if not phone:
raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.")
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
login_token = None
if settings.S2S_LOGIN_BY_PHONE:
login_token, _login_obj = await providers_caller._login_by_phone(phone)
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}"
app_logging.logger.debug(
"[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s",
settings.VOCERO_USER_PHONE_AUTH_URL,
len(phone),
"yes" if login_token else "no",
)
try:
headers = {}
if login_token:
headers["Authorization"] = f"Bearer {login_token}"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
resp = await http.get(target_url, headers=headers or None)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de información del usuario.") # noqa: B904
# Forward the upstream status + JSON verbatim; never rewrite the payload.
if resp.status_code >= 500:
app_logging.logger.warning(
"[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s",
resp.status_code,
resp.text,
)
try:
payload = resp.json()
except Exception:
raise HTTPException( # noqa: B904
status_code=502,
detail="El servicio de información del usuario devolvió un JSON no válido.",
)
return validated_json_response(
payload, UserProfileResponse, status_code=resp.status_code if resp.status_code != 204 else 200
)
@router.get("/api/user/reports", response_model=ReportsResponse)
async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
"""Fetch + normalize the scheduled reports list for the caller UI.
Mirrors the same sequence used in the WebSocket connect handler and
/api/reasoning:
0. login-by-phone (optional) → bearer token
1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth
2. run the same resolver used for prompt injection: filter 3 fields,
sort DESC, build hints + entries → JSON returned verbatim so the
browser can render a "reports loaded" summary in the UI header.
Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no
reports feature enabled) so the browser silently skips UI rendering.
Response shape on success:
{
"count": N,
"wrappedLen": X,
"hints": [ "• Report #1: '<label>' — scheduled on <date>", ... ],
"entries": [
{"sourceScheduleLabel": "...", "date": "...", "podcastScript": "..."},
...
]
}
USER FIX 2026-09-11: remove mass injection of scheduled reports.
• Even this BROWSER-FACING endpoint (/api/user/reports) now honours the new
S2S_INJECT_REPORTS default=False gate: when S2S_INJECT_REPORTS is not
explicitly true, we return a polite 403 + empty response — the endpoint
does NOT execute any upstream S2S_REPORT_LIST HTTP call, does NOT expose
any podcastScript bodies, and does NOT emit entries/hints. This avoids
a subtle loophole where the browser could still fetch scheduled reports
through /api/user/reports while the LLM prompt paths were gated.
"""
if settings.S2S_MCP_URL:
raise HTTPException(409, "Los reportes se consultan durante la conversación con AquaPortal.")
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
if not settings.S2S_INJECT_REPORTS:
return validated_json_response(
model=ReportsResponse,
status_code=200,
content={
"count": 0,
"wrappedLen": 0,
"hints": [],
"entries": [],
"skipped": True,
"reason": "S2S_INJECT_REPORTS está deshabilitado en este despliegue; no se muestran reportes programados en la interfaz ni se envían al modelo.",
},
)
# ───────────────────────────────────────────────────────────────────────
if not settings.S2S_REPORT_LIST:
raise HTTPException(
status_code=503,
detail="S2S_REPORT_LIST no está configurado en este despliegue.",
)
phone = (phoneNumber or "").strip() or None
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
# (same rule as WS connect / reasoning handlers — never-fail).
login_token = None
if settings.S2S_LOGIN_BY_PHONE and phone:
login_token, _login_obj = await providers_caller._login_by_phone(phone)
reports_raw = await providers_caller._fetch_reports_list(phone, login_token)
# Re-run the same resolver used for prompt injection → identical logic
# guarantees UI and LLM see exactly the same filtered/sorted list.
ctx_str = services_context._resolve_reports_context(reports_raw)
if not ctx_str or not isinstance(reports_raw, list):
return validated_json_response(
{
"count": 0,
"wrappedLen": 0,
"hints": [],
"entries": [],
"skipped": True,
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
},
ReportsResponse,
)
# Rebuild hints + normalized entries with the exact filtered output
items_list: list = reports_raw
# Re-derive filtered + sorted list (same as _resolve_reports_context) so
# we don't accidentally expose raw unfiltered upstream payload to the UI.
filtered: list[dict] = []
for item in items_list:
if not isinstance(item, dict):
continue
cleaned: dict = {}
label_val: object = None
if item.get("sourceScheduleLabel") is not None:
label_val = item["sourceScheduleLabel"]
elif isinstance(item.get("sourceSchedule"), dict):
ss = item["sourceSchedule"]
for k in ("label", "name", "title"):
if ss.get(k) is not None:
label_val = ss[k]
break
if label_val is not None:
cleaned["sourceScheduleLabel"] = label_val
date_val: object = None
if item.get("date") is not None:
date_val = item["date"]
elif isinstance(item.get("sourceSchedule"), dict):
ss = item["sourceSchedule"]
for k in ("date", "scheduledAt", "publishedAt"):
if ss.get(k) is not None:
date_val = ss[k]
break
if date_val is not None:
cleaned["date"] = date_val
script_val: object = None
for k in ("podcastScript", "script", "text", "content"):
if item.get(k) is not None:
script_val = item[k]
break
if script_val is not None:
cleaned["podcastScript"] = script_val
if cleaned:
filtered.append(cleaned)
filtered.sort(key=lambda r: services_context._parse_date_key(r.get("date")))
# 2026-09-11 user fix: configure report quick-hint limits through .env.
# Apply the SAME cap used by the LLM prompt resolver: after sort by newest,
# keep only first S2S_REPORTS_QUICK_HINT_MAX entries. This keeps counts between
# /api/user/reports UI JSON and the LLM prompt identical — avoids confusing
# mismatches where the UI shows 20 hints but the prompt only saw 5.
total_raw_ui = len(filtered)
cap_ui = max(1, int(settings.S2S_REPORTS_QUICK_HINT_MAX or 5))
truncated_ui_note: str | None = None
if total_raw_ui > cap_ui:
filtered = filtered[:cap_ui]
truncated_ui_note = (
f"Se muestran los {cap_ui} reportes más recientes de un total de {total_raw_ui}. "
"Ajuste la variable de entorno S2S_REPORTS_QUICK_HINT_MAX para cambiar la cantidad."
)
import datetime as _dt
# Deduplicate by label for the UI JSON response only.
# NOTE: the LLM prompt injection uses the full non-deduped list (different
# dates = different content even when the scheduled report name repeats).
# We only dedup entries_out, hints_list, and entries for the browser payload.
seen_labels: set[str] = set()
dedup_filtered: list[dict] = []
for r in filtered:
lbl_raw = r.get("sourceScheduleLabel")
if lbl_raw is None:
# Items with no label are kept as-is (can't meaningfully dedupe)
dedup_filtered.append(r)
continue
key = str(lbl_raw).strip()
if not key:
dedup_filtered.append(r)
continue
if key in seen_labels:
continue
seen_labels.add(key)
dedup_filtered.append(r)
hints_list: list[str] = []
entries_out: list[dict] = []
for idx, r in enumerate(dedup_filtered, start=1):
entries_out.append(r)
label_piece = str(r["sourceScheduleLabel"]).strip() if r.get("sourceScheduleLabel") else ""
date_piece = ""
if r.get("date") is not None:
raw_d = r["date"]
try:
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
ts_sec = float(raw_d)
if ts_sec > 1e12:
ts_sec = ts_sec / 1000.0
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.UTC)
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
else:
s = str(raw_d).strip()
if "T" in s and len(s) >= 16:
date_piece = s.replace("T", " ")[:16]
else:
date_piece = s
except Exception:
date_piece = str(raw_d)[:32]
if label_piece and date_piece:
hints_list.append(f"• Reporte #{idx}: '{label_piece}' — programado para {date_piece}")
elif label_piece:
hints_list.append(f"• Reporte #{idx}: '{label_piece}'")
elif date_piece:
hints_list.append(f"• Reporte #{idx}: (sin título) — programado para {date_piece}")
resp_body = {
"count": len(entries_out),
"rawCount": total_raw_ui,
"cap": cap_ui,
"wrappedLen": len(ctx_str),
"hints": hints_list,
# Strip the long podcastScript from the summary JSON to keep the
# browser response lightweight (UI only needs label/date).
"entries": [
{
"sourceScheduleLabel": r.get("sourceScheduleLabel"),
"date": r.get("date"),
}
for r in entries_out
],
}
if truncated_ui_note is not None:
resp_body["truncated"] = True
resp_body["note"] = truncated_ui_note
return validated_json_response(resp_body, ReportsResponse)