Files
vocero-client/vocero-api/vocero_api/api/routes/caller.py
T
alvaro abd03ed07d refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts.

Make AGENTS.md self-contained and remove external skill and workflow references.
2026-10-06 19:26:55 -03:00

290 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 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.
"""
# ── 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)