refactor(api): scope uv to s2s-client and validate compatible responses
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from s2s_client.api.responses import validated_json_response
|
||||
from s2s_client.core import config as settings
|
||||
from s2s_client.core import logging as app_logging
|
||||
from s2s_client.models.responses import ReportsResponse, UserProfileResponse
|
||||
from s2s_client.providers import caller as providers_caller
|
||||
from s2s_client.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)
|
||||
Reference in New Issue
Block a user