refactor(server): adopt uv and modular typed FastAPI structure

This commit is contained in:
2026-10-06 18:18:36 -03:00
parent 34f0d1b125
commit 92c53dc7ca
38 changed files with 8363 additions and 6622 deletions
View File
View File
+16
View File
@@ -0,0 +1,16 @@
"""Register routes in their original order without changing public paths."""
from fastapi import APIRouter
from s2s_client.api.routes import apipilot, caller, config, search, sessions
from s2s_client.realtime.proxy import realtime_proxy
from s2s_client.services.reasoning import reasoning
router = APIRouter()
router.include_router(config.router)
router.include_router(apipilot.router)
router.add_api_route("/api/reasoning", reasoning, methods=["POST"])
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
router.include_router(caller.router)
router.include_router(search.router)
router.include_router(sessions.router)
View File
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests
from s2s_client.providers import apipilot as providers_apipilot
router = APIRouter()
@router.get("/api/apipilot/tools", response_model=None)
async def apipilot_list_tools(
request: Request,
include_unauthorized: bool = True,
refresh: bool = False,
) -> dict[str, Any] | JSONResponse:
"""Public proxy for ApiPilot tool discovery (guide §2.1).
Forwards Authorization / x-demo-session headers from the caller to the
upstream backend verbatim and returns the list of `ToolDefinition` records
exactly as documented:
{ "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false }
A short in-process TTL cache is applied (default 5 min, configurable via
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
if not settings.S2S_APIPILOT_TOOLS_URL:
return JSONResponse(
status_code=501,
content={
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
},
)
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
try:
tools = await providers_apipilot._apipilot_fetch_tools(
auth_header=auth,
demo_session=demo,
include_unauthorized=bool(include_unauthorized),
force_refresh=bool(refresh),
)
except HTTPException as exc:
# Normalize to envelope-style payload so downstream code is uniform.
detail = (
exc.detail
if isinstance(exc.detail, dict)
else {
"code": "APIPILOT_ERROR",
"message": str(exc.detail) if exc.detail else "Error",
"status_code": exc.status_code,
"raw": None,
}
)
return JSONResponse(
status_code=exc.status_code,
content={"success": False, "data": None, "error": detail},
)
app_logging.logger.info(
"[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s",
len(tools),
bool(include_unauthorized),
bool(refresh),
)
return {
"success": True,
"data": {
"count": len(tools),
"include_unauthorized": bool(include_unauthorized),
"tools": tools,
},
"error": None,
}
@router.post("/api/apipilot/execute")
async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
"""Public proxy for ApiPilot /execute (guide §2.2).
Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool
discovery, `requires_auth` gate, strict JSON-Schema argument validation),
forwards the request to the upstream backend, and returns the envelope
`{success, data, error}`.
Show only `error.message` to the end user. `error.code`, `error.status_code`
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
if not settings.S2S_APIPILOT_TOOLS_URL:
return JSONResponse(
status_code=501,
content={
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
},
)
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s",
(body.name or ""),
sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [],
("Y" if auth or demo else "none"),
)
envelope = await providers_apipilot._apipilot_execute_tool(
auth_header=auth,
demo_session=demo,
name=body.name,
arguments=body.arguments if isinstance(body.arguments, dict) else {},
)
status_code = 200
if (not envelope.get("success")) and isinstance(envelope.get("error"), dict):
code = envelope["error"].get("status_code")
try:
code_int = int(code)
if 400 <= code_int < 599:
status_code = code_int
except Exception:
pass
app_logging.logger.info(
"[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s",
body.name,
envelope.get("success"),
status_code,
)
return JSONResponse(status_code=status_code, content=envelope)
+283
View File
@@ -0,0 +1,283 @@
from __future__ import annotations
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
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")
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 JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload)
@router.get("/api/user/reports")
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 JSONResponse(
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 JSONResponse(
{
"count": 0,
"wrappedLen": 0,
"hints": [],
"entries": [],
"skipped": True,
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
}
)
# 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 JSONResponse(resp_body)
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.core import urls as core_urls
router = APIRouter()
def _effective_s2s_url(request: Request) -> str:
return settings.SPEECH_TO_SPEECH_URL or core_urls._proxy_public_url(request)
@router.get("/api/config")
async def config(request: Request) -> dict[str, Any]:
"""Client bootstrap: whether web search is available, whether the deploy runs
behind a load balancer (so the browser uses the /api/session proxy),
and whether the user may instead set a direct
s2s server URL. The LB address itself is intentionally NOT included.
2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the
new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see
docs/client/tool-calling.md) that the browser may call directly instead of
hardcoding upstream tool URLs / names."""
app_logging.logger.debug(
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
)
primary_prompt = settings.S2S_PRIMARY_PROMPT
out = {
"search": bool(settings.SERPER_KEY),
"lb": bool(settings.LOAD_BALANCER_URL),
"allowDirect": not settings.LOAD_BALANCER_URL,
"s2sUrl": _effective_s2s_url(request),
"primaryPrompt": primary_prompt,
"reasoningType": settings.S2S_REASONING_TYPE,
"injectProfile": settings.S2S_INJECT_PROFILE,
"debugPrompt": settings.S2S_DEBUG_PROMPT,
"startSessionMode": settings.S2S_START_SESSION_MODE,
"theme": settings.S2S_THEME,
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
}
app_logging.logger.debug(
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
len(primary_prompt),
settings.S2S_REASONING_TYPE,
settings.S2S_START_SESSION_MODE or "-",
settings.S2S_THEME or "-",
out["apipilot"],
)
return out
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import json
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests
router = APIRouter()
@router.post("/api/search")
async def search(req: models_requests.SearchRequest) -> JSONResponse:
"""Proxy a Google search via Serper.dev. The key stays on the server unless
the user brought their own (then theirs is used for this request only)."""
query = (req.query or "").strip()
if not query:
raise HTTPException(status_code=400, detail="La consulta está vacía.")
key = (req.key or "").strip() or settings.SERPER_KEY
has_user_key = bool((req.key or "").strip())
if not key:
# No server key and the user didn't supply one — search is unavailable.
raise HTTPException(status_code=503, detail="La búsqueda no está configurada.")
headers = {"X-API-KEY": key, "Content-Type": "application/json"}
payload = {"q": query, "num": settings.MAX_RESULTS}
app_logging.logger.debug(
"[EXT][SERPER] search POST %s query=%s userKey=%s num=%d",
settings.SERPER_URL,
json.dumps(query, ensure_ascii=True)[:200],
has_user_key,
settings.MAX_RESULTS,
)
try:
async with httpx.AsyncClient(timeout=12.0) as http:
resp = await http.post(settings.SERPER_URL, headers=headers, json=payload)
except httpx.RequestError as exc:
app_logging.logger.warning("Serper unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el proveedor de búsqueda.") # noqa: B904
app_logging.logger.debug(
"[EXT][SERPER] search RESP status=%s len=%dB",
resp.status_code,
len(resp.content or b""),
)
if resp.status_code != 200:
# Serper's error body carries the real reason (e.g. "Not enough
# credits") and contains no key, so it's safe to log and relay.
body = resp.text[:300]
app_logging.logger.warning("Serper error %s: %s", resp.status_code, body)
msg = None
try:
msg = resp.json().get("message")
except Exception:
pass
detail = f"Error del proveedor de búsqueda ({resp.status_code})"
if msg:
detail += f": {msg}"
raise HTTPException(status_code=502, detail=detail)
data = resp.json()
results = []
for item in (data.get("organic") or [])[: settings.MAX_RESULTS]:
results.append(
{
"title": item.get("title", ""),
"snippet": item.get("snippet", ""),
"url": item.get("link", ""),
}
)
# A direct answer when Google has one — saves the model a hop.
box = data.get("answerBox") or {}
answer = box.get("answer") or box.get("snippet") or None
if not answer:
kg = data.get("knowledgeGraph") or {}
answer = kg.get("description") or None
app_logging.logger.debug(
"[EXT][SERPER] search PARSED hasAnswer=%s resultCount=%d titles=%s",
bool(answer),
len(results),
[r.get("title", "")[:40] for r in results],
)
return JSONResponse({"query": query, "answer": answer, "results": results})
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
router = APIRouter()
@router.post("/api/session")
async def session(request: Request) -> JSONResponse:
"""Relay a session grant or queue ticket, keeping the LB address server-side.
The grant's `connect_url` contains the compute host and short-lived token the
browser needs to connect directly to the allocated speech service."""
client_ip = request.client.host if request.client else "?"
if not settings.LOAD_BALANCER_URL:
# No LB configured — this deploy is direct-mode only; the browser should
# never call this. 404 so it's indistinguishable from a missing route.
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] session: client=%s", client_ip)
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/session"
app_logging.logger.debug("[EXT][LB] session POST %s (handshake)", url)
try:
async with httpx.AsyncClient(timeout=15.0) as http:
lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
except httpx.RequestError as exc:
app_logging.logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
app_logging.logger.debug(
"[EXT][LB] session RESP status=%s len=%dB",
lb.status_code,
len(lb.content or b""),
)
# The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is
# so the client shows a soft "try again shortly", not a hard error.
if lb.status_code == 503:
body = _safe_json(lb)
if body.get("state") == "at_capacity":
app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full")
return JSONResponse({"state": "at_capacity"}, status_code=503)
if lb.status_code != 200:
# The LB's error body may name the reason (e.g. capacity); it carries no
# secret, so relay a trimmed copy.
app_logging.logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"No se pudo establecer la sesión ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120]
session_id = data.get("session_id") or data.get("sessionId")
app_logging.logger.debug(
"[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s",
state,
session_id,
bool(connect_url),
connect_url,
)
# Busy pool: the LB queued us. Relay the ticket for subsequent polling.
if data.get("state") == "queued":
app_logging.logger.debug(
"[EXT][LB] session: queued queueId=%s position=%s",
data.get("queue_id") or data.get("queueId"),
data.get("position"),
)
return JSONResponse(data)
@router.get("/api/queue/{queue_id}")
async def queue_status(queue_id: str, request: Request) -> JSONResponse:
"""Relay a waiting ticket's position or its newly claimed session grant."""
client_ip = request.client.host if request.client else "?"
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug(
"[EXT][LB] queue poll: client=%s queueId=%s",
client_ip,
queue_id[:16],
)
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
app_logging.logger.debug("[EXT][LB] queue GET %s", url)
try:
async with httpx.AsyncClient(timeout=15.0) as http:
lb = await http.get(url)
except httpx.RequestError as exc:
app_logging.logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
app_logging.logger.debug(
"[EXT][LB] queue RESP status=%s len=%dB",
lb.status_code,
len(lb.content or b""),
)
if lb.status_code == 404:
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
# to start over rather than spin.
app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
return JSONResponse({"state": "expired"}, status_code=404)
if lb.status_code != 200:
app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"No se pudo consultar la cola de espera ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
position = data.get("position")
app_logging.logger.debug(
"[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s",
queue_id[:16],
state,
position,
data.get("session_id") or data.get("sessionId"),
)
return JSONResponse(data)
@router.delete("/api/queue/{queue_id}")
async def queue_leave(queue_id: str) -> dict[str, bool]:
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
await _lb_leave(queue_id)
return {"ok": True}
@router.post("/api/queue/end")
async def queue_end(request: Request) -> dict[str, bool]:
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only
POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
qid = await _queue_id(request)
if qid:
app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
await _lb_leave(qid)
return {"ok": True}
async def _lb_leave(queue_id: str) -> None:
"""Best-effort: tell the LB to drop a waiting ticket."""
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
app_logging.logger.debug("[EXT][LB] queue DELETE %s", url)
try:
async with httpx.AsyncClient(timeout=5.0) as http:
resp = await http.delete(url)
app_logging.logger.debug(
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
queue_id[:16],
resp.status_code,
)
except httpx.RequestError as exc:
app_logging.logger.warning("Queue leave failed: %r", exc)
def _safe_json(response: httpx.Response) -> dict[str, Any]:
try:
body = response.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
async def _queue_id(request: Request) -> str:
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
try:
data = await request.json()
except Exception:
return ""
return (data or {}).get("queueId", "") if isinstance(data, dict) else ""
View File
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import os
from dotenv import load_dotenv
from s2s_client.core import logging as app_logging
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False)
app_logging.logger.debug("Loaded .env from %s", ROOT_DIR)
def _normalize_reasoning_type(value: str) -> str:
normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper()
if normalized == "OPENAI":
return "OPENAI"
if normalized in {"NODE_RED", "NODERED"}:
return "NODE_RED"
return "PROXY"
SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip()
if S2S_PRIMARY_PROMPT:
S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n")
# External user-by-phone info endpoint. When set, GET /api/user/phone proxies
# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the
# browser so the UI can render caller identity / caller context.
VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/")
# Feature flag: when "true" (case-insensitive) => injects the resolved caller
# profile into the realtime session (patch session.update client->upstream,
# append profile to the LLM system instructions prompt instructions, and prepend it to the
# reasoning user_context for the /api/reasoning HTTP body.
# Default = disabled (generic deploy; pure passthrough; all characterization lives
# in the backend RAG per original architectural decision.)
S2S_INJECT_PROFILE = (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() in {"1", "true", "yes", "on"}
# Endpoint URL for fetching the latest per-caller conversation summary, used when
# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely
# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber=<x>
# and expects JSON back (optionally wrapped as {"result": <value>}). Empty
# string means the feature is off (no network call, nothing injected).
S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip()
# Static / hardcoded latest-summary text. When set (non-empty string), it takes
# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and
# injects this raw plain-text value directly into the prompt as the latest
# conversation summary. Designed for cases where the operator wants a clean,
# essential, pre-cleaned summary snippet without depending on an upstream API.
# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no
# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context
# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper).
S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip()
if S2S_LATEST_SUMMARY_TEXT:
# Interpret \n escape sequences so multi-line summaries can be written as a
# single env line (matches S2S_PRIMARY_PROMPT convention above).
S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n")
# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and
# phoneNumber is available), before fetching user profile / latest summary the proxy
# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body,
# extracts an auth token from the response (if any), and forwards it as
# Authorization: Bearer <token> for subsequent profile / summary / reports HTTP calls.
S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/")
# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true)
# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}),
# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC,
# and injects the compiled block into the LLM instructions / user_context between
# the caller profile block and the latest conversation summary block.
S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/")
# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST
# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT)
# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and
# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue:
# "All reports are currently injected with their complete podcastScript."
#
# Historical note (2026-09-11): before this flag existed, if S2S_INJECT_PROFILE=true AND
# S2S_REPORT_LIST was set the server eagerly fetched every scheduled report for the
# caller, serialized all their full podcastScript fields into a single block (capped at
# 12000 chars) and appended it to every LLM turn. That was a huge prompt-bloat injection
# the user explicitly flagged as unwanted. The safe default is therefore DISABLED: to
# restore mass-report-injection, operators must opt in with S2S_INJECT_REPORTS=true.
S2S_INJECT_REPORTS = (os.environ.get("S2S_INJECT_REPORTS", "") or "").strip().lower() in {"1", "true", "yes", "on"}
# Max number of scheduled reports to surface in the QUICK-HINT line block (the TOC of
# label + date shown before the serialized JSON entries). Also applied as a hard cap to
# the final filtered+serialized entries list — so the wrapped prompt block never carries
# content for more than N reports, even when the upstream API returns 20+. Configurable
# from .env; value is integer-parsed and clamped to [1, 50] (invalid values fall back to
# the default = 5, which is the user's requested "max 3-5" default range). User request
# 2026-09-11: "Limit report quick hints to 3-5 entries, configurable through .env."
try:
_S2S_REPORTS_QUICK_HINT_MAX_RAW = int((os.environ.get("S2S_REPORTS_QUICK_HINT_MAX", "") or "5").strip())
except Exception:
_S2S_REPORTS_QUICK_HINT_MAX_RAW = 5
S2S_REPORTS_QUICK_HINT_MAX = max(1, min(50, _S2S_REPORTS_QUICK_HINT_MAX_RAW))
del _S2S_REPORTS_QUICK_HINT_MAX_RAW
# Feature flag: when "true" → prepend a TODAY date + anti-hallucination guard to
# every LLM prompt and session instructions. Fixes user bug 3 2026-09-11:
# "Qwen2.5 has a training cutoff around mid-2024 and produces Chinese meta-comments
# about 2026 reports: 'Today is 2023; why are these reports dated 2026?'"
# Default = ON (safe even when empty/unset). Explicit S2S_INJECT_TODAY="false" to disable.
# Injected text contains:
# • The actual date: "TODAY is YYYY-MM-DD" (ISO, server local date).
# • Earlier reports are historical records; do not question their dates.
# • No internal meta-comments or prompt introspection.
# • No thinking leaks with GPTQ Int4: only words Vocero says aloud.
# • Retrieve report content through RAG rather than inventing it from summaries.
S2S_INJECT_TODAY = (os.environ.get("S2S_INJECT_TODAY", "") or "").strip().lower() not in {"0", "false", "no", "off"}
# Feature flag: S2S_REPORTS_INJECT_MODE controls what the server sends into the LLM
# prompt for scheduled reports. Values (case-insensitive, default="minimal"):
# • "minimal" → ONLY last N = S2S_REPORTS_QUICK_HINT_MAX items as a pure
# TOC (label + date ONLY). NO podcastScript is ever exposed. This
# the user's "Keep reports only as a MINIMAL summary"
# fix. Token budget: ~50 tokens instead of ~3200.
# • "full" → legacy behaviour: full JSON array with podcastScript
# (still capped to S2S_REPORTS_QUICK_HINT_MAX items). Used to be
# the default before 2026-09-11; kept only for explicit opt-in
# because of the user explicitly flagged the massive script injection
# as unwanted on this session.
# • "off" / "none" → no reports injection at all (same as S2S_INJECT_REPORTS=false)
# even when S2S_INJECT_REPORTS=true. Use this to keep the HTTP
# fetch running while keeping the prompt clean, or as a redundant kill.
S2S_REPORTS_INJECT_MODE = (os.environ.get("S2S_REPORTS_INJECT_MODE", "") or "").strip().lower()
if S2S_REPORTS_INJECT_MODE not in {"minimal", "full", "off", "none"}:
S2S_REPORTS_INJECT_MODE = "minimal"
# Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just
# lengths) going to the upstream LLM/realtime backends. Includes:
# - WS session.update session.instructions (before/after patch)
# - /api/reasoning NODE_RED instructions + user_context full text
# - /api/reasonING OPENAI full system message + user transcript
# Default = off; only length summaries are logged (current behaviour).
# WARNING: setting this on will log potentially PII / sensitive caller
# profile data / user transcripts. Use only in development / debugging.
S2S_DEBUG_PROMPT = (os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower() in {"1", "true", "yes", "on"}
S2S_START_SESSION_MODE = (os.environ.get("S2S_START_SESSION_MODE", "") or "").strip().upper()
S2S_THEME = (os.environ.get("S2S_THEME", "") or "").strip().lower()
if S2S_THEME and S2S_THEME not in {"white", "dark"}:
S2S_THEME = ""
S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY"))
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/")
OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip()
OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip()
OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip()
# ──────────────────────────────────────────────────────────────────────────────
# APIPILOT — Backend AquaPortal dynamic-tool bridge (docs/client/tool-calling.md)
# ──────────────────────────────────────────────────────────────────────────────
# Base URL for ApiPilot dynamic tool discovery and execution. When configured,
# the proxy sends GET <url>/tools with include_unauthorized=true (guide §2.1)
# to discover tools, and POST <url>/execute (§2.2) to invoke one. The trailing
# slash is optional; the proxy normalizes it with rstrip("/") below.
#
# Example: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot
S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/")
# Explicit execution URL override (guide §2.2). When set, it takes precedence
# over S2S_APIPILOT_TOOLS_URL for POST execution calls. Remove trailing slashes
# and append "/execute" only if the URL does not already end with that suffix.
# Example 1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute
# Example 2: S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute
S2S_APIPILOT_TOOLS_EXECUTE_URL = os.environ.get("S2S_APIPILOT_TOOLS_EXECUTE_URL", "").strip().rstrip("/")
# Optional fixed bearer token, used only when the caller supplies no session
# token (Authorization / x-demo-session). Useful for staging service tokens.
S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip()
# 2026-09-25 SUPER API KEY (TEMPORARY AQUAPORTAL AUTHENTICATION BYPASS).
# When set, send all three header forms for maximum compatibility:
# 1) X-API-Key: <key>
# 2) Authorization: Bearer <key>
# 3) x-demo-session: <key>
# Highest priority; completely disables the unnecessary login_by_phone call.
# Clear the value to restore standard login.
S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'")
# Tool discovery cache TTL in seconds. The guide recommends a five-minute
# client cache; the server uses the same default: 300 seconds.
try:
_APIPILOT_TTL_RAW = int((os.environ.get("S2S_APIPILOT_TTL_SEC", "") or "300").strip())
except Exception:
_APIPILOT_TTL_RAW = 300
APIPILOT_CACHE_TTL_SEC = max(15, min(3600, _APIPILOT_TTL_RAW))
del _APIPILOT_TTL_RAW
# HTTP timeout for ApiPilot discovery and execution calls, in seconds.
try:
_APIPILOT_TO_RAW = float((os.environ.get("S2S_APIPILOT_TIMEOUT_SEC", "") or "15.0").strip())
except Exception:
_APIPILOT_TO_RAW = 15.0
APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW))
del _APIPILOT_TO_RAW
# Node-RED reasoning engine endpoint (NODE_RED mode).
# Examples: http://localhost:1881 or http://s2s-reasoning:1880 (Docker network).
NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/")
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
# (which proxies <lb>/session here, server-side) and connects to the URL the LB
# returns (the original flow). The LB address itself is never sent to the browser.
# When empty, the user may instead set a direct s2s server URL in Settings and the
# browser connects to it straight (no load balancer).
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
# Direct s2s server URL pinned by the deploy. Takes priority over the load
# balancer: when set, LB allocation and queuing are disabled, and the browser
# connects to this URL directly. Unlike the LB address it is NOT a secret —
# /api/config sends it to the client, which
# shows it read-only in Settings.
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
# Optional same-origin WebSocket relay target. When set, the browser dials this
# app over ws(s)://<this-app>/v1/realtime and the server relays frames to the
# upstream speech-to-speech endpoint, keeping the internal host private.
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
LOAD_BALANCER_URL = ""
SERPER_URL = "https://google.serper.dev/search"
# Cap results so the tool output stays small enough to feed back to the model.
MAX_RESULTS = 5
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
# Resolve the execution URL for the startup banner using the same normalization
# as _apipilot_resolve_execute_url.
def _resolve_exec_url_for_startup_banner() -> str:
base = (settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL).strip().rstrip("/")
if not base:
return ""
if base.lower().endswith("/execute"):
return base
return f"{base}/execute"
_STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
del _resolve_exec_url_for_startup_banner
app_logging.logger.info(
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
settings.S2S_REASONING_TYPE,
settings.NODE_RED_REASONING_URL or "-",
bool(settings.SERPER_KEY),
bool(settings.LOAD_BALANCER_URL),
bool(settings.S2S_PROXY_TARGET),
bool(settings.SPEECH_TO_SPEECH_URL),
bool(settings.VOCERO_USER_PHONE_AUTH_URL),
bool(settings.S2S_REPORT_LIST),
settings.S2S_INJECT_REPORTS,
settings.S2S_REPORTS_QUICK_HINT_MAX,
settings.S2S_REPORTS_INJECT_MODE,
settings.S2S_INJECT_TODAY,
bool(settings.S2S_LATEST_SUMMARY),
bool(settings.S2S_LATEST_SUMMARY_TEXT),
bool(settings.S2S_LOGIN_BY_PHONE),
settings.S2S_INJECT_PROFILE,
settings.S2S_DEBUG_PROMPT,
settings.S2S_START_SESSION_MODE or "-",
settings.S2S_THEME or "-",
settings.S2S_APIPILOT_TOOLS_URL or "-",
(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})")
if settings.S2S_APIPILOT_TOOLS_EXECUTE_URL
else "-",
"Y" if bool(settings.S2S_APIPILOT_SERVICE_TOKEN) else "-",
settings.APIPILOT_CACHE_TTL_SEC,
settings.APIPILOT_HTTP_TIMEOUT,
"Y" if (bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-",
(
"YES head="
+ (
settings.S2S_APIPILOT_API_KEY[:6] + "..."
if len(settings.S2S_APIPILOT_API_KEY) > 6
else settings.S2S_APIPILOT_API_KEY
)
+ " (3 FORMS!)"
)
if settings.S2S_APIPILOT_API_KEY
else "-",
)
del _STARTUP_APIPILOT_EXEC_URL
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import logging
import os
logger = logging.getLogger("s2s")
_logging_configured = False
if not _logging_configured:
_handler = logging.StreamHandler()
_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-7s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
logging.basicConfig(level=logging.DEBUG, handlers=[_handler], force=True)
_lvl_env = (os.environ.get("LOG_LEVEL") or os.environ.get("S2S_LOG_LEVEL") or "INFO").strip().upper()
_is_debug = _lvl_env in {"DEBUG", "TRACE", "10"}
# Keep noisy loggers quiet unless LOG_LEVEL explicitly enables debug.
logging.getLogger("httpx").setLevel(logging.DEBUG if _is_debug else logging.WARNING)
logging.getLogger("websockets").setLevel(logging.DEBUG if _is_debug else logging.INFO)
logging.getLogger("uvicorn").setLevel(logging.DEBUG if _is_debug else logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.DEBUG if _is_debug else logging.INFO)
logging.getLogger("httpcore").setLevel(logging.DEBUG if _is_debug else logging.WARNING)
logger.setLevel(logging.DEBUG if _is_debug else logging.DEBUG) # s2s always uses at least DEBUG in development
_logging_configured = True
logger.debug(
"LOG_LEVEL env=%s — noisy-logger levels: httpx=%s uvicorn=%s websockets=%s",
_lvl_env,
logging.getLevelName(logging.getLogger("httpx").level),
logging.getLevelName(logging.getLogger("uvicorn").level),
logging.getLevelName(logging.getLogger("websockets").level),
)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import Request, WebSocket
from s2s_client.core import config as settings
def _url_with_query(url: str, **params: str) -> str:
target = urlsplit(url)
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
for key, value in params.items():
if value:
merged_query[key] = value
return urlunsplit(
(
target.scheme,
target.netloc,
target.path,
urlencode(merged_query, doseq=True),
target.fragment,
)
)
def _proxy_public_url(request: Request) -> str:
if not settings.S2S_PROXY_TARGET:
return ""
forwarded_proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip()
forwarded_host = (
(request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc)
.split(",")[0]
.strip()
)
ws_scheme = "wss" if forwarded_proto in {"https", "wss"} else "ws"
root_path = (request.scope.get("root_path") or "").rstrip("/")
return f"{ws_scheme}://{forwarded_host}{root_path}/v1/realtime"
def _proxy_upstream_url(ws: WebSocket) -> str:
target = urlsplit(settings.S2S_PROXY_TARGET)
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
merged_query.update(parse_qsl(ws.url.query, keep_blank_values=True))
return urlunsplit(
(
target.scheme,
target.netloc,
target.path or "/v1/realtime",
urlencode(merged_query, doseq=True),
target.fragment,
)
)
+25
View File
@@ -0,0 +1,25 @@
"""Assemble the client API and retain its existing documentation URLs."""
import os
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from s2s_client.api.router import router
from s2s_client.core import config, diagnostics # noqa: F401 - retain the startup banner
app = FastAPI(title="s2s-demo")
app.include_router(router)
@app.get("/project", include_in_schema=False)
@app.get("/project/", include_in_schema=False)
@app.get("/project/index.html", include_in_schema=False)
@app.get("/public/docs.html", include_in_schema=False)
async def project_documentation() -> RedirectResponse:
return RedirectResponse("/docs/proposals/vocero-architecture.html", status_code=307)
# The existing FastAPI /docs page remains registered ahead of this mount.
app.mount("/docs", StaticFiles(directory=os.path.join(config.ROOT_DIR, "docs"), html=True), name="documentation")
View File
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from pydantic import BaseModel
class SearchRequest(BaseModel):
query: str
# Optional user-supplied key (fallback when the deploy has no server key).
# Used for this request only; never stored.
key: str | None = None
class ReasoningRequest(BaseModel):
transcript: str
instructions: str = ""
language: str = ""
user_context: str = ""
# Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches
# the caller profile from VOCERO_USER_PHONE_AUTH_URL and:
# 1. prepends the serialized profile to user_context before feeding the
# engine (NODE_RED or OPENAI);
# 2. appends the profile context to the LLM instructions prompt.
phoneNumber: str | None = None
# Optional podcast episode ID. When set the proxy fetches the episode from
# the Aquachile AzurianLab API and injects it as the PRIMARY context into
# both user_context (prepended) and LLM instructions prompt (appended first).
episode_id: str | None = None
class ApipilotExecuteRequest(BaseModel):
"""Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape."""
name: str
arguments: dict = {}
View File
+597
View File
@@ -0,0 +1,597 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from time import monotonic
from typing import Any, cast
import httpx
from fastapi import HTTPException, Request
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
# In-process cache and asynchronous lock: one caller refreshes while others
# read the stale entry. `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`.
_APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {}
_APIPILOT_CACHE_LOCK = asyncio.Lock()
# Monotonic time helper for duration measurements unaffected by NTP drift.
_monotonic_now: Callable[[], float] = monotonic
def _apipilot_auth_fingerprint(auth_header: str | None, demo_session: str | None) -> str:
"""Return a short string used as cache key scoped to the caller identity.
We don't want to share cached tool-lists across users with different auth
tokens (requires_auth flags can be token-dependent via /tools?include_unauthorized=true
because the back-end may still attach per-role metadata)."""
parts: list[str] = []
if auth_header:
# Don't cache the raw bearer. SHA-256 truncated 12 chars = safe fingerprint.
import hashlib
parts.append("h:" + hashlib.sha256(auth_header.encode("utf-8")).hexdigest()[:12])
if demo_session:
parts.append("d:" + demo_session)
return "|".join(parts) if parts else "anon"
def _apipilot_parse_authorization(request: Request | None) -> tuple[str | None, str | None]:
"""Extract (Authorization header value, x-demo-session header value) from a FastAPI request.
Returns a tuple of two optional strings. Both are forwarded to the upstream
ApiPilot backend verbatim when non-empty (see docs/client/tool-calling.md).
"""
if request is None:
return None, None
auth = None
demo = None
try:
raw_auth = request.headers.get("authorization") or request.headers.get("Authorization")
if isinstance(raw_auth, str) and raw_auth.strip():
auth = raw_auth.strip()
raw_demo = request.headers.get("x-demo-session") or request.headers.get("X-Demo-Session")
if isinstance(raw_demo, str) and raw_demo.strip():
demo = raw_demo.strip()
except Exception:
pass
return auth, demo
def _apipilot_resolve_execute_url() -> str:
"""Resolve the final POST URL for ApiPilot tool execution.
Priority (guide §2.2 and explicit environment override):
1. When S2S_APIPILOT_TOOLS_EXECUTE_URL is set, append "/execute" if needed.
2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute".
If neither URL is configured, the caller (usually _apipilot_execute_tool)
must return an APIPILOT_NOT_CONFIGURED error envelope before calling this.
"""
base = settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL
base = base.strip().rstrip("/")
if not base:
return ""
if base.lower().endswith("/execute"):
return base
return f"{base}/execute"
def _apipilot_strip_tool_for_llm(tool_def: dict) -> dict:
"""Return a tool definition that is safe for LLM function-calling providers.
Guide §4 forbids passing raw /tools output directly to the model because
requires_auth is outside the function-calling standard and some providers
reject it. Project the native `ToolDefinition` into the OpenAI-compatible
`{type: "function", function: {name, description, parameters}}`.
"""
if not isinstance(tool_def, dict):
return {}
name = tool_def.get("name") if isinstance(tool_def.get("name"), str) else ""
description = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
parameters = tool_def.get("parameters")
if not isinstance(parameters, dict):
parameters = {"type": "object", "properties": {}, "required": []}
out: dict = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
}
return out
async def _apipilot_fetch_tools(
*,
auth_header: str | None,
demo_session: str | None,
include_unauthorized: bool = True,
force_refresh: bool = False,
refresh: bool | None = None,
) -> list[dict]:
"""Fetch ApiPilot tool definitions with a short in-process TTL cache.
Implements §2.1 (GET /tools?include_unauthorized=true). The cache key is
scoped by caller auth fingerprint (see `_apipilot_auth_fingerprint`) so
per-token variations in `requires_auth` or list length don't leak between
identities.
Returns a list of tool definitions (parsed JSON, may be empty). Raises
`HTTPException` only when `S2S_APIPILOT_TOOLS_URL` is set but the backend
replied with a non-2xx / non-JSON response. When env is empty → returns
`[]` silently (passthrough).
"""
# 2026-09-25 compatibility alias: WS connect uses `refresh`; when present,
# apply it to force_refresh.
if refresh is not None:
force_refresh = bool(refresh) or bool(force_refresh)
if not settings.S2S_APIPILOT_TOOLS_URL:
return []
import time as _time_mod
# 2026-09-25 fingerprint consistency: determine effective auth/demo values
# first (SUPER API KEY takes precedence), then calculate the cache fingerprint.
# An anonymous fingerprint with APIKEY headers would mismatch the cache and
# demo session, causing Aquachile user_type=null and a 403 response.
if settings.S2S_APIPILOT_API_KEY:
final_auth: str | None = "BYPASS-API-KEY"
_demo_used: str | None = settings.S2S_APIPILOT_API_KEY
_effective_auth_for_fp = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
_effective_demo_for_fp = settings.S2S_APIPILOT_API_KEY
else:
final_auth = auth_header or (
f"Bearer {settings.S2S_APIPILOT_SERVICE_TOKEN}" if settings.S2S_APIPILOT_SERVICE_TOKEN else None
)
_demo_used = demo_session
_effective_auth_for_fp = (
final_auth if isinstance(final_auth, str) and final_auth.strip() else (auth_header or "")
)
_effective_demo_for_fp = _demo_used if isinstance(_demo_used, str) and _demo_used.strip() else ""
fp = _apipilot_auth_fingerprint(_effective_auth_for_fp, _effective_demo_for_fp)
now = _time_mod.monotonic()
cached = _APIPILOT_CACHE.get(fp)
if (not force_refresh) and cached and (now - cached[0]) < settings.APIPILOT_CACHE_TTL_SEC:
app_logging.logger.debug(
"[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d",
settings.APIPILOT_CACHE_TTL_SEC - (now - cached[0]),
fp,
len(cached[1]),
)
return cached[1]
# Prefer to serve stale (if any) rather than block many callers.
stale_tools = cached[1] if cached else None
async with _APIPILOT_CACHE_LOCK:
# Double-check after acquiring lock (another coroutine may have refreshed).
now2 = _time_mod.monotonic()
recheck = _APIPILOT_CACHE.get(fp)
if (not force_refresh) and recheck and (now2 - recheck[0]) < settings.APIPILOT_CACHE_TTL_SEC:
return recheck[1]
url = f"{settings.S2S_APIPILOT_TOOLS_URL}/tools"
params = {"include_unauthorized": "true" if include_unauthorized else "false"}
headers = {"accept": "application/json"}
# 2026-09-25 SUPER API KEY overrides other authentication and sends all
# three header forms simultaneously for maximum compatibility.
if settings.S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
else:
if final_auth:
headers["Authorization"] = final_auth
if _demo_used:
headers["x-demo-session"] = _demo_used
app_logging.logger.info(
"[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s",
url,
params["include_unauthorized"],
fp,
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")),
)
try:
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.get(url, params=params, headers=headers)
except Exception as exc:
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc)
if stale_tools is not None:
app_logging.logger.warning(
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
)
return stale_tools
raise HTTPException( # noqa: B904
status_code=502,
detail={
"code": "APIPILOT_NETWORK_ERROR",
"message": f"Error al contactar ApiPilot tools backend: {exc}",
},
)
if resp.status_code >= 300:
app_logging.logger.error(
"[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400]
)
if stale_tools is not None:
app_logging.logger.warning(
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
)
return stale_tools
raise HTTPException(
status_code=502,
detail={
"code": "APIPILOT_BAD_UPSTREAM",
"message": f"Backend ApiPilot tools devolvió HTTP {resp.status_code}",
"status_code": resp.status_code,
},
)
try:
payload = resp.json()
except Exception:
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500])
if stale_tools is not None:
return stale_tools
raise HTTPException( # noqa: B904
status_code=502,
detail={"code": "APIPILOT_BAD_JSON", "message": "La respuesta de ApiPilot tools no es JSON válido."},
)
# Backend might return: list OR {"result": [...]} OR {"tools": [...]} OR
# {"data": {"tools": [...]}}. Unwrap generic wrapper-style payloads.
tools: list[dict] = []
if isinstance(payload, list):
tools = [t for t in payload if isinstance(t, dict)]
elif isinstance(payload, dict):
for key in ("tools", "result", "data"):
cand = payload.get(key)
if isinstance(cand, list):
tools = [t for t in cand if isinstance(t, dict)]
break
if isinstance(cand, dict):
sub = cand.get("tools") if isinstance(cand.get("tools"), list) else cand.get("result")
if isinstance(sub, list):
tools = [t for t in sub if isinstance(t, dict)]
break
# Validate minimal ToolDefinition contract §2.1: name + description + parameters.
sanitized: list[dict] = []
for raw in tools:
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
continue
description = raw.get("description") if isinstance(raw.get("description"), str) else ""
parameters = raw.get("parameters")
if not isinstance(parameters, dict):
parameters = {"type": "object", "properties": {}, "required": []}
requires_auth = raw.get("requires_auth", False)
if not isinstance(requires_auth, bool):
# Some backends return 0/1; normalize to bool as required.
try:
requires_auth = bool(int(requires_auth))
except Exception:
requires_auth = False
tool_def = {
"name": name.strip(),
"description": description,
"parameters": parameters,
"requires_auth": requires_auth,
}
sanitized.append(tool_def)
_APIPILOT_CACHE[fp] = (now2, sanitized)
app_logging.logger.info(
"[APIPILOT-DISCOVERY] ← OK tools=%s fp=%s status=%s upstreamFmt=%s cachedTools=%d",
len(sanitized),
fp,
resp.status_code,
"list" if isinstance(payload, list) else "object",
len(sanitized),
)
if settings.S2S_DEBUG_PROMPT and sanitized:
app_logging.logger.debug(
"[APIPILOT-DISCOVERY] raw first tool preview: %s",
json.dumps(sanitized[0], ensure_ascii=False)[:400],
)
return sanitized
def _apipilot_validate_arguments(arguments: dict | None, tool_def: dict) -> tuple[dict, list[str]]:
"""Validate / execute arguments strictly against the tool's JSON-Schema parameters.
Implements §2.2 PRE-CHECK #3:
• Remove unknown keys only when parameters.properties is nonempty.
• AquaPortal tool.get_user_briefing has parameters.properties={} and
required=[]: no explicit schema, so arguments remain flexible. The
backend resolves phone from the token; preserve supplied arguments.
• Validate required parameters and return errors for missing values.
• Never invent parameters or add keys.
Returns `(filtered_args, list_of_validation_errors_in_spanish)`.
If the returned errors list is non-empty, MUST block the execution (see
the `_apipilot_execute_tool` caller).
"""
errors: list[str] = []
schema = cast(dict[str, Any], tool_def.get("parameters")) if isinstance(tool_def.get("parameters"), dict) else {}
properties = cast(dict[str, Any], schema.get("properties")) if isinstance(schema.get("properties"), dict) else {}
required = cast(list[Any], schema.get("required")) if isinstance(schema.get("required"), list) else []
required = [x for x in required if isinstance(x, str)]
args = arguments if isinstance(arguments, dict) else {}
# 2026-09-25 empty-schema flexibility: preserve all arguments when no
# properties are explicitly declared, as with AquaPortal get_user_briefing.
schema_is_open: bool = (not isinstance(properties, dict)) or len(properties) == 0
requires_auth_flag = bool(tool_def.get("requires_auth"))
# 2026-09-25 auth-only tools: requires_auth=true, empty properties and
# required=[], with a valid-session description, resolve user data from
# the bearer JWT. Send arguments={} to avoid conflicts between a phone
# argument and the token's user, which would produce a 403 response.
auth_only_tool = False
if schema_is_open and requires_auth_flag and len(required) == 0:
_d = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
if (not _d) or "valid session token" in _d.lower() or "authenticated" in _d.lower():
auth_only_tool = True
if auth_only_tool:
cleaned = {}
app_logging.logger.info(
"[APIPILOT-VALIDATE] tool=%s requires only an authenticated session → arguments forced to {} (avoid phone-argument/user-token conflicts)",
tool_def.get("name") or "?",
)
elif schema_is_open:
cleaned = dict(args)
app_logging.logger.info(
"[APIPILOT-VALIDATE] tool=%s open schema (empty parameters.properties) → preserve all arguments N=%d keys=%s",
tool_def.get("name") or "?",
len(cleaned),
sorted(list(cleaned.keys())),
)
else:
cleaned = {}
for key in args.keys():
if key in properties:
cleaned[key] = args[key]
else:
app_logging.logger.warning(
"[APIPILOT-VALIDATE] tool=%s removing unknown arg=%s",
tool_def.get("name") or "?",
key,
)
# Check required fields only for a closed schema; open required=[] is vacuous.
if not schema_is_open:
for req in required:
if req not in cleaned:
errors.append(f"Falta el parámetro requerido: {req}")
return cleaned, errors
async def _apipilot_execute_tool(
*,
auth_header: str | None,
demo_session: str | None,
name: str,
arguments: dict | None,
) -> dict:
"""Run a single ApiPilot tool via POST /execute.
Implements the full PRE-CHECK flow (§2.2) BEFORE sending the HTTP
request:
1. Fresh discovery (TTL cache; force_refresh=True if a tool is missing).
2. requires_auth: return HTTP 401 when authentication is required but absent.
3. Strict JSON-Schema validation: filter unknown parameters and check required.
Always return the standard `{success, data, error}` envelope (guide §2.2).
Pre-check failures populate error.{code,message,status_code}, with success=false.
"""
name_clean = (name or "").strip() if isinstance(name, str) else ""
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=EXECUTE_ENTRYPOINT name=%r argsKeys=%s authHead=%.20s demo=%.20s",
name_clean,
sorted(list((arguments or {}).keys())) if isinstance(arguments, dict) else [],
str(auth_header or "")[:20],
str(demo_session or "")[:20],
)
if not settings.S2S_APIPILOT_TOOLS_URL:
return {
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
"status_code": 501,
"raw": None,
},
}
if not name_clean:
return {
"success": False,
"data": None,
"error": {
"code": "TOOL_NAME_REQUIRED",
"message": "Falta el campo obligatorio: name (nombre de la herramienta).",
"status_code": 400,
"raw": None,
},
}
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=False)
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
if tool_def is None:
# Unknown tool: refresh discovery and retry once.
app_logging.logger.info("[APIPILOT-EXEC] tool=%s not found → forced re-discovery", name_clean)
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=True)
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
if tool_def is None:
return {
"success": False,
"data": None,
"error": {
"code": "TOOL_NOT_FOUND",
"message": (
f"Herramienta «{name_clean}» no encontrada en el catálogo ApiPilot. "
"Actualice la lista de herramientas y vuelva a intentarlo."
),
"status_code": 404,
"raw": None,
},
}
# ── PRE-CHECK 2: requires_auth ────────────────────────────────────────────
requires_auth = bool(tool_def.get("requires_auth"))
has_token = bool(auth_header) or bool(demo_session) or bool(settings.S2S_APIPILOT_SERVICE_TOKEN)
if requires_auth and not has_token:
app_logging.logger.warning("[APIPILOT-EXEC] tool=%s requires_auth=true blocked (no token)", name_clean)
return {
"success": False,
"data": None,
"error": {
"code": "AUTH_REQUIRED",
"message": (
f"La herramienta «{name_clean}» requiere autenticación. "
"Inicie sesión o proporcione un token para continuar."
),
"status_code": 401,
"raw": None,
},
}
# Pre-check 3: strict JSON-Schema argument validation.
cleaned_args, errors = _apipilot_validate_arguments(arguments, tool_def)
if errors:
return {
"success": False,
"data": None,
"error": {
"code": "INVALID_ARGUMENTS",
"message": " / ".join(errors),
"status_code": 400,
"raw": {
"removed": sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
},
},
}
# ── HTTP: POST /execute ───────────────────────────────────────────────────
url = _apipilot_resolve_execute_url()
if not url:
return {
"success": False,
"data": None,
"error": {
"code": "APIPILOT_NOT_CONFIGURED",
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL y S2S_APIPILOT_TOOLS_EXECUTE_URL están vacíos).",
"status_code": 501,
"raw": None,
},
}
headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"}
# 2026-09-25 SUPER API KEY (highest priority): bypass AquaPortal authentication.
# Send all three header forms simultaneously for maximum compatibility:
# - X-API-Key: <key>
# - Authorization: Bearer <key>
# - x-demo-session: <key>
# Ignore service tokens and login_by_phone when the API KEY is set.
if settings.S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
final_auth: str | None = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
else:
# Authentication priority: service-token override, then caller auth_header
# from phone login/discovery/pass-2 fallback, then no authentication.
if settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
final_auth = (f"Bearer {_svc}" if (not _svc.lower().startswith("bearer ")) else _svc) if _svc else None
else:
final_auth = auth_header if isinstance(auth_header, str) and auth_header.strip() else None
if final_auth:
headers["Authorization"] = final_auth
if demo_session:
headers["x-demo-session"] = demo_session
payload = {"name": name_clean, "arguments": cleaned_args}
removed = sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
app_logging.logger.info(
"[APIPILOT-EXEC] → POST %s name=%s argsKeys=%s removedKeys=%s auth=%s demo=%s",
url,
name_clean,
sorted(cleaned_args.keys()),
removed,
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")),
)
try:
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.post(url, json=payload, headers=headers)
except Exception as exc:
app_logging.logger.error("[APIPILOT-EXEC] ✗ network error: %s", exc)
return {
"success": False,
"data": None,
"error": {
"code": "EXECUTION_ERROR",
"message": f"Error de red al ejecutar «{name_clean}»: {exc}",
"status_code": 502,
"raw": None,
},
}
# Parse a standard envelope; construct a matching error for non-JSON/5xx responses.
raw_text = resp.text
# Detailed diagnostics are always enabled for HTTP >= 400: request payload,
# unmasked demo session, response status and bounded raw response body.
if resp.status_code >= 400:
try:
payload_dump = __import__("json").dumps(payload, ensure_ascii=False)
except Exception: # noqa: BLE001
payload_dump = str(payload)
app_logging.logger.warning(
"[APIPILOT-EXEC][DIAGNOSTIC 4xx] HTTP=%d url=%s name=%s demoSession=%.60s AUTH=%s requestPAYLOAD=%s |RAW RESPONSE BODY (max 1800 chars)=|%s|",
resp.status_code,
url,
name_clean,
(demo_session if isinstance(demo_session, str) else "?"),
("YES len=" + str(len(final_auth)) if isinstance(final_auth, str) and final_auth.strip() else "-"),
payload_dump,
(raw_text or "")[:1800],
)
try:
parsed = resp.json()
except Exception:
app_logging.logger.error("[APIPILOT-EXEC] ✗ non-JSON body HTTP %s: %s", resp.status_code, raw_text[:500])
return {
"success": False,
"data": None,
"error": {
"code": "EXECUTION_ERROR",
"message": f"La respuesta de ejecución «{name_clean}» no es JSON válido (HTTP {resp.status_code}).",
"status_code": resp.status_code,
"raw": raw_text[:2000] if raw_text else None,
},
}
# Return standard {success,data,error} envelopes unchanged.
if isinstance(parsed, dict) and "success" in parsed and isinstance(parsed.get("success"), bool):
envelope_ok = parsed["success"] and parsed.get("error") in (None, {})
app_logging.logger.info(
"[APIPILOT-EXEC] ← %s name=%s success=%s http=%s dataKeys=%s",
"OK" if envelope_ok else "FAIL",
name_clean,
bool(parsed.get("success")),
resp.status_code,
sorted(list(cast(dict, parsed.get("data")).keys())) if isinstance(parsed.get("data"), dict) else "-",
)
if (not envelope_ok) and isinstance(parsed.get("error"), dict):
err = parsed["error"]
app_logging.logger.warning(
"[APIPILOT-EXEC] ERROR: code=%s status=%s msg=%s",
err.get("code"),
err.get("status_code"),
(str(err.get("message"))[:200] if err.get("message") is not None else "-"),
)
return parsed
# Wrap raw backend data in the required envelope (guide §2.2) for stable
# downstream behavior when an upstream response omits the wrapper.
app_logging.logger.warning(
"[APIPILOT-EXEC] backend omitted standard envelope → wrapping response http=%s", resp.status_code
)
return {
"success": 200 <= resp.status_code < 300,
"data": parsed if (200 <= resp.status_code < 300) else None,
"error": None
if (200 <= resp.status_code < 300)
else {
"code": "EXECUTION_ERROR",
"message": (f"Ejecución «{name_clean}» devolvió HTTP {resp.status_code} sin envelope estándar."),
"status_code": resp.status_code,
"raw": (raw_text[:2000] if raw_text else None),
},
}
+558
View File
@@ -0,0 +1,558 @@
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlencode
import httpx
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None:
"""Fetch scheduled reports list from S2S_REPORT_LIST.
Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends
?phoneNumber=<x> if a phone is provided. Accepts JSON (optional {"result": [...]}
wrapper) and returns the list or unwrapped list. On any error logs and returns None
(never fails the proxy / reasoning tunnel).
USER FIX 2026-09-11: remove mass injection of scheduled reports.
• If S2S_INJECT_REPORTS is FALSE (DEFAULT) we short-circuit to None at the
very top — NO HTTP call is made to S2S_REPORT_LIST at all. The upstream
endpoint is never hit, not even for a "cheap empty list"; combined with
_resolve_reports_context → "" above, this guarantees ZERO scheduled-report
contents reach any LLM prompt anywhere (WS session instructions patch,
/api/reasoning user_context, NODE_RED instructions augment, quick reports
lookup response).
• S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND
the full injection into every prompt path).
"""
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
if not settings.S2S_INJECT_REPORTS:
return None
# ───────────────────────────────────────────────────────────────────────
if not settings.S2S_REPORT_LIST:
return None
if phone_number:
sep = "&" if "?" in settings.S2S_REPORT_LIST else "?"
target_url = f"{settings.S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}"
else:
target_url = settings.S2S_REPORT_LIST
app_logging.logger.info(
"[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s",
settings.S2S_REPORT_LIST,
len(phone_number) if phone_number else "none",
"yes" if auth_token else "no",
)
try:
headers = {}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
resp = await http.get(target_url, headers=headers or None)
if resp.status_code != 200:
app_logging.logger.warning(
"[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s",
resp.status_code,
resp.text,
)
return None
text = (resp.text or "").strip()
if not text:
app_logging.logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.")
return None
try:
payload = resp.json()
except Exception:
app_logging.logger.warning(
"[INJECT-REPORTS] ⛔ upstream returned non-JSON body len=%d; cannot parse list. response=%.250s",
len(text),
text,
)
return None
except Exception as exc:
app_logging.logger.warning(
"[INJECT-REPORTS] ⛔ fetch unreachable error=%r",
exc,
)
return None
# Unwrap {"result": [...]} or other common list-wrapping dict keys
# (e.g. {"data": [...], "reports": [...], "items": [...], "content": [...], "value": [...]}).
unwrapped: object = payload
if isinstance(payload, dict):
# Log full structure for debugging if enabled
if settings.S2S_DEBUG_PROMPT:
try:
keys_preview = ",".join(sorted(str(k) for k in payload.keys()))
preview = json.dumps(payload, ensure_ascii=False)
preview = preview[:4000]
app_logging.logger.info(
"[DEBUG-REPORTS][fetch] upstream response dict keys=[%s] full PREVIEW (len=%d):\n%s\n",
keys_preview,
len(preview),
preview,
)
except Exception:
pass
# Preferred explicit "result" key first
if "result" in payload and isinstance(payload["result"], list):
unwrapped = payload["result"]
else:
# Scan all common list-wrapper keys (case-insensitive match on the key)
wrapper_keys = {
k.lower()
for k in (
"data",
"reports",
"reportList",
"report_list",
"report",
"items",
"itemList",
"item_list",
"list",
"content",
"scheduled",
"scheduledReports",
"scheduled_reports",
"podcastList",
"podcast_list",
"podcasts",
"entries",
"records",
"rows",
"pageContent",
"page_content",
"value",
)
}
for k, v in payload.items():
if isinstance(k, str) and isinstance(v, list) and k.lower() in wrapper_keys:
unwrapped = v
break
# Fallback: if it's still a dict with only ONE list-valued key,
# assume that's the list (common pattern like {"data": [...]} with
# unusual key name we don't hardcode).
if not isinstance(unwrapped, list):
list_valued_keys = [k for k, v in payload.items() if isinstance(v, list) and len(v) > 0]
if len(list_valued_keys) == 1:
unwrapped = payload[list_valued_keys[0]]
# Validate array shape
if isinstance(unwrapped, list):
n_items = len(unwrapped)
n_dict = sum(1 for x in unwrapped if isinstance(x, dict))
app_logging.logger.info(
"[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)",
n_items,
n_dict,
)
return unwrapped
app_logging.logger.info(
"[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.",
type(unwrapped).__name__,
)
if settings.S2S_DEBUG_PROMPT and isinstance(payload, dict):
# Log final top-level keys to help diagnose wrapper shape
app_logging.logger.info(
"[DEBUG-REPORTS][fetch] wrapper not detected — top-level keys of dict response: [%s] "
"(list values under keys: %s)",
",".join(sorted(str(k) for k in payload.keys())),
",".join(sorted(str(k) + "=list[" + str(len(v)) + "]" for k, v in payload.items() if isinstance(v, list)))
or "none",
)
return None
def _extract_login_token(login_payload: dict | None) -> str | None:
"""Given the parsed response from the S2S_LOGIN_BY_PHONE endpoint (already
{"result":…}-unwrapped), try to extract an auth / session token.
Looks under common names (case-insensitive). Returns None if nothing found.
"""
if not login_payload or not isinstance(login_payload, dict):
return None
# Unwrap one level of nesting if result is still an dict: handled by caller already.
candidates = {
c.lower()
for c in (
"token",
"jwt",
"access_token",
"accessToken",
"auth_token",
"authToken",
"session_token",
"sessionToken",
"id_token",
"idToken",
"bearer",
"bearer_token",
"bearerToken",
"authentication_token",
)
}
for k, v in login_payload.items():
if isinstance(k, str) and k.lower() in candidates and isinstance(v, str) and v.strip():
return v.strip()
# Second pass: nested dict {"data": {"token": …}} or {"auth": {"jwt": …}}
for _k, v in login_payload.items():
if isinstance(v, dict):
nested = _extract_login_token(v)
if nested:
return nested
# Third pass: list like [{"token": ...}] (uncommon but safe)
for _k, v in login_payload.items():
if isinstance(v, list) and v:
for elem in v:
if isinstance(elem, dict):
nested = _extract_login_token(elem)
if nested:
return nested
return None
async def _login_by_phone(phone_number: str) -> tuple[str | None, dict | None]:
"""Perform login-by-phone: POST S2S_LOGIN_BY_PHONE with JSON
{"phoneNumber": phone_number}. Returns (bearer_token_or_None, response_obj_or_None).
Never fails: on any HTTP/parse error logs a warning and returns (None, None)
so subsequent profile/summary calls simply proceed without a Bearer token.
"""
if not settings.S2S_LOGIN_BY_PHONE or not phone_number:
return None, None
body = {"phoneNumber": phone_number}
app_logging.logger.info(
"[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d",
settings.S2S_LOGIN_BY_PHONE,
len(phone_number),
)
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
resp = await http.post(
settings.S2S_LOGIN_BY_PHONE,
json=body,
headers={"Content-Type": "application/json"},
)
if resp.status_code != 200:
app_logging.logger.warning(
"[INJECT-LOGIN] ⛔ upstream HTTP %s phoneLen=%d response=%.250s",
resp.status_code,
len(phone_number),
resp.text,
)
return None, None
text = (resp.text or "").strip()
if not text:
app_logging.logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.")
return None, None
try:
payload = resp.json()
except Exception:
app_logging.logger.warning(
"[INJECT-LOGIN] ⛔ upstream returned non-JSON body len=%d; cannot extract token. response=%.250s",
len(text),
text,
)
return None, None
except Exception as exc:
app_logging.logger.warning(
"[INJECT-LOGIN] ⛔ fetch unreachable phoneLen=%d error=%r",
len(phone_number),
exc,
)
return None, None
# Unwrap {"result": …} OR {"data": …} if present (common wrapper conventions).
unwrapped: dict | None = None
if isinstance(payload, dict):
for _wrap_key in ("result", "data", "payload"):
if isinstance(payload.get(_wrap_key), dict):
unwrapped = payload[_wrap_key]
break
if unwrapped is None:
unwrapped = payload
token = (
_extract_login_token(unwrapped)
if isinstance(unwrapped, dict)
else _extract_login_token(payload if isinstance(payload, dict) else None)
)
# Always log login-token diagnostics, even without S2S_DEBUG_PROMPT:
# token length, first eight characters, response keys and wrapper layer.
_all_keys: list[str] = []
if isinstance(payload, dict):
_all_keys.extend(str(k) for k in payload.keys())
_unwrap_keys: list[str] = []
if isinstance(unwrapped, dict):
_unwrap_keys.extend(str(k) for k in unwrapped.keys())
if isinstance(unwrapped, dict):
_t_preview = ""
_t_len = 0
if isinstance(token, str) and token.strip():
_t_len = len(token.strip())
if _t_len > 8:
_t_preview = token.strip()[:8] + "..."
else:
_t_preview = token.strip()
_fmt_note = ""
if _t_len >= 120:
_fmt_note = " [JWT-like]"
elif _t_len > 0:
_fmt_note = " [SHORT]"
_wrap_layer = "ROOT"
if isinstance(payload, dict):
for _wk in ("result", "data", "payload"):
if isinstance(payload.get(_wk), dict):
_wrap_layer = "result/data/payload"
break
_all_keys_s = ",".join(sorted(_all_keys)) if _all_keys else "-"
_unwrap_keys_s = ",".join(sorted(_unwrap_keys)) if _unwrap_keys else "-"
_t_info = "MISSING"
if _t_len:
_t_info = "YES len=" + str(_t_len) + " head=" + _t_preview
msg = f"[INJECT-LOGIN] ✅ login OK: wrapLayer={_wrap_layer} topKeys={_all_keys_s} unwrappedKeys={_unwrap_keys_s} token={_t_info}{_fmt_note}"
app_logging.logger.info("%s", msg)
return token, unwrapped
# Fallback: non-dict payload (string/list/number) — still log it, but no token expected.
app_logging.logger.info(
"[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.",
type(payload).__name__,
)
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n",
len(str(payload)),
payload,
)
return None, None
async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | None = None) -> Any:
"""Fetch the latest caller summary via S2S_LATEST_SUMMARY URL.
Generic / completely deploy-agnostic: plain GET
S2S_LATEST_SUMMARY?phoneNumber=<phone>; accepts JSON (optional {"result": …}
wrapper) or plain text. Never fails the proxy / reasoning; returns None on
any error and logs it. If `auth_token` is provided it is sent as
Authorization: Bearer <auth_token>.
OVERRIDE (2026-09-23): when S2S_LATEST_SUMMARY_TEXT is set (non-empty) it
takes absolute priority over S2S_LATEST_SUMMARY. No HTTP call is made at all;
the raw plain-text value is returned directly. Designed for operators who want
a clean, pre-curated, essential summary snippet without any upstream API
dependency. Works even without a phone_number (static snippet).
"""
if settings.S2S_LATEST_SUMMARY_TEXT:
override_len = len(settings.S2S_LATEST_SUMMARY_TEXT)
app_logging.logger.info(
"[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=%d chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.",
override_len,
)
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s",
settings.S2S_LATEST_SUMMARY_TEXT[:800],
)
return settings.S2S_LATEST_SUMMARY_TEXT
if not settings.S2S_LATEST_SUMMARY or not phone_number:
return None
target_url = f"{settings.S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}"
app_logging.logger.info(
"[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s",
settings.S2S_LATEST_SUMMARY,
len(phone_number),
"yes" if auth_token else "no",
)
try:
headers = {}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
resp = await http.get(target_url, headers=headers or None)
if resp.status_code != 200:
app_logging.logger.warning(
"[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
resp.status_code,
len(phone_number),
resp.text,
)
return None
# Accept JSON first, then fall back to plain text (some summary APIs
# return text/plain).
text = (resp.text or "").strip()
if not text:
return ""
try:
payload = resp.json()
except Exception:
app_logging.logger.info(
"[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.",
len(text),
)
return text
except Exception as exc:
app_logging.logger.warning(
"[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r",
len(phone_number),
exc,
)
return None
# Unwrap {"result": …} just like profile fetch (common wrapper pattern).
if isinstance(payload, dict) and "result" in payload:
payload = payload["result"]
# Filter out empty dict/list / empty string after unwrap.
empty = (
payload is None
or (isinstance(payload, str) and not payload.strip())
or (isinstance(payload, dict) and len(payload) == 0)
or (isinstance(payload, list) and len(payload) == 0)
)
if empty:
app_logging.logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.")
return None
if isinstance(payload, dict):
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s",
",".join(sorted(str(k) for k in payload.keys())),
)
elif isinstance(payload, list):
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d",
len(payload),
)
else:
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d",
len(str(payload)),
)
return payload
async def _fetch_podcast_episode(episode_id: str) -> Any:
"""Fetch a single podcast episode by ID from the Aquachile AzurianLab API.
Completely generic / deploy-agnostic: plain GET
https://aquachile.azurianlab.com/api/v1/podcast/episodes/<episode_id>
Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails
the proxy / reasoning; returns None on any error and logs it.
"""
if not episode_id:
return None
target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}"
app_logging.logger.info(
"[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s",
target_url,
episode_id,
)
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
resp = await http.get(target_url)
if resp.status_code != 200:
app_logging.logger.warning(
"[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s",
resp.status_code,
episode_id,
resp.text,
)
return None
text = (resp.text or "").strip()
if not text:
return ""
try:
payload = resp.json()
except Exception:
app_logging.logger.info(
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
len(text),
)
return text
except Exception as exc:
app_logging.logger.warning(
"[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r",
episode_id,
exc,
)
return None
if isinstance(payload, dict) and "result" in payload:
payload = payload["result"]
empty = (
payload is None
or (isinstance(payload, str) and not payload.strip())
or (isinstance(payload, dict) and len(payload) == 0)
or (isinstance(payload, list) and len(payload) == 0)
)
if empty:
app_logging.logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
return None
if isinstance(payload, dict):
app_logging.logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
",".join(sorted(str(k) for k in payload.keys())),
)
elif isinstance(payload, list):
app_logging.logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
len(payload),
)
else:
app_logging.logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d",
len(str(payload)),
)
return payload
async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None:
"""Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone.
Returns the raw {"result": ...}-unwrapped object or None. Errors are logged
then suppressed (never fail the proxy / reasoning because of this).
When `auth_token` is provided it is forwarded as Authorization: Bearer <auth_token>
to the upstream profile endpoint.
"""
if not settings.VOCERO_USER_PHONE_AUTH_URL or not phone_number:
return None
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}"
app_logging.logger.info(
"[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s",
settings.VOCERO_USER_PHONE_AUTH_URL,
len(phone_number),
"yes" if auth_token else "no",
)
try:
headers = {}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
resp = await http.get(target_url, headers=headers or None)
if resp.status_code != 200:
app_logging.logger.info(
"[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
resp.status_code,
len(phone_number),
resp.text,
)
return None
payload = resp.json()
except Exception as exc:
app_logging.logger.warning(
"[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r",
len(phone_number),
exc,
)
return None
if isinstance(payload, dict) and "result" in payload and isinstance(payload["result"], dict):
profile_obj = payload["result"]
elif isinstance(payload, dict) and not isinstance(payload, list):
profile_obj = payload
else:
app_logging.logger.info(
"[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?",
type(payload).__name__,
)
return None
app_logging.logger.info(
"[INJECT-PROFILE] ✅ profile loaded OK keys=%s",
",".join(sorted(profile_obj.keys())),
)
return profile_obj
View File
+461
View File
@@ -0,0 +1,461 @@
from __future__ import annotations
import json
import re
from typing import Any, cast
from s2s_client.core import logging as app_logging
from s2s_client.services import context as services_context
from s2s_client.services import tool_context as services_tool_context
async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None:
return
def _realtime_event_summary(raw: str) -> dict | None:
try:
payload = json.loads(raw)
except Exception:
return None
if not isinstance(payload, dict):
return None
event_type = payload.get("type")
if not isinstance(event_type, str):
return None
summary: dict[str, Any] = {"type": event_type}
if event_type == "session.update":
session = cast(dict[str, Any], payload.get("session")) if isinstance(payload.get("session"), dict) else {}
tools = cast(list[Any], session.get("tools")) if isinstance(session.get("tools"), list) else []
summary["toolCount"] = len(tools)
summary["toolNames"] = [tool.get("name") for tool in tools if isinstance(tool, dict)]
summary["toolChoice"] = session.get("tool_choice")
summary["instructionLength"] = len(session.get("instructions") or "")
elif event_type in {"response.function_call_arguments.done", "error", "session.updated"}:
for key in ("name", "call_id", "code", "message", "response_id"):
if key in payload:
summary[key] = payload.get(key)
# --- INPUT (user speech → STT transcript) ---
# Can arrive from upstream (server-side STT) — match either direction.
elif event_type == "conversation.item.input_audio_transcription.completed":
summary["transcript"] = payload.get("transcript", "")
summary["item_id"] = payload.get("item_id")
elif event_type == "conversation.item.created":
item = payload.get("item") if isinstance(payload.get("item"), dict) else None
if isinstance(item, dict):
summary["item_id"] = item.get("id")
summary["role"] = item.get("role")
summary["item_type"] = item.get("type")
summary["status"] = item.get("status")
content_parts = []
for c in item.get("content") or []:
if isinstance(c, dict):
if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str):
content_parts.append(c["text"])
elif c.get("type") == "transcript" and isinstance(c.get("transcript"), str):
content_parts.append(c["transcript"])
if content_parts:
summary["text"] = " ".join(content_parts)
# --- OUTPUT (agent text/speech generation) ---
elif event_type == "response.output_item.done":
item = payload.get("item") if isinstance(payload.get("item"), dict) else None
if isinstance(item, dict):
summary["item_id"] = item.get("id")
summary["role"] = item.get("role")
summary["status"] = item.get("status")
content_parts = []
for c in item.get("content") or []:
if isinstance(c, dict):
if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"]:
content_parts.append(c["text"])
elif c.get("type") == "audio" and isinstance(c.get("transcript"), str) and c["transcript"]:
content_parts.append(c["transcript"])
if content_parts:
summary["text"] = " ".join(content_parts)
elif event_type == "response.content_part.done":
part = payload.get("part") if isinstance(payload.get("part"), dict) else None
if isinstance(part, dict):
summary["response_id"] = payload.get("response_id")
summary["item_id"] = payload.get("item_id")
summary["output_index"] = payload.get("output_index")
summary["content_index"] = payload.get("content_index")
if part.get("type") == "text":
summary["text"] = part.get("text", "")
elif part.get("type") == "audio":
if isinstance(part.get("transcript"), str) and part["transcript"]:
summary["text"] = part["transcript"]
# Standard OpenAI variant:
elif event_type == "response.audio_transcript.done":
summary["response_id"] = payload.get("response_id")
summary["item_id"] = payload.get("item_id")
summary["text"] = payload.get("transcript", "")
# Custom upstream variant observed in user logs:
elif event_type == "response.output_audio_transcript.done":
summary["response_id"] = payload.get("response_id")
summary["item_id"] = payload.get("item_id")
summary["output_index"] = payload.get("output_index")
# Try field names in order of likelihood
text = ""
for key in (
"transcript",
"text",
"output_text",
"audio_text",
"output_transcript",
"final_transcript",
"output_audio_text",
"content",
"message",
"utterance",
):
v = payload.get(key)
if isinstance(v, str) and v.strip():
text = v.strip()
break
# If text is still empty, search known nested objects.
if not text:
for container in ("item", "part", "content", "output", "delta", "audio"):
sub = payload.get(container)
if isinstance(sub, dict):
for key in ("transcript", "text", "output_text"):
v = sub.get(key)
if isinstance(v, str) and v.strip():
text = v.strip()
break
if text:
break
if not text:
# Last resort: the content[] array.
arr = payload.get("content")
if isinstance(arr, list):
parts = []
for c in arr:
if isinstance(c, dict):
for key in ("transcript", "text"):
v = c.get(key)
if isinstance(v, str) and v.strip():
parts.append(v.strip())
if parts:
text = " ".join(parts)
summary["text"] = text
# If no text was found, retain a raw preview to inspect the custom format.
if not text:
preview = raw[:800]
try:
pretty = json.dumps(payload, ensure_ascii=True)[:800]
except Exception:
pretty = preview
summary["_raw_preview"] = pretty
elif event_type == "response.function_call_arguments.done":
summary["arguments"] = payload.get("arguments")
# This event confirms strategy B: ai-server accepted native tools[].
_tool_name = payload.get("name") if isinstance(payload.get("name"), str) else ""
_call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else ""
_args_json = cast(str, payload.get("arguments")) if isinstance(payload.get("arguments"), str) else "{}"
app_logging.logger.info(
"[TOOLCALL] ✓ UPSTREAM emitted response.function_call_arguments.done! "
"tool=%s callId=%s argsLen=%d argsPreview=%s",
_tool_name or "(empty)",
_call_id or "-",
len(_args_json),
_args_json[:200],
)
try:
_args_parsed = json.loads(_args_json or "{}")
app_logging.logger.info(
"[TOOLCALL] ✓ Tool call parsed argsKeys=%s",
sorted(_args_parsed.keys()) if isinstance(_args_parsed, dict) else "N/A",
)
except Exception:
pass
return summary
# Suppress noisy high-frequency events in detailed logs. Internal debug
# reporting still tracks them when enabled.
_NOISY_REALTIME_EVENT_TYPES = frozenset(
{
"response.output_audio.delta",
"response.audio.delta",
"conversation.item.input_audio_transcription.delta",
"response.text.delta",
"response.output_text.delta",
"rate_limits.updated",
}
)
# Suppress client-side high-frequency events such as input_audio_buffer.append.
_NOISY_CLIENT_EVENT_TYPES = frozenset(
{
"input_audio_buffer.append",
"input_audio_buffer.commit",
"input_audio_buffer.clear",
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
}
)
def _extract_profile_for_session(session_ctx: dict) -> dict:
"""Return the profile dict stored in session_ctx (phone auth result)."""
p = session_ctx.get("profile_obj") or session_ctx.get("user_profile") or {}
if isinstance(p, dict) and isinstance(p.get("user"), dict):
return cast(dict[str, Any], p["user"])
if isinstance(p, dict):
return p
return {}
def _sanitize_agent_output(text: Any, session_ctx: dict[str, Any] | None = None) -> str:
"""Server-side FINAL filter applied to ALL assistant output text.
Guarantees (belt-and-suspenders over the 6 prompt layers):
1. No [...][xxx] bracket markers of any kind.
2. No inline { ... } JSON / dict literals.
3. No [-]: <internal action name> pseudo-markers.
4. No name anglicization: Alessandro → restored to Alessandro.
5. Trim + collapse multiple spaces.
"""
s = str(text or "")
if not s:
return s
# (0) Remove "action line" pseudo-markers FIRST, BEFORE stripping all
# other brackets, because the pattern uses the literal "[-]" prefix
# which would get silently swallowed by step (1) otherwise. Match:
# [-]: checking_podcast_report (bracket literal then colon)
# []: action_name (empty bracket variant)
# [ - ] : do_stuff (spaces inside & around)
# Pattern is intentionally TIGHT: after the colon we only take up to 6
# underscore/hyphen words so we cannot accidentally eat the following
# real user speech (bug: greedy class with ,. and 200-char upper bound ate
# the entire "Claro, revisaré el reporte." clause on 2026-09-11).
s = re.sub(
r"\[[\s\-]{0,6}\]\s*:\s*[a-z0-9_\-]+(?:[ \t]+[a-z0-9_\-]+){0,5}",
" ",
s,
)
# Also catch dangling residual patterns if brackets got normalized to a
# single char already — handles cases where upstream writes the "colon"
# action marker without explicit brackets.
s = re.sub(r"(?<!\w):\s*checking_[A-Za-z0-9_\-]{1,120}", " ", s)
# (1) Remove ALL bracket-markers — [rightspeak], [endspeak], [thinking],
# [anything_else01]. We also remove stray [ / ] that don't form a
# complete pair (e.g. leftover "hello] there").
s = re.sub(r"\[[^\]\n]{0,80}\]", " ", s)
# (2) Remove inline dict / JSON literals. Pattern: { ... }, possibly
# split across a few \n (not greedy). Conservative: matches balanced
# pairs up to a reasonable length, otherwise fallback.
_max_iter = 5
for _ in range(_max_iter):
new_s = re.sub(r"\{[^{}]{0,1600}\}", " ", s)
if new_s == s:
break
s = new_s
# (3) Name anglicization. Iterate profile name's known variants →
# replace with original (case-insensitive match).
if session_ctx is not None and isinstance(session_ctx, dict):
profile = _extract_profile_for_session(session_ctx)
if profile:
full_name = str(profile.get("name") or "").strip()
if full_name:
# Split into parts and check each
parts = [p for p in re.split(r"[\s\-_]+", full_name) if p]
for part in parts:
variants = services_context._common_name_variants(part)
orig_cap = part[0].upper() + part[1:] if part else part
for variant in variants:
# Case-insensitive global replace → original case
s = re.sub(
r"\b" + re.escape(variant) + r"\b",
orig_cap,
s,
flags=re.IGNORECASE,
)
# Also try full-name variants (combined)
combined = full_name.replace(" ", "") # noqa: F841
# (5) Collapse whitespace. Re-punctuation cleanup: avoid normalizing
# legitimate terminal "?" / "!" into nothing; only collapse doubled
# or space-separated punctuation that resulted from earlier deletes.
s = re.sub(r"[ \t\u00a0]+", " ", s)
s = re.sub(r"\n{3,}", "\n\n", s)
# Interior punctuation: normalize spaces, but keep the char itself.
# Example: "hello , world" → "hello, world"
s = re.sub(r"\s+([,.;:])\s+", r"\1 ", s)
# Terminal / exclamation / interrogation: only fix spacing, never strip.
s = re.sub(r"\s+([!?])", r"\1", s)
s = re.sub(r"([!?])\s{2,}", r"\1 ", s)
# Strip leftover whitespace. Do NOT strip the legitimate terminal
# punctuation chars ?, !, ., because they are part of the real speech.
# Only strip repeated trailing commas / semicolons that appear because
# we deleted bracket/JSON fragments that separated clauses.
s = s.strip()
# Strip 2+ identical terminal punctuation chars (e.g. "hello.." → "hello.")
s = re.sub(r"([,.;:!?])\1{1,}$", r"\1", s)
# Strip terminal comma / semicolon / colon that now dangle (clause joins)
# but preserve ., ?, !.
s = re.sub(r"[,;:]$", "", s).strip()
return s
def _sanitize_realtime_event_raw(raw_message: str, session_ctx: dict[str, Any] | None = None) -> tuple[str, bool]:
"""If the raw upstream event contains assistant OUTPUT text, sanitize it
in-place and return (new_json_str, True) — otherwise return (raw, False).
Mirrors exactly the same traversal used by _realtime_event_summary so we
touch every field that would become summary["text"].
"""
import json as _json
if not isinstance(raw_message, str) or not raw_message.strip():
return raw_message, False
try:
payload = _json.loads(raw_message)
except Exception:
return raw_message, False
if not isinstance(payload, dict):
return raw_message, False
event_type = payload.get("type")
if not isinstance(event_type, str):
return raw_message, False
changed = False
def _sanitize_value(v: Any) -> Any:
nonlocal changed
if isinstance(v, str) and v.strip():
cleaned = _sanitize_agent_output(v, session_ctx)
# Layer 8 removes internal ApiPilot CALL markers before UI/TTS.
# The pass-2 accumulator retains the original transcript so
# execution can still be triggered by its marker.
cleaned = services_tool_context._sanitize_text_strip_apipilot_markers(cleaned)
if cleaned != v:
changed = True
return cleaned
return v
# --- Event dispatch (same shape as _realtime_event_summary) -------------
# Sanitize streaming deltas as well as .done events. Qwen3-TTS reads deltas
# before generating binary audio and could otherwise pronounce the marker.
if event_type in {"response.audio_transcript.done", "response.audio_transcript.delta"}:
if "transcript" in payload:
payload["transcript"] = _sanitize_value(payload["transcript"])
if "delta" in payload and isinstance(payload.get("delta"), str):
payload["delta"] = _sanitize_value(payload["delta"])
for container in ("item", "part", "content", "output", "audio"):
sub = payload.get(container)
if isinstance(sub, dict):
for key in ("transcript", "text", "delta"):
if key in sub and isinstance(sub.get(key), str):
sub[key] = _sanitize_value(sub[key])
elif event_type in {"response.output_audio_transcript.done", "response.output_audio_transcript.delta"}:
# Top-level keys
for key in (
"transcript",
"text",
"output_text",
"audio_text",
"output_transcript",
"final_transcript",
"output_audio_text",
"content",
"message",
"utterance",
"delta",
):
if key in payload and isinstance(payload.get(key), str):
payload[key] = _sanitize_value(payload[key])
# Sub-containers
for container in ("item", "part", "content", "output", "delta", "audio"):
sub = payload.get(container)
if isinstance(sub, dict):
for key in ("transcript", "text", "output_text", "delta"):
if key in sub and isinstance(sub.get(key), str):
sub[key] = _sanitize_value(sub[key])
# content[] array
if isinstance(sub.get("content"), list):
for c in sub["content"]:
if isinstance(c, dict):
for k2 in ("transcript", "text", "delta"):
if k2 in c and isinstance(c.get(k2), str):
c[k2] = _sanitize_value(c[k2])
if isinstance(payload.get("content"), list):
for c in payload["content"]:
if isinstance(c, dict):
for k2 in ("transcript", "text", "delta"):
if k2 in c and isinstance(c.get(k2), str):
c[k2] = _sanitize_value(c[k2])
elif event_type in {
"response.text.delta",
"response.text.done",
"response.output_text.delta",
"response.output_text.done",
}:
for key in ("text", "output_text", "delta", "final_text", "content"):
if key in payload and isinstance(payload.get(key), str):
payload[key] = _sanitize_value(payload[key])
for container in ("item", "part", "output", "content", "text"):
sub = payload.get(container)
if isinstance(sub, dict):
for key in ("text", "output_text", "delta"):
if key in sub and isinstance(sub.get(key), str):
sub[key] = _sanitize_value(sub[key])
if isinstance(sub.get("content"), list):
for c in sub["content"]:
if isinstance(c, dict):
for k2 in ("text", "transcript", "delta"):
if k2 in c and isinstance(c.get(k2), str):
c[k2] = _sanitize_value(c[k2])
if isinstance(payload.get("content"), list):
for c in payload["content"]:
if isinstance(c, dict):
for k2 in ("text", "transcript", "delta"):
if k2 in c and isinstance(c.get(k2), str):
c[k2] = _sanitize_value(c[k2])
elif event_type == "response.content_part.done":
part = payload.get("part")
if isinstance(part, dict):
if "text" in part and isinstance(part.get("text"), str):
part["text"] = _sanitize_value(part["text"])
if "transcript" in part and isinstance(part.get("transcript"), str):
part["transcript"] = _sanitize_value(part["transcript"])
elif event_type == "response.content_part.added":
part = payload.get("part")
if isinstance(part, dict):
if "text" in part and isinstance(part.get("text"), str):
part["text"] = _sanitize_value(part["text"])
if "transcript" in part and isinstance(part.get("transcript"), str):
part["transcript"] = _sanitize_value(part["transcript"])
elif event_type in {
"response.content_part.text.delta",
"response.output_content.text.delta",
"response.content.text.delta",
}:
for key in ("text", "delta"):
if key in payload and isinstance(payload.get(key), str):
payload[key] = _sanitize_value(payload[key])
elif event_type in {"response.output_item.done", "response.output_item.added"}:
item = payload.get("item")
if isinstance(item, dict):
for c in item.get("content") or []:
if isinstance(c, dict):
if "text" in c and isinstance(c["text"], str):
c["text"] = _sanitize_value(c["text"])
if "transcript" in c and isinstance(c["transcript"], str):
c["transcript"] = _sanitize_value(c["transcript"])
elif event_type == "conversation.item.created":
item = payload.get("item")
if isinstance(item, dict) and item.get("role") == "assistant":
for c in item.get("content") or []:
if isinstance(c, dict):
if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str):
c["text"] = _sanitize_value(c["text"])
if c.get("type") == "transcript" and isinstance(c.get("transcript"), str):
c["transcript"] = _sanitize_value(c["transcript"])
if not changed:
return raw_message, False
try:
new_raw = _json.dumps(payload, ensure_ascii=False)
except Exception:
return raw_message, False
return new_raw, True
File diff suppressed because it is too large Load Diff
View File
File diff suppressed because it is too large Load Diff
+960
View File
@@ -0,0 +1,960 @@
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
from s2s_client.models import requests as models_requests
from s2s_client.providers import apipilot as providers_apipilot
from s2s_client.providers import caller as providers_caller
from s2s_client.services import context as services_context
from s2s_client.services import tool_context as services_tool_context
def _openai_headers() -> dict[str, str]:
if not settings.OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY is not configured.")
headers = {
"Authorization": f"Bearer {settings.OPENAI_API_KEY}",
"Content-Type": "application/json",
}
if settings.OPENAI_ORGANIZATION:
headers["OpenAI-Organization"] = settings.OPENAI_ORGANIZATION
if settings.OPENAI_PROJECT:
headers["OpenAI-Project"] = settings.OPENAI_PROJECT
return headers
async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
if settings.S2S_REASONING_TYPE == "PROXY":
raise HTTPException(status_code=409, detail="El servicio de razonamiento está deshabilitado en el modo PROXY.")
transcript = (req.transcript or "").strip()
if not transcript:
raise HTTPException(status_code=400, detail="Se requiere el campo transcript.")
# ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ──
# (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context
# and instructions are taken verbatim from the request body.)
inject_episode_text = ""
inject_profile_text = ""
inject_reports_text = ""
inject_summary_text = ""
phone = (req.phoneNumber or "").strip()
episode_id = (req.episode_id or "").strip()
if not settings.S2S_INJECT_PROFILE:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). "
"Using verbatim request body; no fetch, no augmentation."
)
else:
needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
# 2026-09-11 user fix: remove mass injection of scheduled reports.
# treats S2S_REPORT_LIST as "need to fetch reports" ONLY when BOTH the URL is
# configured AND the new opt-in S2S_INJECT_REPORTS flag is true. When
# S2S_INJECT_REPORTS=false (default) needs_reports stays False here → we skip
# the branch entirely, no HTTP call, no context string, no prompt bloat.
needs_reports = bool(settings.S2S_REPORT_LIST.strip()) and settings.S2S_INJECT_REPORTS
needs_summary = bool(settings.S2S_LATEST_SUMMARY.strip()) or bool(settings.S2S_LATEST_SUMMARY_TEXT)
needs_login = bool(settings.S2S_LOGIN_BY_PHONE.strip())
can_inject = needs_phone_authed or needs_reports or needs_summary or needs_login or bool(episode_id)
if (
(not needs_phone_authed)
and (not needs_reports)
and (not needs_summary)
and (not needs_login)
and (not episode_id)
):
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY/S2S_LATEST_SUMMARY_TEXT and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request; OR S2S_INJECT_REPORTS is false so reports injection is globally disabled). "
"Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable login / fetch episode/profile/reports/summary for REASONING endpoint."
)
elif (not phone) and (not needs_reports) and (not episode_id) and (not settings.S2S_LATEST_SUMMARY_TEXT):
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). "
"Browser-side issue: check VoiceController passes phoneNumber and/or episode_id in the JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
)
elif not can_inject:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)."
)
else:
# ###################################################################
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env set)
# Obtains a bearer token that will be forwarded for profile/reports/summary fetches.
# ###################################################################
login_token = None
if settings.S2S_LOGIN_BY_PHONE and phone:
login_token, _login_resp = await providers_caller._login_by_phone(phone)
# ###################################################################
# 0.5) Podcast episode fetch (independent of phone — uses req.episode_id)
# ###################################################################
if episode_id:
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...",
episode_id,
)
episode_raw = await providers_caller._fetch_podcast_episode(episode_id)
inject_episode_text = services_context._resolve_episode_context(episode_raw)
if inject_episode_text.strip():
app_logging.logger.info(
"[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
episode_id,
len(inject_episode_text),
)
else:
app_logging.logger.info(
"[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)."
)
# ###################################################################
# 1) Profile fetch (optional — only if env is configured)
# ###################################################################
if needs_phone_authed and phone:
profile_obj = await providers_caller._fetch_user_profile_for_phone(phone, auth_token=login_token)
inject_profile_text = services_context._resolve_user_context_text(phone, profile_obj, loaded=True)
if inject_profile_text.strip():
app_logging.logger.info(
"[INJECT-PROFILE] ✅ REASONING: profile READY phoneLen=%d contextLen=%d -> augmenting instructions + user_context before sending upstream.",
len(phone),
len(inject_profile_text),
)
else:
app_logging.logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: profile fetch OK but resolved context empty."
)
# ###################################################################
# 1.5) Scheduled reports / podcast scripts list (optional — S2S_REPORT_LIST)
# ###################################################################
if needs_reports:
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-REPORTS] starting REASONING fetch S2S_REPORT_LIST (phoneLen=%s, bearer=%s)...",
len(phone) if phone else "none",
"yes" if login_token else "no",
)
reports_raw = await providers_caller._fetch_reports_list(
phone_number=phone if phone else None, auth_token=login_token
)
inject_reports_text = services_context._resolve_reports_context(reports_raw)
if inject_reports_text.strip():
app_logging.logger.info(
"[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
len(inject_reports_text),
)
else:
app_logging.logger.info(
"[INJECT-REPORTS] ⏭️ REASONING: reports list skipped (context empty / upstream returned empty list / fetch failed)."
)
# ###################################################################
# 2) Latest summary fetch (optional — only if env is configured)
# ###################################################################
if needs_summary and (phone or settings.S2S_LATEST_SUMMARY_TEXT):
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s.",
bool(settings.S2S_LATEST_SUMMARY),
bool(settings.S2S_LATEST_SUMMARY_TEXT),
len(phone) if phone else "none",
)
latest_summary_raw = await providers_caller._fetch_latest_summary_for_phone(
phone, auth_token=login_token
)
inject_summary_text = services_context._resolve_latest_summary_context(latest_summary_raw)
if inject_summary_text.strip():
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ REASONING: latest summary READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
len(phone) if phone else "none",
bool(settings.S2S_LATEST_SUMMARY_TEXT),
len(inject_summary_text),
)
else:
app_logging.logger.info(
"[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)"
)
# Load dynamic ApiPilot tools before building context, independently of
# S2S_INJECT_PROFILE.
needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)
apipilot_tools_pass1: list[dict] = []
apipilot_auth_header: str | None = None
if isinstance(login_token, str) and login_token.strip():
tk = login_token.strip()
if tk.lower().startswith("bearer "):
apipilot_auth_header = tk
else:
apipilot_auth_header = f"Bearer {tk}"
# S2S_LOGIN_BY_PHONE resolves an AquaPortal JWT, used for requires_auth=true
# execution. S2S_APIPILOT_SERVICE_TOKEN overrides it when configured.
if settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
if needs_apipilot:
try:
apipilot_t0 = providers_apipilot._monotonic_now()
apipilot_tools_pass1 = await providers_apipilot._apipilot_fetch_tools(
auth_header=apipilot_auth_header,
demo_session=None,
force_refresh=False,
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s",
len(apipilot_tools_pass1),
int((providers_apipilot._monotonic_now() - apipilot_t0) * 1000),
"Y" if apipilot_auth_header else "-",
)
except Exception as exc:
app_logging.logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc)
apipilot_tools_pass1 = []
inject_apipilot_text_pass1 = services_tool_context._resolve_apipilot_reasoning_context_block(
apipilot_tools_pass1,
call_result=None,
)
user_context_parts: list[str] = []
# ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too
# (the same anti-hallucination block gets prefixed in the SYSTEM instructions
# path via _augment_instructions_with_profile; we also prepend here so the
# OPENAI/ANYSCALE messages[] array (where caller context is user_message[0])
# ALSO gets the same guard.)
# ─── USER FIX 2026-09-11 HALLUCINATION: voice_lock_block FIRST position
voice_lock_block = services_context._build_voice_rules_head_lock_block(profile=profile_obj)
if voice_lock_block.strip():
user_context_parts.append(voice_lock_block)
today_block = services_context._build_today_fixed_context_block()
if today_block.strip():
user_context_parts.append(today_block)
if inject_episode_text:
user_context_parts.append(inject_episode_text)
if inject_profile_text:
user_context_parts.append(inject_profile_text)
if inject_reports_text:
user_context_parts.append(inject_reports_text)
if inject_summary_text:
user_context_parts.append(inject_summary_text)
if inject_apipilot_text_pass1:
user_context_parts.append(inject_apipilot_text_pass1)
original_user_context = (req.user_context or "").strip()
if original_user_context:
user_context_parts.append(original_user_context)
user_context_parts = services_context._dedup_context_blocks_by_type(user_context_parts)
if original_user_context:
server_injected_types_in_client_ctx: set = set()
for part in user_context_parts:
if original_user_context and (part or "").strip() == original_user_context:
continue
tid = services_context._classify_context_block(part)
if tid is None:
continue
for marker_tid, marker in services_context._CONTEXT_DEDUPE_MARKERS:
if marker_tid == tid and marker and marker in original_user_context:
server_injected_types_in_client_ctx.add(tid)
break
if server_injected_types_in_client_ctx:
new_parts: list[str] = []
for part in user_context_parts:
if (part or "").strip() == original_user_context:
new_parts.append(part)
continue
tid = services_context._classify_context_block(part)
if tid in server_injected_types_in_client_ctx:
continue
new_parts.append(part)
if settings.S2S_DEBUG_PROMPT and new_parts != user_context_parts:
app_logging.logger.info(
"[INJECT-DEDUPE] dropped server-injected profile/episode/reports/summary because the same block type was already present in req.user_context client-side context=%s kept=%d",
", ".join(sorted(server_injected_types_in_client_ctx)),
len(new_parts),
)
user_context_parts = new_parts
# ─── 2026-09-11 MARKER LEAK GUARD: strip invisible internal sentinels from EACH part.
stripped_parts: list[str] = []
for p in user_context_parts:
sp = services_context._strip_internal_markers(p)
if sp:
stripped_parts.append(sp)
user_context_parts = stripped_parts
effective_user_context = "\n\n".join(user_context_parts)
if settings.S2S_INJECT_PROFILE:
any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text)
if any_injected:
app_logging.logger.info(
"[INJECT-PROFILE] REASONING user_context final parts=%d serverEpisodeLen=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d",
len(user_context_parts),
len(inject_episode_text),
len(inject_profile_text),
len(inject_reports_text),
len(inject_summary_text),
len(original_user_context),
len(effective_user_context),
)
if settings.S2S_DEBUG_PROMPT and voice_lock_block.strip():
app_logging.logger.info(
"[DEBUG-VOICELOCK][REASONING augment] FULL voice lock block (len=%d):\n%s\n",
len(voice_lock_block),
services_context._strip_internal_markers(voice_lock_block),
)
if settings.S2S_DEBUG_PROMPT and inject_episode_text.strip():
app_logging.logger.info(
"[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n",
len(inject_episode_text),
services_context._strip_internal_markers(inject_episode_text),
)
if settings.S2S_DEBUG_PROMPT and today_block.strip():
app_logging.logger.info(
"[DEBUG-TODAY][REASONING augment] FULL today block (len=%d):\n%s\n",
len(today_block),
services_context._strip_internal_markers(today_block),
)
if settings.S2S_DEBUG_PROMPT and inject_reports_text.strip():
app_logging.logger.info(
"[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n",
len(inject_reports_text),
services_context._strip_internal_markers(inject_reports_text),
)
if settings.S2S_DEBUG_PROMPT and inject_summary_text.strip():
app_logging.logger.info(
"[DEBUG-SUMMARY][REASONING augment] FULL latest summary block (len=%d):\n%s\n",
len(inject_summary_text),
services_context._strip_internal_markers(inject_summary_text),
)
if settings.S2S_DEBUG_PROMPT and inject_profile_text.strip():
app_logging.logger.info(
"[DEBUG-PROFILE][REASONING augment] FULL profile block (len=%d):\n%s\n",
len(inject_profile_text),
services_context._strip_internal_markers(inject_profile_text),
)
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1),
services_context._strip_internal_markers(inject_apipilot_text_pass1),
)
if needs_apipilot and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions and user_context.",
len(inject_apipilot_text_pass1),
len(apipilot_tools_pass1),
)
effective_instructions = services_context._augment_instructions_with_profile(
req.instructions or "",
inject_profile_text,
inject_reports_text,
inject_summary_text,
inject_episode_text,
profile_obj=profile_obj,
)
if inject_apipilot_text_pass1.strip():
effective_instructions = (
effective_instructions + "\n\n" if effective_instructions else ""
) + inject_apipilot_text_pass1
else:
effective_instructions = (req.instructions or "").strip()
if inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions (inject_profile=false).",
len(inject_apipilot_text_pass1),
len(apipilot_tools_pass1),
)
effective_instructions = (
effective_instructions + "\n\n" if effective_instructions else ""
) + inject_apipilot_text_pass1
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
app_logging.logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1),
services_context._strip_internal_markers(inject_apipilot_text_pass1),
)
# ────────────────────────────────────────────────────────────────────
# Mode A: NODE_RED forwards the complete payload; the flow decides behavior.
# Stage 1 (log-only) returns {answer: "", log_only: true}, allowing the browser
# to leave speech generation to the S2S backend. Later stages can return
# {answer: "...", log_only: false}. Expected response fields include answer,
# log_only, usage, debug and latency_ms.
# ────────────────────────────────────────────────────────────────────
if settings.S2S_REASONING_TYPE == "NODE_RED":
if not settings.NODE_RED_REASONING_URL:
raise HTTPException(
status_code=503,
detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).",
)
app_logging.logger.info(
"[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
len(effective_instructions),
len(effective_user_context),
len(phone),
len(episode_id),
)
upstream_body = {
"transcript": transcript,
"instructions": effective_instructions,
"language": (req.language or "").strip(),
"user_context": effective_user_context,
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n",
json.dumps(upstream_body, ensure_ascii=False, indent=2),
)
app_logging.logger.debug(
"[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d",
settings.NODE_RED_REASONING_URL,
(req.language or "")[:20] or "auto",
len(transcript),
)
try:
async with httpx.AsyncClient(timeout=120.0) as http:
resp = await http.post(
f"{settings.NODE_RED_REASONING_URL}/api/reasoning",
json=upstream_body,
headers={
"Content-Type": "application/json",
"X-S2S-Caller": "demo-server",
},
)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][NODE_RED] unreachable: %r", exc)
raise HTTPException( # noqa: B904
status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED."
)
app_logging.logger.debug(
"[EXT][NODE_RED] reasoning RESP status=%s len=%dB",
resp.status_code,
len(resp.content or b""),
)
if resp.status_code != 200:
app_logging.logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500])
raise HTTPException(
status_code=502,
detail=f"El razonamiento de Node-RED falló (HTTP {resp.status_code}).",
)
try:
payload = resp.json()
except Exception:
app_logging.logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500])
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido.") # noqa: B904
answer = payload.get("answer") if isinstance(payload, dict) else None
log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True)
if not isinstance(answer, str):
app_logging.logger.warning(
"[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800]
)
raise HTTPException(status_code=502, detail="Node-RED devolvió un campo answer no válido.")
answer_stripped = answer.strip()
# An empty answer is intentional in log-only mode; forward the flag.
if not answer_stripped and not log_only:
app_logging.logger.warning(
"[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]
)
raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.")
usage = payload.get("usage") if isinstance(payload, dict) else None
if isinstance(usage, dict):
app_logging.logger.debug(
"[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d",
usage.get("prompt_tokens", 0),
usage.get("completion_tokens", 0),
usage.get("total_tokens", 0),
)
extra = {}
for key in ("latency_ms", "intent", "caller_profile_keys", "debug"):
if isinstance(payload, dict) and key in payload:
extra[key] = payload[key]
if log_only:
app_logging.logger.info(
"[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s",
transcript[:300],
json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400],
)
else:
app_logging.logger.info(
"[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s",
transcript[:300],
answer_stripped[:500],
json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400],
)
out: dict[str, Any] = {"answer": answer_stripped}
if log_only:
out["log_only"] = True
if usage:
out["usage"] = usage
out.update({k: v for k, v in extra.items() if k != "debug" or app_logging.logger.level <= logging.DEBUG})
# Capture the ApiPilot marker, execute its tool and run reasoning pass 2.
tool_name_p1, tool_args_p1 = services_tool_context._apipilot_parse_call_marker(answer_stripped)
if tool_name_p1:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s",
tool_name_p1,
sorted(list((tool_args_p1 or {}).keys())),
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 trigger NODE_RED marker name=%s args=%s",
tool_name_p1,
json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300],
)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
tool_name_p1,
)
tool_env_p1 = await providers_apipilot._apipilot_execute_tool(
auth_header=apipilot_auth_header,
demo_session=None,
name=tool_name_p1,
arguments=tool_args_p1 or {},
)
is_tool_err = not bool(tool_env_p1.get("success"))
tool_data = tool_env_p1.get("data")
if isinstance(tool_data, str):
tool_text_p1 = tool_data
elif tool_data is None:
tool_text_p1 = ""
else:
try:
tool_text_p1 = json.dumps(tool_data, ensure_ascii=False, indent=2)
except Exception:
tool_text_p1 = str(tool_data)
if is_tool_err and isinstance(tool_env_p1.get("error"), dict):
err = tool_env_p1["error"]
err_head = f"[{err.get('code')}] {err.get('message')}"
tool_text_p1 = (err_head + ("\n" + tool_text_p1 if tool_text_p1.strip() else "")).strip()
inject_apipilot_text_pass2 = services_tool_context._resolve_apipilot_reasoning_context_block(
[],
call_result={
"name": tool_name_p1,
"text": tool_text_p1,
"error": is_tool_err,
},
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger second Node-RED call",
tool_name_p1,
str(not is_tool_err).lower(),
len(tool_text_p1),
)
# Pass-2 context replaces the tool list with the execution result,
# preserving voice/date/profile context.
user_ctx_parts_2: list[str] = []
if voice_lock_block.strip():
user_ctx_parts_2.append(voice_lock_block)
if today_block.strip():
user_ctx_parts_2.append(today_block)
if inject_episode_text:
user_ctx_parts_2.append(inject_episode_text)
if inject_profile_text:
user_ctx_parts_2.append(inject_profile_text)
if inject_reports_text:
user_ctx_parts_2.append(inject_reports_text)
if inject_summary_text:
user_ctx_parts_2.append(inject_summary_text)
if inject_apipilot_text_pass2:
user_ctx_parts_2.append(inject_apipilot_text_pass2)
if original_user_context:
user_ctx_parts_2.append(original_user_context)
user_ctx_parts_2 = services_context._dedup_context_blocks_by_type(user_ctx_parts_2)
stripped_2: list[str] = []
for p in user_ctx_parts_2:
sp = services_context._strip_internal_markers(p)
if sp:
stripped_2.append(sp)
user_ctx_parts_2 = stripped_2
effective_user_ctx_2 = "\n\n".join(user_ctx_parts_2)
# Preserve augmentation without CALL-marker instructions; the result
# block supplies the pass-2 response instructions.
if settings.S2S_INJECT_PROFILE:
eff_inst_2 = services_context._augment_instructions_with_profile(
req.instructions or "",
inject_profile_text,
inject_reports_text,
inject_summary_text,
inject_episode_text,
profile_obj=profile_obj,
)
else:
eff_inst_2 = (req.instructions or "").strip()
if inject_apipilot_text_pass2.strip():
eff_inst_2 = (eff_inst_2 + "\n\n" if eff_inst_2 else "") + inject_apipilot_text_pass2
upstream_body_2 = {
"transcript": transcript,
"instructions": eff_inst_2,
"language": (req.language or "").strip(),
"user_context": effective_user_ctx_2,
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → NODE_RED PASS-2] FULL upstream_body_2 JSON:\n%s\n",
json.dumps(upstream_body_2, ensure_ascii=False, indent=2),
)
try:
async with httpx.AsyncClient(timeout=120.0) as http2:
resp2 = await http2.post(
f"{settings.NODE_RED_REASONING_URL}/api/reasoning",
json=upstream_body_2,
headers={
"Content-Type": "application/json",
"X-S2S-Caller": "demo-server-pass2",
},
)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc)
raise HTTPException( # noqa: B904
status_code=502,
detail="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).",
)
if resp2.status_code != 200:
app_logging.logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500])
raise HTTPException(
status_code=502,
detail=f"La segunda pasada del razonamiento de Node-RED falló (HTTP {resp2.status_code}).",
)
try:
payload2 = resp2.json()
except Exception:
app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500])
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido (segunda pasada).") # noqa: B904
final_ans = payload2.get("answer") if isinstance(payload2, dict) else None
if not isinstance(final_ans, str):
# Fallback: the first message and raw result.
app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid answer, falling back to raw result text")
final_ans = tool_text_p1 or answer_stripped
final_stripped = final_ans.strip()
# Pass 2 always sets log_only=False: its final answer is ready for speech.
out2: dict = {"answer": final_stripped}
usage2 = payload2.get("usage") if isinstance(payload2, dict) else None
if usage2:
out2["usage_pass2"] = usage2
out2["apipilot_invoked"] = {
"name": tool_name_p1,
"success": not is_tool_err,
"result_length": len(tool_text_p1),
}
app_logging.logger.info(
"[Vocero REASONING] engine=NODE_RED PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
transcript[:300],
final_stripped[:500],
json.dumps(out2["apipilot_invoked"], ensure_ascii=True),
)
return JSONResponse(out2)
# Without a marker, return the original pass-1 answer.
return JSONResponse(out)
# ────────────────────────────────────────────────────────────────────
# Mode B: OPENAI calls OpenAI Chat Completions directly.
# ────────────────────────────────────────────────────────────────────
if not settings.OPENAI_API_KEY:
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
system_parts = []
instructions = effective_instructions or ((req.instructions or "").strip())
if instructions:
system_parts.append(instructions)
if effective_user_context.strip():
system_parts.append(f"User context:\n{effective_user_context.strip()}")
if inject_apipilot_text_pass1:
system_parts.append(inject_apipilot_text_pass1)
if req.language.strip():
system_parts.append(
"Reply only with the final assistant answer text. "
f"Keep the answer natural and concise in this language: {req.language.strip()}."
)
else:
system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.")
app_logging.logger.info(
"[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
len(system_parts),
len(effective_instructions),
len(effective_user_context),
len(phone),
len(episode_id),
)
system_msg = "\n\n".join(system_parts)
body = {
"model": settings.OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg},
{"role": "user", "content": transcript},
],
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n",
len(system_msg),
system_msg,
)
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n",
len(transcript),
transcript,
)
app_logging.logger.debug(
"[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s",
settings.OPENAI_BASE_URL,
settings.OPENAI_REASONING_MODEL,
len(system_parts[0]) if system_parts else 0,
len(transcript),
(req.language or "")[:20] or "auto",
)
try:
async with httpx.AsyncClient(timeout=30.0) as http:
resp = await http.post(
f"{settings.OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body,
)
except httpx.RequestError as exc:
app_logging.logger.warning("OpenAI reasoning unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI.") # noqa: B904
app_logging.logger.debug(
"[EXT][OPENAI] reasoning RESP status=%s len=%dB",
resp.status_code,
len(resp.content or b""),
)
if resp.status_code != 200:
app_logging.logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400])
raise HTTPException(status_code=502, detail=f"El razonamiento de OpenAI falló ({resp.status_code}).")
try:
payload = resp.json()
except Exception:
app_logging.logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400])
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido.") # noqa: B904
answer = ""
choices = payload.get("choices")
if isinstance(choices, list) and choices:
message = choices[0].get("message") if isinstance(choices[0], dict) else None
if isinstance(message, dict):
content = message.get("content")
if isinstance(content, str):
answer = content.strip()
usage = payload.get("usage")
if isinstance(usage, dict):
app_logging.logger.debug(
"[EXT][OPENAI] reasoning USAGE prompt=%d completion=%d total=%d",
usage.get("prompt_tokens", 0),
usage.get("completion_tokens", 0),
usage.get("total_tokens", 0),
)
if not answer:
app_logging.logger.warning(
"OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800]
)
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.")
app_logging.logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500])
# Capture the ApiPilot marker, execute its tool and run OpenAI pass 2.
tool_name_p1_oai, tool_args_p1_oai = services_tool_context._apipilot_parse_call_marker(answer)
if tool_name_p1_oai:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s",
tool_name_p1_oai,
sorted(list((tool_args_p1_oai or {}).keys())),
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s",
tool_name_p1_oai,
json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300],
)
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
tool_name_p1_oai,
)
tool_env_oai_p1 = await providers_apipilot._apipilot_execute_tool(
auth_header=apipilot_auth_header,
demo_session=None,
name=tool_name_p1_oai,
arguments=tool_args_p1_oai or {},
)
is_tool_err_oai = not bool(tool_env_oai_p1.get("success"))
tool_data_oai = tool_env_oai_p1.get("data")
if isinstance(tool_data_oai, str):
tool_text_oai = tool_data_oai
elif tool_data_oai is None:
tool_text_oai = ""
else:
try:
tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2)
except Exception:
tool_text_oai = str(tool_data_oai)
if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict):
err_oai = tool_env_oai_p1["error"]
err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}"
tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip()
inject_apipilot_pass2_oai = services_tool_context._resolve_apipilot_reasoning_context_block(
[],
call_result={
"name": tool_name_p1_oai,
"text": tool_text_oai,
"error": is_tool_err_oai,
},
)
app_logging.logger.info(
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call",
tool_name_p1_oai,
str(not is_tool_err_oai).lower(),
len(tool_text_oai),
)
# Build pass 2 with the same instructions/context structure, replacing
# inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai.
user_ctx_oai_2: list[str] = []
if voice_lock_block.strip():
user_ctx_oai_2.append(voice_lock_block)
if today_block.strip():
user_ctx_oai_2.append(today_block)
if inject_episode_text:
user_ctx_oai_2.append(inject_episode_text)
if inject_profile_text:
user_ctx_oai_2.append(inject_profile_text)
if inject_reports_text:
user_ctx_oai_2.append(inject_reports_text)
if inject_summary_text:
user_ctx_oai_2.append(inject_summary_text)
if inject_apipilot_pass2_oai:
user_ctx_oai_2.append(inject_apipilot_pass2_oai)
if original_user_context:
user_ctx_oai_2.append(original_user_context)
user_ctx_oai_2 = services_context._dedup_context_blocks_by_type(user_ctx_oai_2)
stripped_oai_2: list[str] = []
for p in user_ctx_oai_2:
sp = services_context._strip_internal_markers(p)
if sp:
stripped_oai_2.append(sp)
user_ctx_oai_2 = stripped_oai_2
eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2)
if settings.S2S_INJECT_PROFILE:
eff_inst_oai_2 = services_context._augment_instructions_with_profile(
req.instructions or "",
inject_profile_text,
inject_reports_text,
inject_summary_text,
inject_episode_text,
profile_obj=profile_obj,
)
else:
eff_inst_oai_2 = (req.instructions or "").strip()
sys_parts_oai_2: list[str] = []
instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip())
if instr_oai_2:
sys_parts_oai_2.append(instr_oai_2)
if eff_user_ctx_oai_2.strip():
sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}")
if inject_apipilot_pass2_oai:
sys_parts_oai_2.append(inject_apipilot_pass2_oai)
if req.language.strip():
sys_parts_oai_2.append(
"Reply only with the final assistant answer text. "
f"Keep the answer natural and concise in this language: {req.language.strip()}."
)
else:
sys_parts_oai_2.append(
"Reply only with the final assistant answer text. Keep the answer natural and concise."
)
system_msg_oai_2 = "\n\n".join(sys_parts_oai_2)
body_oai_2 = {
"model": settings.OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg_oai_2},
{"role": "user", "content": transcript},
],
}
if settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n",
len(system_msg_oai_2),
system_msg_oai_2,
)
try:
async with httpx.AsyncClient(timeout=30.0) as http_oai2:
resp_oai_2 = await http_oai2.post(
f"{settings.OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body_oai_2,
)
except httpx.RequestError as exc:
app_logging.logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc)
raise HTTPException( # noqa: B904
status_code=502,
detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).",
)
if resp_oai_2.status_code != 200:
app_logging.logger.warning(
"[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400]
)
raise HTTPException(
status_code=502,
detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).",
)
try:
payload_oai_2 = resp_oai_2.json()
except Exception:
app_logging.logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400])
raise HTTPException( # noqa: B904
status_code=502,
detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).",
)
final_oai_ans = ""
choices_oai_2 = payload_oai_2.get("choices")
if isinstance(choices_oai_2, list) and choices_oai_2:
msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None
if isinstance(msg_oai_2, dict):
content_oai_2 = msg_oai_2.get("content")
if isinstance(content_oai_2, str):
final_oai_ans = content_oai_2.strip()
usage_oai_2 = payload_oai_2.get("usage")
if not final_oai_ans:
# Last-resort fallback: raw tool-result text.
app_logging.logger.warning("[EXT][OPENAI PASS-2] empty answer, falling back to raw tool-result text")
final_oai_ans = (tool_text_oai or answer).strip()
final_out_oai: dict = {"answer": final_oai_ans}
if usage_oai_2:
final_out_oai["usage_pass2"] = usage_oai_2
final_out_oai["apipilot_invoked"] = {
"name": tool_name_p1_oai,
"success": not is_tool_err_oai,
"result_length": len(tool_text_oai),
}
app_logging.logger.info(
"[Vocero REASONING] engine=OPENAI PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
transcript[:300],
final_oai_ans[:500],
json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True),
)
return JSONResponse(final_out_oai)
# Without a marker, return the original pass-1 answer.
return JSONResponse({"answer": answer})
async def _forward_agent_text_to_nodered(payload: dict) -> None:
"""Forward native-backend TTS responses to Node-RED's stage-1 logging endpoint.
Ignore unavailable or unconfigured Node-RED so the realtime loop continues.
"""
if settings.S2S_REASONING_TYPE != "NODE_RED" or not settings.NODE_RED_REASONING_URL:
return
url = f"{settings.NODE_RED_REASONING_URL}/api/log/system-response"
app_logging.logger.debug(
"[EXT][NODE_RED] forward system-response POST %s len=%d source=%s",
settings.NODE_RED_REASONING_URL,
len(str(payload.get("tts", "")) or 0), # type: ignore[arg-type] # Preserve the legacy empty-TTS failure.
payload.get("source_event") or "-",
)
try:
async with httpx.AsyncClient(timeout=5.0) as http:
resp = await http.post(
url,
json=payload,
headers={
"Content-Type": "application/json",
"X-S2S-Caller": "demo-server",
},
)
if resp.status_code != 200:
app_logging.logger.debug(
"[EXT][NODE_RED] forward system-response non-200 status=%s body=%s",
resp.status_code,
(resp.text or "")[:300],
)
except httpx.RequestError as exc:
app_logging.logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc)
except Exception as exc: # noqa: BLE001 — never interrupt the realtime loop
app_logging.logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc)
+629
View File
@@ -0,0 +1,629 @@
from __future__ import annotations
import json
import re
from typing import cast
from s2s_client.core import logging as app_logging
# ---------------------------------------------------------------------------
# APIPILOT — Reasoning engine integration (two passes)
# ---------------------------------------------------------------------------
# Deduplication/start/end markers used by _classify_context_block to avoid
# injecting a block already supplied by the client or found in base_instructions.
APIPILOT_PASS1_LIST_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>"
APIPILOT_PASS2_RESULT_MARKER = "<<<APIPILOT_RESULT:"
# Flow:
# PASS 1: inject {name,description,required,parameters} and marker rules.
# The model matches descriptions and emits the CALL marker.
# PASS 2: capture the marker, execute through S2S_APIPILOT_TOOLS_EXECUTE_URL,
# inject the result text, and request a natural spoken response.
# ---------------------------------------------------------------------------
_APIPILOT_CALL_MARKER_RE = re.compile(
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
re.IGNORECASE | re.DOTALL,
)
# 2026-09-25: observed inverted marker (incorrect LLM syntax):
# >>>APIPILOT_CALL:NAME=tool,ARGUMENTS=json<<<
_APIPILOT_CALL_MARKER_INVERTED_RE = re.compile(
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
re.IGNORECASE | re.DOTALL,
)
def _apipilot_parse_call_marker(answer_text: str, *, defaults: dict | None = None) -> tuple[str | None, dict | None]:
"""Parse the mandatory CALL marker emitted by the LLM in pass 1.
Returns (tool_name, arguments_dict), or (None, None) when no marker exists.
Supports:
(1) ARGUMENTS={"key":"val"}, valid JSON.
(2) ARGUMENTS={}, a valid empty object.
(3) ARGUMENTS= without braces, observed on realtime WebSocket sessions.
(4) key=val,key2=val2 strings through a manual parsing fallback.
(5) After parsing, fill missing required arguments from defaults such as
phone and episode_id.
"""
if not isinstance(answer_text, str) or not answer_text.strip():
app_logging.logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_INPUT name=None markerFound=N")
return None, None
m = _APIPILOT_CALL_MARKER_RE.search(answer_text.strip())
inverted = False
if not m:
m = _APIPILOT_CALL_MARKER_INVERTED_RE.search(answer_text.strip())
inverted = bool(m)
if not m:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_NO_MATCH name=None markerFound=N textHead=%r", answer_text.strip()[:80]
)
return None, None
name_raw = m.group(1).strip()
args_raw = (m.group(2) if m.lastindex and m.lastindex >= 2 and isinstance(m.group(2), str) else "").strip()
if not name_raw:
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_NAME name=None markerFound=Y argsRawHead=%r inverted=%s",
args_raw[:60],
("Y" if inverted else "N"),
)
return None, None
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_MARKER_OK name=%r argsRawLen=%d markerFound=Y inverted=%s",
name_raw,
len(args_raw),
("Y" if inverted else "N"),
)
args: dict = {}
# Parse args_raw into a dict, tolerating common formatting variations.
if args_raw == "" or args_raw in {"{}", "null", "None"}:
args = {}
else:
# Attempt 1: standard JSON, with or without braces.
_candidate = args_raw
try:
args = json.loads(_candidate)
except Exception:
try:
# Attempt 2: supply missing outer braces.
args = json.loads("{" + _candidate + "}")
except Exception:
try:
# Attempt 3: replace single quotes with double quotes.
args = json.loads(_candidate.replace("'", '"'))
except Exception:
try:
# Attempt 4: supply braces and replace single quotes.
args = json.loads("{" + _candidate.replace("'", '"') + "}")
except Exception:
# Manual fallback 5: equals signs and commas.
# Example: args_raw="phone=+56,name=Mario" → dict.
args = {}
for piece in args_raw.split(","):
kv = piece.strip()
if not kv or "=" not in kv:
continue
k, v = kv.split("=", 1)
k = k.strip().strip('"').strip("'")
v = v.strip().strip('"').strip("'")
if k:
args[k] = v
if not isinstance(args, dict):
args = {}
# Stage 2: fill missing arguments from defaults (phone, etc.).
if isinstance(defaults, dict) and defaults:
for dk, dv in defaults.items():
if dk not in args or args[dk] in (None, "", []):
if dv not in (None, "", []):
args[dk] = dv
app_logging.logger.info(
"[APIPILOT-CALL-NAME] USE=PARSE_RETURN_FINAL name=%r finalArgsKeys=%s defaultsApplied=%s",
name_raw,
sorted(list(args.keys())),
("Y" if isinstance(defaults, dict) and defaults else "N"),
)
return name_raw, args
def _resolve_apipilot_reasoning_context_block(
tools_list: list[dict] | None,
*,
call_result: dict | None = None,
context_defaults: dict | None = None,
) -> str:
"""Build the ApiPilot context block for the reasoning prompt.
With call_result=None, pass 1 supplies the tool list and marker syntax
the model must emit when a description matches.
With call_result={'name':str, 'text':str, 'error':bool}, pass 2 injects the
execution result and requests a final spoken/text response for the user.
The optional context_defaults dict adds known default values to pass 1.
The model must use these exact values for required arguments rather than
inventing them. Usually contains {"phone": "+56..."} from inject_phone.
"""
# Pass 2: the tool result is already available.
if isinstance(call_result, dict) and call_result.get("name"):
name = call_result.get("name") or "?"
text = (call_result.get("text") if isinstance(call_result.get("text"), str) else "") or ""
is_err = bool(call_result.get("error"))
status_label = "ERROR" if is_err else "OK"
title = f"<<<APIPILOT_RESULT:STATUS={status_label},NAME={name}>>>"
trailer = (
"Now reinterpret THE RESULT TEXT (above) in clear, concise natural language, "
"exclusively in the user's language (language field; Spanish by default). "
"Do not mention the internal tool or technical details. "
"Respond ONLY with the final content intended for the user."
)
return (
f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n"
"### Invoked ApiPilot tool result\n"
f"{title}\n"
f"\n```tool-output\n"
f"{text.rstrip() if text.strip() else '(empty output)'}\n"
f"```\n\n"
f"{trailer}\n"
)
# Pass 1: available tools and marker rules.
tools = tools_list if isinstance(tools_list, list) else []
# Limit to 100 tools, although the guide specifies at most 50 for the demo.
tools_cap = tools[:100]
if not tools_cap:
return ""
entries: list[str] = []
for t in tools_cap:
if not isinstance(t, dict):
continue
name = t.get("name") if isinstance(t.get("name"), str) else ""
if not name:
continue
desc = cast(str, t.get("description")) if isinstance(t.get("description"), str) else ""
params = t.get("parameters") if isinstance(t.get("parameters"), dict) else None
requires_auth = bool(t.get("requires_auth"))
required_fields: list[str] = []
if isinstance(params, dict) and isinstance(params.get("required"), list):
required_fields = [str(x) for x in cast(list, params.get("required")) if isinstance(x, str)]
# Limit descriptions to about 500 characters to control prompt size.
desc_cut = desc[:500].rstrip()
if len(desc) > 500:
desc_cut += "…"
snippet = f"- name: {name}\n requires_auth: {('true' if requires_auth else 'false')}"
if required_fields:
snippet += f"\n required_parameters: [{', '.join(required_fields[:40])}]"
if desc_cut:
snippet += f"\n description: {desc_cut}"
entries.append(snippet)
if not entries:
return ""
rule = (
f"{APIPILOT_PASS1_LIST_MARKER}\n"
"### Available dynamic tools (ApiPilot AquaPortal)\n"
"\n"
"Each tool has a `name` field (a unique identifier: do NOT modify it, "
"use camelCase, or translate it) and a `description` describing its use case.\n"
"\n"
"MANDATORY EXECUTION RULES:\n"
"1. If the USER REQUEST (transcript) clearly matches the `description` of "
"EXACTLY one tool below, emit ONLY one line as the FIRST line of YOUR RESPONSE, "
"with this exact syntax:\n"
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n'
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). "
"Include ONLY fields listed in `required_parameters:`, if present.\n"
"3. Add NO text after the marker line: no explanations or previews. The engine "
"will execute the tool and supply its result in a second call.\n"
"4. If NO description matches, emit NO marker and respond normally in Spanish.\n"
"5. If multiple descriptions match, choose the most specific one and use the marker.\n"
"6. 🔴 EXCLUSIVE RESPONSE MODE (HIGHEST priority, mandatory): when using a CALL "
"marker, YOUR ENTIRE RESPONSE MUST CONTAIN ONLY THE MARKER LINE. "
"Write NOTHING BEFORE it: no 'Claro que sí', 'Voy a revisar', "
"'Voy a generar tu briefing', greeting, or transition. "
"Write NOTHING AFTER it: no comments, previews, or explanations. "
"Exactly one line containing the marker. Additional text prevents the engine "
"from recognizing the marker and executing the tool.\n"
"8. INCORRECT examples (NEVER do this):\n"
" 'Claro que sí, ahora ejecuto'\n"
" 'Voy a generar tu briefing ejecutivo personal.'\n"
" 'Claro que sí.\\n<<<APIPILOT_CALL:...>>>'\n"
" '<<<APIPILOT_CALL:...>>>\\nAhora proceso el resultado.'\n"
"9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n"
" <<<APIPILOT_CALL:...>>> IS AN INTERNAL SYSTEM SIGNAL and MUST NEVER be spoken,\n"
" read aloud, or synthesized by TTS. When emitting the marker:\n"
" - INTERNAL REASONING (text/transcript) MUST contain the exact marker line.\n"
" - FINAL TTS AUDIO heard by the user MUST contain ONLY this placeholder:\n"
' "Voy a consultar tus datos, un momento por favor."\n'
" - NEVER speak any of these technical terms in final TTS audio:\n"
" APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, function, function call,\n"
" marcador, marker, even to explain or describe the call.\n"
"\n"
)
# 2026-09-25 known defaults: inject values already resolved from the URL or
# profile, such as phoneNumber, so tool arguments use those exact values.
if isinstance(context_defaults, dict) and context_defaults:
defaults_lines = []
for dk, dv in context_defaults.items():
if dv in (None, "", []):
continue
if isinstance(dv, (dict, list)):
try:
dv_repr = json.dumps(dv, ensure_ascii=False)
except Exception:
dv_repr = str(dv)
else:
dv_repr = str(dv)
defaults_lines.append(f'- {dk} = "{dv_repr}"')
if defaults_lines:
rule += (
"KNOWN DEFAULT VALUES (use these exact values in ARGUMENTS={…} when "
"the corresponding tool lists these names in required_parameters; "
"do not invent, translate, or normalize them):\n"
f"{chr(10).join(defaults_lines)}\n"
"\n"
)
rule += f"Tool list (name / description):\n{chr(10).join(entries)}\n<<<APIPILOT_TOOLS_LIST_END>>>"
return rule
def _sanitize_pass2_internal_context(text: str) -> str:
"""Lightly sanitize the pass-2 block injected into the upstream model.
Unlike _sanitize_text_strip_apipilot_markers, preserve the pass-2 wrapper:
the model needs it to recognize tool data. Limit sanitation to filesystem
path leaks, fragmented marker tokens, and contextual phone leaks.
"""
if not isinstance(text, str) or not text.strip():
return text
# Block 3: Unix/Windows/TRAE path leaks, matching the user-output sanitizer.
import re as _re_pass2
text = _re_pass2.sub(
r"(?i)(?:/[A-Za-z0-9_.\-@~]+){2,}/(?:\.trae[^\s\"']*|workspaceStorage[^\s\"']*|User[^\s\"']*|home[^\s\"']*)",
" ",
text,
)
text = _re_pass2.sub(r"(?i)(?:[A-Z]:)?[\\/](?:Users|home|root|tmp|var|mnt|opt|srv)[\\/][^\s\"']{5,}", " ", text)
text = _re_pass2.sub(r"(?i)(?:\.trae-server|\.trae[^\s\"']{0,40}|workspaceStorage[0-9a-f]+)", " ", text)
text = _re_pass2.sub(r"\s{2,}", " ", text)
# Block 4: fragmented marker leaks, tier 4.
tier4_out_parts: list[str] = []
for _line in text.splitlines(keepends=True):
_orig = _line
_low = _line.lower()
if "apipilot" in _low and (
("call" in _low and "name=" in _low) or ("arguments=" in _low) or ("result" in _low and "pass" in _low)
):
if "<<<" in _line or ">>>" in _line:
_line = _re_pass2.sub(
r"[<]{1,3}\s*APIPILOT_(?:CALL|RESULT)[^<>\n]{0,500}[>]{1,3}",
" ",
_line,
flags=_re_pass2.IGNORECASE,
)
if "apipilot" in _line.lower():
_line = _re_pass2.sub(
r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b",
" ",
_line,
)
_line = _re_pass2.sub(
r"(?i)\b(?:NAME|ARGUMENTS|STATUS|REV|PASS2)\s*[:=]",
" ",
_line,
)
tier4_out_parts.append(_line if _line.strip() else _orig)
text = "".join(tier4_out_parts)
text = _re_pass2.sub(r"\s{2,}", " ", text)
return text.strip() if isinstance(text, str) else text
def _sanitize_text_strip_apipilot_markers(
text: str,
*,
placeholder: str | None = None,
) -> str:
"""Remove all internal ApiPilot markers from user-visible chat and TTS text.
Replace pass-1 CALL markers with a Spanish placeholder. Silently remove
pass-2 RESULT markers, their heading, tool-output block and instruction
trailer. These server-only signals must never reach the user.
Args:
text: Text to sanitize.
placeholder: Optional replacement; defaults to the Spanish UI/voice message.
"""
if not isinstance(text, str) or not text:
return text
if placeholder is None:
placeholder = "Voy a consultar tus datos, un momento por favor."
# ================================================================
# Block 1: silently remove pass-2 RESULT markers and wrappers from TTS/UI.
# ================================================================
# (1a) Full pass-2 wrapper first: marker, heading, status, output, trailer.
pass2_full_block_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*"
r"<<<APIPILOT_RESULT:PASS2>>>[ \t]*(?:\r?\n|\r)+"
r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+"
r"<<<APIPILOT_RESULT:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+>>>[ \t]*(?:\r?\n|\r)*"
r"(?:\r?\n|\r)?"
r"```tool-output[ \t]*(?:\r?\n|\r)?"
r"(?P<tool_body>.*?)"
r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}"
r"(?:"
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO|Now reinterpret THE RESULT TEXT)[\s\S]{0,1200}?"
r"(?:\r?\n|\r){0,2}"
r")?"
r"[ \t]*",
re.IGNORECASE | re.DOTALL,
)
text = pass2_full_block_pattern.sub("", text)
# (1b) Standalone PASS2 markers when the wrapper is incomplete or fragmented.
pass2_marker_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_RESULT(?::PASS2|:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+)?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
re.IGNORECASE,
)
text = pass2_marker_pattern.sub("", text)
# (1c) Residual Markdown heading after the marker has been removed.
pass2_title_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)?[ \t]*",
re.IGNORECASE,
)
text = pass2_title_pattern.sub("", text)
# (1d) Standalone tool-output blocks without a PASS2 wrapper.
pass2_codeblock_pattern = re.compile(
r"```tool-output[ \t]*(?:\r?\n|\r)?.*?(?:\r?\n|\r)?```",
re.IGNORECASE | re.DOTALL,
)
text = pass2_codeblock_pattern.sub("", text)
# (1e) Residual instruction trailers missed by the full-wrapper pattern.
pass2_trailer_pattern = re.compile(
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario|Now reinterpret THE RESULT TEXT[\s\S]{0,1200}?final content intended for the user)\.",
re.IGNORECASE | re.DOTALL,
)
text = pass2_trailer_pattern.sub("", text)
# Broader fallback also handles trailers truncated before their closing text.
pass2_trailer_fallback = re.compile(
r"(?:Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)|Now reinterpret THE RESULT TEXT[\s\S]{0,1500}?(?:for the user\.|$))",
re.IGNORECASE | re.DOTALL,
)
text = pass2_trailer_fallback.sub("", text)
# Handle residual middle fragments after the trailer's opening was removed.
pass2_trailer_broad = re.compile(
r"(?:en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario|in clear, concise natural language[\s\S]{0,800}?final content intended for the user)\.?",
re.IGNORECASE | re.DOTALL,
)
text = pass2_trailer_broad.sub("", text)
# ================================================================
# Block 2: pass-1 CALL marker replacement with a Spanish placeholder.
# ================================================================
marker_full_pattern = re.compile(
r"<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>",
re.IGNORECASE | re.DOTALL,
)
line_wrapped_pattern = re.compile(
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
re.IGNORECASE | re.DOTALL,
)
def _replacement_full(_m: re.Match) -> str:
"""Replace standalone or inline markers with the spoken placeholder."""
return placeholder
text = line_wrapped_pattern.sub(_replacement_full, text)
text = marker_full_pattern.sub(_replacement_full, text)
# Remove truncated CALL markers such as <<<APIPILOT_CALL:NAME=tool,ARGS….
trunc_call_pattern = re.compile(
r"<<<APIPILOT_CALL(?::NAME=[A-Za-z0-9_.-]+,?[^>\n]{0,500}?)?>>*",
re.IGNORECASE,
)
text = trunc_call_pattern.sub(_replacement_full, text)
# Make closing brackets optional to remove RESULT markers truncated by TTS,
# such as <<<APIPILOT_RESULT:STATUS=OK,NAM without closing >>>.
trunc_result_pattern = re.compile(
r"<<<APIPILOT_RESULT(?::[A-Za-z0-9_=,:.\- ]{0,800}?)?>?>?>?",
re.IGNORECASE,
)
text = trunc_result_pattern.sub("", text)
# Remove remaining APIPILOT_RESULT fragments after partial sanitation.
trunc_result_residual = re.compile(
r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}",
re.IGNORECASE,
)
text = trunc_result_residual.sub("", text)
# Collapse whitespace left by pass-2 block removal.
text = re.sub(r"[ \t]{2,}", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
# Remove residual trailer fragments and empty parentheses before TTS.
text = re.sub(r"\s*\((?:arriba|above)\)\s*", " ", text, flags=re.IGNORECASE)
text = re.sub(r"\(\s*\)", "", text)
# ================================================================
# Block 3: remove filesystem paths and host information leaked into TTS.
# Match long absolute Unix/Windows/mixed paths and known workspace tokens.
# ================================================================
# (3a) Absolute Unix paths and nested paths with multiple separators.
unix_path_pattern = re.compile(
r"\b(?:/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/\w@.+\-:#$%^&()[\]{}!~=,]*){2,}[A-Za-z0-9_./\-+@#$%^&()[\]{}!~=,:]*\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif)\b"
r"|"
# Broad fallback for long absolute Unix paths under known directories,
# including paths without extensions; avoid short URLs such as /api/v1.
r"(?:^|\s+|\(|[,.;:!?])(/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/A-Za-z0-9_\-+@#$%^&()[\]{}!~=,.]{10,})(?=$|\s|\)|[,.;:!?])",
re.IGNORECASE,
)
text = unix_path_pattern.sub("", text)
# (3b) Windows paths with single/double backslashes and mixed separators.
win_path_pattern = re.compile(
r"(?:^|\s|\(|[,.;:])"
r"([A-Za-z]:[\\/]"
r"|(?:[\\/]{1,2}(?:home|Users|Windows|Program|Program\sFiles|ProgramData|Users|trae|AppData)[\\/])"
r"|(?:[\\/]{1,2}\.(?:trae|trae-server|vscode|config|cache)[\\/])"
r")"
r"[A-Za-z0-9_@.+\-:#$%^&()[\]{}!~=,\\/\s]{10,}"
r"(?:\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif))?"
r"(?=$|\s|\)|[,.;:!?])",
re.IGNORECASE,
)
text = win_path_pattern.sub("", text)
# (3c) TRAE workspace tokens, including fragments without complete paths.
trae_leak_tokens = re.compile(
r"\b(?:workspaceStorage|long-text|\.trae-server|\.trae|ckg_server|ai-agent)\b[^\s()'\"`,.]{0,80}",
re.IGNORECASE,
)
text = trae_leak_tokens.sub("", text)
# (3d) Remove long tokens containing mixed separators or many path segments.
mixed_path_pattern = re.compile(
r"(?=\S{0,200})"
r"(?=\S*(?:[/\\].*){5,})"
r"(?:\S{10,})",
re.IGNORECASE,
)
def _mixed_safe(m: re.Match[str]) -> str:
s = m.group(0)
has_slash = "/" in s
has_back = "\\" in s
if (has_slash and has_back) or s.count("/") + s.count("\\") >= 6:
return ""
return s
text = mixed_path_pattern.sub(_mixed_safe, text)
# ================================================================
# Block 4: remove fragmented APIPILOT CALL text from TTS. The upstream
# pipeline (start_pipeline_rag / Qwen3-TTS) can remove <<< >>> and read:
# "APIPILOT CALL name get_user_briefing arguments phone +56912345678"
# The bracketed patterns cannot catch this, so use contextual fallback tiers.
# ================================================================
# Tier 1: full normalized form without braces, through the sentence ending.
call_normalized_full = re.compile(
r"(?:^|\s+|\(|[,.;:!?\"\'])\s*"
r"(?:"
r"APIPILOT[\s_\-]+CALL\s+(?:marker\s+)?(?:de\s+)?(?:señal\s+)?(?:herramienta\s+)?(?:funci[oó]n\s+)?"
r"|marcador\s+APIPILOT(?:\s+CALL)?\s*"
r"|señal\s+de\s+llamada\s+APIPILOT\s*"
r"|llamada\s+(?:a\s+)?(?:la\s+)?(?:herramienta|funci[oó]n)\s+APIPILOT\s*"
r")"
r"(?:\s*(?:con\s+)?(?:identificador|ID|name|nombre)\s*[:=]?\s*[A-Za-z0-9_.-]+)?"
r"(?:\s*(?:y\s+)?(?:con\s+)?(?:argumentos|ARGUMENTS|params|par[áa]metros)\s*[:=]?\s*(?:\{[^{}]{0,800}\}|[\"'][^\"']{0,200}[\"']|[^\s.!,?;]{3,100}(?:\s*[,:]\s*[^\s.!,?;]{3,100})*\s*)?)?"
r"(?:\s*(?:phone|tel[ée]fono|correo|email)\s*[:=]?\s*[+0-9A-Za-z._@\-]{6,50})?"
r"(?=$|\s+|\)|[,.;:!?\"\'])",
re.IGNORECASE,
)
text = call_normalized_full.sub(placeholder, text)
# Tier 2: shortened forms with APIPILOT_CALL or adjacent APIPILOT and CALL.
apipilot_call_token = re.compile(
r"(?:^|\s|\(|[,.;:!?\"\'])\s*"
r"APIPILOT(?:[\s_\-]+CALL|_CALL|\s+llamada|\s+marker)"
r"(?:[^.!,?;\n]{0,600})?"
r"(?=$|\s|\)|[,.;:!?\"\'])",
re.IGNORECASE,
)
text = apipilot_call_token.sub(placeholder, text)
# Tier 3: standalone service tokens in call/name/argument contexts.
service_token_context = re.compile(
r"(?:"
r"ARGUMENTS\s*[:=]\s*(?:\{[^{}]{0,800}\}|[^\s,.;!?]{3,100})"
r"|\barguments\s+(?:de\s+)?(?:la\s+)?(?:llamada|herramienta|funci[oó]n)\b[^\n.]{0,200}"
r")",
re.IGNORECASE,
)
text = service_token_context.sub("", text)
# Tier 4: remove sentences containing APIPILOT with technical call context.
def _apipilot_sentence(m: re.Match[str]) -> str:
sentence = m.group(0)
lowered = sentence.lower()
# Preserve a sentence already containing the Spanish CALL placeholder,
# including any natural text surrounding it.
if "voy a consultar tus datos" in lowered:
return sentence
ctx_kw = (
"name",
"argument",
"arg",
"herramienta",
"tool",
"funci",
"phone",
"tel",
"+56",
"+44",
"marker",
"call",
"llamada",
"señal",
"marcador",
)
if any(k in lowered for k in ctx_kw) and "apipilot" in lowered:
return ""
return sentence
catchall_sentence = re.compile(
r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}",
re.IGNORECASE,
)
text = catchall_sentence.sub(_apipilot_sentence, text)
# Tier 5: replace any remaining APIPILOT CALL tokens, even without context.
final_apipilot_leak = re.compile(
r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b",
re.IGNORECASE,
)
text = final_apipilot_leak.sub(placeholder, text)
# Tier 6: remove residual fake/real phone numbers from internal call text.
# Match compact and spaced forms to tolerate TTS normalization.
fake_phone_pattern = re.compile(
r"("
# Compact forms without spaces:
r"\+56912345678"
r"|\+1234567890"
r"|\+447432477433"
r"|56912345678"
r"|1234567890"
r"|447432477433"
# Spaced forms with punctuation:
r"|\+?\s*56\s*[\s\-()]?9\s*[\s\-()]?1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8"
r"|\+?\s*1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8\s*[\s\-()]?9\s*[\s\-()]?0"
r"|\+?\s*44\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?2\s*[\s\-()]?4\s*[\s\-()]?7\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?3"
# Generic labeled forms (English or Spanish phone labels):
r"|phone\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
r"|tel(?:éfono|efono)?\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
r")",
re.IGNORECASE,
)
# Strip numbers only from lines with technical call context or the Spanish
# placeholder, indicating that the number belongs to a replaced CALL marker.
lines = text.splitlines(keepends=True)
_phone_ctx = (
"apipilot",
"call",
"arguments",
"marker",
"herramienta",
"funcion",
"función",
"ejecutar",
"llamada",
"marcador",
"señal",
"voy a consultar tus datos",
)
def _phone_line_safe(line: str) -> str:
lowered = line.lower()
has_ctx = any(c in lowered for c in _phone_ctx)
if has_ctx:
# Remove phone numbers in this line (strip also surrounding space)
return fake_phone_pattern.sub(" ", line)
return line
text = "".join(_phone_line_safe(line) for line in lines)
# Collapse whitespace left by marker and path removal.
text = re.sub(r"[ \t]{2,}", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()