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.
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 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)
|
||||
Reference in New Issue
Block a user