Files
vocero-client/s2s-client/server.py
T

6553 lines
324 KiB
Python

"""
Tiny server for the speech-to-speech demo.
The demo used to ship as a `sdk: static` Space, but the web-search tool needs a
search key the browser must NOT see. A static Space has no runtime process, so it
can't hold a secret the front-end uses. This server fixes that: it serves the
unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the
Serper key server-side (see docs/adr/0001).
Everything lives in one container; the speech-to-speech backend stays a separate,
load-balanced service the browser talks to over WebSocket as before. The load
balancer's address is a secret too (like the Serper key): the browser never sees
it. `/api/session` proxies the session handshake server-side so only the
per-session compute URL the LB hands back (which the browser must dial) is exposed.
On the deployed Space the server also meters conversation time by HF login tier
(anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature
is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
the live Space, never locally (even with the LB exported for testing).
`SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is
disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
browser connects directly to that URL, shown read-only in Settings.
Endpoints:
GET /api/config -> { search, lb, allowDirect, s2sUrl, auth }
GET /api/me -> login + tier + remaining budget (LB mode only)
POST /api/search -> { results, answer } Google via Serper.dev
POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
GET /api/queue/{id} -> proxies <LB>/queue/{id}: position, or a grant on claim
DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button)
POST /api/queue/end -> leave the queue (sendBeacon on teardown)
POST /api/session/heartbeat-> extend the reservation; { expired }
POST /api/session/end -> reconcile + refund (sendBeacon on teardown)
/* -> static files (index.html, main.js, ...)
When every compute slot is busy the load balancer hands back a queue ticket
instead of a grant; the browser polls /api/queue/{id} until it reaches the front
and a slot frees. Waiting reserves nothing — the daily budget is only reserved at
the moment a slot is actually claimed (a grant), never while queued.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import datetime as _dt_module
import json
import logging
import os
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import httpx
import websockets
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import auth
import limiter
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),
)
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False)
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 = not (
(os.environ.get("S2S_INJECT_TODAY", "") or "").strip().lower()
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
# 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.
import time as _time_mod_gbl
_monotonic_now: callable = _time_mod_gbl.monotonic
del _time_mod_gbl
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 = S2S_APIPILOT_TOOLS_EXECUTE_URL or 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 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 S2S_APIPILOT_API_KEY:
final_auth = "BYPASS-API-KEY"
_demo_used = S2S_APIPILOT_API_KEY
_effective_auth_for_fp = f"Bearer {S2S_APIPILOT_API_KEY}"
_effective_demo_for_fp = S2S_APIPILOT_API_KEY
else:
final_auth = auth_header or (f"Bearer {S2S_APIPILOT_SERVICE_TOKEN}" if 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]) < APIPILOT_CACHE_TTL_SEC:
logger.debug(
"[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d",
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]) < APIPILOT_CACHE_TTL_SEC:
return recheck[1]
url = f"{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 S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = S2S_APIPILOT_API_KEY
else:
if final_auth:
headers["Authorization"] = final_auth
if _demo_used:
headers["x-demo-session"] = _demo_used
logger.info(
"[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s",
url, params["include_unauthorized"], fp,
("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")),
)
try:
async with httpx.AsyncClient(timeout=APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.get(url, params=params, headers=headers)
except Exception as exc:
logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc)
if stale_tools is not None:
logger.warning("[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools))
return stale_tools
raise HTTPException(
status_code=502,
detail={"code": "APIPILOT_NETWORK_ERROR", "message": f"Error al contactar ApiPilot tools backend: {exc}"},
)
if resp.status_code >= 300:
logger.error("[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400])
if stale_tools is not None:
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:
logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500])
if stale_tools is not None:
return stale_tools
raise HTTPException(
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)
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 S2S_DEBUG_PROMPT and sanitized:
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 = tool_def.get("parameters") if isinstance(tool_def.get("parameters"), dict) else {}
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
required = 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 = {}
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)
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: dict = {}
for key in args.keys():
if key in properties:
cleaned[key] = args[key]
else:
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
# ---------------------------------------------------------------------------
# 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():
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:
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:
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
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
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 = 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 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 += (
"Tool list (name / description):\n"
f"{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):
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):
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(l) for l 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()
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 ""
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 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.
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(S2S_APIPILOT_SERVICE_TOKEN)
if requires_auth and not has_token:
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 S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = S2S_APIPILOT_API_KEY
final_auth = f"Bearer {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 S2S_APIPILOT_SERVICE_TOKEN:
_svc = 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 []
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 S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")),
)
try:
async with httpx.AsyncClient(timeout=APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.post(url, json=payload, headers=headers)
except Exception as exc:
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)
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:
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, {})
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(parsed.get("data").keys())) if isinstance(parsed.get("data"), dict) else "-",
)
if (not envelope_ok) and isinstance(parsed.get("error"), dict):
err = parsed["error"]
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.
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),
},
}
# 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, ALL LB logic is disabled (no /api/session proxy, no queue,
# no limiter, no sign-in) 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 = ""
# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
# locally and on a plain `docker run`. We meter conversation time ONLY on the
# deployed Space — i.e. when BOTH the LB is configured AND we're on a Space.
# Off-Space (local dev, even with the LB exported) the app still proxies the LB,
# but nothing is metered: no budget, no reservations, no sign-in gating.
SPACE_ID = os.environ.get("SPACE_ID", "").strip()
LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID)
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
HERE = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="s2s-demo")
# Wire HF OAuth before the app serves (no-op unless the OAuth env is present).
# Sign-in only matters when we're metering (prod Space), so gate it on that.
AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app)
# Resolve the execution URL for the startup banner using the same normalization
# as _apipilot_resolve_execute_url.
def _resolve_exec_url_for_startup_banner():
base = (S2S_APIPILOT_TOOLS_EXECUTE_URL or 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
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 auth=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
S2S_REASONING_TYPE,
NODE_RED_REASONING_URL or "-",
bool(SERPER_KEY),
bool(LOAD_BALANCER_URL),
bool(S2S_PROXY_TARGET),
bool(SPEECH_TO_SPEECH_URL),
bool(VOCERO_USER_PHONE_AUTH_URL),
bool(S2S_REPORT_LIST),
S2S_INJECT_REPORTS,
S2S_REPORTS_QUICK_HINT_MAX,
S2S_REPORTS_INJECT_MODE,
S2S_INJECT_TODAY,
bool(S2S_LATEST_SUMMARY),
bool(S2S_LATEST_SUMMARY_TEXT),
bool(S2S_LOGIN_BY_PHONE),
S2S_INJECT_PROFILE,
S2S_DEBUG_PROMPT,
S2S_START_SESSION_MODE or "-",
S2S_THEME or "-",
AUTH_ENABLED,
S2S_APIPILOT_TOOLS_URL or "-",
(S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})") if S2S_APIPILOT_TOOLS_EXECUTE_URL else "-",
"Y" if bool(S2S_APIPILOT_SERVICE_TOKEN) else "-",
APIPILOT_CACHE_TTL_SEC,
APIPILOT_HTTP_TIMEOUT,
"Y" if (bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-",
("YES head=" + (S2S_APIPILOT_API_KEY[:6] + "..." if len(S2S_APIPILOT_API_KEY) > 6 else S2S_APIPILOT_API_KEY) + " (3 FORMS!)") if S2S_APIPILOT_API_KEY else "-",
)
del _STARTUP_APIPILOT_EXEC_URL
logger.debug(
"[STARTUP] Runtime config — SPACE_ID=%s LIMITER_ENABLED=%s MAX_RESULTS=%d",
bool(SPACE_ID), LIMITER_ENABLED, MAX_RESULTS,
)
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 = {"type": event_type}
if event_type == "session.update":
session = payload.get("session") if isinstance(payload.get("session"), dict) else {}
tools = 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 = payload.get("arguments") if isinstance(payload.get("arguments"), str) else "{}"
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 "{}")
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",
})
@app.on_event("startup")
async def _startup():
"""Stand up the usage DB and a periodic sweeper — metered (prod Space) only."""
if not LIMITER_ENABLED:
return
limiter.init()
asyncio.create_task(_sweeper())
async def _sweeper():
while True:
await asyncio.sleep(limiter.REAP_AFTER_SEC)
try:
await asyncio.to_thread(limiter.sweep)
except Exception as exc: # pragma: no cover - defensive
logger.warning("usage sweep failed: %r", exc)
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
VOCERO_USER_CONTEXT_HINT = (
" Caller profile resolved from the page phoneNumber: "
)
REPORTS_LIST_HINT = (
" Scheduled reports / podcast scripts available for this caller before the current session. "
"Each entry is a scheduled report; use the podcastScript contents as trusted reference material when the user asks about scheduled topics, reports, or prior content."
" NOTE (2026-09-11 request to remove mass injection of scheduled reports): "
"S2S_INJECT_REPORTS defaults to FALSE. When FALSE the injection below NEVER happens. "
"Operators must explicitly set S2S_INJECT_REPORTS=true to opt back into mass scheduled-report prompt injection. "
)
LATEST_SUMMARY_HINT = (
" Latest conversation summary for this caller (most recent before the current session): "
)
PODCAST_EPISODE_HINT = (
" Podcast episode context loaded from the page episode_id query param (current episode the user is referencing). "
"Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the PRIMARY trusted reference material for this session. "
)
# ──────────────────────────────────────────────────────────────────────────
# User fix 2026-09-11 Bug 3: "Qwen2.5 has training cutoff mid-2024, when it
# sees 39 reports dated 2026-09-10 it hallucinates meta-comments in Chinese
# like 'today is 2023, why are you simulating 2026 reports?'"
#
# Resolved with S2S_INJECT_TODAY default ON + S2S_REPORTS_INJECT_MODE=minimal:
# • TODAY_BLOCK (marker <<<TODAY_DATE_BEGIN>>>):
# - Fixed reference: "TODAY is YYYY-MM-DD".
# - Earlier reports are historical records; do not question their dates.
# - No internal commentary about prompts, dates, data validity or instructions.
# - Clean output: only words Vocero says aloud; no reasoning or prompt checks.
# - Retrieve report details through RAG rather than inventing from summaries.
# • MINIMAL_REPORTS_BLOCK (marker <<<MINIMAL_REPORTS_TOC>>>): only latest N
# label + date lines. NO podcastScript text, NO full JSON entries. Uses the
# pattern the user explicitly requested:
# Recent scheduled reports (latest 3):
# 1. "Mortality Percentage Report" — 2026-09-10
# 2. ...
# (Retrieve full content through knowledge search.)
# • We register both blocks with the dedupe layer via the new TYPE IDs
# "today_fixed" and "reports_minimal" so they also can never get duplicated.
#
# IMPORTANT MARKER CHANGE 2026-09-11: markers used to be human-readable
# strings like "<<<TODAY_DATE_BEGIN>>>" which got written into the LLM
# prompt verbatim. Qwen2.5 (especially GPTQ quantized variants) would
# sometimes *leak those markers back into the output* alongside meta-comment
# hallucinations (e.g. emitting "[-]: checking_podcast_report" or literal
# "<<<...>>>"). To fix this we now use INVISIBLE markers: a SHA-1 hash of
# stable strings. These hashes are NEVER seen by the LLM as text because
# they are NOT written into the prompt. They exist ONLY as Python-side
# substring sentinels prepended to the *Python string* returned by each
# block builder — stripped out at the very end with .replace() just before
# the string is assembled into the prompt. Result: the dedupe logic still
# works perfectly, but the LLM NEVER sees any marker / guard token.
# ──────────────────────────────────────────────────────────────────────────
def _s2s_marker(label: str) -> str:
"""Return a short, unique, invisible sentinel for dedup classification.
Sentinel is 40 hex chars (SHA1) of a stable string; it is NEVER written
into the prompt — we strip all markers at the end of each builder.
"""
import hashlib
return hashlib.sha1(f"S2S-ctx-marker/{label}/v1".encode("utf-8")).hexdigest()
TODAY_BLOCK_MARKER = _s2s_marker("today_fixed_block")
MINIMAL_REPORTS_MARKER = _s2s_marker("reports_minimal_toc")
VOICE_RULES_HEAD_MARKER = _s2s_marker("voice_rules_head_lock")
REPORTS_FULL_MARKER = _s2s_marker("reports_full_block")
SUMMARY_FULL_MARKER = _s2s_marker("summary_full_block")
PROFILE_FULL_MARKER = _s2s_marker("profile_full_block")
EPISODE_FULL_MARKER = _s2s_marker("episode_full_block")
ALL_S2S_INTERNAL_MARKERS: tuple = (
TODAY_BLOCK_MARKER,
MINIMAL_REPORTS_MARKER,
VOICE_RULES_HEAD_MARKER,
REPORTS_FULL_MARKER,
SUMMARY_FULL_MARKER,
PROFILE_FULL_MARKER,
EPISODE_FULL_MARKER,
)
def _strip_internal_markers(text) -> str:
"""Strip every internal S2S dedup marker from the final prompt text.
Must be called right before a block is joined to the final instructions /
user_context so the LLM NEVER sees any of our sentinel strings. This is
the final guarantee that prevents marker leakage into spoken output.
"""
s = str(text or "")
if not s:
return s
for m in ALL_S2S_INTERNAL_MARKERS:
if m and m in s:
s = s.replace(m, "")
return s.strip()
def _extract_user_name_parts(profile) -> list[str]:
"""Given a profile dict (possibly wrapped in {"user": ...} or {"result": ...})
return the list of non-empty first/last name parts in order (e.g.
["Alessandro", "Valenti"]). Returns [] when profile has no name.
Used by:
• _build_voice_rules_head_lock_block — prompt rule #5, exact name.
• _resolve_user_context_text — dynamic profile note against name translation.
• _sanitize_agent_output — layer 7 final whole-word regex replacement.
"""
if not profile or not isinstance(profile, dict):
return []
# Unwrap common container keys (server returns both shapes depending on
# the auth source path — direct /phone/ endpoint vs login proxy wrapper).
nested = profile
for wrapper in ("user", "result", "data"):
inner = nested.get(wrapper)
if isinstance(inner, dict):
nested = inner
# Prefer explicit firstName+lastName when available (structured fields)
first = str(nested.get("firstName") or nested.get("first_name") or "").strip()
last = str(nested.get("lastName") or nested.get("last_name") or "").strip()
if first or last:
out: list[str] = []
for token in (first, last):
token_clean = token.strip()
if token_clean:
for sub in token_clean.split():
if sub:
out.append(sub)
return out
# Fallback: split the combined "name" string by whitespace
full_name = str(nested.get("name") or "").strip()
if not full_name:
return []
return [p for p in full_name.split() if p]
def _build_voice_rules_head_lock_block(profile=None) -> str:
"""Return the #1 PRIORITY block prepended to EVERY prompt: voice lock.
Fixes 6 hallucination categories seen in user log 2026-09-11 07:57:
(1) [-]: checking_podcast_report — internal "action thinking" leak
(2) English 'Sure, here's my best focused expression while I look into it'
(3) JSON literal {'reportDetails': '...'} inside output
(4) Unexpected Italian phrases.
(5) leak markers <<<...>>> in TTS output.
(6) Invented content about nonexistent podcasts.
This block contains short English instructions requiring Spanish output. We put
it as the VERY FIRST thing in the prompt (even before the caller profile),
because quantized models obey the first ~400 tokens way more strongly
than rules buried at the end.
2026-09-11: NEW rule #5 — PROPER NAME LOCK. Model keeps writing "Alexander"
for the Italian caller "Alessandro Valenti". We now emit an explicit,
non-ambiguous "Exact name: Alessandro. NEVER Alexander/Alexandre/..."
that the sanitizer's _common_name_variants map can also repair.
INVISIBLE marker: prefixes VOICE_RULES_HEAD_MARKER (stripped before prompt
join, never seen by LLM) to enable dedupe so we never double-prepend.
"""
base = (
"PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\n"
"1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. "
"Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. "
"No words, phrases or confirmations in another language.\n"
"2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. "
"NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. "
"No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\n"
"3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. "
"For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\n"
"4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. "
"Use a couple of short sentences, then ask whether the user wants more details.\n"
)
# Dynamic proper-name lock based on the profile.
name_lines = ""
parts = _extract_user_name_parts(profile)
if parts:
full_name = " ".join(parts)
variants_all: list[str] = []
for part in parts:
vs = _common_name_variants(part)
for v in vs:
if v not in variants_all and v != part:
variants_all.append(v)
cleaned_variants = variants_all[:8]
if cleaned_variants:
name_lines = (
"5) PROPER NAME (mandatory for this session): "
f"the user's exact name, WRITTEN AND PRONOUNCED AS GIVEN, is: {full_name}. "
f"NEVER adapt, translate or normalize it: "
+ ", ".join(f'NEVER "{v}"' for v in cleaned_variants)
+ ". Write and pronounce only the exact profile name.\n"
)
body = base + name_lines
return VOICE_RULES_HEAD_MARKER + "\n" + body
def _build_today_fixed_context_block(
today_override=None,
) -> str:
"""Return the TODAY + anti-hallucination guard block.
2026-09-11 rewrite:
• English internal instructions; user responses remain Spanish.
Earlier mixed Italian/Spanish prompts caused output language leaks.
• Only 3 bullet sentences, nothing fancy, nothing that triggers meta.
• Invisible dedup marker TODAY_BLOCK_MARKER is prefixed and will be
stripped before prompt join → LLM never sees.
"""
if not S2S_INJECT_TODAY:
return ""
today = today_override if today_override is not None else _dt_module.date.today()
iso_date = today.strftime("%Y-%m-%d")
weekday = [
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
][today.weekday()]
body = (
f"Today's date (mandatory reference): {iso_date} ({weekday}).\n"
"Reports dated BEFORE today are HISTORICAL RECORDS, not future dates. Do not comment on this.\n"
"A period '.' separates decimals; a comma ',' separates thousands (the standard international format used by this system).\n"
)
return TODAY_BLOCK_MARKER + "\n" + body
def _build_minimal_reports_toc_block(
filtered_sorted,
cap=None,
) -> str:
"""Return the user-requested MINIMAL scheduled-reports TOC block (NO script).
2026-09-11 rewrite:
• Marker <<<...>>> removed because it was leaking into output. Now the
invisible internal sentinel MINIMAL_REPORTS_MARKER (stripped before
joining) is used for dedupe classification.
• Footer is now the strict RAG rule, not a soft note.
"""
items = list(filtered_sorted or [])
if cap is None:
cap = max(1, int(S2S_REPORTS_QUICK_HINT_MAX or 3))
if len(items) > cap:
items = items[:cap]
if not items:
return ""
lines: list[str] = []
lines.append(
f"Recent scheduled reports (latest {len(items)}):"
)
for idx, r in enumerate(items, start=1):
label = (str(r.get("sourceScheduleLabel") or "").strip()
or "(untitled)")
raw_d = r.get("date")
date_str = ""
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_module.datetime.fromtimestamp(ts_sec, tz=_dt_module.timezone.utc)
date_str = dt.strftime("%Y-%m-%d")
elif raw_d is not None:
s = str(raw_d).strip()
if "T" in s and len(s) >= 10:
date_str = s[:10]
else:
date_str = s
except Exception:
date_str = str(raw_d)[:10] if raw_d else ""
if date_str:
lines.append(f' {idx}. "{label}" — {date_str}')
else:
lines.append(f' {idx}. "{label}"')
lines.append(
"ALWAYS retrieve full report content through knowledge search (RAG). Do not invent details you have not retrieved."
)
return MINIMAL_REPORTS_MARKER + "\n" + "\n".join(lines) + "\n"
# ──────────────────────────────────────────────────────────────────────────
# 2026-09-11 user fix: remove duplicated caller profiles.
# Each context block returned by the _resolve_* helpers above starts with a
# unique, stable, documented HINT prefix (see VOCERO_USER_CONTEXT_HINT /
# PODCAST_EPISODE_HINT / REPORTS_LIST_HINT / LATEST_SUMMARY_HINT). We use
# these prefixes as logical "block type IDs" so a single context type is
# NEVER appended twice to the same prompt / session.instructions / user_context
# — even if two different call paths (phoneNumber fetch + identity /
# client-side supplied user_context + server-side fetch) both carry the same
# block type.
#
# Historical duplicate reported by the user:
# Caller profile resolved from the page phoneNumber: {name:"Alessandro.."}
# Caller profile resolved from the page phoneNumber: {name:"Alessandro.."}
# ↑ IDENTICAL SECOND COPY ↑
# The most likely cause is a client-side reasoning / profile fetch supplying
# the profile as part of `req.user_context` (or two overlapping server
# injection channels) while the server re-fetches it again for the
# instructions augment. Instead of trying to hunt every single call site, we
# add TWO dedupe layers below that are immune to future regressions:
#
# 1) _dedup_context_blocks_by_type([A, B, C, ...]) — given a list of
# candidate context strings, returns a new list where only the FIRST
# occurrence of each logical block TYPE (profile / episode / reports /
# summary) survives. All HINT prefixes are treated as block-type IDs.
# Used inside _augment_instructions_with_profile and the
# effective_user_context join list.
# 2) _strip_already_injected_types_from_base(base_text, [candidates]) —
# before appending new candidates to an existing base prompt string,
# scan the base for each HINT prefix. If a prefix is already present
# somewhere inside base_text, that candidate is DROPPED (skipped).
# Catches the case where session.instructions was ALREADY patched on a
# previous session.update frame, or where the client sent us the
# profile already baked into req.instructions / req.user_context.
#
# Together these two layers guarantee: per prompt / per LLM call, each block
# type appears AT MOST ONCE (zero or one).
# ──────────────────────────────────────────────────────────────────────────
_CONTEXT_DEDUPE_MARKERS: tuple = (
# (logical_type_id, unique_prefix_marker_in_text)
("voice_rules_head", VOICE_RULES_HEAD_MARKER), # NEW: 2026-09-11 #1 PRIORITY voice-language lock
("episode", PODCAST_EPISODE_HINT.strip()),
("profile", VOCERO_USER_CONTEXT_HINT.strip()),
("reports_full", REPORTS_FULL_MARKER),
("reports", "<<<REPORTS_LIST_BEGIN>>>"), # legacy back-compat marker
("summary", "<<<LATEST_SUMMARY_BEGIN>>>"), # legacy back-compat marker
("summary_full", SUMMARY_FULL_MARKER),
("today_fixed", TODAY_BLOCK_MARKER),
("reports_minimal", MINIMAL_REPORTS_MARKER),
("apipilot_tools_list", APIPILOT_PASS1_LIST_MARKER),
("apipilot_result", APIPILOT_PASS2_RESULT_MARKER),
)
def _classify_context_block(block: str) -> str | None:
"""Return the logical type of block or None for unrecognised text.
Uses the HINT prefixes / block begin markers above. Uses substring
containment (not startswith) because the augmented prompt may carry the
markers after 1-2 leading spaces. Uses the STRIPPED version of each HINT
to avoid missing matches due to leading whitespace.
"""
if not block:
return None
haystack = (block or "").strip()
if not haystack:
return None
for type_id, marker in _CONTEXT_DEDUPE_MARKERS:
if marker and marker in haystack:
return type_id
return None
def _dedup_context_blocks_by_type(candidates: list[str]) -> list[str]:
"""Keep only the FIRST occurrence of each logical block TYPE in order.
Drops duplicated block types silently and logs + returns a new list with
only the surviving blocks. Also drops empty / whitespace-only candidates
which would otherwise be a no-op append. This is layer 1 of the dedupe.
"""
if not candidates:
return []
seen_types: set[str] = set()
out: list[str] = []
dropped_types: dict[str, int] = {}
for candidate in candidates:
c = (candidate or "").strip()
if not c:
continue
type_id = _classify_context_block(c)
if type_id is None:
# Unknown type (free-form text, plain user_context, ...) is NEVER
# deduped — we only dedupe the 4 well-known S2S injected block
# types so user-supplied arbitrary context is always preserved.
out.append(candidate)
continue
if type_id in seen_types:
dropped_types[type_id] = dropped_types.get(type_id, 0) + 1
continue
seen_types.add(type_id)
out.append(candidate)
if dropped_types and S2S_DEBUG_PROMPT:
# Log only when dedupe actually fired (avoids spamming). Use level
# DEBUG / info level so the admin can see "profile duplicate was
# dropped" when troubleshooting prompt size issues.
logger.info(
"[INJECT-DEDUPE] dropped duplicate injected block types=%s kept=%d candidates=%d",
", ".join(f"{k}x{v}" for k, v in dropped_types.items()),
len(out),
len(candidates),
)
return out
def _strip_already_injected_types_from_base(
base_text: str, candidates: list[str]
) -> list[str]:
"""Layer 2: for each candidate, check if its HINT prefix ALREADY appears
anywhere inside base_text (i.e. was injected by a previous patch / baked
into the instructions sent by the browser). If yes, drop that candidate
entirely (keep only candidates whose types are NOT already present in
base). Works even when base_text contains the profile twice — after this
filter we won't add a third copy; layer 1 handles any further duplicates
among the candidates themselves."""
b = (base_text or "")
if not b:
return list(candidates)
out: list[str] = []
dropped_base: list[str] = []
for candidate in candidates:
c = (candidate or "").strip()
if not c:
continue
type_id = _classify_context_block(c)
if type_id is None:
# Unknown context blocks → never dedupe, always append.
out.append(candidate)
continue
# Does base already contain ANY marker for this type? Use _ALL_
# markers for this type, not just the single canonical one, in case
# an older deploy used a different stringification (though prefix
# stability is guaranteed by the module-level constants here).
already = False
for marker_tid, marker in _CONTEXT_DEDUPE_MARKERS:
if marker_tid == type_id and marker and marker in b:
already = True
break
if already:
dropped_base.append(type_id)
continue
out.append(candidate)
if dropped_base and S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-DEDUPE] dropped candidate block types already present in base instructions=%s baseLen=%d",
", ".join(dropped_base),
len(b or ""),
)
return out
def _resolve_user_context_text(phone_number, profile_obj, loaded: bool = True) -> str:
"""Given a phone+profile object -> the short context string appended to
LLM system prompt / reasoning user_context when S2S_INJECT_PROFILE is on.
Generic (deploy-agnostic): profile_obj is the JSON returned by
VOCERO_USER_PHONE_AUTH_URL (optionally nested under {"result": ...}).
2026-09-11 anti-language-leak rule: names like "Alessandro Valenti" are
Italian/Luxembourgish names but our voice assistant uses Castilian Spanish, so
we append an EXPLICIT NOTE that the model sees alongside the caller info.
2026-09-11 anti-anglicization rule (Alexander → Alessandro): when the
profile contains a known given-name (e.g. Alessandro) we emit a DYNAMIC
list of forbidden anglicizations so the model cannot "normalize" it.
"""
if not loaded or not profile_obj or not phone_number:
return ""
try:
serialized = json.dumps(profile_obj, ensure_ascii=False)
except Exception:
serialized = str(profile_obj)
if len(serialized) > 1600:
serialized = serialized[:1600] + "..."
# ─── anti-anglicization dynamic note ─────────────────────────────────
parts = _extract_user_name_parts(profile_obj) or []
forbidden_lines: list[str] = []
for name_part in parts:
variants = _common_name_variants(name_part)
if variants:
# keep the first 6 variants max so the prompt stays tight
short = variants[:6]
quoted = ', '.join('"' + v + '"' for v in short)
forbidden_lines.append(
f"For '{name_part}': NEVER write or pronounce {quoted} or similar English/French/Russian variants. "
f"Use only '{name_part}' exactly as given."
)
if forbidden_lines:
proper_name_note = (
"PROPER NAME (mandatory for this session): "
+ " ".join(forbidden_lines)
+ " "
)
else:
# fallback to generic example so the rule's existence is still taught
proper_name_note = (
"PROPER NAME: never adapt, translate or normalize proper names "
"(for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name. "
)
# We build with invisible internal PROFILE_FULL_MARKER so dedup + strip
# works consistently with today/voice_rules_head blocks.
body = (
f"{VOCERO_USER_CONTEXT_HINT}{serialized}. Use this as trusted caller context when relevant. "
"IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') "
"or a name from any other language, respond ONLY in SPANISH (Castilian). "
"Foreign names do NOT authorize switching languages. "
"Never write phrases, words, questions or confirmations in Italian, English or another language. "
"Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. "
f"{proper_name_note}"
)
return PROFILE_FULL_MARKER + "\n" + body
def _resolve_latest_summary_context(summary_value) -> str:
"""Normalize the payload returned by S2S_LATEST_SUMMARY endpoint into a
single context string to be appended next to the profile block.
Accepts plain string / object (dumped JSON) / None. Capped at 1600 chars.
2026-09-11: uses INVISIBLE SUMMARY_FULL_MARKER (never seen by LLM) so the
dedupe layer can still classify it; human <<<...>>> markers removed to
stop leakage into output.
Returns "" when no summary is available.
"""
if summary_value is None:
return ""
if isinstance(summary_value, dict) or isinstance(summary_value, list):
try:
serialized = json.dumps(summary_value, ensure_ascii=False)
except Exception:
serialized = str(summary_value)
else:
serialized = str(summary_value)
trimmed = serialized.strip()
if not trimmed:
return ""
if len(trimmed) > 1600:
trimmed = trimmed[:1600] + "..."
body = f"{LATEST_SUMMARY_HINT}\n{trimmed}\n. Use this as trusted history when the user references prior calls."
return SUMMARY_FULL_MARKER + "\n" + body
def _resolve_episode_context(raw_value) -> str:
"""Normalize the podcast episode API response into a prompt block injected
at the TOP of the contexts (most specific / current reference).
Accepts a dict (or {"result": {...}} wrapped) with any arbitrary episode
fields (title, description, transcript, script, showNotes, guests,
publishedAt, audioUrl, duration, author, etc.). Capped at 24000 chars
(episodes can have long transcripts).
2026-09-11: uses INVISIBLE EPISODE_FULL_MARKER so dedupe still classifies
it without leaking <<<...>>> wrappers into TTS.
Returns "" if empty / fetch failed / None.
"""
if raw_value is None:
return ""
if isinstance(raw_value, dict) and len(raw_value) == 1 and isinstance(raw_value.get("result"), (dict, list)):
raw_value = raw_value["result"]
if isinstance(raw_value, dict) or isinstance(raw_value, list):
try:
serialized = json.dumps(raw_value, ensure_ascii=False)
except Exception:
serialized = str(raw_value)
else:
serialized = str(raw_value)
trimmed = serialized.strip()
if not trimmed:
return ""
if len(trimmed) > 24000:
trimmed = trimmed[:24000] + "...(episode truncated)"
body = f"{PODCAST_EPISODE_HINT}\n{trimmed}\n. Treat as PRIMARY trusted reference for this session."
return EPISODE_FULL_MARKER + "\n" + body
def _parse_date_key(value) -> tuple:
"""Return a sortable tuple for an arbitrary date-like value.
Sort order is DESC (newest first). Non-dates / empty sort to the end (lowest priority).
Accepts ISO strings ("YYYY-MM-DD", "YYYY-MM-DDTHH:MM:SS"), Python datetime objects,
int/float unix timestamps, numbers.
"""
import datetime as _dt
if value is None:
return (1, 0)
if isinstance(value, _dt.datetime):
try:
return (0, -int(value.timestamp()))
except Exception:
return (1, 0)
if isinstance(value, _dt.date):
try:
ts = int(_dt.datetime.combine(value, _dt.time.min).timestamp())
return (0, -ts)
except Exception:
return (1, 0)
if isinstance(value, (int, float)):
try:
return (0, -int(value))
except Exception:
return (1, 0)
if isinstance(value, str):
s = value.strip()
if not s:
return (1, 0)
# Try ISO parse first (handles most formats)
try:
dt = _dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
return (0, -int(dt.timestamp()))
except Exception:
pass
# Fallback: date-only YYYY-MM-DD
try:
dt = _dt.datetime.strptime(s[:10], "%Y-%m-%d")
return (0, -int(dt.timestamp()))
except Exception:
pass
# Last resort: lexicographic sort DESC (newest first still works for ISO-ish)
return (0, tuple(-ord(c) for c in s[:32]))
return (1, 0)
def _resolve_reports_context(raw_value: object) -> str:
"""Normalize / filter the S2S_REPORT_LIST response into a prompt block.
Expects an array/list (or dict {"result": [...]} unwrapped by the fetcher).
For each dict item keeps only fields: sourceScheduleLabel, date, podcastScript.
Also supports nested shape: sourceSchedule.label / sourceSchedule.date.
Sorts by date DESC (newest first) via _parse_date_key. Capped at 12000 chars
(podcastScript can be long). Wrapped with <<<REPORTS_LIST_BEGIN/END>>> markers.
Returns "" if no valid items / empty list.
USER FIX 2026-09-11: remove mass injection of scheduled reports.
• If the new env flag S2S_INJECT_REPORTS is FALSE (DEFAULT), this function
ALWAYS returns "" — NO scheduled reports of any kind are ever injected
into the LLM prompt, not even a TOC line with label+date. The function
returns the empty string at the very top before touching the input, so
we also skip JSON processing / sorting / serialization work.
• If S2S_INJECT_REPORTS=TRUE, behaviour is unchanged: full injection of
podcastScript text for every report, just as before.
"""
# ── MASS-INJECTION KILL SWITCH (user request) ────────────────────────
if not S2S_INJECT_REPORTS:
return ""
# ─────────────────────────────────────────────────────────────────────
if raw_value is None:
return ""
items_list: list
if isinstance(raw_value, list):
items_list = raw_value
elif isinstance(raw_value, dict) and isinstance(raw_value.get("result"), list):
items_list = raw_value["result"]
else:
return ""
# Keep only dict items; for each project ONLY the three required fields (in order).
filtered: list[dict] = []
for item in items_list:
if not isinstance(item, dict):
continue
cleaned: dict = {}
# Field extraction with fallback to nested sourceSchedule.* shape.
# 1. Label: prefer flat sourceScheduleLabel, else nested sourceSchedule.label
label_val: object = None
if item.get("sourceScheduleLabel") is not None:
label_val = item["sourceScheduleLabel"]
elif isinstance(item.get("sourceSchedule"), dict):
ss = item["sourceSchedule"]
if ss.get("label") is not None:
label_val = ss["label"]
if label_val is None and ss.get("name") is not None:
label_val = ss["name"]
if label_val is None and ss.get("title") is not None:
label_val = ss["title"]
if label_val is not None:
cleaned["sourceScheduleLabel"] = label_val
# 2. Date: prefer flat "date", else nested sourceSchedule.date
date_val: object = None
if item.get("date") is not None:
date_val = item["date"]
elif isinstance(item.get("sourceSchedule"), dict):
ss = item["sourceSchedule"]
if ss.get("date") is not None:
date_val = ss["date"]
if date_val is None and ss.get("scheduledAt") is not None:
date_val = ss["scheduledAt"]
if date_val is None and ss.get("publishedAt") is not None:
date_val = ss["publishedAt"]
if date_val is not None:
cleaned["date"] = date_val
# 3. Script: prefer flat podcastScript, else nested podcastScript / script / text
script_val: object = None
if item.get("podcastScript") is not None:
script_val = item["podcastScript"]
elif item.get("script") is not None:
script_val = item["script"]
elif item.get("text") is not None:
script_val = item["text"]
elif item.get("content") is not None:
script_val = item["content"]
if script_val is not None:
cleaned["podcastScript"] = script_val
if cleaned:
filtered.append(cleaned)
if not filtered:
return ""
# Sort by date DESC (newest first)
filtered.sort(key=lambda r: _parse_date_key(r.get("date")))
# 2026-09-11 user fix: limit report quick hints through .env (default 3-5).
# after sort by newest → keep ONLY the first S2S_REPORTS_QUICK_HINT_MAX reports.
# Applies cap to BOTH the quick-hint TOC lines AND the serialized JSON entries,
# so the final wrapped block stays small and contains no more than N scripts.
total_raw = len(filtered)
cap = max(1, int(S2S_REPORTS_QUICK_HINT_MAX or 5))
if total_raw > cap:
filtered = filtered[:cap]
truncated_note = ""
if total_raw > cap:
truncated_note = (
f"\n(Showing the {cap} newest scheduled reports of {total_raw} total "
f"returned upstream; older reports omitted. Set S2S_REPORTS_QUICK_HINT_MAX "
f"in .env to raise or lower this window.)\n"
)
# ──────────────────────────────────────────────────────────────────────
# USER FIX BUG 3 2026-09-11: Qwen2.5 cutoff mid-2024 hallucinates meta on 2026 dates.
# Route output through the new tri-state S2S_REPORTS_INJECT_MODE:
# • "off"/"none" → skip entirely (no reports block in prompt, 0 tokens).
# • "minimal" → ONLY last-N label + date TOC. NO podcastScript text, no JSON entries,
# just the requested recent scheduled reports block.
# ~50 tokens instead of ~3200.
# • "full" → legacy mode with hints + full JSON entries + podcastScript.
# Kept only for explicit opt-in because user explicitly asked
# to eliminate massive script injection.
mode = (S2S_REPORTS_INJECT_MODE or "minimal").lower()
if mode in {"off", "none"}:
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-REPORTS] mode=%s → no scheduled reports injected into prompt (cap=%d totalRaw=%d)",
mode, cap, total_raw,
)
return ""
if mode == "minimal":
minimal_block = _build_minimal_reports_toc_block(filtered, cap=cap)
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-REPORTS] mode=minimal blockLen=%d (no podcastScript injected) cap=%d totalRaw=%d",
len(minimal_block), cap, total_raw,
)
return minimal_block
# ───── mode == "full" — legacy behaviour (opt-in only) ───────────────
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-REPORTS] mode=full (LEGACY, podcastScript included) cap=%d totalRaw=%d",
cap, total_raw,
)
# Build a quick HINT LINE per report (label + date) so the LLM can see at a
# glance which scheduled topics are available before diving into scripts.
hint_lines = []
import datetime as _dt
for idx, r in enumerate(filtered, start=1):
label_piece = ""
if r.get("sourceScheduleLabel"):
label_piece = str(r["sourceScheduleLabel"]).strip()
date_piece = ""
if r.get("date") is not None:
raw_d = r["date"]
try:
# Integer / float timestamp → render as YYYY-MM-DD HH:MM (local)
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
ts_sec = float(raw_d)
if ts_sec > 1e12: # ms → seconds
ts_sec = ts_sec / 1000.0
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.timezone.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:
hint_lines.append(
f" • Report #{idx}: '{label_piece}' — scheduled on {date_piece}"
)
elif label_piece:
hint_lines.append(f" • Report #{idx}: '{label_piece}'")
elif date_piece:
hint_lines.append(f" • Report #{idx}: (no label) — scheduled on {date_piece}")
# Serialize: compact JSON (pretty-ish but one line per report saves space)
entries_lines = []
for idx, r in enumerate(filtered, start=1):
try:
entries_lines.append(f" [{idx}] {json.dumps(r, ensure_ascii=False)}")
except Exception:
entries_lines.append(f" [{idx}] {str(r)[:500]}")
hints_block = ""
if hint_lines:
hints_block = "\n".join(hint_lines) + "\n"
body = truncated_note + hints_block + "\n--- Full JSON entries ---\n" + "\n".join(entries_lines)
wrapped = (
f"{REPORTS_LIST_HINT}\n"
f"Quick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n"
f"{body}\n"
f"<<<REPORTS_LIST_END>>>. Use these scheduled reports as trusted prior "
"content relevant to the caller's company topics. Reference individual "
"reports by their label when answering."
)
# Build wrapped with HINT BLOCK before the JSON. We prepend a summary line per
# report so the LLM has a quick table-of-contents of what's available.
if len(wrapped) > 12000:
# Truncate body preserving markers + hint
budget = (
12000
- len(REPORTS_LIST_HINT)
- len(f"\nQuick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n")
- len("\n--- Full JSON entries ---\n")
- len("\n<<<REPORTS_LIST_END>>>...truncated.")
- 200 # reserve for wrap sentence
)
if budget > 0:
body = body[:budget] + "..."
wrapped = (
f"{REPORTS_LIST_HINT}\n"
f"Quick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n"
f"{body}\n"
f"<<<REPORTS_LIST_END>>>...truncated (cap 12000 chars). Use these "
"scheduled reports as trusted prior content. Reference reports by label when relevant."
)
if total_raw and total_raw > cap and len(truncated_note) and S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-REPORTS] quick-hint+entries cap applied: kept=%d upstreamTotal=%d cap=%d",
len(filtered), total_raw, cap,
)
return wrapped
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 S2S_INJECT_REPORTS:
return None
# ───────────────────────────────────────────────────────────────────────
if not S2S_REPORT_LIST:
return None
from urllib.parse import urlencode
if phone_number:
sep = "&" if "?" in S2S_REPORT_LIST else "?"
target_url = f"{S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}"
else:
target_url = S2S_REPORT_LIST
logger.info(
"[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s",
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:
logger.warning(
"[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s",
resp.status_code, resp.text,
)
return None
text = (resp.text or "").strip()
if not text:
logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.")
return None
try:
payload = resp.json()
except Exception:
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:
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 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]
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))
logger.info(
"[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)",
n_items, n_dict,
)
return unwrapped
logger.info(
"[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.",
type(unwrapped).__name__,
)
if S2S_DEBUG_PROMPT and isinstance(payload, dict):
# Log final top-level keys to help diagnose wrapper shape
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 S2S_LOGIN_BY_PHONE or not phone_number:
return None, None
body = {"phoneNumber": phone_number}
logger.info(
"[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d",
S2S_LOGIN_BY_PHONE,
len(phone_number),
)
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
resp = await http.post(
S2S_LOGIN_BY_PHONE,
json=body,
headers={"Content-Type": "application/json"},
)
if resp.status_code != 200:
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:
logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.")
return None, None
try:
payload = resp.json()
except Exception:
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:
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 = "[INJECT-LOGIN] ✅ login OK: wrapLayer=%s topKeys=%s unwrappedKeys=%s token=%s%s" % (_wrap_layer, _all_keys_s, _unwrap_keys_s, _t_info, _fmt_note)
logger.info("%s", msg)
return token, unwrapped
# Fallback: non-dict payload (string/list/number) — still log it, but no token expected.
logger.info(
"[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.",
type(payload).__name__,
)
if S2S_DEBUG_PROMPT:
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) -> str | dict | None:
"""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 S2S_LATEST_SUMMARY_TEXT:
override_len = len(S2S_LATEST_SUMMARY_TEXT)
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 S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s",
S2S_LATEST_SUMMARY_TEXT[:800],
)
return S2S_LATEST_SUMMARY_TEXT
if not S2S_LATEST_SUMMARY or not phone_number:
return None
from urllib.parse import urlencode
target_url = f"{S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}"
logger.info(
"[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s",
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:
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:
logger.info(
"[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.",
len(text),
)
return text
except Exception as exc:
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:
logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.")
return None
if isinstance(payload, dict):
logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s",
",".join(sorted(str(k) for k in payload.keys())),
)
elif isinstance(payload, list):
logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d",
len(payload),
)
else:
logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d",
len(str(payload)),
)
return payload
async def _fetch_podcast_episode(episode_id: str) -> dict | str | None:
"""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}"
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:
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:
logger.info(
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
len(text),
)
return text
except Exception as exc:
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:
logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
return None
if isinstance(payload, dict):
logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
",".join(sorted(str(k) for k in payload.keys())),
)
elif isinstance(payload, list):
logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
len(payload),
)
else:
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 VOCERO_USER_PHONE_AUTH_URL or not phone_number:
return None
from urllib.parse import urlencode
target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}"
logger.info(
"[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s",
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:
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:
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:
logger.info(
"[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?",
type(payload).__name__,
)
return None
logger.info(
"[INJECT-PROFILE] ✅ profile loaded OK keys=%s",
",".join(sorted(profile_obj.keys())),
)
return profile_obj
def _augment_instructions_with_profile(base_instructions, user_context_text, reports_context_text=None, summary_context_text=None, episode_context_text=None, profile_obj=None) -> str:
"""When S2S_INJECT_PROFILE is true: prepend TODAY fixed context block (Bug 3 fix)
then append the episode + profile + (optional) reports list + (optional) latest
summary context strings to the LLM system/instructions prompt.
Append order (most specific → most generic / chronologically newest → oldest):
0. VOICE_RULES_HEAD_LOCK (absolute language lock + #1 priority)
1. TODAY_block (date of today + anti-hallucination date rules)
2. episode_ctx (CURRENT podcast episode the user is interacting with)
3. base instructions (primary prompt / user settings / default)
4. profile_ctx (caller identity block)
5. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC)
6. summary_ctx (latest conversation summary)
Preserves the existing instructions text.
USER FIX 2026-09-11: remove duplicate caller profiles with TWO deduplication layers
so the same block type (profile / episode / reports / summary) NEVER appears twice
in the final augmented instructions, even if the caller passed it both in
base_instructions (e.g. client-supplied profile baked into req.instructions) AND in the
explicit profile_ctx parameter:
1) layer 2 — _strip_already_injected_types_from_base removes from parameters
if their marker already appears in base_instructions.
2) layer 1 — _dedup_context_blocks_by_type then removes any remaining
duplicates among the parameters themselves.
2026-09-11: NEW keyword profile_obj → forwarded to _build_voice_rules_head_lock_block
so the prompt lock can emit rule #5 "Exact name: Alessandro Valenti. NEVER
Alexander" for models that keep anglicizing proper names (Qwen2.5-GPTQ).
"""
base = (base_instructions or "").strip()
# ─── USER FIX BUG 3: TODAY fixed block prepended (HEAD of prompt) ─────
voice_lock_block = _build_voice_rules_head_lock_block(profile=profile_obj) # NEW #1 priority: absolute language+output lock
today_block = _build_today_fixed_context_block() # #2: date guard for Spanish responses
episode_ctx = (episode_context_text or "").strip()
profile_ctx = (user_context_text or "").strip()
reports_ctx = (reports_context_text or "").strip()
summary_ctx = (summary_context_text or "").strip()
# raw_candidates is ORDERED by prompt-priority! voice_lock_block (#0) is
# inserted FIRST so it becomes position 0 of the final instructions and
# gets highest LLM attention, then today, then episode, etc.
raw_candidates = [voice_lock_block, today_block, episode_ctx, profile_ctx, reports_ctx, summary_ctx]
# Layer 2: strip candidates whose HINT is ALREADY in base instructions
# (e.g. client sent profile baked in, we won't re-add it).
candidates_against_base = _strip_already_injected_types_from_base(base, raw_candidates)
# Layer 1: dedupe among themselves (e.g. episode was both episode_ctx passed in the same raw_candidates twice via identity + raw).
contexts = _dedup_context_blocks_by_type(candidates_against_base)
if not contexts:
return base_instructions or ""
augmented = base
for ctx in contexts:
augmented = (augmented + " " + ctx).strip() if augmented else ctx
# ─── 2026-09-11 MARKER LEAK GUARD: STRIP INTERNAL SENTINELS ──────────
# All invisible dedup markers are NOW stripped from the final prompt text
# before leaving this function → LLM NEVER sees ANY <<<X>>> / hash string.
augmented = _strip_internal_markers(augmented)
changed = augmented != base
# Compute per-type lengths for log (based on what actually survived dedupe)
final_lock_len = sum(len(c) for c in contexts if _classify_context_block(c) == "voice_rules_head")
final_today_len = sum(len(c) for c in contexts if _classify_context_block(c) == "today_fixed")
final_ep_len = sum(len(c) for c in contexts if _classify_context_block(c) == "episode")
final_pf_len = sum(len(c) for c in contexts if _classify_context_block(c) == "profile")
final_rp_len = sum(len(c) for c in contexts if _classify_context_block(c) in {"reports", "reports_minimal", "reports_full"})
final_sm_len = sum(len(c) for c in contexts if _classify_context_block(c) in {"summary", "summary_full"})
logger.info(
"[INJECT-PROFILE] ✏️ prepended VOICE_LOCK + TODAY_block + appended episode/profile/reports/summary to LLM instructions. "
"baseLen=%d voiceLockLen=%d todayCtxLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s dedupedCandidates=%d",
len(base), final_lock_len, final_today_len, final_ep_len, final_pf_len, final_rp_len, final_sm_len, len(augmented), changed, len(contexts),
)
if S2S_DEBUG_PROMPT and final_lock_len:
for c in contexts:
if _classify_context_block(c) == "voice_rules_head":
logger.info(
"[DEBUG-VOICELOCK][instructions augment] FULL voice lock block (len=%d):\n%s\n",
len(c), _strip_internal_markers(c),
)
if S2S_DEBUG_PROMPT and final_today_len:
for c in contexts:
if _classify_context_block(c) == "today_fixed":
logger.info(
"[DEBUG-TODAY][instructions augment] FULL today+anti-hallucination block (len=%d):\n%s\n",
len(c), _strip_internal_markers(c),
)
if S2S_DEBUG_PROMPT and final_ep_len:
for c in contexts:
if _classify_context_block(c) == "episode":
logger.info(
"[DEBUG-EPISODE][instructions augment] FULL podcast episode block (len=%d):\n%s\n",
len(c), _strip_internal_markers(c),
)
if S2S_DEBUG_PROMPT and final_rp_len:
for c in contexts:
if _classify_context_block(c) in {"reports", "reports_minimal", "reports_full"}:
logger.info(
"[DEBUG-REPORTS][instructions augment] FULL reports block (len=%d mode=%s):\n%s\n",
len(c), _classify_context_block(c), _strip_internal_markers(c),
)
if S2S_DEBUG_PROMPT and final_sm_len:
for c in contexts:
if _classify_context_block(c) in {"summary", "summary_full"}:
logger.info(
"[DEBUG-SUMMARY][instructions augment] FULL latest summary block (len=%d):\n%s\n",
len(c), _strip_internal_markers(c),
)
return augmented
def _patch_client_session_update_with_profile(raw_text, user_context_text, reports_context_text=None, summary_context_text=None, episode_context_text=None, profile_obj=None, apipilot_context_text_pass1=None) -> str:
"""If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is
true, append episode_context_text + user_context_text + (optional) reports_context_text +
(optional) summary_context_text + (optional) apipilot_context_text_pass1 to session.instructions.
Returns the modified JSON string, or the original string unchanged if:
- it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG)
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text AND apipilot_context_text_pass1 are all empty
- S2S_INJECT_PROFILE is False (but if we have apipilot_context_text_pass1 -> PATCH even if S2S_INJECT_PROFILE=false, tools injection is independent.)
- parse fails
2026-09-11: NEW keyword profile_obj. Forwarded to
_augment_instructions_with_profile so the prompt lock can emit the proper-name
anti-anglicization rule for this caller (e.g. Alessandro never Alexander).
2026-09-25: NEW keyword apipilot_context_text_pass1. Injected IN ADDITION so WS
Realtime LLM sees the tools list match description / marker rules (same as the
/api/reasoning HTTP endpoint). Independent gate: even S2S_INJECT_PROFILE=false,
if apipilot_context_text_pass1 has content -> PATCH instructions.
"""
# Step 1: only consider session.update frames. All other frame types
# (response.create, conversation.item.*, audio_* etc.) pass through
# SILENTLY — we never emit logs for them, which avoids spamming the
# console with 6-20 identical "SKIPPED" lines per session.
try:
payload = json.loads(raw_text)
except Exception:
return raw_text
if not isinstance(payload, dict) or payload.get("type") != "session.update":
return raw_text
# From here on: we know this is a real session.update (typically emitted
# just once or twice per realtime session). We can log safely — 1 line,
# not 20 repeated lines.
has_apipilot_pass1 = bool((apipilot_context_text_pass1 or "").strip())
if not S2S_INJECT_PROFILE and not has_apipilot_pass1:
logger.info(
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false and no apipilot list pass1). "
"WS stays pure passthrough."
)
return raw_text
has_episode = bool((episode_context_text or "").strip())
has_profile = bool((user_context_text or "").strip())
has_reports = bool((reports_context_text or "").strip())
has_summary = bool((summary_context_text or "").strip())
if not (has_episode or has_profile or has_reports or has_summary or has_apipilot_pass1):
logger.info(
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary, no apipilot tools list pass1). "
"Probably phoneNumber/episode_id query param missing / fetch failed upstream / "
"endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result."
)
return raw_text
if has_episode and not has_profile:
logger.info(
"[INJECT-EPISODE] profile missing, but PODCAST EPISODE block loaded. "
"Will inject episode alone into session.update."
)
if has_summary and not has_profile and not has_episode:
logger.info(
"[INJECT-SUMMARY] profile missing, but LATEST SUMMARY block loaded. "
"Will inject summary alone into session.update."
)
if has_reports and not has_profile and not has_episode:
logger.info(
"[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. "
"Will inject reports alone into session.update."
)
if has_apipilot_pass1 and not has_profile and not has_episode and not has_reports and not has_summary:
logger.info(
"[INJECT-APIPILOT] profile/episode/reports/summary missing, but APIPILOT TOOLS list PASS-1 block loaded (%d chars). "
"Will inject tools list alone into session.update.",
len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0,
)
top_session = payload.get("session")
if not isinstance(top_session, dict):
return raw_text
current_instructions = (
top_session.get("instructions")
if isinstance(top_session.get("instructions"), str)
else ""
)
augmented = _augment_instructions_with_profile(
current_instructions, user_context_text, reports_context_text, summary_context_text, episode_context_text,
profile_obj=profile_obj,
)
if S2S_DEBUG_PROMPT:
logger.info(
"[DEBUG-PROMPT][WS session.update] BEFORE instructions (len=%d):\n%s\n",
len(current_instructions), current_instructions,
)
# Append ApiPilot pass-1 tools and marker rules after profile/report/episode/
# summary context, matching /api/reasoning. If a second client session.update
# overwrites instructions without the tool-list marker, append the list again.
already_has_apipilot_pass1 = (
isinstance(current_instructions, str)
and APIPILOT_PASS1_LIST_MARKER in current_instructions
)
if has_apipilot_pass1 and not already_has_apipilot_pass1:
augmented = (
(augmented + "\n\n" if augmented.strip() else "")
+ (apipilot_context_text_pass1 if isinstance(apipilot_context_text_pass1, str) else "")
)
# Do not skip a tool-list repair even if profile augmentation was unchanged.
if augmented == current_instructions and not (has_apipilot_pass1 and not already_has_apipilot_pass1):
logger.info(
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; "
"episode/profile already injected or context empty or apipilot same)"
)
if S2S_DEBUG_PROMPT and augmented.strip():
logger.info(
"[DEBUG-PROMPT][WS session.update] FINAL unchanged (len=%d):\n%s\n",
len(augmented), augmented,
)
return raw_text
top_session["instructions"] = augmented
logger.info(
"[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. "
"origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s apipilotPass1=%s(len=%d, wasAlreadyPresent=%s)",
len(current_instructions),
len(augmented),
(len(augmented) - len(current_instructions)),
has_episode, has_profile, has_reports, has_summary,
has_apipilot_pass1,
(len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0),
("true" if already_has_apipilot_pass1 else "false"),
)
if S2S_DEBUG_PROMPT:
logger.info(
"[DEBUG-PROMPT][WS session.update] AFTER patch (len=%d):\n%s\n",
len(augmented), augmented,
)
if S2S_DEBUG_PROMPT and episode_context_text and episode_context_text.strip():
logger.info(
"[DEBUG-EPISODE][WS session.update] FULL podcast episode block (len=%d):\n%s\n",
len(episode_context_text.strip()), episode_context_text.strip(),
)
if S2S_DEBUG_PROMPT and reports_context_text and reports_context_text.strip():
logger.info(
"[DEBUG-REPORTS][WS session.update] FULL reports list block (len=%d):\n%s\n",
len(reports_context_text.strip()), reports_context_text.strip(),
)
if S2S_DEBUG_PROMPT and isinstance(apipilot_context_text_pass1, str) and apipilot_context_text_pass1.strip():
logger.info(
"[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=%d):\n%s\n",
len(apipilot_context_text_pass1.strip()), _strip_internal_markers(apipilot_context_text_pass1),
)
# Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and
# this is a session.update (so we see the EXACT text reaching the model even
# when S2S_INJECT_PROFILE is off and no patch happened.)
if (not S2S_INJECT_PROFILE) and S2S_DEBUG_PROMPT and current_instructions.strip():
logger.info(
"[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n",
len(current_instructions), current_instructions,
)
try:
return json.dumps(payload)
except Exception:
logger.info(
"[INJECT-PROFILE] ⛔ JSON re-serialize failed on session.update. "
"Forwarding unpatched."
)
return raw_text
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
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 _openai_headers() -> dict[str, str]:
if not OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY is not configured.")
headers = {
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json",
}
if OPENAI_ORGANIZATION:
headers["OpenAI-Organization"] = OPENAI_ORGANIZATION
if OPENAI_PROJECT:
headers["OpenAI-Project"] = OPENAI_PROJECT
return headers
def _effective_s2s_url(request: Request) -> str:
return SPEECH_TO_SPEECH_URL or _proxy_public_url(request)
@app.get("/api/config")
async def config(request: Request):
"""Client bootstrap: whether web search is available, whether the deploy runs
behind a load balancer (so the browser uses the /api/session proxy + limiter),
whether HF sign-in is available, 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."""
logger.debug("[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?")
primary_prompt = S2S_PRIMARY_PROMPT
out = {
"search": bool(SERPER_KEY),
"lb": bool(LOAD_BALANCER_URL),
"allowDirect": not LOAD_BALANCER_URL,
"s2sUrl": _effective_s2s_url(request),
"primaryPrompt": primary_prompt,
"reasoningType": S2S_REASONING_TYPE,
"auth": AUTH_ENABLED,
"injectProfile": S2S_INJECT_PROFILE,
"debugPrompt": S2S_DEBUG_PROMPT,
"startSessionMode": S2S_START_SESSION_MODE,
"theme": S2S_THEME,
"apipilot": bool(S2S_APIPILOT_TOOLS_URL),
}
logger.debug(
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
len(primary_prompt),
S2S_REASONING_TYPE,
S2S_START_SESSION_MODE or "-",
S2S_THEME or "-",
out["apipilot"],
)
return out
class ApipilotExecuteRequest(BaseModel):
"""Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape."""
name: str
arguments: dict = {}
@app.get("/api/apipilot/tools")
async def apipilot_list_tools(
request: Request,
include_unauthorized: bool = True,
refresh: bool = False,
):
"""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 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 = _apipilot_parse_authorization(request)
try:
tools = await _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},
)
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,
}
@app.post("/api/apipilot/execute")
async def apipilot_execute(request: Request, body: ApipilotExecuteRequest):
"""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 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 = _apipilot_parse_authorization(request)
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 _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
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)
@app.post("/api/reasoning")
async def reasoning(req: ReasoningRequest):
if S2S_REASONING_TYPE == "PROXY":
raise HTTPException(status_code=409, detail="Reasoning endpoint is disabled in PROXY mode.")
transcript = (req.transcript or "").strip()
if not transcript:
raise HTTPException(status_code=400, detail="transcript is required.")
# ── 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 S2S_INJECT_PROFILE:
logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). "
"Using verbatim request body; no fetch, no augmentation."
)
else:
needs_phone_authed = bool(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(S2S_REPORT_LIST.strip()) and S2S_INJECT_REPORTS
needs_summary = bool(S2S_LATEST_SUMMARY.strip()) or bool(S2S_LATEST_SUMMARY_TEXT)
needs_login = bool(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):
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 S2S_LATEST_SUMMARY_TEXT):
logger.info(
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). "
"Browser-side issue: check main.js passes voceroUserPhoneNumber and/or episode_id in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
)
elif not can_inject:
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 S2S_LOGIN_BY_PHONE and phone:
login_token, _login_resp = await _login_by_phone(phone)
# ###################################################################
# 0.5) Podcast episode fetch (independent of phone — uses req.episode_id)
# ###################################################################
if episode_id:
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...",
episode_id,
)
episode_raw = await _fetch_podcast_episode(episode_id)
inject_episode_text = _resolve_episode_context(episode_raw)
if inject_episode_text.strip():
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:
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 _fetch_user_profile_for_phone(phone, auth_token=login_token)
inject_profile_text = _resolve_user_context_text(phone, profile_obj, loaded=True)
if inject_profile_text.strip():
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:
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 S2S_DEBUG_PROMPT:
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 _fetch_reports_list(phone_number=phone if phone else None, auth_token=login_token)
inject_reports_text = _resolve_reports_context(reports_raw)
if inject_reports_text.strip():
logger.info(
"[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
len(inject_reports_text),
)
else:
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 S2S_LATEST_SUMMARY_TEXT):
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s.",
bool(S2S_LATEST_SUMMARY),
bool(S2S_LATEST_SUMMARY_TEXT),
len(phone) if phone else "none",
)
latest_summary_raw = await _fetch_latest_summary_for_phone(phone, auth_token=login_token)
inject_summary_text = _resolve_latest_summary_context(latest_summary_raw)
if inject_summary_text.strip():
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(S2S_LATEST_SUMMARY_TEXT),
len(inject_summary_text),
)
else:
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(S2S_APIPILOT_TOOLS_URL) or bool(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 S2S_APIPILOT_SERVICE_TOKEN:
_svc = 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 = _monotonic_now()
apipilot_tools_pass1 = await _apipilot_fetch_tools(
auth_header=apipilot_auth_header,
demo_session=None,
force_refresh=False,
)
logger.info(
"[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s",
len(apipilot_tools_pass1),
int((_monotonic_now() - apipilot_t0) * 1000),
"Y" if apipilot_auth_header else "-",
)
except Exception as exc:
logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc)
apipilot_tools_pass1 = []
inject_apipilot_text_pass1 = _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 = _build_voice_rules_head_lock_block(profile=profile_obj)
if voice_lock_block.strip():
user_context_parts.append(voice_lock_block)
today_block = _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 = _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 = _classify_context_block(part)
if tid is None:
continue
for marker_tid, marker in _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 = _classify_context_block(part)
if tid in server_injected_types_in_client_ctx:
continue
new_parts.append(part)
if S2S_DEBUG_PROMPT and new_parts != user_context_parts:
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 = _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 S2S_INJECT_PROFILE:
any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text)
if any_injected:
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 S2S_DEBUG_PROMPT and voice_lock_block.strip():
logger.info(
"[DEBUG-VOICELOCK][REASONING augment] FULL voice lock block (len=%d):\n%s\n",
len(voice_lock_block), _strip_internal_markers(voice_lock_block),
)
if S2S_DEBUG_PROMPT and inject_episode_text.strip():
logger.info(
"[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n",
len(inject_episode_text), _strip_internal_markers(inject_episode_text),
)
if S2S_DEBUG_PROMPT and today_block.strip():
logger.info(
"[DEBUG-TODAY][REASONING augment] FULL today block (len=%d):\n%s\n",
len(today_block), _strip_internal_markers(today_block),
)
if S2S_DEBUG_PROMPT and inject_reports_text.strip():
logger.info(
"[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n",
len(inject_reports_text), _strip_internal_markers(inject_reports_text),
)
if S2S_DEBUG_PROMPT and inject_summary_text.strip():
logger.info(
"[DEBUG-SUMMARY][REASONING augment] FULL latest summary block (len=%d):\n%s\n",
len(inject_summary_text), _strip_internal_markers(inject_summary_text),
)
if S2S_DEBUG_PROMPT and inject_profile_text.strip():
logger.info(
"[DEBUG-PROFILE][REASONING augment] FULL profile block (len=%d):\n%s\n",
len(inject_profile_text), _strip_internal_markers(inject_profile_text),
)
if S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1), _strip_internal_markers(inject_apipilot_text_pass1),
)
if needs_apipilot and inject_apipilot_text_pass1.strip():
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 = _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():
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 S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
logger.info(
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
len(inject_apipilot_text_pass1), _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 main.js
# 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 S2S_REASONING_TYPE == "NODE_RED":
if not NODE_RED_REASONING_URL:
raise HTTPException(
status_code=503,
detail="S2S_REASONING_NODE_RED_URL is not configured. Set the env var to the Node-RED endpoint (e.g. http://localhost:1881).",
)
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 S2S_DEBUG_PROMPT:
logger.info(
"[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n",
json.dumps(upstream_body, ensure_ascii=False, indent=2),
)
logger.debug(
"[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d",
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"{NODE_RED_REASONING_URL}/api/reasoning",
json=upstream_body,
headers={
"Content-Type": "application/json",
"X-S2S-Caller": "demo-server",
},
)
except httpx.RequestError as exc:
logger.warning("[EXT][NODE_RED] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable.")
logger.debug(
"[EXT][NODE_RED] reasoning RESP status=%s len=%dB",
resp.status_code, len(resp.content or b""),
)
if resp.status_code != 200:
logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500])
raise HTTPException(
status_code=502,
detail=f"Node-RED reasoning failed (HTTP {resp.status_code}).",
)
try:
payload = resp.json()
except Exception:
logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500])
raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON.")
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):
logger.warning("[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800])
raise HTTPException(status_code=502, detail="Node-RED returned invalid answer field.")
answer_stripped = answer.strip()
# An empty answer is intentional in log-only mode; forward the flag.
if not answer_stripped and not log_only:
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 returned no answer without log_only flag.")
usage = payload.get("usage") if isinstance(payload, dict) else None
if isinstance(usage, dict):
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:
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:
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 = {"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 logger.level <= logging.DEBUG})
# Capture the ApiPilot marker, execute its tool and run reasoning pass 2.
tool_name_p1, tool_args_p1 = _apipilot_parse_call_marker(answer_stripped)
if tool_name_p1:
logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s", tool_name_p1, sorted(list((tool_args_p1 or {}).keys())))
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],
)
logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", tool_name_p1)
tool_env_p1 = await _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 = _resolve_apipilot_reasoning_context_block(
[],
call_result={
"name": tool_name_p1,
"text": tool_text_p1,
"error": is_tool_err,
},
)
logger.info(
"[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger seconda chiamata Node-RED",
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 = _dedup_context_blocks_by_type(user_ctx_parts_2)
stripped_2: list[str] = []
for p in user_ctx_parts_2:
sp = _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 S2S_INJECT_PROFILE:
eff_inst_2 = _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 S2S_DEBUG_PROMPT:
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"{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:
logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable (pass 2).")
if resp2.status_code != 200:
logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500])
raise HTTPException(status_code=502, detail=f"Node-RED reasoning pass 2 failed (HTTP {resp2.status_code}).")
try:
payload2 = resp2.json()
except Exception:
logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500])
raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON (pass 2).")
final_ans = payload2.get("answer") if isinstance(payload2, dict) else None
if not isinstance(final_ans, str):
# Fallback: the first message and raw result.
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),
}
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 OPENAI_API_KEY:
raise HTTPException(status_code=503, detail="OPENAI_API_KEY is not configured.")
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.")
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": OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg},
{"role": "user", "content": transcript},
],
}
if S2S_DEBUG_PROMPT:
logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n",
len(system_msg), system_msg,
)
logger.info(
"[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n",
len(transcript), transcript,
)
logger.debug(
"[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s",
OPENAI_BASE_URL, 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"{OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body,
)
except httpx.RequestError as exc:
logger.warning("OpenAI reasoning unreachable: %r", exc)
raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable.")
logger.debug(
"[EXT][OPENAI] reasoning RESP status=%s len=%dB",
resp.status_code, len(resp.content or b""),
)
if resp.status_code != 200:
logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400])
raise HTTPException(status_code=502, detail=f"OpenAI reasoning failed ({resp.status_code}).")
try:
payload = resp.json()
except Exception:
logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400])
raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON.")
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):
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:
logger.warning("OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800])
raise HTTPException(status_code=502, detail="OpenAI reasoning returned no answer.")
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 = _apipilot_parse_call_marker(answer)
if tool_name_p1_oai:
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())))
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],
)
logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", tool_name_p1_oai)
tool_env_oai_p1 = await _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 = _resolve_apipilot_reasoning_context_block(
[],
call_result={
"name": tool_name_p1_oai,
"text": tool_text_oai,
"error": is_tool_err_oai,
},
)
logger.info(
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → seconda chiamata chat/completions",
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 = _dedup_context_blocks_by_type(user_ctx_oai_2)
stripped_oai_2: list[str] = []
for p in user_ctx_oai_2:
sp = _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 S2S_INJECT_PROFILE:
eff_inst_oai_2 = _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": OPENAI_REASONING_MODEL,
"messages": [
{"role": "system", "content": system_msg_oai_2},
{"role": "user", "content": transcript},
],
}
if S2S_DEBUG_PROMPT:
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"{OPENAI_BASE_URL}/chat/completions",
headers=_openai_headers(),
json=body_oai_2,
)
except httpx.RequestError as exc:
logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable (pass 2).")
if resp_oai_2.status_code != 200:
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"OpenAI reasoning pass 2 failed ({resp_oai_2.status_code}).")
try:
payload_oai_2 = resp_oai_2.json()
except Exception:
logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400])
raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON (pass 2).")
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.
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),
}
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 S2S_REASONING_TYPE != "NODE_RED" or not NODE_RED_REASONING_URL:
return
url = f"{NODE_RED_REASONING_URL}/api/log/system-response"
logger.debug(
"[EXT][NODE_RED] forward system-response POST %s len=%d source=%s",
NODE_RED_REASONING_URL,
len(str(payload.get("tts", "")) or 0),
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:
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:
logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc)
except Exception as exc: # noqa: BLE001 — never interrupt the realtime loop
logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc)
@app.websocket("/v1/realtime")
async def realtime_proxy(ws: WebSocket):
"""Relay the browser's same-origin realtime WebSocket to the configured upstream."""
client_ip = ws.client.host if ws.client else "?"
if not S2S_PROXY_TARGET:
logger.debug("[EXT][S2S-PROXY] client=%s rejected: S2S_PROXY_TARGET not configured", client_ip)
await ws.accept()
await ws.close(code=1013, reason="Realtime proxy not configured.")
return
await ws.accept()
upstream_url = _proxy_upstream_url(ws)
subprotocols = _subprotocols(ws)
# ── S2S_INJECT_PROFILE: resolve caller profile + podcast episode + latest summary ──
# Query params (browser URL carries ?phoneNumber=...&episode_id=...) are forwarded by the
# client WS library: we read ?phoneNumber= and ?episode_id= from the incoming WS query.
# When S2S_INJECT_PROFILE=false or envs missing -> ctx fields stay empty
# and the tunnel stays 100% pure passthrough (original behaviour).
injected_episode_context = ""
injected_profile_context = ""
injected_reports_context = ""
injected_summary_context = ""
injected_apipilot_pass1 = ""
apipilot_tools_pass1: list = []
inject_phone = ""
inject_episode_id = ""
client_query = {}
login_token: str | None = None
login_response_obj: dict | None = None
profile_obj: dict | None = None
needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL)
try:
client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True))
except Exception: # noqa: BLE001
client_query = {}
# ApiPilot works independently of profile injection. When enabled, extract
# phone/episode query parameters, resolve the bearer for authenticated tools,
# and load the profile for email defaults when its endpoint is configured.
if needs_apipilot:
inject_phone = (client_query.get("phoneNumber") or "").strip() or inject_phone
inject_episode_id = (client_query.get("episode_id") or "").strip() or inject_episode_id
_needs_login_apipilot = bool(S2S_LOGIN_BY_PHONE.strip())
_needs_profile_apipilot = bool(VOCERO_USER_PHONE_AUTH_URL.strip())
# Always log initial WS state to diagnose missing phone, token or login
# configuration that could leave the AquaPortal user anonymous.
_s_init = {
"needs_apipilot": needs_apipilot,
"phoneLen": len(inject_phone or ""),
"phoneHead": (("+" + inject_phone.lstrip("+")[:3] + "...") if (inject_phone and len(inject_phone) > 6) else (inject_phone or "")),
"loginTokenLen": (len(login_token) if isinstance(login_token, str) and login_token.strip() else 0),
"loginByPhone": ("YES" if _needs_login_apipilot else "NO"),
"profileUrl": ("YES" if _needs_profile_apipilot else "NO"),
"S2S_INJECT_PROFILE": S2S_INJECT_PROFILE,
}
logger.info(
"[INJECT-APIPILOT][WS connect] INITIAL STATE: " + ", ".join("%s=%s" % (k, v) for k, v in sorted(_s_init.items())),
)
# The API-key bypass makes login_by_phone unnecessary.
if not S2S_APIPILOT_API_KEY and _needs_login_apipilot and inject_phone and (not isinstance(login_token, str) or not login_token.strip() or (isinstance(login_token, str) and 0 < len(login_token.strip()) < 80)):
try:
login_token, _lresp = await _login_by_phone(inject_phone)
if isinstance(_lresp, dict) and _lresp:
login_response_obj = _lresp
logger.info(
"[INJECT-APIPILOT][WS connect] login_by_phone for authenticated ApiPilot execution phoneLen=%d token=%s",
len(inject_phone or ""),
("YES len=%d" % len(login_token or "")) if isinstance(login_token, str) and login_token else "NONE (403!)",
)
except Exception as _exc_login: # noqa: BLE001
logger.info(
"[INJECT-APIPILOT][WS connect] login_by_phone failed (anonymous ApiPilot user, likely 403) phoneLen=%d err=%r",
len(inject_phone or ""), _exc_login,
)
if _needs_profile_apipilot and inject_phone and (not isinstance(profile_obj, dict) or not profile_obj):
try:
profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token)
logger.info(
"[INJECT-APIPILOT][WS connect] profile fetch for email defaults phoneLen=%d profile=%s",
len(inject_phone or ""),
("FOUND user=%s" % (((profile_obj or {}).get("user") or {}).get("name") or "?")) if isinstance(profile_obj, dict) else "NONE",
)
except Exception as _exc_p: # noqa: BLE001
logger.info(
"[INJECT-APIPILOT][WS connect] profile fetch failed phoneLen=%d err=%r",
len(inject_phone or ""), _exc_p,
)
if not needs_apipilot:
logger.info(
"[INJECT-APIPILOT] ⏭️ WS connect: APIPILOT tools injection DISABLED (S2S_APIPILOT_TOOLS_URL='%s' S2S_APIPILOT_TOOLS_EXECUTE_URL='%s'). "
"No ApiPilot fetch or session.update patch in this tunnel.",
S2S_APIPILOT_TOOLS_URL,
S2S_APIPILOT_TOOLS_EXECUTE_URL,
)
if not S2S_INJECT_PROFILE:
logger.info(
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). "
"No profile/episode fetch; tunnel stays pure passthrough for injects EXCEPT apipilot."
)
else:
needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip())
# 2026-09-11 user fix: remove mass injection of scheduled reports.
# SAME gate as the REASONING endpoint above. "needs_reports" becomes True
# only when BOTH the URL env is configured AND the new opt-in S2S_INJECT_REPORTS
# flag is true. With the default (S2S_INJECT_REPORTS=false) this entire 1.5)
# branch of the WS connect path is skipped — reports-list fetch never runs.
needs_reports = bool(S2S_REPORT_LIST.strip()) and S2S_INJECT_REPORTS
needs_summary = bool(S2S_LATEST_SUMMARY.strip()) or bool(S2S_LATEST_SUMMARY_TEXT)
needs_login = bool(S2S_LOGIN_BY_PHONE.strip())
inject_phone = (client_query.get("phoneNumber") or "").strip()
inject_episode_id = (client_query.get("episode_id") or "").strip()
can_inject = (
needs_phone_authed or needs_reports or needs_summary or needs_login
or bool(inject_episode_id)
)
if not can_inject:
logger.info(
"[INJECT-PROFILE] ⏭️ WS connect: 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 query; OR S2S_INJECT_REPORTS is false so scheduled report injection is globally disabled). "
"Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable caller context / reports / login / episode."
)
elif (not inject_phone) and (not needs_reports) and (not inject_episode_id) and (not S2S_LATEST_SUMMARY_TEXT):
# reports list can be fetched without phone (generic list); episode can be fetched without phone;
# S2S_LATEST_SUMMARY_TEXT also works without phone (static text override)
logger.info(
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber= AND no ?episode_id=, AND S2S_REPORT_LIST+S2S_INJECT_REPORTS not enabled). "
"Make sure the browser opens the WS with the phone or episode_id query param, or set S2S_INJECT_REPORTS=true if you want scheduled reports without phone."
)
else:
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
# Obtains a bearer token used on subsequent profile/reports/summary fetches.
if needs_login and inject_phone:
login_token, login_response_obj = await _login_by_phone(inject_phone)
# 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query)
if inject_episode_id:
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-EPISODE] WS connect: starting podcast episode fetch episodeId=%.80s...",
inject_episode_id,
)
episode_raw = await _fetch_podcast_episode(inject_episode_id)
injected_episode_context = _resolve_episode_context(episode_raw)
if injected_episode_context.strip():
logger.info(
"[INJECT-EPISODE] ✅ WS connect: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> will patch session.update frames in this tunnel.",
inject_episode_id, len(injected_episode_context),
)
else:
logger.info(
"[INJECT-EPISODE] ⏭️ WS connect: episode skipped (context empty / upstream returned empty / fetch failed)."
)
# 1) Profile fetch (optional — only if env is set)
if needs_phone_authed and inject_phone:
profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token)
injected_profile_context = _resolve_user_context_text(
inject_phone, profile_obj, loaded=True
)
if injected_profile_context.strip():
logger.info(
"[INJECT-PROFILE] ✅ WS connect: profile READY phoneLen=%d contextLen=%d -> will patch all client session.update frames in this tunnel.",
len(inject_phone), len(injected_profile_context),
)
else:
logger.info(
"[INJECT-PROFILE] ⏭️ WS connect: profile fetch OK but resolved empty. Tunnel stays pure passthrough for profile."
)
# 1.5) Reports list fetch (optional — only if env is set)
if needs_reports:
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-REPORTS] WS connect: starting S2S_REPORT_LIST fetch (phoneLen=%s bearer=%s)...",
len(inject_phone) if inject_phone else "none",
"yes" if login_token else "no",
)
reports_raw = await _fetch_reports_list(
phone_number=inject_phone if inject_phone else None,
auth_token=login_token,
)
injected_reports_context = _resolve_reports_context(reports_raw)
if injected_reports_context.strip():
logger.info(
"[INJECT-REPORTS] ✅ WS connect: reports list READY wrappedLen=%d -> will patch session.update frames in this tunnel.",
len(injected_reports_context),
)
else:
logger.info(
"[INJECT-REPORTS] ⏭️ WS connect: reports list skipped (context empty / upstream returned empty list / fetch failed)."
)
# 2) Latest summary fetch (optional — only if env is set)
if needs_summary and (inject_phone or S2S_LATEST_SUMMARY_TEXT):
if S2S_DEBUG_PROMPT:
logger.info(
"[INJECT-SUMMARY] WS connect: starting summary load (S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s)...",
bool(S2S_LATEST_SUMMARY),
bool(S2S_LATEST_SUMMARY_TEXT),
len(inject_phone) if inject_phone else "none",
)
latest_summary_raw = await _fetch_latest_summary_for_phone(inject_phone, auth_token=login_token)
injected_summary_context = _resolve_latest_summary_context(latest_summary_raw)
if injected_summary_context.strip():
logger.info(
"[INJECT-SUMMARY] ✅ WS connect: latest SUMMARY READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> will patch session.update frames in this tunnel.",
len(inject_phone) if inject_phone else "none",
bool(S2S_LATEST_SUMMARY_TEXT),
len(injected_summary_context),
)
else:
logger.info(
"[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)."
)
# 3) Fetch the ApiPilot pass-1 tool list independently of S2S_INJECT_PROFILE,
# even without login/phone. Use the resolved token or anonymous fingerprint,
# matching the reasoning endpoint's server cache.
if needs_apipilot:
apipilot_auth_header = (f"Bearer {login_token}" if isinstance(login_token, str) and login_token.strip() else None)
# Phone login resolves the AquaPortal JWT for execution. The configured
# S2S_APIPILOT_SERVICE_TOKEN overrides it.
if S2S_APIPILOT_SERVICE_TOKEN:
_svc = S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
apipilot_auth_header = (f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc)
demo_session_key = client_query.get("x-demo-session") if isinstance(client_query, dict) else None
if not demo_session_key:
demo_session_key = f"anon-ws-{client_ip}-{id(ws)}"
try:
# Always log discovery, matching reasoning pass 1.
logger.info(
"[INJECT-APIPILOT] WS connect: fetching tools (auth=%s demoSession=%.40s...) upstream=%s...",
"yes" if apipilot_auth_header else "anon",
(demo_session_key[:40] if isinstance(demo_session_key, str) else "?"),
S2S_APIPILOT_TOOLS_URL or "<using override execute>",
)
if S2S_DEBUG_PROMPT:
logger.info(
"[DEBUG-APIPILOT][WS connect] tools fetch detail toolsURL=%s executeURL=%s overrideExec=%s",
S2S_APIPILOT_TOOLS_URL,
S2S_APIPILOT_TOOLS_EXECUTE_URL,
_apipilot_resolve_execute_url(),
)
_t0 = _monotonic_now()
apipilot_tools_pass1 = await _apipilot_fetch_tools(
auth_header=apipilot_auth_header,
demo_session=demo_session_key if isinstance(demo_session_key, str) else None,
refresh=False,
) or []
_el = (_monotonic_now() - _t0)
# Supply known phone/episode defaults to pass 1 so the model does
# not leave required arguments empty (observed ARGUMENTS=>>> bug).
_apipilot_defaults_p1: dict[str, str] = {}
if isinstance(inject_phone, str) and inject_phone.strip():
_apipilot_defaults_p1["phone"] = inject_phone.strip()
if isinstance(inject_episode_id, str) and inject_episode_id.strip():
_apipilot_defaults_p1["episode_id"] = inject_episode_id.strip()
# Also supply email when the resolved profile has a valid value.
if isinstance(profile_obj, dict) and profile_obj:
try:
u = profile_obj.get("user") if isinstance(profile_obj.get("user"), dict) else None
if isinstance(u, dict):
email = u.get("email") if isinstance(u.get("email"), str) else ""
if email.strip():
_apipilot_defaults_p1["email"] = email.strip()
except Exception: # noqa: BLE001
pass
session_ctx_apipilot_defaults = dict(_apipilot_defaults_p1)
injected_apipilot_pass1 = _resolve_apipilot_reasoning_context_block(
tools=apipilot_tools_pass1, context_defaults=_apipilot_defaults_p1,
) or ""
if injected_apipilot_pass1.strip():
logger.info(
"[APIPILOT] REASONING PASS-1 (WS realtime): tool list ready N=%d t=%.0fms auth=%s defaults=%s",
len(apipilot_tools_pass1), _el,
"Y" if isinstance(apipilot_auth_header, str) else "anon",
list(_apipilot_defaults_p1.keys()),
)
logger.info(
"[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=%d wrappedLen=%d defaultsPhone=%s -> will patch session.update frames in this tunnel.",
len(apipilot_tools_pass1), len(injected_apipilot_pass1),
("yes" if _apipilot_defaults_p1.get("phone") else "no"),
)
else:
logger.info(
"[INJECT-APIPILOT] ⏭️ WS connect: apipilot fetch returned empty tools list; PASS-1 injection skipped for this tunnel.",
)
except Exception as exc: # noqa: BLE001
logger.info(
"[INJECT-APIPILOT] ⛔ WS connect: apipilot tools fetch failed. Skipping PASS-1 injection for tunnel. err=%r",
exc,
)
injected_apipilot_pass1 = ""
apipilot_tools_pass1 = []
session_ctx_apipilot_defaults = {}
# Preserve the exact discovery authentication and demo-session values.
# AquaPortal binds authorized tools to GET /tools' x-demo-session; a new
# ws-pass2 value would appear anonymous and produce a 403 response.
_ws_apipilot_auth_for_execute = apipilot_auth_header
_ws_apipilot_demo_for_execute = demo_session_key if isinstance(demo_session_key, str) else None
# Preserve API-key identity in every propagation variable, matching the
# discovery cache fingerprint and HTTP headers even during pass-2 fallback.
if S2S_APIPILOT_API_KEY:
_ws_apipilot_auth_for_execute = f"Bearer {S2S_APIPILOT_API_KEY}"
_ws_apipilot_demo_for_execute = S2S_APIPILOT_API_KEY
else:
# With ApiPilot unconfigured, keep defaults as an empty dict rather than None.
session_ctx_apipilot_defaults = {}
injected_apipilot_pass1 = ""
apipilot_tools_pass1 = []
_ws_apipilot_auth_for_execute = None
_ws_apipilot_demo_for_execute = None
logger.info(
"[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s injectApipilotPass1=%s(Ntools=%d) login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d apipilotCtxLen=%d target=%s",
bool(injected_episode_context),
bool(injected_profile_context),
bool(injected_reports_context),
bool(injected_summary_context),
bool(injected_apipilot_pass1.strip()),
len(apipilot_tools_pass1),
("token-present" if login_token else "skipped"),
len(inject_episode_id),
len(inject_phone),
len(injected_episode_context),
len(injected_profile_context),
len(injected_reports_context),
len(injected_summary_context),
len(injected_apipilot_pass1),
upstream_url,
)
if S2S_DEBUG_PROMPT and injected_episode_context.strip():
logger.info(
"[DEBUG-EPISODE][WS connect] FULL podcast episode block loaded (len=%d):\n%s\n",
len(injected_episode_context), injected_episode_context,
)
if S2S_DEBUG_PROMPT and injected_reports_context.strip():
logger.info(
"[DEBUG-REPORTS][WS connect] FULL reports list block loaded (len=%d):\n%s\n",
len(injected_reports_context), injected_reports_context,
)
if S2S_DEBUG_PROMPT and injected_summary_context.strip():
logger.info(
"[DEBUG-SUMMARY][WS connect] FULL latest summary block loaded (len=%d):\n%s\n",
len(injected_summary_context), injected_summary_context,
)
try:
async with websockets.connect(
upstream_url,
subprotocols=subprotocols or None,
open_timeout=15,
max_size=None,
) as upstream:
logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip)
session_ctx = {
"last_user_transcript": "",
"last_item_id": None,
"injected_episode_context": injected_episode_context,
"injected_profile_context": injected_profile_context,
"injected_reports_context": injected_reports_context,
"injected_summary_context": injected_summary_context,
"injected_apipilot_pass1": injected_apipilot_pass1,
"inject_phone": inject_phone,
"inject_episode_id": inject_episode_id,
"login_token": login_token,
"login_response": login_response_obj,
# 2026-09-11 proper-name anglicization guard: keep the full
# upstream profile object so:
# (a) _build_voice_rules_head_lock_block can emit rule #5
# "Exact name: Alessandro Valenti — NEVER Alexander"
# (b) layer 7 post-processing _sanitize_agent_output can
# deterministically replace Alexander/Alexandre back to
# Alessandro with word-boundary regex even when the
# prompt lock fails on GPTQ.
"profile_obj": profile_obj,
"apipilot_tools_pass1": apipilot_tools_pass1,
# Accumulate transcripts per response_id and prevent pass-2
# injection loops after ApiPilot execution.
"_ws_apipilot_asst_transcript_acc": {}, # response_id -> str full transcript
"_ws_apipilot_pass2_active": False, # True during the second response; prevents infinite marker loops.
"_ws_apipilot_pass2_count": 0, # Session execution count, limited to three attempts.
# Defaults prepared by pass 1 fill missing pass-2 arguments.
"apipilot_defaults": (session_ctx_apipilot_defaults if needs_apipilot else {}),
# Pass-2 phone fallback also works when defaults are empty but
# the query string supplied phoneNumber.
"inject_phone": (inject_phone if isinstance(inject_phone, str) else ""),
# Bearer for authenticated execution, resolved by phone login
# independently of S2S_INJECT_PROFILE.
"login_token": (login_token if isinstance(login_token, str) else None),
# Execute with the exact auth/demo values used for WS discovery;
# AquaPortal binds the authorized tool list to that demo session.
"apipilot_exec_auth": _ws_apipilot_auth_for_execute,
"apipilot_exec_demo": _ws_apipilot_demo_for_execute,
}
client_to_upstream = asyncio.create_task(
_client_to_upstream(ws, upstream, client_ip, session_ctx)
)
upstream_to_client = asyncio.create_task(
_upstream_to_client(ws, upstream, client_ip, session_ctx)
)
done, pending = await asyncio.wait(
{client_to_upstream, upstream_to_client},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
for task in done:
exc = task.exception()
if exc:
raise exc
except WebSocketDisconnect:
logger.debug("[EXT][S2S-PROXY] client=%s disconnected (WSDisconnect)", client_ip)
return
except Exception as exc:
logger.warning("Realtime proxy failed: %r", exc)
logger.debug("[EXT][S2S-PROXY] client=%s ERROR %s", client_ip, type(exc).__name__)
try:
await ws.close(code=1011, reason="Upstream realtime unavailable.")
except Exception:
pass
finally:
logger.debug("[EXT][S2S-PROXY] client=%s TUNNEL CLOSED", client_ip)
@app.get("/api/user/phone")
async def user_by_phone(phoneNumber: str):
"""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.
"""
from urllib.parse import urlencode
if not VOCERO_USER_PHONE_AUTH_URL:
raise HTTPException(
status_code=503,
detail="VOCERO_USER_PHONE_AUTH_URL is not configured on this deploy.",
)
phone = (phoneNumber or "").strip()
if not phone:
raise HTTPException(status_code=400, detail="phoneNumber is required.")
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
login_token = None
if S2S_LOGIN_BY_PHONE:
login_token, _login_obj = await _login_by_phone(phone)
target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}"
logger.debug(
"[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s",
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:
logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Upstream user-info endpoint is unreachable.")
# Forward the upstream status + JSON verbatim; never rewrite the payload.
if resp.status_code >= 500:
logger.warning(
"[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s",
resp.status_code, resp.text,
)
try:
payload = resp.json()
except Exception:
raise HTTPException(
status_code=502,
detail="Upstream user-info endpoint returned invalid JSON.",
)
return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload)
@app.get("/api/user/reports")
async def user_reports(phoneNumber: str | None = None):
"""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 S2S_INJECT_REPORTS:
return JSONResponse(
status_code=200,
content={
"count": 0,
"wrappedLen": 0,
"hints": [],
"entries": [],
"skipped": True,
"reason": "S2S_INJECT_REPORTS is disabled on this deploy; no scheduled reports exposed to UI or LLM.",
},
)
# ───────────────────────────────────────────────────────────────────────
if not S2S_REPORT_LIST:
raise HTTPException(
status_code=503,
detail="S2S_REPORT_LIST is not configured on this deploy.",
)
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 S2S_LOGIN_BY_PHONE and phone:
login_token, _login_obj = await _login_by_phone(phone)
reports_raw = await _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 = _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": "empty list / upstream error / nothing to inject",
})
# 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: _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(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"Showing newest {cap_ui} of {total_raw_ui} upstream reports. "
f"Adjust S2S_REPORTS_QUICK_HINT_MAX env to raise/lower the window."
)
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.timezone.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"• Report #{idx}: '{label_piece}' — scheduled on {date_piece}"
)
elif label_piece:
hints_list.append(f"• Report #{idx}: '{label_piece}'")
elif date_piece:
hints_list.append(f"• Report #{idx}: (no label) — scheduled on {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)
@app.get("/api/me")
async def me(request: Request):
"""Login state, tier, and remaining daily budget. Only meaningful in LB mode;
sets the anonymous tracking cookie when first seen."""
client_ip = request.client.host if request.client else "?"
logger.debug(
"[INTERNAL] /api/me client=%s LIMITER=%s",
client_ip, LIMITER_ENABLED,
)
if not LIMITER_ENABLED:
return {"enabled": False}
view = auth.user_view(request)
tier, keys, set_cookie = auth.resolve_identity(request)
unlimited = limiter.budget_for(tier) is None
rem = None if unlimited else await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug(
"[INTERNAL] /api/me client=%s tier=%s unlimited=%s remaining=%s setCookie=%s",
client_ip, tier, unlimited, rem, bool(set_cookie),
)
out = {
"enabled": True,
"auth": AUTH_ENABLED,
**view,
"remainingSec": rem,
"limitSec": limiter.budget_for(tier),
"loginUrl": auth.OAUTH_LOGIN_PATH if AUTH_ENABLED else None,
"logoutUrl": auth.OAUTH_LOGOUT_PATH if AUTH_ENABLED else None,
}
resp = JSONResponse(out)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
@app.post("/api/search")
async def search(req: SearchRequest):
"""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="Empty query.")
key = (req.key or "").strip() or 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="Search is not configured.")
headers = {"X-API-KEY": key, "Content-Type": "application/json"}
payload = {"q": query, "num": MAX_RESULTS}
logger.debug(
"[EXT][SERPER] search POST %s query=%s userKey=%s num=%d",
SERPER_URL, json.dumps(query, ensure_ascii=True)[:200], has_user_key, MAX_RESULTS,
)
try:
async with httpx.AsyncClient(timeout=12.0) as http:
resp = await http.post(SERPER_URL, headers=headers, json=payload)
except httpx.RequestError as exc:
logger.warning("Serper unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Search provider unreachable.")
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]
logger.warning("Serper error %s: %s", resp.status_code, body)
msg = None
try:
msg = resp.json().get("message")
except Exception:
pass
detail = f"Search provider error ({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 [])[: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
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})
@app.post("/api/session")
async def session(request: Request):
"""Proxy the session handshake to the load balancer, keeping its URL secret,
and meter conversation time by tier.
The browser POSTs here (same-origin); we resolve the caller's tier, refuse if
today's budget is already spent (402), otherwise POST <LOAD_BALANCER_URL>/session
and relay the JSON back. The LB body carries a per-session `connect_url`
(compute host + short-lived token) the browser must dial directly — that one
URL is unavoidably exposed, but the stable load-balancer address is not. On a
successful grant we reserve the first time chunk against the day's budget."""
client_ip = request.client.host if request.client else "?"
if not 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="Not found.")
tier, keys, set_cookie = auth.resolve_identity(request)
# Metering runs only on the deployed Space; off-Space the LB still proxies but
# nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either.
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
logger.debug(
"[EXT][LB] session: client=%s tier=%s tracked=%s LIMITER=%s",
client_ip, tier, tracked, LIMITER_ENABLED,
)
# Refuse before troubling the LB if the day's budget is already gone. Done
# here (at enqueue) so we never put a user who can't talk into the queue.
if tracked:
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug("[EXT][LB] session: client=%s budget remaining=%s", client_ip, rem)
if rem is not None and rem <= 0:
resp = JSONResponse(
{"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402
)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
url = f"{LOAD_BALANCER_URL.rstrip('/')}/session"
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:
logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Speech service unreachable.")
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":
logger.debug("[EXT][LB] session: at_capacity — queue full")
resp = JSONResponse({"state": "at_capacity"}, status_code=503)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
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.
logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"Session handshake failed ({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")
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 untouched — crucially with NO
# reservation, so waiting in line never costs the day's budget.
if data.get("state") == "queued":
data["tier"] = tier
logger.debug("[EXT][LB] session: queued queueId=%s position=%s",
data.get("queue_id") or data.get("queueId"),
data.get("position"))
resp = JSONResponse(data)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
# A slot was free: reserve the first chunk now and return the grant.
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
@app.get("/api/queue/{queue_id}")
async def queue_status(queue_id: str, request: Request):
"""Poll a waiting ticket: relay the position, or — when the head of the line
claims a freed slot — reserve the budget now and return the grant. Re-checks the
daily budget at claim, since a multi-minute wait could have spent it elsewhere."""
client_ip = request.client.host if request.client else "?"
if not LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="Not found.")
tier, keys, set_cookie = auth.resolve_identity(request)
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
logger.debug(
"[EXT][LB] queue poll: client=%s queueId=%s tier=%s",
client_ip, queue_id[:16], tier,
)
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
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:
logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="Speech service unreachable.")
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.
logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
resp = JSONResponse({"state": "expired"}, status_code=404)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
if lb.status_code != 200:
logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"Queue poll failed ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
position = data.get("position")
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"),
)
if data.get("state") == "queued":
data["tier"] = tier
resp = JSONResponse(data)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
# Claimed a slot. Re-check the budget: it may have been spent in another tab
# during the wait. If so, refuse — the just-claimed slot is now a pending
# session on the LB and its pending-timeout reaper reclaims it shortly.
if tracked:
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug(
"[EXT][LB] queue CLAIMED queueId=%s budget remaining=%s",
queue_id[:16], rem,
)
if rem is not None and rem <= 0:
resp = JSONResponse(
{"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402
)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
@app.delete("/api/queue/{queue_id}")
async def queue_leave(queue_id: str):
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
if not LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="Not found.")
logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
await _lb_leave(queue_id)
return {"ok": True}
@app.post("/api/queue/end")
async def queue_end(request: Request):
"""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 LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="Not found.")
qid = await _queue_id(request)
if qid:
logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
await _lb_leave(qid)
return {"ok": True}
async def _finalize_grant(data, keys, tier, tracked, set_cookie):
"""Shared grant tail (fast path or queue claim): reserve the first chunk, attach
the metering fields the client needs, and set the anon cookie."""
remaining = None
session_id = data.get("session_id")
if tracked and session_id:
logger.debug(
"[EXT][LB] grant FINALIZE sessionId=%s tier=%s tracked=%s",
session_id, tier, tracked,
)
await asyncio.to_thread(limiter.begin, session_id, keys, tier)
remaining = await asyncio.to_thread(limiter.remaining, keys, tier)
logger.debug(
"[EXT][LB] grant RESERVED sessionId=%s remaining=%s",
session_id, remaining,
)
data.update({
"tier": tier,
"limited": tracked,
"remainingSec": remaining,
"heartbeatSec": limiter.HEARTBEAT_SEC,
})
resp = JSONResponse(data)
if set_cookie:
auth.set_anon_cookie(resp, set_cookie)
return resp
async def _lb_leave(queue_id: str) -> None:
"""Best-effort: tell the LB to drop a waiting ticket."""
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
logger.debug("[EXT][LB] queue DELETE %s", url)
try:
async with httpx.AsyncClient(timeout=5.0) as http:
resp = await http.delete(url)
logger.debug(
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
queue_id[:16], resp.status_code,
)
except httpx.RequestError as exc:
logger.warning("Queue leave failed: %r", exc)
def _safe_json(response) -> dict:
try:
body = response.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
def _proxy_public_url(request: Request) -> str:
if not 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(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,
)
)
def _subprotocols(ws: WebSocket) -> list[str]:
raw = ws.headers.get("sec-websocket-protocol", "")
return [item.strip() for item in raw.split(",") if item.strip()]
async def _client_to_upstream(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None:
while True:
message = await ws.receive()
if message["type"] == "websocket.disconnect":
# #region debug-point B:client-disconnect
await _report_server_debug(
"B",
"server.py:_client_to_upstream",
"client websocket disconnected",
{"code": message.get("code")},
)
# #endregion
logger.debug(
"[EXT][S2S-PROXY] client=%s client→upstream DISCONNECT code=%s",
client_ip, message.get("code"),
)
await upstream.close()
return
if message.get("text") is not None:
# #region debug-point B:client-text-forward
summary = _realtime_event_summary(message["text"])
if summary:
await _report_server_debug(
"B",
"server.py:_client_to_upstream",
"forwarding client event to upstream",
summary,
)
event_type = summary.get("type")
# Client-side user input: typed text and inserted items.
user_text = ""
if event_type == "conversation.item.created" and summary.get("role") == "user":
user_text = (summary.get("text") or "").strip()
if user_text:
logger.info(
"[USER INPUT] client=%s text=%s",
client_ip, user_text,
)
if (event_type not in _NOISY_CLIENT_EVENT_TYPES
and event_type not in _NOISY_REALTIME_EVENT_TYPES):
logger.debug(
"[EXT][S2S-PROXY] client=%s →UPSTREAM text event=%s summary=%s",
client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300],
)
# #endregion
# --- S2S_INJECT_PROFILE: if this is a session.update client message
# and we have a resolved caller profile / podcast episode / latest caller summary,
# patch instructions to include them before forwarding upstream.
# Pure no-op when S2S_INJECT_PROFILE=false (original pure
# passthrough behaviour).
ctx_episode_context = session_ctx.get("injected_episode_context") if isinstance(session_ctx, dict) else ""
ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else ""
ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else ""
ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else ""
ctx_apipilot_pass1 = session_ctx.get("injected_apipilot_pass1") if isinstance(session_ctx, dict) else ""
ctx_profile_obj = session_ctx.get("profile_obj") if isinstance(session_ctx, dict) else None
forward_text = _patch_client_session_update_with_profile(
message["text"], ctx_profile_context or "", ctx_reports_context or "", ctx_summary_context or "", ctx_episode_context or "",
profile_obj=ctx_profile_obj,
apipilot_context_text_pass1=ctx_apipilot_pass1 or "",
)
await upstream.send(forward_text)
elif message.get("bytes") is not None:
# #region debug-point B:client-bytes-forward
await _report_server_debug(
"B",
"server.py:_client_to_upstream",
"forwarding client binary frame to upstream",
{"bytes": len(message["bytes"])},
)
logger.debug(
"[EXT][S2S-PROXY] client=%s →UPSTREAM bytes=%dB",
client_ip, len(message["bytes"]),
)
# #endregion
await upstream.send(message["bytes"])
# ──────────────────────────────────────────────────────────────────────────
# 2026-09-11 AGENT OUTPUT SANITATION (server-side, layer 7)
#
# Quantized Qwen2.5 GPTQ still leaks output through six layers of prompt rules:
# • [rightspeak] ... [endspeak] → unwanted bracketed markers.
# • [-]: checking_* → internal thoughts.
# • {'reportDetails': '...'} → inline JSON in TTS.
# • Alessandro → Alexander → unwanted proper-name translation.
#
# Layer 7 filters all upstream text before forwarding it to the client or logging.
# ──────────────────────────────────────────────────────────────────────────
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 p["user"]
if isinstance(p, dict):
return p
return {}
def _common_name_variants(original_name: str) -> list[str]:
"""For a user's first/last name, return known anglicized / adapted variants
that the model may produce (e.g. Alessandro → Alexander). Used by
sanitizer to replace variant → original.
Map is conservative (only common names we have observed in the wild so
far). Any new name can be added here without touching other code.
"""
variants_map: dict[str, tuple[str, ...]] = {
# Western / Romance masculine first names — anglicized / cognates
"Alessandro": ("Alexander", "Alexandre", "Aleksander", "Alexsander",
"Alessander", "Aleksandr", "Alexander", "Alejandro",
"Alesandro", "Allessandro", "Alex"),
"Alessandra": ("Alexandra", "Aleksandra", "Alejandra", "Alex"),
"Valenti": ("Valentini", "Valentine", "Valentin", "Valente",
"Valentín", "Valentí"),
"Giovanni": ("John", "Juan", "Giovani", "Gianni", "Joan", "Ivan"),
"Paolo": ("Paul", "Pablo", "Paulo", "Paul"),
"Marco": ("Marc", "Mark", "Marcos"),
"Luca": ("Lucas", "Luke", "Lukas"),
"Matteo": ("Matthew", "Matías", "Mateo", "Matt"),
"Francesco": ("Francis", "Francisco", "Francesc"),
"Leonardo": ("Leonard", "Leonhard"),
"Giuseppe": ("Joseph", "José", "Giuseppi", "Pepe"),
"Mario": ("Maryo", "Mari", "Marion"),
# Feminine
"Sofia": ("Sophia", "Sophie", "Sofía", "Sofie"),
"Giulia": ("Julia", "Juliana", "Julie", "Giuliana"),
"Anna": ("Ana", "Anne", "Hannah", "Ann"),
}
out: list[str] = []
if not original_name:
return out
o = original_name.strip()
if o in variants_map:
out.extend(variants_map[o])
# Also check case-insensitive match as safety net
lower_map = {k.lower(): v for k, v in variants_map.items()}
if o.lower() in lower_map:
for v in lower_map[o.lower()]:
if v not in out:
out.append(v)
return out
def _sanitize_agent_output(text, session_ctx=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.
"""
import re
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 = _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(" ", "")
# (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, session_ctx=None) -> tuple:
"""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):
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 = _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
async def _upstream_to_client(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None:
async for message in upstream:
if isinstance(message, bytes):
# Drop audio only for the current response_id marked as containing
# a CALL marker: Qwen3 may already have spoken it. Matching any ID
# would also discard valid pass-2 audio.
drop_now = False
matched_rid = ""
drop_ids = session_ctx.get("_ws_drop_audio_for_response_ids")
if isinstance(drop_ids, dict) and drop_ids:
rid_ctx = session_ctx.get("_ws_current_response_id")
if isinstance(rid_ctx, str) and rid_ctx and drop_ids.get(rid_ctx):
drop_now = True
matched_rid = rid_ctx
elif (not isinstance(rid_ctx, str) or not rid_ctx) and len(drop_ids) == 1:
# Fallback when no current ID exists but exactly one response
# remains in the dictionary.
only_rid = next(iter(drop_ids.keys()))
if drop_ids.get(only_rid):
drop_now = True
matched_rid = only_rid
if drop_now:
logger.info("[APIPILOT-AUDIO-DROP] FRAME DROP bytes=%dB matchedResponse=%s (CALL marker in this response → leaked Qwen3 audio DISCARDED)", len(message), (matched_rid or session_ctx.get("_ws_current_response_id") or "-"))
continue
else:
logger.debug(
"[APIPILOT-AUDIO-ALLOW] FRAME PASS bytes=%dB responseCtx=%s dropIds=%s",
len(message),
(session_ctx.get("_ws_current_response_id") or "-"),
(sorted(list(drop_ids.keys())) if isinstance(drop_ids, dict) else "none"),
)
# #region debug-point B:upstream-bytes-forward
await _report_server_debug(
"B",
"server.py:_upstream_to_client",
"forwarding upstream binary frame to client",
{"bytes": len(message)},
)
logger.debug(
"[EXT][S2S-PROXY] client=%s UPSTREAM→ bytes=%dB",
client_ip, len(message),
)
# #endregion
await ws.send_bytes(message)
else:
# Extract the original message before sanitation removes pass-2 markers.
pre_sanitize_summary = _realtime_event_summary(message)
pre_event_type = pre_sanitize_summary.get("type") if isinstance(pre_sanitize_summary, dict) else None
pre_response_id: str | None = None
if isinstance(pre_sanitize_summary, dict):
if isinstance(pre_sanitize_summary.get("response_id"), str):
pre_response_id = pre_sanitize_summary["response_id"]
elif isinstance(pre_sanitize_summary.get("id"), str):
pre_response_id = pre_sanitize_summary["id"]
# Track the current response_id for the binary-audio gate.
if isinstance(pre_response_id, str) and pre_response_id:
session_ctx["_ws_current_response_id"] = pre_response_id
# Detect CALL markers in every text event, including before response.done.
# Mark this response_id to drop its audio and sanitize later events.
if (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type not in {"response.done"}
):
quick_text_parts: list[str] = []
for _k in ("text", "transcript", "output_text", "audio_text",
"output_transcript", "final_transcript", "output_audio_text",
"content", "message", "utterance", "delta", "_raw_preview"):
_v = pre_sanitize_summary.get(_k)
if isinstance(_v, str) and _v.strip():
quick_text_parts.append(_v)
for _cname in ("item", "part", "output", "audio"):
_sub = pre_sanitize_summary.get(_cname)
if isinstance(_sub, dict):
for _k2 in ("text", "transcript", "output_text"):
_v2 = _sub.get(_k2)
if isinstance(_v2, str) and _v2.strip():
quick_text_parts.append(_v2)
quick_text = (" ".join(quick_text_parts)).strip()
if quick_text:
_detected_name, _detected_args = _apipilot_parse_call_marker(quick_text)
if isinstance(_detected_name, str) and _detected_name:
drop_ids2 = session_ctx.get("_ws_drop_audio_for_response_ids")
if not isinstance(drop_ids2, dict):
drop_ids2 = {}
session_ctx["_ws_drop_audio_for_response_ids"] = drop_ids2
_flag_rid = (pre_response_id if isinstance(pre_response_id, str) and pre_response_id
else f"auto-{id(session_ctx)}")
drop_ids2[_flag_rid] = True
# Remove older IDs when more than ten remain.
if len(drop_ids2) > 10:
try:
for _old_k in list(drop_ids2.keys())[:-10]:
drop_ids2.pop(_old_k, None)
except Exception:
pass
logger.info("[APIPILOT-SANITIZE] CALL marker in event=%s response=%s name=%s → AUDIO DROP GATE enabled (discard potential leaked Qwen3 audio)", pre_event_type, _flag_rid, _detected_name)
# 1a) Accumulate original TTS transcripts for pass-2 marker parsing.
if (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type in {"response.audio_transcript.done",
"response.output_audio_transcript.done"}
):
pre_agent_text = (pre_sanitize_summary.get("text") if isinstance(pre_sanitize_summary.get("text"), str) else "").strip()
rid_key = (
pre_response_id if isinstance(pre_response_id, str) and pre_response_id
else f"auto-{id(session_ctx)}"
)
if rid_key and pre_agent_text:
acc = session_ctx.get("_ws_apipilot_asst_transcript_acc")
if not isinstance(acc, dict):
acc = {}
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc
prev = acc.get(rid_key, "")
if prev:
acc[rid_key] = f"{prev} {pre_agent_text}".strip()
else:
acc[rid_key] = pre_agent_text
# 1b) Also accumulate assistant conversation.item.created text,
# which can contain markers outside TTS transcripts.
elif (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type == "conversation.item.created"
and pre_sanitize_summary.get("role") == "assistant"
):
pre_conv_text = (pre_sanitize_summary.get("text") if isinstance(pre_sanitize_summary.get("text"), str) else "").strip()
rid_key2 = (
pre_response_id if isinstance(pre_response_id, str) and pre_response_id
else f"auto-{id(session_ctx)}"
)
if rid_key2 and pre_conv_text:
acc2 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
if not isinstance(acc2, dict):
acc2 = {}
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc2
prev2 = acc2.get(rid_key2, "")
if prev2:
acc2[rid_key2] = f"{prev2} {pre_conv_text}".strip()
else:
acc2[rid_key2] = pre_conv_text
# 2) Parse the original marker at response.done to trigger pass 2.
if (
isinstance(pre_sanitize_summary, dict)
and isinstance(pre_event_type, str)
and pre_event_type == "response.done"
):
rid_val: str = (
pre_response_id if isinstance(pre_response_id, str)
else (pre_sanitize_summary.get("id") if isinstance(pre_sanitize_summary.get("id"), str) else f"auto-{id(session_ctx)}")
)
full_text = ""
acc3 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
if rid_val and isinstance(acc3, dict):
full_text = (acc3.pop(rid_val, "") if isinstance(acc3, dict) else "") or ""
# Use the sole remaining accumulator when an upstream changes
# response_id between events.
if (not isinstance(full_text, str) or not full_text.strip()) and isinstance(acc3, dict) and len(acc3) == 1:
_only_key = next(iter(acc3.keys()))
full_text = (acc3.pop(_only_key, "") if isinstance(acc3, dict) else "") or ""
marker_name, marker_args = None, None
if isinstance(full_text, str) and full_text.strip():
_p1_defaults = session_ctx.get("apipilot_defaults") if isinstance(session_ctx.get("apipilot_defaults"), dict) else {}
marker_name, marker_args = _apipilot_parse_call_marker(full_text, defaults=_p1_defaults)
logger.info("[APIPILOT-CALL-NAME] USE=WS_RESPONSE_DONE_DETECTION markerFound=%s name=%r argsKeys=%s", ("Y" if isinstance(marker_name, str) and marker_name else "N"), (marker_name or ""), list((marker_args or {}).keys()))
# Always log pass-2 marker detection diagnostics.
try:
_preview = full_text.replace("\n", " ")
if len(_preview) > 500:
_preview = _preview[:500] + "…"
except Exception:
_preview = str(full_text)[:500]
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=%d markerFound=%s markerName=%s argsKeys=%s defaultsAutoFill=%s acc3Remaining=%d fullTextPreview=%r",
len(full_text or ""),
("Y" if isinstance(marker_name, str) and marker_name else "N"),
(marker_name if isinstance(marker_name, str) and marker_name else ""),
(list(marker_args.keys()) if isinstance(marker_args, dict) else []),
(list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none"),
(len(acc3) if isinstance(acc3, dict) else -1),
_preview,
)
elif isinstance(full_text, str):
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=0 EMPTY markerFound=N response_id=%r acc3Remaining=%d",
rid_val,
(len(acc3) if isinstance(acc3, dict) else -1),
)
else:
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullText is not str type=%r acc3=%r",
type(full_text).__name__,
(list(acc3.keys()) if isinstance(acc3, dict) else "?"),
)
needs_pass2 = (
isinstance(marker_name, str) and bool(marker_name)
and not bool(session_ctx.get("_ws_apipilot_pass2_active"))
and (int(session_ctx.get("_ws_apipilot_pass2_count") or 0) < 3)
)
if needs_pass2:
# Clear active audio-drop gates before pass 2: its response
# contains actual user data and must be played normally.
drop_ids_clean = session_ctx.get("_ws_drop_audio_for_response_ids")
if isinstance(drop_ids_clean, dict) and drop_ids_clean:
drop_ids_clean.clear()
logger.info("[APIPILOT-AUDIO-DROP] PASS-2 start: clear audio-drop gate → forward pass-2 briefing audio normally")
args = marker_args if isinstance(marker_args, dict) else {}
# Fill missing arguments or replace fictional prompt example
# values with actual defaults. When defaults are empty, use
# session_ctx["inject_phone"] from the query string.
_p1_defaults_use = session_ctx.get("apipilot_defaults") if isinstance(session_ctx.get("apipilot_defaults"), dict) else {}
_fallback_inject_phone = session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else ""
# Known fictional example values appearing in prompts.
_FAKE_PHONE_PLACEHOLDERS: set[str] = {
"+56912345678", # Prompt example from rule 7.
"56912345678", # Variant without +.
"+5612345678",
"+1234567890",
"+12345678",
"+56XXXXXXX",
"+56xxxxxxx",
"",
}
def _is_phone_fake(v: str) -> bool:
c = str(v or "").strip()
if c in _FAKE_PHONE_PLACEHOLDERS:
return True
if len(c) < 7:
return True
if c.endswith("00000"):
return True
if "123456" in c or "xxxxxx" in c.lower() or "xxxx" in c.lower():
return True
if (c.startswith("+56") and len(c) <= 9):
return True
return False
# Apply defaults and fallback to all available keys.
if isinstance(_p1_defaults_use, dict) and _p1_defaults_use:
for k, default_v in _p1_defaults_use.items():
if default_v in (None, "", []):
continue
if k not in args or args[k] in (None, "", []):
args[k] = default_v
elif k == "phone" and isinstance(args.get("phone"), str):
if _is_phone_fake(str(args.get("phone"))):
args[k] = default_v
# Replace fictional phone values even when defaults lacks phone.
if (
isinstance(args.get("phone"), str)
and _is_phone_fake(str(args.get("phone")))
and isinstance(_fallback_inject_phone, str)
and _fallback_inject_phone.strip()
):
args["phone"] = _fallback_inject_phone.strip()
# Refresh _p1_defaults for subsequent logging.
_p1_defaults = _p1_defaults_use
session_ctx["_ws_apipilot_pass2_count"] = int(session_ctx.get("_ws_apipilot_pass2_count") or 0) + 1
session_ctx["_ws_apipilot_pass2_active"] = True
# Reuse exact discovery auth/demo values: AquaPortal binds
# authorized tools to the x-demo-session used for GET /tools.
_exec_auth = session_ctx.get("apipilot_exec_auth") if isinstance(session_ctx.get("apipilot_exec_auth"), str) else None
_exec_demo = session_ctx.get("apipilot_exec_demo") if isinstance(session_ctx.get("apipilot_exec_demo"), str) else None
# Compatibility fallback for missing values:
if not isinstance(_exec_auth, str) or not _exec_auth.strip():
login_tok = session_ctx.get("login_token")
_exec_auth = f"Bearer {login_tok}" if isinstance(login_tok, str) and login_tok.strip() else None
if not isinstance(_exec_demo, str) or not _exec_demo.strip():
_exec_demo = f"ws-pass2-{client_ip}-{id(session_ctx)}"
# The SUPER API KEY overrides even SERVICE_TOKEN. Force bearer
# and demo-session values to match the discovery bypass identity.
if S2S_APIPILOT_API_KEY:
_exec_auth = f"Bearer {S2S_APIPILOT_API_KEY}"
_exec_demo = S2S_APIPILOT_API_KEY
# Apply the service-token override only without a SUPER API KEY.
elif S2S_APIPILOT_SERVICE_TOKEN:
_svc = S2S_APIPILOT_SERVICE_TOKEN.strip()
if _svc:
_exec_auth = (f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc)
# For authenticated tools with an empty/short bearer, force
# phone login again so AquaPortal receives a valid user token.
# The length heuristic treats fewer than 80 characters as short.
_phone_lfb = (
session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str)
else ""
) or (inject_phone if isinstance(inject_phone, str) else "")
_phone_lfb = _phone_lfb.strip() if isinstance(_phone_lfb, str) else ""
_current_bearer_token = ""
if isinstance(_exec_auth, str) and _exec_auth.lower().startswith("bearer "):
_current_bearer_token = _exec_auth[len("bearer "):].strip()
_is_jwt_like = bool(_current_bearer_token) and len(_current_bearer_token) >= 120 and _current_bearer_token.count(".") >= 2
# Resolve requires_auth from recent session/cache tools; default true.
_tools_recent: list[dict] = (
session_ctx.get("apipilot_tools_pass1")
if isinstance(session_ctx.get("apipilot_tools_pass1"), list)
else []
)
if not _tools_recent:
try:
_cached = _APIPILOT_CACHE.get("tools") if _APIPILOT_CACHE else None
if isinstance(_cached, tuple) and _cached and isinstance(_cached[0], list):
_tools_recent = _cached[0]
except Exception: # noqa: BLE001
_tools_recent = []
_mtool = next((t for t in _tools_recent if isinstance(t, dict) and t.get("name") == marker_name), None)
_requires_auth = True if (_mtool is None) else bool(_mtool.get("requires_auth"))
if (
(not S2S_APIPILOT_API_KEY)
and S2S_LOGIN_BY_PHONE
and _phone_lfb
and _requires_auth
and (not _is_jwt_like)
):
try:
_l_tok, _l_obj = await _login_by_phone(_phone_lfb)
if isinstance(_l_tok, str) and _l_tok.strip():
_exec_auth = f"Bearer {_l_tok.strip()}"
_tok_len = len(_l_tok.strip())
_is_new_jwt = _tok_len >= 120 and _l_tok.strip().count(".") >= 2
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN (tool requires_auth) phoneLen=%d token=%s%s",
len(_phone_lfb),
f"YES len={_tok_len}",
(" JWT?" if _is_new_jwt else (" SHORT?" if _tok_len < 80 else " OK")),
)
else:
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN token EXTRACTION FAILED phoneLen=%d (tool requires_auth → likely 403)",
len(_phone_lfb),
)
except Exception as _lfb_exc: # noqa: BLE001
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN failed phoneLen=%d err=%r",
len(_phone_lfb), _lfb_exc,
)
bearer = _exec_auth
demo_key = _exec_demo
try:
logger.info("[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_TRIGGER name=%r argsKeys=%s argsPhone=%r", marker_name, list(args.keys()) if args else [], (args.get("phone") if isinstance(args.get("phone"), str) else ""))
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): trigger marker name=%s argsKeys=%s argsPhone=%s auth=%s defaultsAutoFill=%s demoSessionKey=%.40s",
marker_name,
list(args.keys()) if args else [],
(args.get("phone") if isinstance(args.get("phone"), str) else ""),
"Y" if bearer else "anon",
list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none",
demo_key if isinstance(demo_key, str) else "?",
)
t0 = _monotonic_now()
logger.info("[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", marker_name)
env_exec = _apipilot_execute_tool(
auth_header=bearer,
demo_session=demo_key,
name=marker_name,
arguments=args,
)
if hasattr(asyncio, "create_task"):
envelope = await asyncio.wait_for(env_exec, timeout=60.0)
else:
envelope = await env_exec
t1 = (_monotonic_now() - t0)
ok = bool(isinstance(envelope, dict) and envelope.get("success"))
data = envelope.get("data") if isinstance(envelope, dict) else None
err_obj = envelope.get("error") if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict) else None
if isinstance(data, str):
tool_text = data
elif isinstance(data, (dict, list)):
try:
tool_text = json.dumps(data, indent=2, ensure_ascii=False)
except Exception: # noqa: BLE001
tool_text = str(data)
else:
tool_text = ""
if not ok:
code = (
err_obj.get("code") if isinstance(err_obj, dict) and err_obj.get("code") is not None
else (envelope.get("code") if isinstance(envelope, dict) else None)
)
msg = (
err_obj.get("message") if isinstance(err_obj, dict) and isinstance(err_obj.get("message"), str)
else ""
)
prefix = ""
if code is not None or msg:
prefix = f"[{code if code is not None else 'ERROR'}] {msg}".strip() + "\n"
tool_text = f"{prefix}{tool_text}".strip()
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): execute DONE name=%s success=%s len=%d t=%.0fms",
marker_name, "ok" if ok else "error", len(tool_text), t1,
)
call_result_dict = {
"name": marker_name,
"text": tool_text or "",
"error": (not ok),
}
# Use the light internal sanitizer for pass 2. The user-output
# sanitizer would remove the RESULT wrapper and prevent the
# upstream model from receiving the briefing result.
len_raw_tool_text = len(call_result_dict.get("text") or "")
if isinstance(call_result_dict.get("text"), str):
call_result_dict["text"] = _sanitize_pass2_internal_context(call_result_dict["text"])
len_sanitized_tool_text = len(call_result_dict.get("text") or "")
blk_pass2 = _resolve_apipilot_reasoning_context_block(
None, call_result=call_result_dict,
) or ""
if not blk_pass2.strip():
blk_pass2 = (tool_text or "").strip()
len_raw_wrapper = len(blk_pass2 or "")
# Light sanitation preserves the pass-2 RESULT wrapper.
if blk_pass2.strip():
blk_pass2 = _sanitize_pass2_internal_context(blk_pass2)
len_sanitized_wrapper = len(blk_pass2 or "")
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): block SIZES len_raw_tool_text=%d len_sanitized_tool_text=%d len_raw_wrapper=%d len_sanitized_wrapper=%d success=%s",
len_raw_tool_text, len_sanitized_tool_text,
len_raw_wrapper, len_sanitized_wrapper,
("ok" if ok else "error"),
)
if blk_pass2.strip():
# Reset _ws_current_response_id before pass-2 injection so
# valid briefing audio cannot match the previous CALL response.
session_ctx["_ws_current_response_id"] = None
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): inject PASS-2 block len=%d -> send conversation.item.create + response.create upstream",
len(blk_pass2),
)
try:
await upstream.send(
json.dumps(
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": blk_pass2}
],
},
},
ensure_ascii=False,
)
)
await upstream.send(json.dumps({"type": "response.create"}, ensure_ascii=False))
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): injection OK. Awaiting model response (pass2_active=Y until next response.done)",
)
except Exception as exc2: # noqa: BLE001
session_ctx["_ws_apipilot_pass2_active"] = False
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): inject FAIL upstream send err=%r. pass2_active=N.",
exc2,
)
else:
session_ctx["_ws_apipilot_pass2_active"] = False
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): EMPTY result block -> skip injection. pass2_active=N.",
)
except Exception as exc: # noqa: BLE001
session_ctx["_ws_apipilot_pass2_active"] = False
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): ABORT marker=%s err=%r. pass2_active=N (safety).",
marker_name, exc,
)
elif isinstance(session_ctx.get("_ws_apipilot_pass2_active"), bool) and bool(session_ctx.get("_ws_apipilot_pass2_active")):
session_ctx["_ws_apipilot_pass2_active"] = False
logger.info(
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done PASS-2 FINALE -> pass2_active=N safety counter=%s",
int(session_ctx.get("_ws_apipilot_pass2_count") or 0),
)
# Layer 7 post-processing: after marker handling, safely remove
# internal markers before forwarding text to the client.
message, was_sanitized = _sanitize_realtime_event_raw(message, session_ctx)
# #region debug-point B:upstream-text-forward
summary = _realtime_event_summary(message)
if summary:
await _report_server_debug(
"B",
"server.py:_upstream_to_client",
"forwarding upstream event to client",
summary,
)
event_type = summary.get("type")
# User input from upstream server-side STT.
user_text = ""
if event_type == "conversation.item.input_audio_transcription.completed":
user_text = (summary.get("transcript") or "").strip()
item_id = summary.get("item_id")
if user_text:
logger.info(
"[USER INPUT] client=%s stt=%s",
client_ip, user_text,
)
session_ctx["last_user_transcript"] = user_text
if item_id:
session_ctx["last_item_id"] = item_id
elif event_type == "conversation.item.created" and summary.get("role") == "user":
user_text = (summary.get("text") or "").strip()
item_id = summary.get("item_id") or summary.get("id")
if user_text:
logger.info(
"[USER INPUT] client=%s text=%s",
client_ip, user_text,
)
session_ctx["last_user_transcript"] = user_text
if item_id:
session_ctx["last_item_id"] = item_id
# Agent output, including all event variants.
agent_text = ""
response_id = summary.get("response_id") or summary.get("id")
item_id = summary.get("item_id")
if event_type in {"response.audio_transcript.done",
"response.output_audio_transcript.done"}:
# Pass-2 accumulation already retained the original text.
# This sanitized agent_text is only for UI and logs.
agent_text = (summary.get("text") or "").strip()
if agent_text:
logger.info(
"[AGENT OUTPUT] client=%s tts=%s",
client_ip, agent_text,
)
asyncio.create_task(_forward_agent_text_to_nodered({
"tts": agent_text,
"response_id": response_id,
"item_id": item_id,
"client_ip": client_ip,
"role": "assistant",
"source_event": event_type,
"user_transcript": session_ctx.get("last_user_transcript") or "",
}))
else:
raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True)
logger.debug(
"[AGENT OUTPUT][INSPECTION] client=%s %s has summary=%s raw=%s",
client_ip, event_type,
json.dumps(summary, ensure_ascii=True)[:400],
raw_preview[:800],
)
elif event_type == "response.content_part.done":
agent_text = (summary.get("text") or "").strip()
if agent_text:
logger.info(
"[AGENT OUTPUT] client=%s content_part=%s",
client_ip, agent_text,
)
asyncio.create_task(_forward_agent_text_to_nodered({
"tts": agent_text,
"response_id": response_id,
"item_id": item_id,
"client_ip": client_ip,
"role": "assistant",
"source_event": event_type,
"user_transcript": session_ctx.get("last_user_transcript") or "",
}))
elif event_type == "response.output_item.done" and summary.get("role") == "assistant":
agent_text = (summary.get("text") or "").strip()
if agent_text:
logger.info(
"[AGENT OUTPUT] client=%s output_item=%s",
client_ip, agent_text,
)
asyncio.create_task(_forward_agent_text_to_nodered({
"tts": agent_text,
"response_id": response_id,
"item_id": item_id,
"client_ip": client_ip,
"role": "assistant",
"source_event": event_type,
"user_transcript": session_ctx.get("last_user_transcript") or "",
}))
elif event_type == "conversation.item.created" and summary.get("role") == "assistant":
agent_text = (summary.get("text") or "").strip()
if agent_text:
logger.info(
"[AGENT OUTPUT] client=%s created=%s",
client_ip, agent_text,
)
asyncio.create_task(_forward_agent_text_to_nodered({
"tts": agent_text,
"response_id": response_id,
"item_id": item_id,
"client_ip": client_ip,
"role": "assistant",
"source_event": event_type,
"user_transcript": session_ctx.get("last_user_transcript") or "",
}))
if event_type not in _NOISY_REALTIME_EVENT_TYPES:
logger.debug(
"[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s",
client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300],
)
# Suppress internal tools[] injection errors from the browser.
# Strategy B currently fails because ai-server does not support tools[].
# Inspect both message and nested type in these upstream formats:
# (A) Standard wrapper observed in logs:
# {"type":"error","error":{"message":"Unknown or invalid event: session.update","type":"unknown_or_invalid_event","code":null,...}}
# (B) Flat fallback from other compatible upstreams:
# {"type":"error","message":"...","code":"..."}
if isinstance(message, str):
try:
_evt = json.loads(message)
except Exception:
_evt = None
if isinstance(_evt, dict):
_evt_type = _evt.get("type")
if _evt_type == "error":
# Prefer the nested wrapper for message and error_type.
_err_msg = ""
_err_type = ""
_inner = _evt.get("error")
if isinstance(_inner, dict):
for _k in ("message", "error", "msg"):
_v = _inner.get(_k)
if isinstance(_v, str) and _v.strip():
_err_msg = _v.strip()
break
_t = _inner.get("type")
if isinstance(_t, str):
_err_type = _t
# Fall back to flat fields when the wrapper has no message.
if not _err_msg:
for _k in ("message", "error", "msg"):
_v = _evt.get(_k)
if isinstance(_v, str) and _v.strip():
_err_msg = _v.strip()
break
if not _err_type:
_t = _evt.get("code")
if isinstance(_t, str):
_err_type = _t
# Decide whether to suppress the error.
_suppress = False
_suppress_reason = ""
if "Unknown or invalid event: session.update" in _err_msg:
_suppress = True
_suppress_reason = (
"injected session.update with tools[] rejected — ai-server currently does not support "
"tools[]. Log only on the server and do not forward to the browser."
)
elif "session.update" in _err_msg and ("tool" in _err_msg.lower() or "invalid" in _err_msg.lower()):
_suppress = True
_suppress_reason = (
f"upstream rejected session.update (message): {_err_msg[:160]}. Do not forward to the browser."
)
elif _err_type == "unknown_or_invalid_event" and "session.update" in _err_msg:
_suppress = True
_suppress_reason = (
f"upstream rejected session.update (type=unknown_or_invalid_event): {_err_msg[:160]}. Do not forward to the browser."
)
if _suppress:
logger.info(
"[WS SUPPRESS] suppressed upstream error (not sent to browser): %s",
_suppress_reason,
)
logger.debug(
"[WS SUPPRESS] full upstream suppressed error payload=%s",
json.dumps(_evt, ensure_ascii=True)[:600],
)
return
# Log unsuppressed errors with message/type to assess new rules.
logger.debug(
"[WS SUPPRESS] upstream error NOT suppressed (passthrough to browser) — "
"evt.error.message=%s evt.error.type=%s evtTopKeys=%s",
_err_msg[:200] if _err_msg else "(empty)",
_err_type or "(none)",
sorted(_evt.keys()),
)
# #endregion
await ws.send_text(message)
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 ""
async def _session_id(request: Request) -> str:
"""Pull `sessionId` from a JSON body, tolerating sendBeacon's blob posts."""
try:
data = await request.json()
except Exception:
return ""
return (data or {}).get("sessionId", "") if isinstance(data, dict) else ""
@app.post("/api/session/heartbeat")
async def session_heartbeat(request: Request):
"""Extend the live reservation one chunk at a time. `expired` once the day's
budget is spent — the client then tears down."""
client_ip = request.client.host if request.client else "?"
if not LIMITER_ENABLED:
raise HTTPException(status_code=404, detail="Not found.")
sid = await _session_id(request)
alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid)
logger.debug(
"[INTERNAL] /api/session/heartbeat client=%s sessionId=%s alive=%s",
client_ip, (sid or "")[:16], alive,
)
return {"expired": not alive}
@app.post("/api/session/end")
async def session_end(request: Request):
"""Clean teardown: reconcile to real elapsed time and refund the unused
chunk. Sent via navigator.sendBeacon, so it must succeed without a response."""
client_ip = request.client.host if request.client else "?"
if not LIMITER_ENABLED:
raise HTTPException(status_code=404, detail="Not found.")
sid = await _session_id(request)
logger.debug(
"[INTERNAL] /api/session/end client=%s sessionId=%s",
client_ip, (sid or "")[:16],
)
if sid:
await asyncio.to_thread(limiter.end, sid)
return {"ok": True}
@app.middleware("http")
async def no_cache_static_assets(request: Request, call_next):
response = await call_next(request)
path = request.url.path or ""
nocache_ext = (".html", ".js", ".mjs", ".css", ".wasm", ".worklet.js")
if any(path.endswith(ext) for ext in nocache_ext) or path == "/" or path.endswith("/"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Preserve the former proposal URLs after moving the document out of the client.
@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():
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(ROOT_DIR, "docs"), html=True), name="documentation")
# Static front-end. Registered last so the /api routes win. `html=True` serves
# index.html at "/". The repo is public anyway, so serving the dir is fine.
app.mount("/", StaticFiles(directory=HERE, html=True), name="static")