""" Tiny server for the speech-to-speech demo. The demo used to ship as a `sdk: static` Space, but the web-search tool needs a search key the browser must NOT see. A static Space has no runtime process, so it can't hold a secret the front-end uses. This server fixes that: it serves the unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the Serper key server-side (see docs/adr/0001). Everything lives in one container; the speech-to-speech backend stays a separate, load-balanced service the browser talks to over WebSocket as before. The load balancer's address is a secret too (like the Serper key): the browser never sees it. `/api/session` proxies the session handshake server-side so only the per-session compute URL the LB hands back (which the browser must dial) is exposed. On the deployed Space the server also meters conversation time by HF login tier (anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on the live Space, never locally (even with the LB exported for testing). `SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is disabled entirely (no session proxy, no queue, no metering, no sign-in) and the browser connects directly to that URL, shown read-only in Settings. Endpoints: GET /api/config -> { search, lb, allowDirect, s2sUrl, auth } GET /api/me -> login + tier + remaining budget (LB mode only) POST /api/search -> { results, answer } Google via Serper.dev POST /api/session -> proxies /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) POST /api/session/heartbeat-> extend the reservation; { expired } POST /api/session/end -> reconcile + refund (sendBeacon on teardown) /* -> static files (index.html, main.js, ...) When every compute slot is busy the load balancer hands back a queue ticket instead of a grant; the browser polls /api/queue/{id} until it reaches the front and a slot frees. Waiting reserves nothing — the daily budget is only reserved at the moment a slot is actually claimed (a grant), never while queued. """ from __future__ import annotations import asyncio from datetime import datetime, timezone import datetime as _dt_module import json import logging import os import re from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import httpx import websockets from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel import auth import limiter logger = logging.getLogger("s2s") _logging_configured = False if not _logging_configured: _handler = logging.StreamHandler() _handler.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-7s [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )) logging.basicConfig(level=logging.DEBUG, handlers=[_handler], force=True) _lvl_env = (os.environ.get("LOG_LEVEL") or os.environ.get("S2S_LOG_LEVEL") or "INFO").strip().upper() _is_debug = _lvl_env in {"DEBUG", "TRACE", "10"} # Logger rumorosi: a meno che LOG_LEVEL=debug esplicito, tienili quiet. 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 sempre almeno DEBUG in dev _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: # "Attualmente stai iniettando TUTTI i reports con tutto il loro podcastScript integrale." # # 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: "riduci il quick hint report max 3-5 configurabile da .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 training cutoff ~2024 metà, vede report 2026 e impazzisce meta-commenti in cinese: # 'L'ora mondiale oggi è 2023, perché report nel 2026?'" # Default = ON (safe even when empty/unset). Explicit S2S_INJECT_TODAY="false" to disable. # Injected text contains: # • real "La data di OGGI è: YYYY-MM-DD (ISO, server local date) # • "Report con date precedenti = archivio storico → non commentare date future # • Rule anti-meta-commenti: NIENTE commenti interni / prompt introspection # • Rule anti-thinking-leak (GPTQ Int4 quantized): "sólo lo que dice Vocero en voz alta" # • Rule RAG report: "para contenido de reportes usa RAG, no inventes con el resumen" 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 "Mantieni i report? Allora solo sommario MINIMO" # 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 (GUIDA_APIPILOT_CLIENTE.md) # ────────────────────────────────────────────────────────────────────────────── # URL base per il discovery e l'esecuzione dei tool dinamici ApiPilot. Quando # valorizzata, il proxy effettua GET /tools con include_unauthorized=true # (§2.1 della guida) per ottenere la lista tool e POST /execute per # invocarne uno (§2.2). Può finire o meno con "/": il proxy normalizza # sempre con rstrip("/") sotto. # # ES.: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/") # Override ESPLICITO per l'URL di execute (§2.2 GUIDA_APIPILOT_CLIENTE.md). # Se valorizzato, ha la precedenza su S2S_APIPILOT_TOOLS_URL per le chiamate POST execute. # Normalizza togliendo "/" alla fine; il proxy appenderebbe "/execute" SOLO se la URL # non termina già con "/execute". # ES.1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot # → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute # ES.2 (utente .env:45): 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("/") # Bearer token fisso (opzionale) usato SOLO se il chiamante NON fornisce un # token di sessione (Authorization / x-demo-session). Utile in ambienti di # pre-produzione con token di servizio hardcoded. S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip() # 2026-09-25 SUPER API KEY (BYPASS AUTENTICAZIONE AQUAPORTAL TEMPORANEO). # Se valorizzata: 3 forme contemporaneamente in headers per max compatibilità: # 1) X-API-Key: # 2) Authorization: Bearer # 3) x-demo-session: # Ha MAXIMA priorità e disabilita completamente login_by_phone (non serve). # Basta vuotare la stringa per tornare al login standard. S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'") # TTL della cache di discovery tools (in secondi). La guida dice "max ogni 5 # minuti se tieni cache lato client"; lato server usiamo lo stesso valore per # default: 300 secondi = 5 minuti. 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 # Timeout HTTP per chiamate ApiPilot (discovery + execute), in secondi. 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 # Cache in-process + lock asyncrona (uno solo refresha, gli altri leggono la # entry stantia). Struttura `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`. _APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {} _APIPILOT_CACHE_LOCK = asyncio.Lock() # Helper tempo monotonic per misure duration senza drift NTP. 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 GUIDA_APIPILOT_CLIENTE.md §2.1 note). """ 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. Priorità (§2.2 + override esplicito utente .env riga 45): 1. S2S_APIPILOT_TOOLS_EXECUTE_URL se valorizzato: se NON termina con "/execute", appendiamo "/execute". 2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute". La funzione non può mai restituire stringa vuota: se nessuna è configurata è responsabilità del caller (tipicamente _apipilot_execute_tool) tornare envelope errore APIPILOT_NOT_CONFIGURED prima di arrivare qui. """ 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. §4 della guida: "NON mandare direttamente l'output grezzo di /tools al modello, perché contiene il campo requires_auth che non fa parte dello standard function-calling e alcuni provider lo rifiutano." Questa funzione è l'esatto complemento di quella regola: prende un `ToolDefinition` nativo e restituisce la proiezione OpenAI-compatibile `{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 ALIAS BACKWARD: parametro `refresh` usato in WS connect L4632 # (nome sbagliato) — se presente, usa force_refresh corrispondente. 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 FIX FINGERPRINT CONSISTENCY: PRIMA determiniamo auth/demo EFFETTIVI # (SUPER API KEY PRIORITÀ MASSIMA), POI calcoliamo fp cache. # Altrimenti fp = anon-ws... ma headers reali = APIKEY → chiave cache sbagliata # e sessione demo session non matcha (Aquachile user_type=null → 403). 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: MAX PRIORITY. Se valorizzata → disabilita tutti gli altri auth # e mette TUTTE e 3 le forme contemporaneamente per max compatibilità. 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] ✗ risposta non JSON: %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 risposta di ApiPilot tools non è JSON valido."}, ) # 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. Guard per regola. 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: • rimuove TUTTE le chiavi extra non in parameters.properties — MA SOLO SE parameters.properties è un dict NON VUOTO. • Caso speciale 2026-09-25 (OSSERVATO AquaPortal): tool.get_user_briefing ha parameters.properties={} e required=[]. Significa "nessuno schema esplicito dichiarato / args flessibili" (phone viene preso internamente al backend dal token). In questo caso NON facciamo stripping di chiavi (altrimenti rimuoveremmo phone indispensabile per la business logic). • valorizza required (restituisce errori se mancanti) • NON inventare parametri → non vengono aggiunte chiavi. Returns `(filtered_args, list_of_validation_errors_in_spagnolo)`. 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 FLESSIBILITÀ SCHEMA VUOTO: se parameters non ha properties # dichiarate esplicitamente (dict vuoto) → pass-through TUTTI gli args, # nessuno stripping. Utile per AquaPortal tool tipo 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 TOOL AUTH-ONLY: requires_auth=true + parameters.properties vuoto # + required=[] + description contiene "Requires a valid session token" # → AquaPortal ricava TUTTI i dati utente dal bearer JWT del token. # In questo caso passiamo arguments={} (nessun parametro) per evitare # conflitti (es. phone argomento vs phone utente token) → 403. 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 richiede SOLO sessione auth (nessun parametro) → arguments forzati {} (evitiamo conflitto phone-args vs user-token)", tool_def.get("name") or "?", ) elif schema_is_open: cleaned = dict(args) logger.info( "[APIPILOT-VALIDATE] tool=%s schema aperto (parameters.properties vuoto) → pass-through tutti gli args 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, ) # Required check: abilitato SOLO se lo schema NON è open (altrimenti # required=[] è vacuo e non ha senso). 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 — Integrazione nel motore di reasoning (2 passate) # --------------------------------------------------------------------------- # Marker dedup/inizio/fine usati da _classify_context_block per evitare # doppie injection dello stesso blocco (se client già lo invia o se dedup layer 2 # lo trova già nel base_instructions originale). APIPILOT_PASS1_LIST_MARKER = "<<>>" APIPILOT_PASS2_RESULT_MARKER = "<<>>. # PASSATA 2: marker catturato → esegui S2S_APIPILOT_TOOLS_EXECUTE_URL via # helper → prendi body text risultato → inietta nel prompt e # chiedi all'AI di rielaborarlo in linguaggio naturale per # raccontarlo all'utente via voce. # --------------------------------------------------------------------------- _APIPILOT_CALL_MARKER_RE = re.compile( r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}", re.IGNORECASE | re.DOTALL, ) # 2026-09-25: marker INVERTITO caso osservato (LLM sbaglia sintassi): # >>>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 passata 1. Returns (tool_name, arguments_dict) oppure (None, None) se nessun marker. Gestisce: (1) ARGUMENTS={"key":"val"} JSON valido (formato corretto) (2) ARGUMENTS={} vuoto corretto (3) ARGUMENTS= vuoto SENZA graffe (OSSERVATO oggi su WS realtime) (4) args_raw tipo key=val,key2=val2 string semplice (fallback manual parse) (5) FASE 2: dopo il parse, applica un dict di defaults (es. phone, episode_id) riempiendo parametri required MANCANTI in args (se non sono presenti). """ 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 a dict (varianti tollerate) ──────────────────────────── if args_raw == "" or args_raw in {"{}", "null", "None"}: args = {} else: # Tenta 1: JSON standard (graffe incluse o NO) _candidate = args_raw try: args = json.loads(_candidate) except Exception: try: # Tenta 2: mancano graffe esterne, aggiungiamo { } attorno args = json.loads("{" + _candidate + "}") except Exception: try: # Tenta 3: virgolette singole invece che doppie args = json.loads(_candidate.replace("'", '"')) except Exception: try: # Tenta 4: { } + virgolette singole args = json.loads("{" + _candidate.replace("'", '"') + "}") except Exception: # Fallback 5 manuale: separatore = e virgole. # Es. 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 = {} # ── FASE 2: auto-fill defaults (phone, etc.) per parametri mancanti ────── 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. Quando call_result=None → PASSATA 1: emetti la lista strumenti + la regola sintattica del marker che l'AI deve emettere quando description matcha. Quando call_result={'name':str, 'text':str, 'error':bool} → PASSATA 2: inietta il risultato testuale ottenuto da execute e chiedi all'AI di rielaborarlo per l'utente finale (voce / testo). Parametro NUOVO 2026-09-25 context_defaults (dict opzionale): per PASSATA 1 se non-None aggiunge una sezione "VALORES POR DEFECTO CONOCIDOS" per dire all'AI "quando lo strumento richiede 'phone' usa esattamente questo valore già disponibile (non inventarlo)." Tipicamente contiene {"phone": "+56..."} da inject_phone del caller. """ # ── PASSATA 2: Risultato strumento già iniettato ───────────────────────── 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 = ( "Ahora debes reinterpretar EL TEXTO RESULTADO (arriba) en lenguaje natural claro " "y conciso, exclusivamente en el idioma del usuario (language field). " "No menciones la herramienta internamente ni detalles técnicos. " "Responde SOLAMENTE con el contenido final que le quieres transmitir al usuario." ) return ( f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n" "### Resultado de la herramienta ApiPilot invocada\n" f"{title}\n" f"\n```tool-output\n" f"{text.rstrip() if text.strip() else '(salida vacía)'}\n" f"```\n\n" f"{trailer}\n" ) # ── PASSATA 1: Lista strumenti disponibili + regola marker ─────────────── tools = tools_list if isinstance(tools_list, list) else [] # Cap: max 100 strumenti (anche se la guida ne dichiara max 50 in 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)] # Truncate description a ~500 chars per evitare injection enorme di token 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" "### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" "\n" "Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " "NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" "\n" "REGLAS EJECUCIÓN OBLIGATORIAS:\n" "1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " "de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " "una sola riga como PRIMERA línea de TU RESPUESTA, con este formato sintattico:\n" " <<>>\n" "2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " "commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" "3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " "niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " "risultato in una seconda chiamata.\n" "4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" "5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" "6. 🔴 MODO RISPOSTA ESCLUSIVO (priorità MASSIMA, non negoziabile): quando decidi di usare il " "marker CALL, la TUA RISPOSTA INTERA DEVE ESSERE ESCLUSIVAMENTE LA RIGA DEL MARKER. " "NON scrivere NIENTE PRIMA (niente 'Claro que sí', niente 'Voy a revisar', niente " "'Voy a generar tu briefing', nessun saluto, nessuna frase transitoria). " "NON scrivere NIENTE DOPO (nessun commento, nessuna anticipazione, nessuna spiegazione). " "Una sola riga, esattamente il marker. Se scrivi altro testo oltre al marker il motore " "non lo riconosce e non esegue alcuna herramienta.\n" "8. Ejemplos SBAGLIATI (NON fare MAI):\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. 🔴 REGLAS TTS ANTI-LEAK (NON negoziabili, priorità MASSIMA sul audio):\n" " Il marcador <<>> È UN SEGNALE INTERNO DI SISTEMA e NON DEVE MAI\n" " essere pronunciato / letto / sintetizzato dall' audio TTS. Quando emetti il marcador:\n" " - Il canale RAGIONAMENTO INTERNO (text/transcript) DEVE contenere la riga esatta del marcador.\n" " - Il canale AUDIO TTS FINALE che ascolta l'utente DEVE contenere SOLAMENTE il placeholder:\n" " \"Voy a consultar tus datos, un momento por favor.\"\n" " - NESSUNA delle parole seguenti deve MAI comparire nel TTS audio finale pronunciato:\n" " APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, funzione, funzione chiamata,\n" " marcador, marker. (Nemmeno come spiegazione o descrizione della chiamata.)\n" "\n" ) # ── 2026-09-25 SECCIÓN NUEVA: VALORES POR DEFECTO CONOCIDOS ───────────────── # I valori che GIA' ABBIAMO da URL o profilo (es. phoneNumber della sessione # corrente) sono iniettati QUI così che quando l'AI decide di chiamare lo # strumento inserisce esattamente i parametri senza lasciarli vuoti. 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 += ( "VALORES POR DEFECTO CONOCIDOS (usa estos valores textualmente " "en el ARGUMENTS={…} cuando la herramienta correspondiente tenga " "estos nombres en required_parameters; no los inventes, no los " "traduzcas, no los normalices):\n" f"{chr(10).join(defaults_lines)}\n" "\n" ) rule += ( "Lista herramientas (name / description):\n" f"{chr(10).join(entries)}\n" "<<>>" ) return rule def _sanitize_pass2_internal_context(text: str) -> str: """Sanitize LEGGERA per blocco PASS-2 iniettato ALL'AI upstream (NON all'utente). NON USIAMO _sanitize_text_strip_apipilot_markers perché quella cancella l'intero wrapper <<>> tramite BLOCCO 1 FULL STRIP — quello wrapper serve ALL'AI per riconoscere che sono dati della chiamata. Qui facciamo SOLO: - BLOCCO 3 PATH leak filesystem (.trae-server / workspaceStorage / paths) - BLOCCO 4 FRAGMENT marker parole singole leak - TIER 6 phone contextual leak (solo se riga contiene marker) """ if not isinstance(text, str) or not text.strip(): return text # ── BLOCCO 3 (path leaks Unix/Windows/TRAE token) ──────────────────── # (pattern identico a _sanitize_text_strip_apipilot_markers Blocco3) 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) # ── BLOCCO 4 FRAGMENT marker leak 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: """Rimuovi TUTTI i marker interni di ApiPilot (PASS-1 CALL e PASS-2 RESULT) dal testo che arriva all'utente finale (UI chat + audio TTS + conversation item visualizzazione). Regole stripping: - MARKER CALL <<>> → placeholder spagnolo (se l'AI emette il marker e viene mandato alla TTS per sbaglio, lo mascheriamo con messaggio UI coerente). - MARKER PASS-2 <<>> + <<>> + wrapper "### Resultado de la herramienta ApiPilot..." + blocco ```tool-output ... ``` + re-instruction trailer finale → RIMOSSI SILENZIOSAMENTE (nessun placeholder: sono segnali server interni per la PASS-2 di reasoning; NON devono mai arrivare a TTS/UI. Se l'AI per sbaglio li ripete nella risposta finale, li cancelliamo senza lasciare traccia). Parametri: - text: la stringa da sanificare - placeholder: None = default spagnolo "Voy a consultar tus datos, un momento por favor." (lingua coerente con la lingua UI e vocale target) """ if not isinstance(text, str) or not text: return text if placeholder is None: placeholder = "Voy a consultar tus datos, un momento por favor." # ================================================================ # BLOCCO 1: PASS-2 RESULT markers + wrapper (RIMOZIONE SILENZIOSA) # Questi NON devono MAI finire in TTS/UI. # ================================================================ # (1a) Blocco intero PASS-2 wrapper (order matters: blocco largo PRIMA) # Pattern: riga PASS2>>> marker + titolo MD + RESULT STATUS marker + # block ```tool-output ... ``` (multilinea DOTALL) + 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[ \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[\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) Marker PASS2 standalone (se il wrapper non è completo o frammentato) 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) Titolo MD "### Resultado de la herramienta ApiPilot invocada" # residuo (se marker già stato rimosso prima) pass2_title_pattern = re.compile( r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+Resultado de la herramienta ApiPilot invocada[ \t]*(?:\r?\n|\r)?[ \t]*", re.IGNORECASE, ) text = pass2_title_pattern.sub("", text) # (1d) Blocco ```tool-output``` residuo standalone (senza wrapper PASS2) 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) Trailer instruction residuo standalone "Ahora debes reinterpretar..." # (intero o frammenti parziali che non hanno preso il pattern largo) pass2_trailer_pattern = re.compile( r"Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario\.", re.IGNORECASE | re.DOTALL, ) text = pass2_trailer_pattern.sub("", text) # (1e-bis) Safety più largo: "Ahora debes reinterpretar" → qualsiasi cosa # fino a fine frase "al usuario." (anche troncata nel mezzo) — questo # gestisce i casi edge dove il pattern DOTALL non ha chiuso. pass2_trailer_fallback = re.compile( r"Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)", re.IGNORECASE | re.DOTALL, ) text = pass2_trailer_fallback.sub("", text) # (1e-tris) Safety ancora più largo: frammento trailer tipo # "en lenguaje natural claro y conciso ... contenido final al usuario" # (casi in cui Ahora debes... è stato consumato fuori e rimane residuo). pass2_trailer_broad = re.compile( r"en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario\.?", re.IGNORECASE | re.DOTALL, ) text = pass2_trailer_broad.sub("", text) # ================================================================ # BLOCCO 2: PASS-1 CALL marker (placeholder spagnolo, come oggi) # ================================================================ 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: """Se l'intera riga è solo il marker → placeholder. Se inline → placeholder circondato da spazi.""" return placeholder text = line_wrapped_pattern.sub(_replacement_full, text) text = marker_full_pattern.sub(_replacement_full, text) # Safety extra: marker troncato CALL tipo <<\n]{0,500}?)?>>*", re.IGNORECASE, ) text = trunc_call_pattern.sub(_replacement_full, text) # Safety extra: marker troncato RESULT tipo <<` FINALI opzionali (+ opzionali `>` dopo) # così gestisce anche casi TTS audio troncato a metà stringa # (es. "<<>>) trunc_result_pattern = re.compile( r"<<?>?>?", re.IGNORECASE, ) text = trunc_result_pattern.sub("", text) # (Safety più largo: se rimane frammento APIPILOT_RESULT senza <<< # per casi TTS/sanitize frammentati, cancelliamo anche quello # MA SOLO se non è contenuto in testo normale - regex su "APIPILOT_RESULT" # sarebbe troppo aggressivo → solo con prefisso "<" o "<<<" residuo) trunc_result_residual = re.compile( r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}", re.IGNORECASE, ) text = trunc_result_residual.sub("", text) # Collapse final whitespace (non vogliamo spazi multipli / newline vuoti # lasciati dalla rimozione di blocchi PASS-2) text = re.sub(r"[ \t]{2,}", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) # Safety cleanup: frammenti residuali super-comuni del trailer PASS-2 # tipo "(arriba)" o parentesi vuote (non hanno senso pronunciati da TTS) text = re.sub(r"\s*\(arriba\)\s*", " ", text, flags=re.IGNORECASE) text = re.sub(r"\(\s*\)", "", text) # ================================================================ # BLOCCO 3: SANITIZZAZIONE PATH FILE SYSTEM (hallucination / leak TTS) # Qualsiasi path assoluto misto o Windows/Unix contenente /home, # \Users, .trae-server, workspaceStorage, .txt locale, unità C:\ # NON deve MAI arrivare alla TTS (sono leak host). # Strategia: matcha path lungo 20+ chars con almeno 3 separatori. # ================================================================ # (3a) Path Unix-like assoluti: /home/... /tmp/... /var/... /root/... /opt/... # + qualsiasi path con >2 slash consecutivi tipo .../xyz/abc/def.ext 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"|" # Fallback larghissimo: qualsiasi path ASSOLUTO Unix (inizia con / + cartella nota) # con len>=20 chars, anche senza estensione — evitiamo falsi positivi tipo "/api/v1" # ma catturiamo /home/duxiter/..., /tmp/prova, /var/log/... etc. 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) Path Windows-like con backslash singoli o doppi: \Users\... C:\... # e misti come /home\duxiter\.trae-server\... (hallucination mix) 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) Hallucination folder markers specifici di TRAE (se arrivano frammenti # senza path completo, rimuoviamo anche i token chiave) 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) Safety: qualsiasi token lungo > 80 chars che contenga BOTH (/) e (\) # o più di 5 slash/backslash ravvicinati → 100% path leak → rimuovi 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) # ================================================================ # BLOCCO 4: SANITIZZAZIONE FRAGMENT-BASED APIPILOT CALL (anti-TTS leak) # Quando il pipeline upstream (start_pipeline_rag / Qwen3-TTS) # normalizza il marker RIMUOVENDO <<< >>>, il TTS pronuncia letteralmente: # "APIPILOT CALL name get_user_briefing arguments phone +56912345678" # Questi pattern NON sono più catturati dai marker <<<...>>> del Blocco 2. # Quindi catturiamo FRAMMENTI con regole di contesto, 5 tier fallback. # ================================================================ # Tier 1 - Forma normalizzata completa (senza graffe): # APIPILOT CALL name arguments 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 - Forme abbreviate (qualsiasi contesto con token APIPILOT_CALL o APIPILOT + CALL vicini) 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 - Singoli token di servizio che non devono MAI comparire soli in # output assistant (se lo fanno → 99% leak marker normalizzato): # ARGUMENTS=... o APIPILOT solitario in contesto "call / name / argument" 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 - Safety catch-all: se dopo i blocchi 1/2/3/4 rimane la # sequenza "APIPILOT" (non APIClient o simili) in un contesto che ha # "name", "arguments", "tool", "herramienta", "phone", "+56" o "+44" # nella stessa frase → rimuoviamo l'intera frase. def _apipilot_frase(m): frase = m.group(0) lowered = frase.lower() # Se già contiene il placeholder spagnolo di sanitize CALL → Tier1/2/5 # hanno già sostituito il marker. Non cancellare la frase intera # (altrimenti perdiamo testo naturale "prima/dopo" attorno). if "voy a consultar tus datos" in lowered: return frase 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 frase catchall_frase = re.compile( r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}", re.IGNORECASE, ) text = catchall_frase.sub(_apipilot_frase, text) # Tier 5 - Safety ultimo: se rimane "APIPILOT CALL" o "APIPILOT_CALL" # anche senza contesto → placeholder (non si pronuncia mai) final_apipilot_leak = re.compile( r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b", re.IGNORECASE, ) text = final_apipilot_leak.sub(placeholder, text) # Tier 6 - Phone blacklist safety (placeholder fake e/o phone reale leaks): # Quando APIPILOT CALL viene catturato ma la parte "phone" e' # residua, rimuoviamo i numeri di telefono finti/placeholder che # NON devono mai comparire in TTS (sono interni al motore). # N.B. Le regex matchano SIA forma compatta (+56912345678) SIA spaziata # (+56 9 1 2 3 4 5 6 7 8) per tolleranza alla normalizzazione TTS. fake_phone_pattern = re.compile( r"(" # Forme COMPATTE (nessuno spazio): r"\+56912345678" r"|\+1234567890" r"|\+447432477433" r"|56912345678" r"|1234567890" r"|447432477433" # Forme SPAZIATE / CON SEGNI: 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" # Forme GENERICHE con etichetta (phone/teléfono): 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, ) # Applichiamo lo strip SOLO se nella frase (stessa riga / stessa frase) # e' presente "APIPILOT", "CALL", "ARGUMENTS", "marker", "herramienta", # "función", "ejecutar", "llamada", "marcador", "señal" # OPPURE se c'e' il placeholder spagnolo (Voy a consultar tus datos...) # perche' vuol dire che abbiamo appena sostituito un marker CALL in # questa stessa frase, quindi il phone residuo appartiene a quel contesto. 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 finale (dopo strip di marker/path si creano buchi) 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 tool discovery (<= TTL cache, force_refresh=True se tool non trovato) 2. requires_auth check: blocco con HTTP 401 se true ma nessun token 3. Strict JSON-Schema validation: filtra params extra, controlla required Il payload di ritorno è SEMPRE la busta standard `{success, data, error}` della guida §2.2. In caso di errori PRE-CHECK (network discovery, requires_auth, validazione arg) la busta ha `success=false` e `error.{code,message,status_code}` popolati. """ 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: # Tool sconosciuto: forza refresh discovery e riprova (max 1). logger.info("[APIPILOT-EXEC] tool=%s non 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 bloccato (nessun 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: validazione argomenti strict JSON-Schema ───────────────── 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 e S2S_APIPILOT_TOOLS_EXECUTE_URL entrambe vuote).", "status_code": 501, "raw": None, }, } headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"} # 2026-09-25 SUPER API KEY (PRIORITÀ ASSOLUTA 🔴): bypass autenticazione AquaPortal. # Tutti e 3 le forme contemporaneamente per max compatibilità: # - X-API-Key: # - Authorization: Bearer # - x-demo-session: # Qualsiasi altro auth (service token, login_by_phone) viene completamente # ignorato quando la API KEY è valorizzata. 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: # 2026-09-25 Priorità auth: 1) S2S_APIPILOT_SERVICE_TOKEN (override max .env), # 2) auth_header (Bearer AquaPortal da _login_by_phone, da WS connect DISCOVERY # o fallback PASS-2 login), 3) nessun auth. 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}, } # Tentativo di deserializzare come busta standard. Se il backend risponde # 5xx o body non-JSON, costruisce una envelope errore coerente. raw_text = resp.text # 2026-09-25 Diagnostica Dettagliata ALWAYS-ON se http >= 400 (401/403/404/5xx) # Log: payload request (name + arguments RAW completo demo session NON mascherato # response http status code, raw response body full (max 2000 chars). 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, }, } # Caso: envelope standard `{success,data,error}` — ritorno as-is. 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 # Caso: backend ha restituito data pura (senza envelope). Wrappa come # success=true (§2.2 della guida: envelope obbligatorio — se non lo # rispetta noi normalizziamo lato proxy per stabilità downstream). logger.warning("[APIPILOT-EXEC] backend non ha restituito envelope standard → wrap manuale 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 (modalità NODE_RED). # Es.: http://localhost:1881 oppure http://s2s-reasoning:1880 (rete docker) 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, ALL LB logic is disabled (no /api/session proxy, no queue, # no limiter, no sign-in) and the browser connects to this URL directly. Unlike # the LB address it is NOT a secret — /api/config sends it to the client, which # shows it read-only in Settings. SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip() # Optional same-origin WebSocket relay target. When set, the browser dials this # app over ws(s):///v1/realtime and the server relays frames to the # upstream speech-to-speech endpoint, keeping the internal host private. S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip() if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET: LOAD_BALANCER_URL = "" # HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent # locally and on a plain `docker run`. We meter conversation time ONLY on the # deployed Space — i.e. when BOTH the LB is configured AND we're on a Space. # Off-Space (local dev, even with the LB exported) the app still proxies the LB, # but nothing is metered: no budget, no reservations, no sign-in gating. SPACE_ID = os.environ.get("SPACE_ID", "").strip() LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID) SERPER_URL = "https://google.serper.dev/search" # Cap results so the tool output stays small enough to feed back to the model. MAX_RESULTS = 5 HERE = os.path.dirname(os.path.abspath(__file__)) app = FastAPI(title="s2s-demo") # Wire HF OAuth before the app serves (no-op unless the OAuth env is present). # Sign-in only matters when we're metering (prod Space), so gate it on that. AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app) # Calcolo dell'URL execute risolto (serve per STARTUP banner e _apipilot_resolve_execute_url). # Nota: la funzione formale _apipilot_resolve_execute_url è definita più avanti (dopo # _apipilot_parse_authorization) e usa la stessa identica logica; la ripetiamo qui # perché il banner STARTUP viene stampato PRIMA della funzione. def _resolve_exec_url_for_startup_banner(): base = (S2S_APIPILOT_TOOLS_EXECUTE_URL or S2S_APIPILOT_TOOLS_URL).strip().rstrip("/") if not base: return "" if base.lower().endswith("/execute"): return base return f"{base}/execute" _STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner() del _resolve_exec_url_for_startup_banner logger.info( "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s auth=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s", S2S_REASONING_TYPE, NODE_RED_REASONING_URL or "-", bool(SERPER_KEY), bool(LOAD_BALANCER_URL), bool(S2S_PROXY_TARGET), bool(SPEECH_TO_SPEECH_URL), bool(VOCERO_USER_PHONE_AUTH_URL), bool(S2S_REPORT_LIST), S2S_INJECT_REPORTS, S2S_REPORTS_QUICK_HINT_MAX, S2S_REPORTS_INJECT_MODE, S2S_INJECT_TODAY, bool(S2S_LATEST_SUMMARY), bool(S2S_LATEST_SUMMARY_TEXT), bool(S2S_LOGIN_BY_PHONE), S2S_INJECT_PROFILE, S2S_DEBUG_PROMPT, S2S_START_SESSION_MODE or "-", S2S_THEME or "-", AUTH_ENABLED, S2S_APIPILOT_TOOLS_URL or "-", (S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(risolto:{_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 FORME!)") if S2S_APIPILOT_API_KEY else "-", ) del _STARTUP_APIPILOT_EXEC_URL logger.debug( "[STARTUP] Runtime config — SPACE_ID=%s LIMITER_ENABLED=%s MAX_RESULTS=%d", bool(SPACE_ID), LIMITER_ENABLED, MAX_RESULTS, ) async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None: return def _realtime_event_summary(raw: str) -> dict | None: try: payload = json.loads(raw) except Exception: return None if not isinstance(payload, dict): return None event_type = payload.get("type") if not isinstance(event_type, str): return None summary = {"type": event_type} if event_type == "session.update": session = payload.get("session") if isinstance(payload.get("session"), dict) else {} tools = session.get("tools") if isinstance(session.get("tools"), list) else [] summary["toolCount"] = len(tools) summary["toolNames"] = [tool.get("name") for tool in tools if isinstance(tool, dict)] summary["toolChoice"] = session.get("tool_choice") summary["instructionLength"] = len(session.get("instructions") or "") elif event_type in {"response.function_call_arguments.done", "error", "session.updated"}: for key in ("name", "call_id", "code", "message", "response_id"): if key in payload: summary[key] = payload.get(key) # --- INPUT (user speech → STT transcript) --- # Can arrive from upstream (server-side STT) — match either direction. elif event_type == "conversation.item.input_audio_transcription.completed": summary["transcript"] = payload.get("transcript", "") summary["item_id"] = payload.get("item_id") elif event_type == "conversation.item.created": item = payload.get("item") if isinstance(payload.get("item"), dict) else None if isinstance(item, dict): summary["item_id"] = item.get("id") summary["role"] = item.get("role") summary["item_type"] = item.get("type") summary["status"] = item.get("status") content_parts = [] for c in (item.get("content") or []): if isinstance(c, dict): if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str): content_parts.append(c["text"]) elif c.get("type") == "transcript" and isinstance(c.get("transcript"), str): content_parts.append(c["transcript"]) if content_parts: summary["text"] = " ".join(content_parts) # --- OUTPUT (agent text/speech generation) --- elif event_type == "response.output_item.done": item = payload.get("item") if isinstance(payload.get("item"), dict) else None if isinstance(item, dict): summary["item_id"] = item.get("id") summary["role"] = item.get("role") summary["status"] = item.get("status") content_parts = [] for c in (item.get("content") or []): if isinstance(c, dict): if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"]: content_parts.append(c["text"]) elif c.get("type") == "audio" and isinstance(c.get("transcript"), str) and c["transcript"]: content_parts.append(c["transcript"]) if content_parts: summary["text"] = " ".join(content_parts) elif event_type == "response.content_part.done": part = payload.get("part") if isinstance(payload.get("part"), dict) else None if isinstance(part, dict): summary["response_id"] = payload.get("response_id") summary["item_id"] = payload.get("item_id") summary["output_index"] = payload.get("output_index") summary["content_index"] = payload.get("content_index") if part.get("type") == "text": summary["text"] = part.get("text", "") elif part.get("type") == "audio": if isinstance(part.get("transcript"), str) and part["transcript"]: summary["text"] = part["transcript"] # Variante standard OpenAI: 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", "") # Variante upstream custom (osservato nel log dell'utente): 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 # Se text ancora vuoto, cerca all'interno di eventuali sotto-oggetti noti 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: # Ultima risorsa: 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 # Se non abbiamo ancora trovato testo, salva un'anteprima del raw # per capire dove lo sta emettendo l'upstream custom. 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") # Questo evento è il KEY CHECK della Strategia B: se lo vediamo = # ai-server ha accettato tools[] nativi (native toolcall detected). _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 ha emesso 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 # Eventi realtime ad alta frequenza da silenziare nel log dettagliato (rumore). # Essi vengono comunque tracciati dal report_server_debug interno se abilitato. _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", }) # Eventi realtime silenziati anche quando arrivano dal lato cliente (input_audio_buffer.append ecc.). _NOISY_CLIENT_EVENT_TYPES = frozenset({ "input_audio_buffer.append", "input_audio_buffer.commit", "input_audio_buffer.clear", "input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped", }) @app.on_event("startup") async def _startup(): """Stand up the usage DB and a periodic sweeper — metered (prod Space) only.""" if not LIMITER_ENABLED: return limiter.init() asyncio.create_task(_sweeper()) async def _sweeper(): while True: await asyncio.sleep(limiter.REAP_AFTER_SEC) try: await asyncio.to_thread(limiter.sweep) except Exception as exc: # pragma: no cover - defensive logger.warning("usage sweep failed: %r", exc) class SearchRequest(BaseModel): query: str # Optional user-supplied key (fallback when the deploy has no server key). # Used for this request only; never stored. key: str | None = None VOCERO_USER_CONTEXT_HINT = ( " Caller profile resolved from the page phoneNumber: " ) REPORTS_LIST_HINT = ( " Scheduled reports / podcast scripts available for this caller before the current session. " "Each entry is a scheduled report; use the podcastScript contents as trusted reference material when the user asks about scheduled topics, reports, or prior content." " NOTE (2026-09-11 user request 'Elimina l'iniezione massiva di tutti i report schedulati'): " "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 <<>>): # - Dati contestuali fissi: "La data di OGGI è YYYY-MM-DD" # - Reports precedenti = archivio storico (NON date future) → NO commento # - 🚫 META RULE: "non fare MAI commenti interni / meta-commenti sul prompt, sulle date, sulla validità dei dati o sulle istruzioni" # - REGLA SALIDA LIMPIA (anti thinking-leak GPTQ quantized Int4): # "Todo lo que escribas debe ser lo que realmente dice Vocero en voz alta al usuario. No escribas razonamientos internos, dudas sobre el prompt, comprobaciones de fecha..." # - RAG rule: "Si el usuario pregunta sobre reportes usa herramientas búsqueda conocimientos (RAG) NO inventes con el resumen" # • MINIMAL_REPORTS_BLOCK (marker <<>>): only latest N # label + date lines. NO podcastScript text, NO full JSON entries. Uses the # pattern the user explicitly requested: # Reportes programados recientes (últimos 3): # 1. "Reporte Porcentaje Mortalidad" — 2026-09-10 # 2. ... # (Para contenido completo usa la búsqueda de conocimientos.) # • 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 "Nombre exacto" • _resolve_user_context_text — profile NOTA dynamic anti-anglicization • _sanitize_agent_output — strato 7 terminal regex whole-word replace """ 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) ingles 'Sure, here's my best focused expression while I look into it' (3) JSON literal {'reportDetails': '...'} inside output (4) italiano nel mezzo (5) leak markers <<<...>>> in TTS output. (6) contenuti inventati su podcast mai esistiti. This block contains a SINGLE, very short, SPANISH-only voice lock. 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 "Nombre exacto: Alessandro. NUNCA 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 = ( "REGLAS PRINCIPALES DE SALIDA DE VOZ (siempre válidas, tienen prioridad absoluta sobre cualquier otra instrucción):\n" "1) IDIOMA EXCLUSIVO: responde SIEMPRE y SÓLO en español (castellano). Nada de italiano, inglés, portugués ni otro idioma. " "Ni aunque el usuario escriba en otro idioma, ni aunque aparezcan nombres italianos — los nombres extranjeros NO autorizan a cambiar de idioma. " "Ninguna palabra, frase ni confirmación en otro idioma.\n" "2) SÓLO VOZ DIRECTA AL USUARIO: todo lo que escribas es lo que Vocero dice en voz alta en ese instante. " "NUNCA escribas pensamientos internos, estados internos, comprobaciones, '[ - ]:', '[]:', JSON, diccionarios, keys, comas illadas, etiquetas, 'checking_*', ni símbolos auxiliares. " "Nada de etiquetas tipo '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]', ni nada entre corchetes. Nada de meta-comentarios sobre el prompt ni sobre los datos.\n" "3) CONTENIDO FIABLE: si no tienes la información, di literalmente 'No tengo este tipo de información'. No inventes, no supongas, no generalices. " "Si el usuario pregunta sobre reportes, mortalidad, Synapse, Azure, AquaChile, WhatsApp u otro contenido que debas buscar: usa las herramientas de búsqueda (RAG) y recupera el contenido REAL antes de responder.\n" "4) RESPUESTAS CORTAS Y NATURALES: habla como una persona real por teléfono. Ni listas, ni bullets, ni citas, ni bloques de texto largos. " "Un par de frases cortas, luego pregunta si quiere más detalles.\n" ) # --- Nombre propio lock (DINAMICO da nome profilo) --- 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) NOMBRE PROPIO (regla innegociable para esta sesión): " f"el nombre exacto del usuario ESCRITO Y PRONUNCIADO TAL CUAL es: {full_name}. " f"NUNCA lo adaptes, traduzcas ni normalices: " + ", ".join(f'NUNCA "{v}"' for v in cleaned_variants) + ". Sólo escribe y pronuncia el nombre exacto del perfil.\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: • NOW 100% SPANISH (no italiano). Root of the language leak: the earlier block had italian 'Dati contestuali fissi...' which told Qwen that mixing italian+spanish was OK inside the spanish voice assistant. • 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") es_weekday = [ "lunes","martes","miércoles","jueves","viernes","sábado","domingo", ][today.weekday()] body = ( f"Fecha de hoy (referencia innegociable): {iso_date} ({es_weekday}).\n" f"Los reportes con fechas ANTERIORES a hoy son ARCHIVO HISTÓRICO, NO son fechas futuras. No comentes nada al respecto.\n" "El punto '.' en números separa decimales; la coma ',' separa miles (formato internacional estándar en este sistema).\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"Reportes programados recientes (últimos {len(items)}):" ) for idx, r in enumerate(items, start=1): label = (str(r.get("sourceScheduleLabel") or "").strip() or "(sin título)") 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( "Para el contenido completo de cualquier reporte usa SIEMPRE las herramientas de búsqueda (RAG). No inventes detalles que no hayas recuperado." ) return MINIMAL_REPORTS_MARKER + "\n" + "\n".join(lines) + "\n" # ────────────────────────────────────────────────────────────────────────── # User fix 2026-09-11 "Rimuovi il profilo chiamante DUPLICATO". # 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/luxemburgish names but our voice assistant is 100% castellano 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"Para el nombre '{name_part}': NUNCA escribas ni pronuncies {quoted} ni versiones similares en inglés/francés/ruso. " f"Sólo '{name_part}' tal cual está." ) if forbidden_lines: nombre_propio_note = ( "NOMBRE PROPIO (regla innegociable para esta sesión): " + " ".join(forbidden_lines) + " " ) else: # fallback to generic example so the rule's existence is still taught nombre_propio_note = ( "NOMBRE PROPIO: nunca adaptes, traduzcas ni 'normalices' nombres propios " "(ej. nunca pases 'Alessandro' a 'Alexander'). Escribe y pronuncia sólo el nombre exacto del perfil. " ) # 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. " "NOTA IMPORTANTE (regla de idioma): aunque el usuario tenga nombre italiano (p. ej. 'Alessandro Valenti') " "o nombre en cualquier otro idioma, SÓLO debes responderle en ESPAÑOL (castellano). " "Los nombres extranjeros NO autorizan a cambiar de idioma. " "Nunca escribas frases, palabras, preguntas ni confirmaciones en italiano, inglés ni ningún otro idioma. " "Siempre habla como Vocero: voz española directa, sin prefijos, sin JSON, sin meta-comentarios. " f"{nombre_propio_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 "Elimina l'iniezione massiva di tutti i report schedulati": • 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"))) # USER FIX 2026-09-11 "riduci il quick hint report max 3-5 configurabile da .env": # 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 user-requested Reportes programados recientes 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 "Elimina l'iniezione massiva di tutti i report schedulati": • 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 ) # 2026-09-25 Diagnostica SEMPRE-ATTIVA login token (anche senza S2S_DEBUG_PROMPT) # per verificare token length (JWT > 150 chars), anteprima primi 8 chars. # Chiavi response + unwrap usato. _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 "Rimuovi il profilo chiamante DUPLICATO": adds TWO dedupe 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 "Nombre exacto: Alessandro Valenti. NUNCA 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 (spanish-only rewrite) 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 (lista strumenti + regole marker) SE presente, # DOPO profile/reports/episode/summary (struttura identica a /api/reasoning). # # 2026-09-25 FIX "client manda DUE session.update e il secondo SOVRASCRIVE instructions" # → dopo augment/profile controlliamo anche: le istruzioni CONTENGONO gia' il marker # <<>> ? SE NO e abbiamo injected_apipilot_pass1 → riappendiamo. 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 "") ) # Ora gate "unchanged": SE augmented == current MA c'è differenza apipilot per il post-patch # del client (sovrascrittura instructions senza marker) → NON skippare. if augmented == current_instructions and not (has_apipilot_pass1 and not already_has_apipilot_pass1): logger.info( "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; " "episode/profile already injected or context empty or apipilot same)" ) if S2S_DEBUG_PROMPT and augmented.strip(): logger.info( "[DEBUG-PROMPT][WS session.update] FINAL unchanged (len=%d):\n%s\n", len(augmented), augmented, ) return raw_text top_session["instructions"] = augmented logger.info( "[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. " "origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s apipilotPass1=%s(len=%d, wasAlreadyPresent=%s)", len(current_instructions), len(augmented), (len(augmented) - len(current_instructions)), has_episode, has_profile, has_reports, has_summary, has_apipilot_pass1, (len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0), ("true" if already_has_apipilot_pass1 else "false"), ) if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][WS session.update] AFTER patch (len=%d):\n%s\n", len(augmented), augmented, ) if S2S_DEBUG_PROMPT and episode_context_text and episode_context_text.strip(): logger.info( "[DEBUG-EPISODE][WS session.update] FULL podcast episode block (len=%d):\n%s\n", len(episode_context_text.strip()), episode_context_text.strip(), ) if S2S_DEBUG_PROMPT and reports_context_text and reports_context_text.strip(): logger.info( "[DEBUG-REPORTS][WS session.update] FULL reports list block (len=%d):\n%s\n", len(reports_context_text.strip()), reports_context_text.strip(), ) if S2S_DEBUG_PROMPT and isinstance(apipilot_context_text_pass1, str) and apipilot_context_text_pass1.strip(): logger.info( "[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=%d):\n%s\n", len(apipilot_context_text_pass1.strip()), _strip_internal_markers(apipilot_context_text_pass1), ) # Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and # this is a session.update (so we see the EXACT text reaching the model even # when S2S_INJECT_PROFILE is off and no patch happened.) if (not S2S_INJECT_PROFILE) and S2S_DEBUG_PROMPT and current_instructions.strip(): logger.info( "[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n", len(current_instructions), current_instructions, ) try: return json.dumps(payload) except Exception: logger.info( "[INJECT-PROFILE] ⛔ JSON re-serialize failed on session.update. " "Forwarding unpatched." ) return raw_text class ReasoningRequest(BaseModel): transcript: str instructions: str = "" language: str = "" user_context: str = "" # Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches # the caller profile from VOCERO_USER_PHONE_AUTH_URL and: # 1. prepends the serialized profile to user_context before feeding the # engine (NODE_RED or OPENAI); # 2. appends the profile context to the LLM instructions prompt. phoneNumber: str | None = None # Optional podcast episode ID. When set the proxy fetches the episode from # the Aquachile AzurianLab API and injects it as the PRIMARY context into # both user_context (prepended) and LLM instructions prompt (appended first). episode_id: str | None = None def _url_with_query(url: str, **params: str) -> str: target = urlsplit(url) merged_query = dict(parse_qsl(target.query, keep_blank_values=True)) for key, value in params.items(): if value: merged_query[key] = value return urlunsplit( ( target.scheme, target.netloc, target.path, urlencode(merged_query, doseq=True), target.fragment, ) ) def _openai_headers() -> dict[str, str]: if not OPENAI_API_KEY: raise RuntimeError("OPENAI_API_KEY is not configured.") headers = { "Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json", } if OPENAI_ORGANIZATION: headers["OpenAI-Organization"] = OPENAI_ORGANIZATION if OPENAI_PROJECT: headers["OpenAI-Project"] = OPENAI_PROJECT return headers def _effective_s2s_url(request: Request) -> str: return SPEECH_TO_SPEECH_URL or _proxy_public_url(request) @app.get("/api/config") async def config(request: Request): """Client bootstrap: whether web search is available, whether the deploy runs behind a load balancer (so the browser uses the /api/session proxy + limiter), whether HF sign-in is available, and whether the user may instead set a direct s2s server URL. The LB address itself is intentionally NOT included. 2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see GUIDA_APIPILOT_CLIENTE.md) that the browser may call directly instead of hardcoding upstream tool URLs / names.""" logger.debug("[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?") primary_prompt = S2S_PRIMARY_PROMPT out = { "search": bool(SERPER_KEY), "lb": bool(LOAD_BALANCER_URL), "allowDirect": not LOAD_BALANCER_URL, "s2sUrl": _effective_s2s_url(request), "primaryPrompt": primary_prompt, "reasoningType": S2S_REASONING_TYPE, "auth": AUTH_ENABLED, "injectProfile": S2S_INJECT_PROFILE, "debugPrompt": S2S_DEBUG_PROMPT, "startSessionMode": S2S_START_SESSION_MODE, "theme": S2S_THEME, "apipilot": bool(S2S_APIPILOT_TOOLS_URL), } logger.debug( "[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s", len(primary_prompt), S2S_REASONING_TYPE, S2S_START_SESSION_MODE or "-", S2S_THEME or "-", out["apipilot"], ) return out class ApipilotExecuteRequest(BaseModel): """Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape.""" name: str arguments: dict = {} @app.get("/api/apipilot/tools") async def apipilot_list_tools( request: Request, include_unauthorized: bool = True, refresh: bool = False, ): """Public proxy for ApiPilot tool discovery (§2.1 della guida). 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 (§2.2 della guida). 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}`. `error.message` è SEMPRE l'unico campo da mostrare all'utente finale; i campi `error.code`, `error.status_code`, `error.raw` sono solo per audit interno (vedi §2.2 "Comportamento UI consigliato sugli errori").""" if not S2S_APIPILOT_TOOLS_URL: return JSONResponse( status_code=501, content={ "success": False, "data": None, "error": { "code": "APIPILOT_NOT_CONFIGURED", "message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", "status_code": 501, "raw": None, }, }, ) auth, demo = _apipilot_parse_authorization(request) logger.info("[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s", (body.name or ""), sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [], ("Y" if auth or demo else "none")) envelope = await _apipilot_execute_tool( auth_header=auth, demo_session=demo, name=body.name, arguments=body.arguments if isinstance(body.arguments, dict) else {}, ) status_code = 200 if (not envelope.get("success")) and isinstance(envelope.get("error"), dict): code = envelope["error"].get("status_code") try: code_int = int(code) if 400 <= code_int < 599: status_code = code_int except Exception: pass logger.info( "[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s", body.name, envelope.get("success"), status_code, ) return JSONResponse(status_code=status_code, content=envelope) @app.post("/api/reasoning") async def reasoning(req: ReasoningRequest): if S2S_REASONING_TYPE == "PROXY": raise HTTPException(status_code=409, detail="Reasoning endpoint is disabled in PROXY mode.") transcript = (req.transcript or "").strip() if not transcript: raise HTTPException(status_code=400, detail="transcript is required.") # ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ── # (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context # and instructions are taken verbatim from the request body.) inject_episode_text = "" inject_profile_text = "" inject_reports_text = "" inject_summary_text = "" phone = (req.phoneNumber or "").strip() episode_id = (req.episode_id or "").strip() if not S2S_INJECT_PROFILE: logger.info( "[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). " "Using verbatim request body; no fetch, no augmentation." ) else: needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip()) # USER FIX 2026-09-11 "Elimina l'iniezione massiva di tutti i report schedulati": # treats S2S_REPORT_LIST as "need to fetch reports" ONLY when BOTH the URL is # configured AND the new opt-in S2S_INJECT_REPORTS flag is true. When # S2S_INJECT_REPORTS=false (default) needs_reports stays False here → we skip # the branch entirely, no HTTP call, no context string, no prompt bloat. needs_reports = bool(S2S_REPORT_LIST.strip()) and S2S_INJECT_REPORTS needs_summary = bool(S2S_LATEST_SUMMARY.strip()) or bool(S2S_LATEST_SUMMARY_TEXT) needs_login = bool(S2S_LOGIN_BY_PHONE.strip()) can_inject = ( needs_phone_authed or needs_reports or needs_summary or needs_login or bool(episode_id) ) if (not needs_phone_authed) and (not needs_reports) and (not needs_summary) and (not needs_login) and (not episode_id): logger.info( "[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY/S2S_LATEST_SUMMARY_TEXT and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request; OR S2S_INJECT_REPORTS is false so reports injection is globally disabled). " "Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable login / fetch episode/profile/reports/summary for REASONING endpoint." ) elif (not phone) and (not needs_reports) and (not episode_id) and (not S2S_LATEST_SUMMARY_TEXT): logger.info( "[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). " "Browser-side issue: check main.js passes voceroUserPhoneNumber and/or episode_id in JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)" ) elif not can_inject: logger.info( "[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)." ) else: # ################################################################### # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env set) # Obtains a bearer token that will be forwarded for profile/reports/summary fetches. # ################################################################### login_token = None if S2S_LOGIN_BY_PHONE and phone: login_token, _login_resp = await _login_by_phone(phone) # ################################################################### # 0.5) Podcast episode fetch (independent of phone — uses req.episode_id) # ################################################################### if episode_id: if S2S_DEBUG_PROMPT: logger.info( "[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...", episode_id, ) episode_raw = await _fetch_podcast_episode(episode_id) inject_episode_text = _resolve_episode_context(episode_raw) if inject_episode_text.strip(): logger.info( "[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", episode_id, len(inject_episode_text), ) else: logger.info( "[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)." ) # ################################################################### # 1) Profile fetch (optional — only if env is configured) # ################################################################### if needs_phone_authed and phone: profile_obj = await _fetch_user_profile_for_phone(phone, auth_token=login_token) inject_profile_text = _resolve_user_context_text(phone, profile_obj, loaded=True) if inject_profile_text.strip(): logger.info( "[INJECT-PROFILE] ✅ REASONING: profile READY phoneLen=%d contextLen=%d -> augmenting instructions + user_context before sending upstream.", len(phone), len(inject_profile_text), ) else: logger.info( "[INJECT-PROFILE] ⏭️ REASONING: profile fetch OK but resolved context empty." ) # ################################################################### # 1.5) Scheduled reports / podcast scripts list (optional — S2S_REPORT_LIST) # ################################################################### if needs_reports: if S2S_DEBUG_PROMPT: logger.info( "[INJECT-REPORTS] starting REASONING fetch S2S_REPORT_LIST (phoneLen=%s, bearer=%s)...", len(phone) if phone else "none", "yes" if login_token else "no", ) reports_raw = await _fetch_reports_list(phone_number=phone if phone else None, auth_token=login_token) inject_reports_text = _resolve_reports_context(reports_raw) if inject_reports_text.strip(): logger.info( "[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", len(inject_reports_text), ) else: logger.info( "[INJECT-REPORTS] ⏭️ REASONING: reports list skipped (context empty / upstream returned empty list / fetch failed)." ) # ################################################################### # 2) Latest summary fetch (optional — only if env is configured) # ################################################################### if needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT): if S2S_DEBUG_PROMPT: logger.info( "[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s.", bool(S2S_LATEST_SUMMARY), bool(S2S_LATEST_SUMMARY_TEXT), len(phone) if phone else "none", ) latest_summary_raw = await _fetch_latest_summary_for_phone(phone, auth_token=login_token) inject_summary_text = _resolve_latest_summary_context(latest_summary_raw) if inject_summary_text.strip(): logger.info( "[INJECT-SUMMARY] ✅ REASONING: latest summary READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", len(phone) if phone else "none", bool(S2S_LATEST_SUMMARY_TEXT), len(inject_summary_text), ) else: logger.info( "[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)" ) # ── APIPILOT: carica strumenti dinamici PRIMA della costruzione context # (gate globale: funziona sia con che senza 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}" # 2026-09-25 IMPORTANTE: S2S_LOGIN_BY_PHONE = aquachile.azurianlab.com AUTH PHONE LOGIN # → login_token È IL TOKEN JWT AQUAPORTAL VERO (non Vocero). # Lo usiamo SEMPRE per tool requires_auth=true (necessario per eseguire /execute). # S2S_APIPILOT_SERVICE_TOKEN se presente → OVERRIDE massima priorità. 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: lista strumenti pronta 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: fetch lista strumenti fallita, ignoro: %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 lista strumenti (len=%d, Ntools=%d) IN instructions (oltre 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 lista strumenti (len=%d, Ntools=%d) IN 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), ) # ──────────────────────────────────────────────────────────────────── # Modalità A: NODE_RED — inoltra TUTTO il payload a Node-RED # Il flow Node-RED decide autonomamente. # FASE 1 (LOG-ONLY): Node-RED risponde con {answer: "", log_only: true} # => il client (main.js) NON interferisce, lascia parlare il backend S2S. # FASI successive: Node-RED produce {answer: "...", log_only: false} # Risposta attesa: {answer, log_only?, usage, debug, latency_ms, …} # ──────────────────────────────────────────────────────────────────── if S2S_REASONING_TYPE == "NODE_RED": if not NODE_RED_REASONING_URL: raise HTTPException( status_code=503, detail="S2S_REASONING_NODE_RED_URL is not configured. Set the env var to the Node-RED endpoint (e.g. http://localhost:1881).", ) logger.info( "[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", len(effective_instructions), len(effective_user_context), len(phone), len(episode_id), ) upstream_body = { "transcript": transcript, "instructions": effective_instructions, "language": (req.language or "").strip(), "user_context": effective_user_context, } if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n", json.dumps(upstream_body, ensure_ascii=False, indent=2), ) logger.debug( "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d", NODE_RED_REASONING_URL, (req.language or "")[:20] or "auto", len(transcript), ) try: async with httpx.AsyncClient(timeout=120.0) as http: resp = await http.post( f"{NODE_RED_REASONING_URL}/api/reasoning", json=upstream_body, headers={ "Content-Type": "application/json", "X-S2S-Caller": "demo-server", }, ) except httpx.RequestError as exc: logger.warning("[EXT][NODE_RED] unreachable: %r", exc) raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable.") logger.debug( "[EXT][NODE_RED] reasoning RESP status=%s len=%dB", resp.status_code, len(resp.content or b""), ) if resp.status_code != 200: logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500]) raise HTTPException( status_code=502, detail=f"Node-RED reasoning failed (HTTP {resp.status_code}).", ) try: payload = resp.json() except Exception: logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500]) raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON.") answer = payload.get("answer") if isinstance(payload, dict) else None log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True) if not isinstance(answer, str): logger.warning("[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800]) raise HTTPException(status_code=502, detail="Node-RED returned invalid answer field.") answer_stripped = answer.strip() # ── Log-only mode: answer vuota + flag log_only sono L'INTENZIONE ── # Non solleviamo errore; il flag viene inoltrato al client. if not answer_stripped and not log_only: logger.warning("[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]) raise HTTPException(status_code=502, detail="Node-RED returned no answer without log_only flag.") usage = payload.get("usage") if isinstance(payload, dict) else None if isinstance(usage, dict): logger.debug( "[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d", usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), ) extra = {} for key in ("latency_ms", "intent", "caller_profile_keys", "debug"): if isinstance(payload, dict) and key in payload: extra[key] = payload[key] if log_only: logger.info( "[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s", transcript[:300], json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], ) else: logger.info( "[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s", transcript[:300], answer_stripped[:500], json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], ) out = {"answer": answer_stripped} if log_only: out["log_only"] = True if usage: out["usage"] = usage out.update({k: v for k, v in extra.items() if k != "debug" or logger.level <= logging.DEBUG}) # ── APIPILOT: cattura marker → esegui tool → SECONDA PASSATA reasoning tool_name_p1, tool_args_p1 = _apipilot_parse_call_marker(answer_stripped) if tool_name_p1: logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s", tool_name_p1, sorted(list((tool_args_p1 or {}).keys()))) logger.info( "[APIPILOT] REASONING PASS-2 trigger NODE_RED marker name=%s args=%s", tool_name_p1, json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300], ) logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", tool_name_p1) tool_env_p1 = await _apipilot_execute_tool( auth_header=apipilot_auth_header, demo_session=None, name=tool_name_p1, arguments=tool_args_p1 or {}, ) is_tool_err = not bool(tool_env_p1.get("success")) tool_data = tool_env_p1.get("data") if isinstance(tool_data, str): tool_text_p1 = tool_data elif tool_data is None: tool_text_p1 = "" else: try: tool_text_p1 = json.dumps(tool_data, ensure_ascii=False, indent=2) except Exception: tool_text_p1 = str(tool_data) if is_tool_err and isinstance(tool_env_p1.get("error"), dict): err = tool_env_p1["error"] err_head = f"[{err.get('code')}] {err.get('message')}" tool_text_p1 = (err_head + ("\n" + tool_text_p1 if tool_text_p1.strip() else "")).strip() inject_apipilot_text_pass2 = _resolve_apipilot_reasoning_context_block( [], call_result={ "name": tool_name_p1, "text": tool_text_p1, "error": is_tool_err, }, ) logger.info( "[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger seconda chiamata Node-RED", tool_name_p1, str(not is_tool_err).lower(), len(tool_text_p1), ) # Build user_context 2nd pass: mette risultato tool ALL'INIZIO (voce # blocco oggi + regole vocali + profile ... ma SOSTITUISCE la lista # strumenti di passata 1 con il risultato della exec). 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) # Istruzioni per seconda passata: stesse augment, ma senza istruzioni # marker (tanto il blocco risultato dice come si rielabora). if S2S_INJECT_PROFILE: eff_inst_2 = _augment_instructions_with_profile( req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text, inject_episode_text, profile_obj=profile_obj, ) else: eff_inst_2 = (req.instructions or "").strip() if inject_apipilot_text_pass2.strip(): eff_inst_2 = ( (eff_inst_2 + "\n\n" if eff_inst_2 else "") + inject_apipilot_text_pass2 ) upstream_body_2 = { "transcript": transcript, "instructions": eff_inst_2, "language": (req.language or "").strip(), "user_context": effective_user_ctx_2, } if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][REASONING → NODE_RED PASS-2] FULL upstream_body_2 JSON:\n%s\n", json.dumps(upstream_body_2, ensure_ascii=False, indent=2), ) try: async with httpx.AsyncClient(timeout=120.0) as http2: resp2 = await http2.post( f"{NODE_RED_REASONING_URL}/api/reasoning", json=upstream_body_2, headers={ "Content-Type": "application/json", "X-S2S-Caller": "demo-server-pass2", }, ) except httpx.RequestError as exc: logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc) raise HTTPException(status_code=502, detail="Node-RED reasoning engine is unreachable (pass 2).") if resp2.status_code != 200: logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500]) raise HTTPException(status_code=502, detail=f"Node-RED reasoning pass 2 failed (HTTP {resp2.status_code}).") try: payload2 = resp2.json() except Exception: logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500]) raise HTTPException(status_code=502, detail="Node-RED returned invalid JSON (pass 2).") final_ans = payload2.get("answer") if isinstance(payload2, dict) else None if not isinstance(final_ans, str): # Fallback: primo messaggio + risultato raw logger.warning("[EXT][NODE_RED PASS-2] answer invalido, fallback a risposta raw text") final_ans = tool_text_p1 or answer_stripped final_stripped = final_ans.strip() # log_only SEMPRE False nella seconda passata (perché ha già la # risposta definitiva da trasmettere al canale vocale). 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) # Nessun marker → return passata 1 come design iniziale return JSONResponse(out) # ──────────────────────────────────────────────────────────────────── # Modalità B: OPENAI — chiamata diretta a OpenAI Chat Completions # ──────────────────────────────────────────────────────────────────── if not OPENAI_API_KEY: raise HTTPException(status_code=503, detail="OPENAI_API_KEY is not configured.") system_parts = [] instructions = effective_instructions or ((req.instructions or "").strip()) if instructions: system_parts.append(instructions) if effective_user_context.strip(): system_parts.append(f"User context:\n{effective_user_context.strip()}") if inject_apipilot_text_pass1: system_parts.append(inject_apipilot_text_pass1) if req.language.strip(): system_parts.append( "Reply only with the final assistant answer text. " f"Keep the answer natural and concise in this language: {req.language.strip()}." ) else: system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.") logger.info( "[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", len(system_parts), len(effective_instructions), len(effective_user_context), len(phone), len(episode_id), ) system_msg = "\n\n".join(system_parts) body = { "model": OPENAI_REASONING_MODEL, "messages": [ {"role": "system", "content": system_msg}, {"role": "user", "content": transcript}, ], } if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n", len(system_msg), system_msg, ) logger.info( "[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n", len(transcript), transcript, ) logger.debug( "[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s", OPENAI_BASE_URL, OPENAI_REASONING_MODEL, len(system_parts[0]) if system_parts else 0, len(transcript), (req.language or "")[:20] or "auto", ) try: async with httpx.AsyncClient(timeout=30.0) as http: resp = await http.post( f"{OPENAI_BASE_URL}/chat/completions", headers=_openai_headers(), json=body, ) except httpx.RequestError as exc: logger.warning("OpenAI reasoning unreachable: %r", exc) raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable.") logger.debug( "[EXT][OPENAI] reasoning RESP status=%s len=%dB", resp.status_code, len(resp.content or b""), ) if resp.status_code != 200: logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400]) raise HTTPException(status_code=502, detail=f"OpenAI reasoning failed ({resp.status_code}).") try: payload = resp.json() except Exception: logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400]) raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON.") answer = "" choices = payload.get("choices") if isinstance(choices, list) and choices: message = choices[0].get("message") if isinstance(choices[0], dict) else None if isinstance(message, dict): content = message.get("content") if isinstance(content, str): answer = content.strip() usage = payload.get("usage") if isinstance(usage, dict): logger.debug( "[EXT][OPENAI] reasoning USAGE prompt=%d completion=%d total=%d", usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), ) if not answer: logger.warning("OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800]) raise HTTPException(status_code=502, detail="OpenAI reasoning returned no answer.") logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500]) # ── APIPILOT: cattura marker → esecuzione tool → SECONDA PASSATA OPENAI tool_name_p1_oai, tool_args_p1_oai = _apipilot_parse_call_marker(answer) if tool_name_p1_oai: logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s", tool_name_p1_oai, sorted(list((tool_args_p1_oai or {}).keys()))) logger.info( "[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s", tool_name_p1_oai, json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300], ) logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", tool_name_p1_oai) tool_env_oai_p1 = await _apipilot_execute_tool( auth_header=apipilot_auth_header, demo_session=None, name=tool_name_p1_oai, arguments=tool_args_p1_oai or {}, ) is_tool_err_oai = not bool(tool_env_oai_p1.get("success")) tool_data_oai = tool_env_oai_p1.get("data") if isinstance(tool_data_oai, str): tool_text_oai = tool_data_oai elif tool_data_oai is None: tool_text_oai = "" else: try: tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2) except Exception: tool_text_oai = str(tool_data_oai) if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict): err_oai = tool_env_oai_p1["error"] err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}" tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip() inject_apipilot_pass2_oai = _resolve_apipilot_reasoning_context_block( [], call_result={ "name": tool_name_p1_oai, "text": tool_text_oai, "error": is_tool_err_oai, }, ) logger.info( "[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → seconda chiamata chat/completions", tool_name_p1_oai, str(not is_tool_err_oai).lower(), len(tool_text_oai), ) # Build pass2: stesso instructions + user_context ma SOSTITUISCO la # lista strumenti (inject_apipilot_text_pass1) con il blocco risultato # (inject_apipilot_pass2_oai). Riuso lo stesso parts pattern iniziale. user_ctx_oai_2: list[str] = [] if voice_lock_block.strip(): user_ctx_oai_2.append(voice_lock_block) if today_block.strip(): user_ctx_oai_2.append(today_block) if inject_episode_text: user_ctx_oai_2.append(inject_episode_text) if inject_profile_text: user_ctx_oai_2.append(inject_profile_text) if inject_reports_text: user_ctx_oai_2.append(inject_reports_text) if inject_summary_text: user_ctx_oai_2.append(inject_summary_text) if inject_apipilot_pass2_oai: user_ctx_oai_2.append(inject_apipilot_pass2_oai) if original_user_context: user_ctx_oai_2.append(original_user_context) user_ctx_oai_2 = _dedup_context_blocks_by_type(user_ctx_oai_2) stripped_oai_2: list[str] = [] for p in user_ctx_oai_2: sp = _strip_internal_markers(p) if sp: stripped_oai_2.append(sp) user_ctx_oai_2 = stripped_oai_2 eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2) if S2S_INJECT_PROFILE: eff_inst_oai_2 = _augment_instructions_with_profile( req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text, inject_episode_text, profile_obj=profile_obj, ) else: eff_inst_oai_2 = (req.instructions or "").strip() sys_parts_oai_2: list[str] = [] instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip()) if instr_oai_2: sys_parts_oai_2.append(instr_oai_2) if eff_user_ctx_oai_2.strip(): sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}") if inject_apipilot_pass2_oai: sys_parts_oai_2.append(inject_apipilot_pass2_oai) if req.language.strip(): sys_parts_oai_2.append( "Reply only with the final assistant answer text. " f"Keep the answer natural and concise in this language: {req.language.strip()}." ) else: sys_parts_oai_2.append( "Reply only with the final assistant answer text. Keep the answer natural and concise." ) system_msg_oai_2 = "\n\n".join(sys_parts_oai_2) body_oai_2 = { "model": OPENAI_REASONING_MODEL, "messages": [ {"role": "system", "content": system_msg_oai_2}, {"role": "user", "content": transcript}, ], } if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n", len(system_msg_oai_2), system_msg_oai_2, ) try: async with httpx.AsyncClient(timeout=30.0) as http_oai2: resp_oai_2 = await http_oai2.post( f"{OPENAI_BASE_URL}/chat/completions", headers=_openai_headers(), json=body_oai_2, ) except httpx.RequestError as exc: logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc) raise HTTPException(status_code=502, detail="OpenAI reasoning unreachable (pass 2).") if resp_oai_2.status_code != 200: logger.warning("[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400]) raise HTTPException(status_code=502, detail=f"OpenAI reasoning pass 2 failed ({resp_oai_2.status_code}).") try: payload_oai_2 = resp_oai_2.json() except Exception: logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400]) raise HTTPException(status_code=502, detail="OpenAI reasoning returned invalid JSON (pass 2).") final_oai_ans = "" choices_oai_2 = payload_oai_2.get("choices") if isinstance(choices_oai_2, list) and choices_oai_2: msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None if isinstance(msg_oai_2, dict): content_oai_2 = msg_oai_2.get("content") if isinstance(content_oai_2, str): final_oai_ans = content_oai_2.strip() usage_oai_2 = payload_oai_2.get("usage") if not final_oai_ans: # Fallback: testo raw risposta strumento come ultima ratio logger.warning("[EXT][OPENAI PASS-2] answer vuota, fallback testo raw risultato strumento") 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) # Nessun marker → risposta pass 1 originale return JSONResponse({"answer": answer}) async def _forward_agent_text_to_nodered(payload: dict) -> None: """Fire-and-forget: inoltra la risposta TTS del backend nativo a Node-RED (endpoint /api/log/system-response) per il logging della FASE 1. Se Node-RED è down o non è configurato: silent ignore, non propagare errori al realtime loop (non vogliamo bloccare la connessione WS).""" 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 — vogliamo non bloccare mai logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc) @app.websocket("/v1/realtime") async def realtime_proxy(ws: WebSocket): """Relay the browser's same-origin realtime WebSocket to the configured upstream.""" client_ip = ws.client.host if ws.client else "?" if not S2S_PROXY_TARGET: logger.debug("[EXT][S2S-PROXY] client=%s rejected: S2S_PROXY_TARGET not configured", client_ip) await ws.accept() await ws.close(code=1013, reason="Realtime proxy not configured.") return await ws.accept() upstream_url = _proxy_upstream_url(ws) subprotocols = _subprotocols(ws) # ── S2S_INJECT_PROFILE: resolve caller profile + podcast episode + latest summary ── # Query params (browser URL carries ?phoneNumber=...&episode_id=...) are forwarded by the # client WS library: we read ?phoneNumber= and ?episode_id= from the incoming WS query. # When S2S_INJECT_PROFILE=false or envs missing -> ctx fields stay empty # and the tunnel stays 100% pure passthrough (original behaviour). injected_episode_context = "" injected_profile_context = "" injected_reports_context = "" injected_summary_context = "" injected_apipilot_pass1 = "" apipilot_tools_pass1: list = [] inject_phone = "" inject_episode_id = "" client_query = {} login_token: str | None = None login_response_obj: dict | None = None profile_obj: dict | None = None needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL) try: client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True)) except Exception: # noqa: BLE001 client_query = {} # ── 2026-09-25 EXTRACT QS + LOGIN PER APIPILOT SEMPRE (fuori gate S2S_INJECT_PROFILE) ── # Perché needs_apipilot è INDIPENDENTE da S2S_INJECT_PROFILE (ApiPilot serve anche # quando non si vuole inject del profilo). # 1) phone + episode_id SEMPRE estratti da ?querystring se needs_apipilot=True # 2) S2S_LOGIN_BY_PHONE bearer SEMPRE ottenuto per tools requires_auth=True (403 anon oggi) # 3) profile_obj SEMPRE risolto se VOCERO_USER_PHONE_AUTH_URL + phone (per email defaults) 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()) # 2026-09-25 Diagnostica SEMPRE-ATTIVA WS connect STATO INIZIALE (prima di qualsiasi cosa) # così sappiamo sempre se inject_phone è arrivato, login_token quanto è lungo, # S2S_LOGIN_BY_PHONE è configurato, ecc. Serve a vedere perché user=(anon) in AquaPortal. _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] STATO INIZIALE (sempre): " + ", ".join("%s=%s" % (k, v) for k, v in sorted(_s_init.items())), ) # 2026-09-25 API KEY BYPASS ATTIVA → saltiamo completamente login_by_phone (non serve) 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 SEMPRE (per Apipilot execute requires_auth) 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 (Apipilot user ANON, 403 probabile) 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 SEMPRE (per 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'). " "Nessun fetch e nessun patch session.update per ApiPilot in questo 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()) # USER FIX 2026-09-11 "Elimina l'iniezione massiva di tutti i report schedulati": # 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) ApiPilot tools list fetch (PASSATA-1) — INDIRIZZO FISSO ALL'AI-SERVER SIA IN S2S_INJECT_PROFILE=True che False. # Siamo indipendenti dal flag profilo perché l'utente ha chiesto che la lista arrivi # sempre, anche quando non c'è login/phone. Usa login_token se presente (stesso bearer di reasoning), # altrimenti usa "anon" fingerprint (stesso cache server-side reasoning). if needs_apipilot: apipilot_auth_header = (f"Bearer {login_token}" if isinstance(login_token, str) and login_token.strip() else None) # 2026-09-25 IMPORTANTE: S2S_LOGIN_BY_PHONE = aquachile AUTH PHONE LOGIN # → login_token È IL TOKEN JWT AQUAPORTAL VERO. Lo usiamo per execute. # S2S_APIPILOT_SERVICE_TOKEN (se valorizzato in .env) → OVERRIDE massimo. 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: # LOG SEMPRE-ATTIVO (non subordinato a S2S_DEBUG_PROMPT) — identico pattern reasoning pass1. 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) # Build defaults dict per PASSATA-1 prompt: phone + episodeId se disponibili. # Iniettati nella sezione "VALORES POR DEFECTO CONOCIDOS" così l'AI # NON lascia arguments vuoto (BUG oggi: "ARGUMENTS=>>>"). _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() # Anche login_by_phone se abbiamo profilo con email valida → email if isinstance(profile_obj, dict) and profile_obj: try: u = profile_obj.get("user") if isinstance(profile_obj.get("user"), dict) else None if isinstance(u, dict): email = u.get("email") if isinstance(u.get("email"), str) else "" if email.strip(): _apipilot_defaults_p1["email"] = email.strip() except Exception: # noqa: BLE001 pass session_ctx_apipilot_defaults = dict(_apipilot_defaults_p1) injected_apipilot_pass1 = _resolve_apipilot_reasoning_context_block( tools=apipilot_tools_pass1, context_defaults=_apipilot_defaults_p1, ) or "" if injected_apipilot_pass1.strip(): logger.info( "[APIPILOT] REASONING PASS-1 (WS realtime): lista strumenti pronta 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 = {} # 2026-09-25 Propagazione ESATTA demo_session_key + apipilot_auth_header USATI NELLA DISCOVERY # (stesso anon-ws-{client_ip}-{id(ws)}), perché AquaPortal lega la sessione utente # al valore x-demo-session della GET /tools. Se nella PASS-2 execute generiamo # un valore DIVERSO (ws-pass2-...) → vede user ANON → 403 "Tool non autorizzato". _ws_apipilot_auth_for_execute = apipilot_auth_header _ws_apipilot_demo_for_execute = demo_session_key if isinstance(demo_session_key, str) else None # 2026-09-25 FIX CONSISTENZA API KEY: se S2S_APIPILOT_API_KEY è attivo, forza TUTTE le # variabili di propagazione all'identità bypass (stesso valore che abbiamo usato # nella fingerprint cache e negli headers HTTP effettivi della discovery). # Questo garantisce che anche nel raro caso in cui il PASS-2 fallback alla # propagation invece del nuovo blocco diretto, usi APIKEY identity e non anon-ws. 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: # needs_apipilot=False (env non configurato): set defaults vuoto cmq # per evitare che session_ctx["apipilot_defaults"] sia 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 # "Nombre exacto: Alessandro Valenti — NUNCA Alexander" # (b) strato 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, # 2026-09-25 PASSATA-2 WS Realtime ApiPilot: accumulo transcript per response_id # e flag anti-loop quando siamo in fase di inject di PASS-2 dopo l'execute. "_ws_apipilot_asst_transcript_acc": {}, # response_id -> str full transcript "_ws_apipilot_pass2_active": False, # True mentre siamo dentro la 2a risposta (anti-loop marker infinito) "_ws_apipilot_pass2_count": 0, # quante volte abbiamo eseguito il loop su questa sessione (cap max 3 safety) # 2026-09-25 defaults parametri (phone/email/episode_id) riempiti in # PASS-1 builder e passati a _apipilot_parse_call_marker in PASS-2 # se LLM lascia arguments vuoto. "apipilot_defaults": (session_ctx_apipilot_defaults if needs_apipilot else {}), # 2026-09-25 fallback phone per PASS-2 safety replace placeholder, # usato anche se defaults è vuoto ma querystring aveva ?phoneNumber=. "inject_phone": (inject_phone if isinstance(inject_phone, str) else ""), # 2026-09-25 Bearer token per execute ApiPilot requires_auth=True (403 anon). # Valorizzato con _login_by_phone se needs_apipilot + phone disponibile # fuori da gate S2S_INJECT_PROFILE. "login_token": (login_token if isinstance(login_token, str) else None), # 2026-09-25 ESATTI auth_header + demo_session_key usati nella GET /tools # DISCOVERY al momento della WS connect. PASS-2 execute usa ESATTAMENTE # questi invece di crearne di nuovi (AquaPortal lega sessione x-demo-session # alla chiamata /tools che ha restituito la lista strumenti autorizzati). "apipilot_exec_auth": _ws_apipilot_auth_for_execute, "apipilot_exec_demo": _ws_apipilot_demo_for_execute, } client_to_upstream = asyncio.create_task( _client_to_upstream(ws, upstream, client_ip, session_ctx) ) upstream_to_client = asyncio.create_task( _upstream_to_client(ws, upstream, client_ip, session_ctx) ) done, pending = await asyncio.wait( {client_to_upstream, upstream_to_client}, return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() await asyncio.gather(*pending, return_exceptions=True) for task in done: exc = task.exception() if exc: raise exc except WebSocketDisconnect: logger.debug("[EXT][S2S-PROXY] client=%s disconnected (WSDisconnect)", client_ip) return except Exception as exc: logger.warning("Realtime proxy failed: %r", exc) logger.debug("[EXT][S2S-PROXY] client=%s ERROR %s", client_ip, type(exc).__name__) try: await ws.close(code=1011, reason="Upstream realtime unavailable.") except Exception: pass finally: logger.debug("[EXT][S2S-PROXY] client=%s TUNNEL CLOSED", client_ip) @app.get("/api/user/phone") async def user_by_phone(phoneNumber: str): """HTTP passthrough: fetches caller identity / profile from the external info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw JSON to the browser. The proxy NEVER inspects this payload nor injects it into the realtime WebSocket session. It is only used by: - the browser UI to render a caller name header (#ident-user-name) - the /api/reasoning HTTP body user_context field (browser's choice). When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can silently skip identity loading (backward compatible with generic deploys). When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone POST with the caller phone and (if a bearer token is extracted) forwards it as Authorization: Bearer on the subsequent profile GET. """ from urllib.parse import urlencode if not VOCERO_USER_PHONE_AUTH_URL: raise HTTPException( status_code=503, detail="VOCERO_USER_PHONE_AUTH_URL is not configured on this deploy.", ) phone = (phoneNumber or "").strip() if not phone: raise HTTPException(status_code=400, detail="phoneNumber is required.") # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). login_token = None if S2S_LOGIN_BY_PHONE: login_token, _login_obj = await _login_by_phone(phone) target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}" logger.debug( "[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s", VOCERO_USER_PHONE_AUTH_URL, len(phone), "yes" if login_token else "no", ) try: headers = {} if login_token: headers["Authorization"] = f"Bearer {login_token}" async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: resp = await http.get(target_url, headers=headers or None) except httpx.RequestError as exc: logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc) raise HTTPException(status_code=502, detail="Upstream user-info endpoint is unreachable.") # Forward the upstream status + JSON verbatim; never rewrite the payload. if resp.status_code >= 500: logger.warning( "[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s", resp.status_code, resp.text, ) try: payload = resp.json() except Exception: raise HTTPException( status_code=502, detail="Upstream user-info endpoint returned invalid JSON.", ) return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload) @app.get("/api/user/reports") async def user_reports(phoneNumber: str | None = None): """Fetch + normalize the scheduled reports list for the caller UI. Mirrors the same sequence used in the WebSocket connect handler and /api/reasoning: 0. login-by-phone (optional) → bearer token 1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth 2. run the same resolver used for prompt injection: filter 3 fields, sort DESC, build hints + entries → JSON returned verbatim so the browser can render a "reports loaded" summary in the UI header. Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no reports feature enabled) so the browser silently skips UI rendering. Response shape on success: { "count": N, "wrappedLen": X, "hints": [ "• Report #1: '