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.
290 lines
13 KiB
Python
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)
|