""" API and WebSocket proxy for the speech-to-speech frontend. Vite serves the React application and forwards backend requests here. Search, caller-profile, and tool credentials stay server-side. `/api/session` proxies load-balancer allocation so the browser receives only the per-session compute URL it must dial. A pinned direct URL or WebSocket proxy target disables the LB. Endpoints: GET /api/config -> frontend settings and available integrations POST /api/search -> { results, answer } Google via Serper.dev POST /api/session -> proxies /session: a grant, or a queue ticket GET /api/queue/{id} -> proxies /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) WS /v1/realtime -> optional speech-service WebSocket proxy GET /docs/* -> shared project documentation 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. Queue deadlines and pending-grant timeouts belong to the LB. """ 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 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= # and expects JSON back (optionally wrapped as {"result": }). 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 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 /tools with include_unauthorized=true (guide §2.1) # to discover tools, and POST /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: # 2) Authorization: Bearer # 3) x-demo-session: # 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_PASS2_RESULT_MARKER = "<<]{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"<<>>" 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" " <<>>\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<<>>'\n" " '<<>>\\nAhora proceso el resultado.'\n" "9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n" " <<>> 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" "<<>>" ) 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"<<>>[ \t]*(?:\r?\n|\r)+" r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+" r"<<>>[ \t]*(?:\r?\n|\r)*" r"(?:\r?\n|\r)?" r"```tool-output[ \t]*(?:\r?\n|\r)?" r"(?P.*?)" 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]*<<>>[ \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"<<>>", re.IGNORECASE | re.DOTALL, ) line_wrapped_pattern = re.compile( r"[ \t]*(?:\r?\n|\r)?[ \t]*<<>>[ \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 <<\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 <<>>. trunc_result_pattern = re.compile( r"<<?>?>?", 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: # - Authorization: Bearer # - x-demo-session: # 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 /session here, server-side) and connects to the URL the LB # returns (the original flow). The LB address itself is never sent to the browser. # When empty, the user may instead set a direct s2s server URL in Settings and the # browser connects to it straight (no load balancer). LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip() # Direct s2s server URL pinned by the deploy. Takes priority over the load # balancer: when set, LB allocation and queuing are disabled, and the browser # connects to this URL directly. Unlike the LB address it is NOT a secret — # /api/config sends it to the client, which # shows it read-only in Settings. SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip() # Optional same-origin WebSocket relay target. When set, the browser dials this # app over ws(s):///v1/realtime and the server relays frames to the # upstream speech-to-speech endpoint, keeping the internal host private. S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip() if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET: LOAD_BALANCER_URL = "" SERPER_URL = "https://google.serper.dev/search" # Cap results so the tool output stays small enough to feed back to the model. MAX_RESULTS = 5 app = FastAPI(title="s2s-demo") # 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 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 "-", 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 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", }) 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 <<>>): # - 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 <<>>): 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 "<<>>" 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", "<<>>"), # legacy back-compat marker ("summary", "<<>>"), # 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 <<>> 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"<<>>. 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<<>>...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"<<>>...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= 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=; 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 . 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/ 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 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 <<>> / 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), 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, "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="El servicio de razonamiento está deshabilitado en el modo PROXY.") transcript = (req.transcript or "").strip() if not transcript: raise HTTPException(status_code=400, detail="Se requiere el campo transcript.") # ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ── # (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context # and instructions are taken verbatim from the request body.) inject_episode_text = "" inject_profile_text = "" inject_reports_text = "" inject_summary_text = "" phone = (req.phoneNumber or "").strip() episode_id = (req.episode_id or "").strip() if not 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 VoiceController passes phoneNumber and/or episode_id in the 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 the browser # to leave speech generation to the S2S backend. Later stages can return # {answer: "...", log_only: false}. Expected response fields include answer, # log_only, usage, debug and latency_ms. # ──────────────────────────────────────────────────────────────────── if S2S_REASONING_TYPE == "NODE_RED": if not NODE_RED_REASONING_URL: raise HTTPException( status_code=503, detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).", ) 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="No se puede conectar con el motor de razonamiento de Node-RED.") 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"El razonamiento de Node-RED falló (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 devolvió un JSON no válido.") 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 devolvió un campo answer no válido.") answer_stripped = answer.strip() # An empty answer is intentional in log-only mode; forward the flag. if not answer_stripped and not log_only: logger.warning("[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]) raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.") usage = payload.get("usage") if isinstance(payload, dict) else None if isinstance(usage, dict): 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 to _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 second Node-RED call", tool_name_p1, str(not is_tool_err).lower(), len(tool_text_p1), ) # Pass-2 context replaces the tool list with the execution result, # preserving voice/date/profile context. user_ctx_parts_2: list[str] = [] if voice_lock_block.strip(): user_ctx_parts_2.append(voice_lock_block) if today_block.strip(): user_ctx_parts_2.append(today_block) if inject_episode_text: user_ctx_parts_2.append(inject_episode_text) if inject_profile_text: user_ctx_parts_2.append(inject_profile_text) if inject_reports_text: user_ctx_parts_2.append(inject_reports_text) if inject_summary_text: user_ctx_parts_2.append(inject_summary_text) if inject_apipilot_text_pass2: user_ctx_parts_2.append(inject_apipilot_text_pass2) if original_user_context: user_ctx_parts_2.append(original_user_context) user_ctx_parts_2 = _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="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).") 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"La segunda pasada del razonamiento de Node-RED falló (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 devolvió un JSON no válido (segunda pasada).") 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 no está configurado.") system_parts = [] instructions = effective_instructions or ((req.instructions or "").strip()) if instructions: system_parts.append(instructions) if effective_user_context.strip(): system_parts.append(f"User context:\n{effective_user_context.strip()}") if inject_apipilot_text_pass1: system_parts.append(inject_apipilot_text_pass1) if req.language.strip(): system_parts.append( "Reply only with the final assistant answer text. " f"Keep the answer natural and concise in this language: {req.language.strip()}." ) else: system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.") 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="No se puede conectar con el servicio de razonamiento de OpenAI.") 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"El razonamiento de OpenAI falló ({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="El servicio de razonamiento de OpenAI devolvió un JSON no válido.") 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="El servicio de razonamiento de OpenAI no devolvió una respuesta.") 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 to _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 → second chat/completions call", tool_name_p1_oai, str(not is_tool_err_oai).lower(), len(tool_text_oai), ) # Build pass 2 with the same instructions/context structure, replacing # inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai. user_ctx_oai_2: list[str] = [] if voice_lock_block.strip(): user_ctx_oai_2.append(voice_lock_block) if today_block.strip(): user_ctx_oai_2.append(today_block) if inject_episode_text: user_ctx_oai_2.append(inject_episode_text) if inject_profile_text: user_ctx_oai_2.append(inject_profile_text) if inject_reports_text: user_ctx_oai_2.append(inject_reports_text) if inject_summary_text: user_ctx_oai_2.append(inject_summary_text) if inject_apipilot_pass2_oai: user_ctx_oai_2.append(inject_apipilot_pass2_oai) if original_user_context: user_ctx_oai_2.append(original_user_context) user_ctx_oai_2 = _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="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).") 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"La segunda pasada del razonamiento de OpenAI falló ({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="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).") final_oai_ans = "" choices_oai_2 = payload_oai_2.get("choices") if isinstance(choices_oai_2, list) and choices_oai_2: msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None if isinstance(msg_oai_2, dict): content_oai_2 = msg_oai_2.get("content") if isinstance(content_oai_2, str): final_oai_ans = content_oai_2.strip() usage_oai_2 = payload_oai_2.get("usage") if not final_oai_ans: # Last-resort fallback: raw tool-result text. 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="El proxy de tiempo real no está configurado.") 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 "", ) 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_list=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="El servicio de tiempo real no está disponible.") 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 no está configurado en este despliegue.", ) phone = (phoneNumber or "").strip() if not phone: raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.") # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). login_token = None if 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="No se puede conectar con el servicio de información del usuario.") # 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="El servicio de información del usuario devolvió un JSON no válido.", ) return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload) @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: '