6702 lines
338 KiB
Python
6702 lines
338 KiB
Python
"""
|
|
Tiny server for the speech-to-speech demo.
|
|
|
|
The demo used to ship as a `sdk: static` Space, but the web-search tool needs a
|
|
search key the browser must NOT see. A static Space has no runtime process, so it
|
|
can't hold a secret the front-end uses. This server fixes that: it serves the
|
|
unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the
|
|
Serper key server-side (see docs/adr/0001).
|
|
|
|
Everything lives in one container; the speech-to-speech backend stays a separate,
|
|
load-balanced service the browser talks to over WebSocket as before. The load
|
|
balancer's address is a secret too (like the Serper key): the browser never sees
|
|
it. `/api/session` proxies the session handshake server-side so only the
|
|
per-session compute URL the LB hands back (which the browser must dial) is exposed.
|
|
|
|
On the deployed Space the server also meters conversation time by HF login tier
|
|
(anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature
|
|
is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
|
|
the live Space, never locally (even with the LB exported for testing).
|
|
|
|
`SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is
|
|
disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
|
|
browser connects directly to that URL, shown read-only in Settings.
|
|
|
|
Endpoints:
|
|
GET /api/config -> { search, lb, allowDirect, s2sUrl, auth }
|
|
GET /api/me -> login + tier + remaining budget (LB mode only)
|
|
POST /api/search -> { results, answer } Google via Serper.dev
|
|
POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
|
|
GET /api/queue/{id} -> proxies <LB>/queue/{id}: position, or a grant on claim
|
|
DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button)
|
|
POST /api/queue/end -> leave the queue (sendBeacon on teardown)
|
|
POST /api/session/heartbeat-> extend the reservation; { expired }
|
|
POST /api/session/end -> reconcile + refund (sendBeacon on teardown)
|
|
/* -> static files (index.html, main.js, ...)
|
|
|
|
When every compute slot is busy the load balancer hands back a queue ticket
|
|
instead of a grant; the browser polls /api/queue/{id} until it reaches the front
|
|
and a slot frees. Waiting reserves nothing — the daily budget is only reserved at
|
|
the moment a slot is actually claimed (a grant), never while queued.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
import datetime as _dt_module
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
import httpx
|
|
import websockets
|
|
from dotenv import load_dotenv
|
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import JSONResponse
|
|
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=<x>
|
|
# and expects JSON back (optionally wrapped as {"result": <value>}). Empty
|
|
# string means the feature is off (no network call, nothing injected).
|
|
S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip()
|
|
# Static / hardcoded latest-summary text. When set (non-empty string), it takes
|
|
# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and
|
|
# injects this raw plain-text value directly into the prompt as the latest
|
|
# conversation summary. Designed for cases where the operator wants a clean,
|
|
# essential, pre-cleaned summary snippet without depending on an upstream API.
|
|
# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no
|
|
# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context
|
|
# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper).
|
|
S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip()
|
|
if S2S_LATEST_SUMMARY_TEXT:
|
|
# Interpret \n escape sequences so multi-line summaries can be written as a
|
|
# single env line (matches S2S_PRIMARY_PROMPT convention above).
|
|
S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n")
|
|
# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and
|
|
# phoneNumber is available), before fetching user profile / latest summary the proxy
|
|
# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body,
|
|
# extracts an auth token from the response (if any), and forwards it as
|
|
# Authorization: Bearer <token> for subsequent profile / summary / reports HTTP calls.
|
|
S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/")
|
|
# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true)
|
|
# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}),
|
|
# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC,
|
|
# and injects the compiled block into the LLM instructions / user_context between
|
|
# the caller profile block and the latest conversation summary block.
|
|
S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/")
|
|
# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST
|
|
# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT)
|
|
# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and
|
|
# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue:
|
|
# "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 <url>/tools con include_unauthorized=true
|
|
# (§2.1 della guida) per ottenere la lista tool e POST <url>/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: <key>
|
|
# 2) Authorization: Bearer <key>
|
|
# 3) x-demo-session: <key>
|
|
# 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_TOOLS_LIST_BEGIN>>>"
|
|
APIPILOT_PASS2_RESULT_MARKER = "<<<APIPILOT_RESULT:"
|
|
|
|
# Regola:
|
|
# PASSATA 1: iniettiamo lista {name,description,required,parameters} + regole
|
|
# marker nel prompt. AI deve decidere se matcha description e
|
|
# emettere <<<APIPILOT_CALL:NAME=nome_tool,ARGUMENTS={...}>>>.
|
|
# 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"<<<APIPILOT_RESULT:STATUS={status_label},NAME={name}>>>"
|
|
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"
|
|
" <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={\"parametro1\":\"valor1\",\"parametro2\":\"valor2\"}>>>\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<<<APIPILOT_CALL:...>>>'\n"
|
|
" '<<<APIPILOT_CALL:...>>>\\nAhora proceso el resultado.'\n"
|
|
"9. 🔴 REGLAS TTS ANTI-LEAK (NON negoziabili, priorità MASSIMA sul audio):\n"
|
|
" Il marcador <<<APIPILOT_CALL:...>>> È 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"
|
|
"<<<APIPILOT_TOOLS_LIST_END>>>"
|
|
)
|
|
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 <<<APIPILOT_RESULT:PASS2>>> 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 <<<APIPILOT_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 <<<APIPILOT_RESULT:...>>> + <<<APIPILOT_RESULT:PASS2>>>
|
|
+ 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"<<<APIPILOT_RESULT:PASS2>>>[ \t]*(?:\r?\n|\r)+"
|
|
r"###[ \t]+Resultado de la herramienta ApiPilot invocada[ \t]*(?:\r?\n|\r)+"
|
|
r"<<<APIPILOT_RESULT:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+>>>[ \t]*(?:\r?\n|\r)*"
|
|
r"(?:\r?\n|\r)?"
|
|
r"```tool-output[ \t]*(?:\r?\n|\r)?"
|
|
r"(?P<tool_body>.*?)"
|
|
r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}"
|
|
r"(?:"
|
|
r"Ahora debes reinterpretar EL TEXTO RESULTADO[\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]*<<<APIPILOT_RESULT(?::PASS2|:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+)?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
|
|
re.IGNORECASE,
|
|
)
|
|
text = pass2_marker_pattern.sub("", text)
|
|
|
|
# (1c) 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"<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
line_wrapped_pattern = re.compile(
|
|
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
|
|
def _replacement_full(_m: re.Match) -> str:
|
|
"""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 <<<APIPILOT_CALL:NAME=tool,ARGS… (incompleto)
|
|
trunc_call_pattern = re.compile(
|
|
r"<<<APIPILOT_CALL(?::NAME=[A-Za-z0-9_.-]+,?[^>\n]{0,500}?)?>>*",
|
|
re.IGNORECASE,
|
|
)
|
|
text = trunc_call_pattern.sub(_replacement_full, text)
|
|
|
|
# Safety extra: marker troncato RESULT tipo <<<APIPILOT_RESULT:... incompleto
|
|
# OSS: rendiamo i caratteri `>` FINALI opzionali (+ opzionali `>` dopo)
|
|
# così gestisce anche casi TTS audio troncato a metà stringa
|
|
# (es. "<<<APIPILOT_RESULT:STATUS=OK,NAM" che non ha chiusura >>>)
|
|
trunc_result_pattern = re.compile(
|
|
r"<<<APIPILOT_RESULT(?::[A-Za-z0-9_=,:.\- ]{0,800}?)?>?>?>?",
|
|
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 <toolname> arguments <qualsiasi cosa fino a end-frases>
|
|
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: <key>
|
|
# - Authorization: Bearer <key>
|
|
# - x-demo-session: <key>
|
|
# 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 <lb>/session here, server-side) and connects to the URL the LB
|
|
# returns (the original flow). The LB address itself is never sent to the browser.
|
|
# When empty, the user may instead set a direct s2s server URL in Settings and the
|
|
# browser connects to it straight (no load balancer).
|
|
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
|
|
# Direct s2s server URL pinned by the deploy. Takes priority over the load
|
|
# balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue,
|
|
# no limiter, no sign-in) and the browser connects to this URL directly. Unlike
|
|
# the LB address it is NOT a secret — /api/config sends it to the client, which
|
|
# shows it read-only in Settings.
|
|
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
|
|
# Optional same-origin WebSocket relay target. When set, the browser dials this
|
|
# app over ws(s)://<this-app>/v1/realtime and the server relays frames to the
|
|
# upstream speech-to-speech endpoint, keeping the internal host private.
|
|
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
|
|
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
|
|
LOAD_BALANCER_URL = ""
|
|
# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
|
|
# locally and on a plain `docker run`. We meter conversation time ONLY on the
|
|
# deployed Space — i.e. when BOTH the LB is configured AND we're on a Space.
|
|
# Off-Space (local dev, even with the LB exported) the app still proxies the LB,
|
|
# but nothing is metered: no budget, no reservations, no sign-in gating.
|
|
SPACE_ID = os.environ.get("SPACE_ID", "").strip()
|
|
LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID)
|
|
SERPER_URL = "https://google.serper.dev/search"
|
|
# Cap results so the tool output stays small enough to feed back to the model.
|
|
MAX_RESULTS = 5
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
app = FastAPI(title="s2s-demo")
|
|
|
|
# Wire HF OAuth before the app serves (no-op unless the OAuth env is present).
|
|
# Sign-in only matters when we're metering (prod Space), so gate it on that.
|
|
AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app)
|
|
|
|
# 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 <<<TODAY_DATE_BEGIN>>>):
|
|
# - 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 <<<MINIMAL_REPORTS_TOC>>>): 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 "<<<TODAY_DATE_BEGIN>>>" which got written into the LLM
|
|
# prompt verbatim. Qwen2.5 (especially GPTQ quantized variants) would
|
|
# sometimes *leak those markers back into the output* alongside meta-comment
|
|
# hallucinations (e.g. emitting "[-]: checking_podcast_report" or literal
|
|
# "<<<...>>>"). To fix this we now use INVISIBLE markers: a SHA-1 hash of
|
|
# stable strings. These hashes are NEVER seen by the LLM as text because
|
|
# they are NOT written into the prompt. They exist ONLY as Python-side
|
|
# substring sentinels prepended to the *Python string* returned by each
|
|
# block builder — stripped out at the very end with .replace() just before
|
|
# the string is assembled into the prompt. Result: the dedupe logic still
|
|
# works perfectly, but the LLM NEVER sees any marker / guard token.
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
def _s2s_marker(label: str) -> str:
|
|
"""Return a short, unique, invisible sentinel for dedup classification.
|
|
|
|
Sentinel is 40 hex chars (SHA1) of a stable string; it is NEVER written
|
|
into the prompt — we strip all markers at the end of each builder.
|
|
"""
|
|
import hashlib
|
|
return hashlib.sha1(f"S2S-ctx-marker/{label}/v1".encode("utf-8")).hexdigest()
|
|
|
|
|
|
TODAY_BLOCK_MARKER = _s2s_marker("today_fixed_block")
|
|
MINIMAL_REPORTS_MARKER = _s2s_marker("reports_minimal_toc")
|
|
VOICE_RULES_HEAD_MARKER = _s2s_marker("voice_rules_head_lock")
|
|
REPORTS_FULL_MARKER = _s2s_marker("reports_full_block")
|
|
SUMMARY_FULL_MARKER = _s2s_marker("summary_full_block")
|
|
PROFILE_FULL_MARKER = _s2s_marker("profile_full_block")
|
|
EPISODE_FULL_MARKER = _s2s_marker("episode_full_block")
|
|
ALL_S2S_INTERNAL_MARKERS: tuple = (
|
|
TODAY_BLOCK_MARKER,
|
|
MINIMAL_REPORTS_MARKER,
|
|
VOICE_RULES_HEAD_MARKER,
|
|
REPORTS_FULL_MARKER,
|
|
SUMMARY_FULL_MARKER,
|
|
PROFILE_FULL_MARKER,
|
|
EPISODE_FULL_MARKER,
|
|
)
|
|
|
|
|
|
def _strip_internal_markers(text) -> str:
|
|
"""Strip every internal S2S dedup marker from the final prompt text.
|
|
|
|
Must be called right before a block is joined to the final instructions /
|
|
user_context so the LLM NEVER sees any of our sentinel strings. This is
|
|
the final guarantee that prevents marker leakage into spoken output.
|
|
"""
|
|
s = str(text or "")
|
|
if not s:
|
|
return s
|
|
for m in ALL_S2S_INTERNAL_MARKERS:
|
|
if m and m in s:
|
|
s = s.replace(m, "")
|
|
return s.strip()
|
|
|
|
|
|
def _extract_user_name_parts(profile) -> list[str]:
|
|
"""Given a profile dict (possibly wrapped in {"user": ...} or {"result": ...})
|
|
return the list of non-empty first/last name parts in order (e.g.
|
|
["Alessandro", "Valenti"]). Returns [] when profile has no name.
|
|
|
|
Used by:
|
|
• _build_voice_rules_head_lock_block — prompt rule #5 "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", "<<<REPORTS_LIST_BEGIN>>>"), # legacy back-compat marker
|
|
("summary", "<<<LATEST_SUMMARY_BEGIN>>>"), # legacy back-compat marker
|
|
("summary_full", SUMMARY_FULL_MARKER),
|
|
("today_fixed", TODAY_BLOCK_MARKER),
|
|
("reports_minimal", MINIMAL_REPORTS_MARKER),
|
|
("apipilot_tools_list", APIPILOT_PASS1_LIST_MARKER),
|
|
("apipilot_result", APIPILOT_PASS2_RESULT_MARKER),
|
|
)
|
|
|
|
|
|
def _classify_context_block(block: str) -> str | None:
|
|
"""Return the logical type of block or None for unrecognised text.
|
|
|
|
Uses the HINT prefixes / block begin markers above. Uses substring
|
|
containment (not startswith) because the augmented prompt may carry the
|
|
markers after 1-2 leading spaces. Uses the STRIPPED version of each HINT
|
|
to avoid missing matches due to leading whitespace.
|
|
"""
|
|
if not block:
|
|
return None
|
|
haystack = (block or "").strip()
|
|
if not haystack:
|
|
return None
|
|
for type_id, marker in _CONTEXT_DEDUPE_MARKERS:
|
|
if marker and marker in haystack:
|
|
return type_id
|
|
return None
|
|
|
|
|
|
def _dedup_context_blocks_by_type(candidates: list[str]) -> list[str]:
|
|
"""Keep only the FIRST occurrence of each logical block TYPE in order.
|
|
|
|
Drops duplicated block types silently and logs + returns a new list with
|
|
only the surviving blocks. Also drops empty / whitespace-only candidates
|
|
which would otherwise be a no-op append. This is layer 1 of the dedupe.
|
|
"""
|
|
if not candidates:
|
|
return []
|
|
seen_types: set[str] = set()
|
|
out: list[str] = []
|
|
dropped_types: dict[str, int] = {}
|
|
for candidate in candidates:
|
|
c = (candidate or "").strip()
|
|
if not c:
|
|
continue
|
|
type_id = _classify_context_block(c)
|
|
if type_id is None:
|
|
# Unknown type (free-form text, plain user_context, ...) is NEVER
|
|
# deduped — we only dedupe the 4 well-known S2S injected block
|
|
# types so user-supplied arbitrary context is always preserved.
|
|
out.append(candidate)
|
|
continue
|
|
if type_id in seen_types:
|
|
dropped_types[type_id] = dropped_types.get(type_id, 0) + 1
|
|
continue
|
|
seen_types.add(type_id)
|
|
out.append(candidate)
|
|
if dropped_types and S2S_DEBUG_PROMPT:
|
|
# Log only when dedupe actually fired (avoids spamming). Use level
|
|
# DEBUG / info level so the admin can see "profile duplicate was
|
|
# dropped" when troubleshooting prompt size issues.
|
|
logger.info(
|
|
"[INJECT-DEDUPE] dropped duplicate injected block types=%s kept=%d candidates=%d",
|
|
", ".join(f"{k}x{v}" for k, v in dropped_types.items()),
|
|
len(out),
|
|
len(candidates),
|
|
)
|
|
return out
|
|
|
|
|
|
def _strip_already_injected_types_from_base(
|
|
base_text: str, candidates: list[str]
|
|
) -> list[str]:
|
|
"""Layer 2: for each candidate, check if its HINT prefix ALREADY appears
|
|
anywhere inside base_text (i.e. was injected by a previous patch / baked
|
|
into the instructions sent by the browser). If yes, drop that candidate
|
|
entirely (keep only candidates whose types are NOT already present in
|
|
base). Works even when base_text contains the profile twice — after this
|
|
filter we won't add a third copy; layer 1 handles any further duplicates
|
|
among the candidates themselves."""
|
|
b = (base_text or "")
|
|
if not b:
|
|
return list(candidates)
|
|
out: list[str] = []
|
|
dropped_base: list[str] = []
|
|
for candidate in candidates:
|
|
c = (candidate or "").strip()
|
|
if not c:
|
|
continue
|
|
type_id = _classify_context_block(c)
|
|
if type_id is None:
|
|
# Unknown context blocks → never dedupe, always append.
|
|
out.append(candidate)
|
|
continue
|
|
# Does base already contain ANY marker for this type? Use _ALL_
|
|
# markers for this type, not just the single canonical one, in case
|
|
# an older deploy used a different stringification (though prefix
|
|
# stability is guaranteed by the module-level constants here).
|
|
already = False
|
|
for marker_tid, marker in _CONTEXT_DEDUPE_MARKERS:
|
|
if marker_tid == type_id and marker and marker in b:
|
|
already = True
|
|
break
|
|
if already:
|
|
dropped_base.append(type_id)
|
|
continue
|
|
out.append(candidate)
|
|
if dropped_base and S2S_DEBUG_PROMPT:
|
|
logger.info(
|
|
"[INJECT-DEDUPE] dropped candidate block types already present in base instructions=%s baseLen=%d",
|
|
", ".join(dropped_base),
|
|
len(b or ""),
|
|
)
|
|
return out
|
|
|
|
|
|
def _resolve_user_context_text(phone_number, profile_obj, loaded: bool = True) -> str:
|
|
"""Given a phone+profile object -> the short context string appended to
|
|
LLM system prompt / reasoning user_context when S2S_INJECT_PROFILE is on.
|
|
|
|
Generic (deploy-agnostic): profile_obj is the JSON returned by
|
|
VOCERO_USER_PHONE_AUTH_URL (optionally nested under {"result": ...}).
|
|
|
|
2026-09-11 anti-language-leak rule: names like "Alessandro Valenti" are
|
|
italian/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 <<<REPORTS_LIST_BEGIN/END>>> 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"<<<REPORTS_LIST_END>>>. Use these scheduled reports as trusted prior "
|
|
"content relevant to the caller's company topics. Reference individual "
|
|
"reports by their label when answering."
|
|
)
|
|
# Build wrapped with HINT BLOCK before the JSON. We prepend a summary line per
|
|
# report so the LLM has a quick table-of-contents of what's available.
|
|
if len(wrapped) > 12000:
|
|
# Truncate body preserving markers + hint
|
|
budget = (
|
|
12000
|
|
- len(REPORTS_LIST_HINT)
|
|
- len(f"\nQuick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n")
|
|
- len("\n--- Full JSON entries ---\n")
|
|
- len("\n<<<REPORTS_LIST_END>>>...truncated.")
|
|
- 200 # reserve for wrap sentence
|
|
)
|
|
if budget > 0:
|
|
body = body[:budget] + "..."
|
|
wrapped = (
|
|
f"{REPORTS_LIST_HINT}\n"
|
|
f"Quick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n"
|
|
f"{body}\n"
|
|
f"<<<REPORTS_LIST_END>>>...truncated (cap 12000 chars). Use these "
|
|
"scheduled reports as trusted prior content. Reference reports by label when relevant."
|
|
)
|
|
if total_raw and total_raw > cap and len(truncated_note) and S2S_DEBUG_PROMPT:
|
|
logger.info(
|
|
"[INJECT-REPORTS] quick-hint+entries cap applied: kept=%d upstreamTotal=%d cap=%d",
|
|
len(filtered), total_raw, cap,
|
|
)
|
|
return wrapped
|
|
|
|
|
|
async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None:
|
|
"""Fetch scheduled reports list from S2S_REPORT_LIST.
|
|
|
|
Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends
|
|
?phoneNumber=<x> if a phone is provided. Accepts JSON (optional {"result": [...]}
|
|
wrapper) and returns the list or unwrapped list. On any error logs and returns None
|
|
(never fails the proxy / reasoning tunnel).
|
|
|
|
USER FIX 2026-09-11 "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=<phone>; accepts JSON (optional {"result": …}
|
|
wrapper) or plain text. Never fails the proxy / reasoning; returns None on
|
|
any error and logs it. If `auth_token` is provided it is sent as
|
|
Authorization: Bearer <auth_token>.
|
|
|
|
OVERRIDE (2026-09-23): when S2S_LATEST_SUMMARY_TEXT is set (non-empty) it
|
|
takes absolute priority over S2S_LATEST_SUMMARY. No HTTP call is made at all;
|
|
the raw plain-text value is returned directly. Designed for operators who want
|
|
a clean, pre-curated, essential summary snippet without any upstream API
|
|
dependency. Works even without a phone_number (static snippet).
|
|
"""
|
|
if S2S_LATEST_SUMMARY_TEXT:
|
|
override_len = len(S2S_LATEST_SUMMARY_TEXT)
|
|
logger.info(
|
|
"[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=%d chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.",
|
|
override_len,
|
|
)
|
|
if S2S_DEBUG_PROMPT:
|
|
logger.info(
|
|
"[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s",
|
|
S2S_LATEST_SUMMARY_TEXT[:800],
|
|
)
|
|
return S2S_LATEST_SUMMARY_TEXT
|
|
if not S2S_LATEST_SUMMARY or not phone_number:
|
|
return None
|
|
from urllib.parse import urlencode
|
|
target_url = f"{S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}"
|
|
logger.info(
|
|
"[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s",
|
|
S2S_LATEST_SUMMARY,
|
|
len(phone_number),
|
|
"yes" if auth_token else "no",
|
|
)
|
|
try:
|
|
headers = {}
|
|
if auth_token:
|
|
headers["Authorization"] = f"Bearer {auth_token}"
|
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
|
resp = await http.get(target_url, headers=headers or None)
|
|
if resp.status_code != 200:
|
|
logger.warning(
|
|
"[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
|
|
resp.status_code, len(phone_number), resp.text,
|
|
)
|
|
return None
|
|
# Accept JSON first, then fall back to plain text (some summary APIs
|
|
# return text/plain).
|
|
text = (resp.text or "").strip()
|
|
if not text:
|
|
return ""
|
|
try:
|
|
payload = resp.json()
|
|
except Exception:
|
|
logger.info(
|
|
"[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.",
|
|
len(text),
|
|
)
|
|
return text
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r",
|
|
len(phone_number), exc,
|
|
)
|
|
return None
|
|
# Unwrap {"result": …} just like profile fetch (common wrapper pattern).
|
|
if isinstance(payload, dict) and "result" in payload:
|
|
payload = payload["result"]
|
|
# Filter out empty dict/list / empty string after unwrap.
|
|
empty = (
|
|
payload is None
|
|
or (isinstance(payload, str) and not payload.strip())
|
|
or (isinstance(payload, dict) and len(payload) == 0)
|
|
or (isinstance(payload, list) and len(payload) == 0)
|
|
)
|
|
if empty:
|
|
logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.")
|
|
return None
|
|
if isinstance(payload, dict):
|
|
logger.info(
|
|
"[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s",
|
|
",".join(sorted(str(k) for k in payload.keys())),
|
|
)
|
|
elif isinstance(payload, list):
|
|
logger.info(
|
|
"[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d",
|
|
len(payload),
|
|
)
|
|
else:
|
|
logger.info(
|
|
"[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d",
|
|
len(str(payload)),
|
|
)
|
|
return payload
|
|
|
|
|
|
async def _fetch_podcast_episode(episode_id: str) -> dict | str | None:
|
|
"""Fetch a single podcast episode by ID from the Aquachile AzurianLab API.
|
|
|
|
Completely generic / deploy-agnostic: plain GET
|
|
https://aquachile.azurianlab.com/api/v1/podcast/episodes/<episode_id>
|
|
Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails
|
|
the proxy / reasoning; returns None on any error and logs it.
|
|
"""
|
|
if not episode_id:
|
|
return None
|
|
target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}"
|
|
logger.info(
|
|
"[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s",
|
|
target_url,
|
|
episode_id,
|
|
)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
|
resp = await http.get(target_url)
|
|
if resp.status_code != 200:
|
|
logger.warning(
|
|
"[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s",
|
|
resp.status_code, episode_id, resp.text,
|
|
)
|
|
return None
|
|
text = (resp.text or "").strip()
|
|
if not text:
|
|
return ""
|
|
try:
|
|
payload = resp.json()
|
|
except Exception:
|
|
logger.info(
|
|
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
|
|
len(text),
|
|
)
|
|
return text
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r",
|
|
episode_id, exc,
|
|
)
|
|
return None
|
|
if isinstance(payload, dict) and "result" in payload:
|
|
payload = payload["result"]
|
|
empty = (
|
|
payload is None
|
|
or (isinstance(payload, str) and not payload.strip())
|
|
or (isinstance(payload, dict) and len(payload) == 0)
|
|
or (isinstance(payload, list) and len(payload) == 0)
|
|
)
|
|
if empty:
|
|
logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
|
|
return None
|
|
if isinstance(payload, dict):
|
|
logger.info(
|
|
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
|
|
",".join(sorted(str(k) for k in payload.keys())),
|
|
)
|
|
elif isinstance(payload, list):
|
|
logger.info(
|
|
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
|
|
len(payload),
|
|
)
|
|
else:
|
|
logger.info(
|
|
"[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d",
|
|
len(str(payload)),
|
|
)
|
|
return payload
|
|
|
|
|
|
async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None:
|
|
"""Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone.
|
|
Returns the raw {"result": ...}-unwrapped object or None. Errors are logged
|
|
then suppressed (never fail the proxy / reasoning because of this).
|
|
|
|
When `auth_token` is provided it is forwarded as Authorization: Bearer <auth_token>
|
|
to the upstream profile endpoint.
|
|
"""
|
|
if not VOCERO_USER_PHONE_AUTH_URL or not phone_number:
|
|
return None
|
|
from urllib.parse import urlencode
|
|
target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}"
|
|
logger.info(
|
|
"[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s",
|
|
VOCERO_USER_PHONE_AUTH_URL,
|
|
len(phone_number),
|
|
"yes" if auth_token else "no",
|
|
)
|
|
try:
|
|
headers = {}
|
|
if auth_token:
|
|
headers["Authorization"] = f"Bearer {auth_token}"
|
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
|
resp = await http.get(target_url, headers=headers or None)
|
|
if resp.status_code != 200:
|
|
logger.info(
|
|
"[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
|
|
resp.status_code, len(phone_number), resp.text,
|
|
)
|
|
return None
|
|
payload = resp.json()
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r",
|
|
len(phone_number), exc,
|
|
)
|
|
return None
|
|
if (isinstance(payload, dict)
|
|
and "result" in payload
|
|
and isinstance(payload["result"], dict)):
|
|
profile_obj = payload["result"]
|
|
elif isinstance(payload, dict) and not isinstance(payload, list):
|
|
profile_obj = payload
|
|
else:
|
|
logger.info(
|
|
"[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?",
|
|
type(payload).__name__,
|
|
)
|
|
return None
|
|
logger.info(
|
|
"[INJECT-PROFILE] ✅ profile loaded OK keys=%s",
|
|
",".join(sorted(profile_obj.keys())),
|
|
)
|
|
return profile_obj
|
|
|
|
|
|
def _augment_instructions_with_profile(base_instructions, user_context_text, reports_context_text=None, summary_context_text=None, episode_context_text=None, profile_obj=None) -> str:
|
|
"""When S2S_INJECT_PROFILE is true: prepend TODAY fixed context block (Bug 3 fix)
|
|
then append the episode + profile + (optional) reports list + (optional) latest
|
|
summary context strings to the LLM system/instructions prompt.
|
|
|
|
Append order (most specific → most generic / chronologically newest → oldest):
|
|
0. VOICE_RULES_HEAD_LOCK (absolute language lock + #1 priority)
|
|
1. TODAY_block (date of today + anti-hallucination date rules)
|
|
2. episode_ctx (CURRENT podcast episode the user is interacting with)
|
|
3. base instructions (primary prompt / user settings / default)
|
|
4. profile_ctx (caller identity block)
|
|
5. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC)
|
|
6. summary_ctx (latest conversation summary)
|
|
Preserves the existing instructions text.
|
|
|
|
USER FIX 2026-09-11 "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 <<<X>>> / hash string.
|
|
augmented = _strip_internal_markers(augmented)
|
|
changed = augmented != base
|
|
# Compute per-type lengths for log (based on what actually survived dedupe)
|
|
final_lock_len = sum(len(c) for c in contexts if _classify_context_block(c) == "voice_rules_head")
|
|
final_today_len = sum(len(c) for c in contexts if _classify_context_block(c) == "today_fixed")
|
|
final_ep_len = sum(len(c) for c in contexts if _classify_context_block(c) == "episode")
|
|
final_pf_len = sum(len(c) for c in contexts if _classify_context_block(c) == "profile")
|
|
final_rp_len = sum(len(c) for c in contexts if _classify_context_block(c) in {"reports", "reports_minimal", "reports_full"})
|
|
final_sm_len = sum(len(c) for c in contexts if _classify_context_block(c) in {"summary", "summary_full"})
|
|
logger.info(
|
|
"[INJECT-PROFILE] ✏️ prepended VOICE_LOCK + TODAY_block + appended episode/profile/reports/summary to LLM instructions. "
|
|
"baseLen=%d voiceLockLen=%d todayCtxLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s dedupedCandidates=%d",
|
|
len(base), final_lock_len, final_today_len, final_ep_len, final_pf_len, final_rp_len, final_sm_len, len(augmented), changed, len(contexts),
|
|
)
|
|
if S2S_DEBUG_PROMPT and final_lock_len:
|
|
for c in contexts:
|
|
if _classify_context_block(c) == "voice_rules_head":
|
|
logger.info(
|
|
"[DEBUG-VOICELOCK][instructions augment] FULL voice lock block (len=%d):\n%s\n",
|
|
len(c), _strip_internal_markers(c),
|
|
)
|
|
if S2S_DEBUG_PROMPT and final_today_len:
|
|
for c in contexts:
|
|
if _classify_context_block(c) == "today_fixed":
|
|
logger.info(
|
|
"[DEBUG-TODAY][instructions augment] FULL today+anti-hallucination block (len=%d):\n%s\n",
|
|
len(c), _strip_internal_markers(c),
|
|
)
|
|
if S2S_DEBUG_PROMPT and final_ep_len:
|
|
for c in contexts:
|
|
if _classify_context_block(c) == "episode":
|
|
logger.info(
|
|
"[DEBUG-EPISODE][instructions augment] FULL podcast episode block (len=%d):\n%s\n",
|
|
len(c), _strip_internal_markers(c),
|
|
)
|
|
if S2S_DEBUG_PROMPT and final_rp_len:
|
|
for c in contexts:
|
|
if _classify_context_block(c) in {"reports", "reports_minimal", "reports_full"}:
|
|
logger.info(
|
|
"[DEBUG-REPORTS][instructions augment] FULL reports block (len=%d mode=%s):\n%s\n",
|
|
len(c), _classify_context_block(c), _strip_internal_markers(c),
|
|
)
|
|
if S2S_DEBUG_PROMPT and final_sm_len:
|
|
for c in contexts:
|
|
if _classify_context_block(c) in {"summary", "summary_full"}:
|
|
logger.info(
|
|
"[DEBUG-SUMMARY][instructions augment] FULL latest summary block (len=%d):\n%s\n",
|
|
len(c), _strip_internal_markers(c),
|
|
)
|
|
return augmented
|
|
|
|
|
|
def _patch_client_session_update_with_profile(raw_text, user_context_text, reports_context_text=None, summary_context_text=None, episode_context_text=None, profile_obj=None, apipilot_context_text_pass1=None) -> str:
|
|
"""If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is
|
|
true, append episode_context_text + user_context_text + (optional) reports_context_text +
|
|
(optional) summary_context_text + (optional) apipilot_context_text_pass1 to session.instructions.
|
|
Returns the modified JSON string, or the original string unchanged if:
|
|
- it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG)
|
|
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text AND apipilot_context_text_pass1 are all empty
|
|
- S2S_INJECT_PROFILE is False (but if we have apipilot_context_text_pass1 -> PATCH even if S2S_INJECT_PROFILE=false, tools injection is independent.)
|
|
- parse fails
|
|
|
|
2026-09-11: NEW keyword profile_obj. Forwarded to
|
|
_augment_instructions_with_profile so the prompt lock can emit the proper-name
|
|
anti-anglicization rule for this caller (e.g. Alessandro never Alexander).
|
|
|
|
2026-09-25: NEW keyword apipilot_context_text_pass1. Injected IN ADDITION so WS
|
|
Realtime LLM sees the tools list match description / marker rules (same as the
|
|
/api/reasoning HTTP endpoint). Independent gate: even S2S_INJECT_PROFILE=false,
|
|
if apipilot_context_text_pass1 has content -> PATCH instructions.
|
|
"""
|
|
# Step 1: only consider session.update frames. All other frame types
|
|
# (response.create, conversation.item.*, audio_* etc.) pass through
|
|
# SILENTLY — we never emit logs for them, which avoids spamming the
|
|
# console with 6-20 identical "SKIPPED" lines per session.
|
|
try:
|
|
payload = json.loads(raw_text)
|
|
except Exception:
|
|
return raw_text
|
|
if not isinstance(payload, dict) or payload.get("type") != "session.update":
|
|
return raw_text
|
|
|
|
# From here on: we know this is a real session.update (typically emitted
|
|
# just once or twice per realtime session). We can log safely — 1 line,
|
|
# not 20 repeated lines.
|
|
has_apipilot_pass1 = bool((apipilot_context_text_pass1 or "").strip())
|
|
if not S2S_INJECT_PROFILE and not has_apipilot_pass1:
|
|
logger.info(
|
|
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false and no apipilot list pass1). "
|
|
"WS stays pure passthrough."
|
|
)
|
|
return raw_text
|
|
has_episode = bool((episode_context_text or "").strip())
|
|
has_profile = bool((user_context_text or "").strip())
|
|
has_reports = bool((reports_context_text or "").strip())
|
|
has_summary = bool((summary_context_text or "").strip())
|
|
if not (has_episode or has_profile or has_reports or has_summary or has_apipilot_pass1):
|
|
logger.info(
|
|
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary, no apipilot tools list pass1). "
|
|
"Probably phoneNumber/episode_id query param missing / fetch failed upstream / "
|
|
"endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result."
|
|
)
|
|
return raw_text
|
|
if has_episode and not has_profile:
|
|
logger.info(
|
|
"[INJECT-EPISODE] profile missing, but PODCAST EPISODE block loaded. "
|
|
"Will inject episode alone into session.update."
|
|
)
|
|
if has_summary and not has_profile and not has_episode:
|
|
logger.info(
|
|
"[INJECT-SUMMARY] profile missing, but LATEST SUMMARY block loaded. "
|
|
"Will inject summary alone into session.update."
|
|
)
|
|
if has_reports and not has_profile and not has_episode:
|
|
logger.info(
|
|
"[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. "
|
|
"Will inject reports alone into session.update."
|
|
)
|
|
if has_apipilot_pass1 and not has_profile and not has_episode and not has_reports and not has_summary:
|
|
logger.info(
|
|
"[INJECT-APIPILOT] profile/episode/reports/summary missing, but APIPILOT TOOLS list PASS-1 block loaded (%d chars). "
|
|
"Will inject tools list alone into session.update.",
|
|
len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0,
|
|
)
|
|
top_session = payload.get("session")
|
|
if not isinstance(top_session, dict):
|
|
return raw_text
|
|
current_instructions = (
|
|
top_session.get("instructions")
|
|
if isinstance(top_session.get("instructions"), str)
|
|
else ""
|
|
)
|
|
augmented = _augment_instructions_with_profile(
|
|
current_instructions, user_context_text, reports_context_text, summary_context_text, episode_context_text,
|
|
profile_obj=profile_obj,
|
|
)
|
|
if S2S_DEBUG_PROMPT:
|
|
logger.info(
|
|
"[DEBUG-PROMPT][WS session.update] BEFORE instructions (len=%d):\n%s\n",
|
|
len(current_instructions), current_instructions,
|
|
)
|
|
# Append apipilot PASS-1 (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
|
|
# <<<APIPILOT_TOOLS_LIST_BEGIN>>> ? 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 "<using override execute>",
|
|
)
|
|
if S2S_DEBUG_PROMPT:
|
|
logger.info(
|
|
"[DEBUG-APIPILOT][WS connect] tools fetch detail toolsURL=%s executeURL=%s overrideExec=%s",
|
|
S2S_APIPILOT_TOOLS_URL,
|
|
S2S_APIPILOT_TOOLS_EXECUTE_URL,
|
|
_apipilot_resolve_execute_url(),
|
|
)
|
|
_t0 = _monotonic_now()
|
|
apipilot_tools_pass1 = await _apipilot_fetch_tools(
|
|
auth_header=apipilot_auth_header,
|
|
demo_session=demo_session_key if isinstance(demo_session_key, str) else None,
|
|
refresh=False,
|
|
) or []
|
|
_el = (_monotonic_now() - _t0)
|
|
# 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: '<label>' — scheduled on <date>", ... ],
|
|
"entries": [
|
|
{"sourceScheduleLabel": "...", "date": "...", "podcastScript": "..."},
|
|
...
|
|
]
|
|
}
|
|
|
|
USER FIX 2026-09-11 "Elimina l'iniezione massiva di tutti i report schedulati":
|
|
• Even this BROWSER-FACING endpoint (/api/user/reports) now honours the new
|
|
S2S_INJECT_REPORTS default=False gate: when S2S_INJECT_REPORTS is not
|
|
explicitly true, we return a polite 403 + empty response — the endpoint
|
|
does NOT execute any upstream S2S_REPORT_LIST HTTP call, does NOT expose
|
|
any podcastScript bodies, and does NOT emit entries/hints. This avoids
|
|
a subtle loophole where the browser could still fetch scheduled reports
|
|
through /api/user/reports while the LLM prompt paths were gated.
|
|
"""
|
|
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
|
|
if not S2S_INJECT_REPORTS:
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
"count": 0,
|
|
"wrappedLen": 0,
|
|
"hints": [],
|
|
"entries": [],
|
|
"skipped": True,
|
|
"reason": "S2S_INJECT_REPORTS is disabled on this deploy; no scheduled reports exposed to UI or LLM.",
|
|
},
|
|
)
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
if not S2S_REPORT_LIST:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="S2S_REPORT_LIST is not configured on this deploy.",
|
|
)
|
|
phone = (phoneNumber or "").strip() or None
|
|
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
|
|
# (same rule as WS connect / reasoning handlers — never-fail).
|
|
login_token = None
|
|
if S2S_LOGIN_BY_PHONE and phone:
|
|
login_token, _login_obj = await _login_by_phone(phone)
|
|
|
|
reports_raw = await _fetch_reports_list(phone, login_token)
|
|
# Re-run the same resolver used for prompt injection → identical logic
|
|
# guarantees UI and LLM see exactly the same filtered/sorted list.
|
|
ctx_str = _resolve_reports_context(reports_raw)
|
|
if not ctx_str or not isinstance(reports_raw, list):
|
|
return JSONResponse({
|
|
"count": 0,
|
|
"wrappedLen": 0,
|
|
"hints": [],
|
|
"entries": [],
|
|
"skipped": True,
|
|
"reason": "empty list / upstream error / nothing to inject",
|
|
})
|
|
# Rebuild hints + normalized entries with the exact filtered output
|
|
items_list: list = reports_raw
|
|
# Re-derive filtered + sorted list (same as _resolve_reports_context) so
|
|
# we don't accidentally expose raw unfiltered upstream payload to the UI.
|
|
filtered: list[dict] = []
|
|
for item in items_list:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
cleaned: dict = {}
|
|
label_val: object = None
|
|
if item.get("sourceScheduleLabel") is not None:
|
|
label_val = item["sourceScheduleLabel"]
|
|
elif isinstance(item.get("sourceSchedule"), dict):
|
|
ss = item["sourceSchedule"]
|
|
for k in ("label", "name", "title"):
|
|
if ss.get(k) is not None:
|
|
label_val = ss[k]
|
|
break
|
|
if label_val is not None:
|
|
cleaned["sourceScheduleLabel"] = label_val
|
|
date_val: object = None
|
|
if item.get("date") is not None:
|
|
date_val = item["date"]
|
|
elif isinstance(item.get("sourceSchedule"), dict):
|
|
ss = item["sourceSchedule"]
|
|
for k in ("date", "scheduledAt", "publishedAt"):
|
|
if ss.get(k) is not None:
|
|
date_val = ss[k]
|
|
break
|
|
if date_val is not None:
|
|
cleaned["date"] = date_val
|
|
script_val: object = None
|
|
for k in ("podcastScript", "script", "text", "content"):
|
|
if item.get(k) is not None:
|
|
script_val = item[k]
|
|
break
|
|
if script_val is not None:
|
|
cleaned["podcastScript"] = script_val
|
|
if cleaned:
|
|
filtered.append(cleaned)
|
|
filtered.sort(key=lambda r: _parse_date_key(r.get("date")))
|
|
# ── USER FIX 2026-09-11 "riduci il quick hint report max 3-5 configurabile da .env" ──
|
|
# Apply the SAME cap used by the LLM prompt resolver: after sort by newest,
|
|
# keep only first S2S_REPORTS_QUICK_HINT_MAX entries. This keeps counts between
|
|
# /api/user/reports UI JSON and the LLM prompt identical — avoids confusing
|
|
# mismatches where the UI shows 20 hints but the prompt only saw 5.
|
|
total_raw_ui = len(filtered)
|
|
cap_ui = max(1, int(S2S_REPORTS_QUICK_HINT_MAX or 5))
|
|
truncated_ui_note: str | None = None
|
|
if total_raw_ui > cap_ui:
|
|
filtered = filtered[:cap_ui]
|
|
truncated_ui_note = (
|
|
f"Showing newest {cap_ui} of {total_raw_ui} upstream reports. "
|
|
f"Adjust S2S_REPORTS_QUICK_HINT_MAX env to raise/lower the window."
|
|
)
|
|
import datetime as _dt
|
|
# Deduplicate by label for the UI JSON response only.
|
|
# NOTE: the LLM prompt injection uses the full non-deduped list (different
|
|
# dates = different content even when the scheduled report name repeats).
|
|
# We only dedup entries_out, hints_list, and entries for the browser payload.
|
|
seen_labels: set[str] = set()
|
|
dedup_filtered: list[dict] = []
|
|
for r in filtered:
|
|
lbl_raw = r.get("sourceScheduleLabel")
|
|
if lbl_raw is None:
|
|
# Items with no label are kept as-is (can't meaningfully dedupe)
|
|
dedup_filtered.append(r)
|
|
continue
|
|
key = str(lbl_raw).strip()
|
|
if not key:
|
|
dedup_filtered.append(r)
|
|
continue
|
|
if key in seen_labels:
|
|
continue
|
|
seen_labels.add(key)
|
|
dedup_filtered.append(r)
|
|
hints_list: list[str] = []
|
|
entries_out: list[dict] = []
|
|
for idx, r in enumerate(dedup_filtered, start=1):
|
|
entries_out.append(r)
|
|
label_piece = (str(r["sourceScheduleLabel"]).strip()
|
|
if r.get("sourceScheduleLabel") else "")
|
|
date_piece = ""
|
|
if r.get("date") is not None:
|
|
raw_d = r["date"]
|
|
try:
|
|
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
|
|
ts_sec = float(raw_d)
|
|
if ts_sec > 1e12:
|
|
ts_sec = ts_sec / 1000.0
|
|
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.timezone.utc)
|
|
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
|
|
else:
|
|
s = str(raw_d).strip()
|
|
if "T" in s and len(s) >= 16:
|
|
date_piece = s.replace("T", " ")[:16]
|
|
else:
|
|
date_piece = s
|
|
except Exception:
|
|
date_piece = str(raw_d)[:32]
|
|
if label_piece and date_piece:
|
|
hints_list.append(
|
|
f"• Report #{idx}: '{label_piece}' — scheduled on {date_piece}"
|
|
)
|
|
elif label_piece:
|
|
hints_list.append(f"• Report #{idx}: '{label_piece}'")
|
|
elif date_piece:
|
|
hints_list.append(f"• Report #{idx}: (no label) — scheduled on {date_piece}")
|
|
resp_body = {
|
|
"count": len(entries_out),
|
|
"rawCount": total_raw_ui,
|
|
"cap": cap_ui,
|
|
"wrappedLen": len(ctx_str),
|
|
"hints": hints_list,
|
|
# Strip the long podcastScript from the summary JSON to keep the
|
|
# browser response lightweight (UI only needs label/date).
|
|
"entries": [
|
|
{
|
|
"sourceScheduleLabel": r.get("sourceScheduleLabel"),
|
|
"date": r.get("date"),
|
|
}
|
|
for r in entries_out
|
|
],
|
|
}
|
|
if truncated_ui_note is not None:
|
|
resp_body["truncated"] = True
|
|
resp_body["note"] = truncated_ui_note
|
|
return JSONResponse(resp_body)
|
|
|
|
|
|
@app.get("/api/me")
|
|
async def me(request: Request):
|
|
"""Login state, tier, and remaining daily budget. Only meaningful in LB mode;
|
|
sets the anonymous tracking cookie when first seen."""
|
|
client_ip = request.client.host if request.client else "?"
|
|
logger.debug(
|
|
"[INTERNAL] /api/me client=%s LIMITER=%s",
|
|
client_ip, LIMITER_ENABLED,
|
|
)
|
|
if not LIMITER_ENABLED:
|
|
return {"enabled": False}
|
|
view = auth.user_view(request)
|
|
tier, keys, set_cookie = auth.resolve_identity(request)
|
|
unlimited = limiter.budget_for(tier) is None
|
|
rem = None if unlimited else await asyncio.to_thread(limiter.remaining, keys, tier)
|
|
logger.debug(
|
|
"[INTERNAL] /api/me client=%s tier=%s unlimited=%s remaining=%s setCookie=%s",
|
|
client_ip, tier, unlimited, rem, bool(set_cookie),
|
|
)
|
|
out = {
|
|
"enabled": True,
|
|
"auth": AUTH_ENABLED,
|
|
**view,
|
|
"remainingSec": rem,
|
|
"limitSec": limiter.budget_for(tier),
|
|
"loginUrl": auth.OAUTH_LOGIN_PATH if AUTH_ENABLED else None,
|
|
"logoutUrl": auth.OAUTH_LOGOUT_PATH if AUTH_ENABLED else None,
|
|
}
|
|
resp = JSONResponse(out)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
|
|
@app.post("/api/search")
|
|
async def search(req: SearchRequest):
|
|
"""Proxy a Google search via Serper.dev. The key stays on the server unless
|
|
the user brought their own (then theirs is used for this request only)."""
|
|
query = (req.query or "").strip()
|
|
if not query:
|
|
raise HTTPException(status_code=400, detail="Empty query.")
|
|
|
|
key = (req.key or "").strip() or SERPER_KEY
|
|
has_user_key = bool((req.key or "").strip())
|
|
if not key:
|
|
# No server key and the user didn't supply one — search is unavailable.
|
|
raise HTTPException(status_code=503, detail="Search is not configured.")
|
|
|
|
headers = {"X-API-KEY": key, "Content-Type": "application/json"}
|
|
payload = {"q": query, "num": MAX_RESULTS}
|
|
logger.debug(
|
|
"[EXT][SERPER] search POST %s query=%s userKey=%s num=%d",
|
|
SERPER_URL, json.dumps(query, ensure_ascii=True)[:200], has_user_key, MAX_RESULTS,
|
|
)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=12.0) as http:
|
|
resp = await http.post(SERPER_URL, headers=headers, json=payload)
|
|
except httpx.RequestError as exc:
|
|
logger.warning("Serper unreachable: %r", exc)
|
|
raise HTTPException(status_code=502, detail="Search provider unreachable.")
|
|
logger.debug(
|
|
"[EXT][SERPER] search RESP status=%s len=%dB",
|
|
resp.status_code, len(resp.content or b""),
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
# Serper's error body carries the real reason (e.g. "Not enough
|
|
# credits") and contains no key, so it's safe to log and relay.
|
|
body = resp.text[:300]
|
|
logger.warning("Serper error %s: %s", resp.status_code, body)
|
|
msg = None
|
|
try:
|
|
msg = resp.json().get("message")
|
|
except Exception:
|
|
pass
|
|
detail = f"Search provider error ({resp.status_code})"
|
|
if msg:
|
|
detail += f": {msg}"
|
|
raise HTTPException(status_code=502, detail=detail)
|
|
|
|
data = resp.json()
|
|
results = []
|
|
for item in (data.get("organic") or [])[:MAX_RESULTS]:
|
|
results.append(
|
|
{
|
|
"title": item.get("title", ""),
|
|
"snippet": item.get("snippet", ""),
|
|
"url": item.get("link", ""),
|
|
}
|
|
)
|
|
|
|
# A direct answer when Google has one — saves the model a hop.
|
|
box = data.get("answerBox") or {}
|
|
answer = box.get("answer") or box.get("snippet") or None
|
|
if not answer:
|
|
kg = data.get("knowledgeGraph") or {}
|
|
answer = kg.get("description") or None
|
|
|
|
logger.debug(
|
|
"[EXT][SERPER] search PARSED hasAnswer=%s resultCount=%d titles=%s",
|
|
bool(answer), len(results), [r.get("title", "")[:40] for r in results],
|
|
)
|
|
return JSONResponse({"query": query, "answer": answer, "results": results})
|
|
|
|
|
|
@app.post("/api/session")
|
|
async def session(request: Request):
|
|
"""Proxy the session handshake to the load balancer, keeping its URL secret,
|
|
and meter conversation time by tier.
|
|
|
|
The browser POSTs here (same-origin); we resolve the caller's tier, refuse if
|
|
today's budget is already spent (402), otherwise POST <LOAD_BALANCER_URL>/session
|
|
and relay the JSON back. The LB body carries a per-session `connect_url`
|
|
(compute host + short-lived token) the browser must dial directly — that one
|
|
URL is unavoidably exposed, but the stable load-balancer address is not. On a
|
|
successful grant we reserve the first time chunk against the day's budget."""
|
|
client_ip = request.client.host if request.client else "?"
|
|
if not LOAD_BALANCER_URL:
|
|
# No LB configured — this deploy is direct-mode only; the browser should
|
|
# never call this. 404 so it's indistinguishable from a missing route.
|
|
raise HTTPException(status_code=404, detail="Not found.")
|
|
|
|
tier, keys, set_cookie = auth.resolve_identity(request)
|
|
# Metering runs only on the deployed Space; off-Space the LB still proxies but
|
|
# nothing is tracked. Within metering, unlimited tiers (pro, org) aren't either.
|
|
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
|
|
|
|
logger.debug(
|
|
"[EXT][LB] session: client=%s tier=%s tracked=%s LIMITER=%s",
|
|
client_ip, tier, tracked, LIMITER_ENABLED,
|
|
)
|
|
|
|
# Refuse before troubling the LB if the day's budget is already gone. Done
|
|
# here (at enqueue) so we never put a user who can't talk into the queue.
|
|
if tracked:
|
|
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
|
|
logger.debug("[EXT][LB] session: client=%s budget remaining=%s", client_ip, rem)
|
|
if rem is not None and rem <= 0:
|
|
resp = JSONResponse(
|
|
{"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402
|
|
)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
url = f"{LOAD_BALANCER_URL.rstrip('/')}/session"
|
|
logger.debug("[EXT][LB] session POST %s (handshake)", url)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as http:
|
|
lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
|
|
except httpx.RequestError as exc:
|
|
logger.warning("Load balancer unreachable: %r", exc)
|
|
raise HTTPException(status_code=502, detail="Speech service unreachable.")
|
|
logger.debug(
|
|
"[EXT][LB] session RESP status=%s len=%dB",
|
|
lb.status_code, len(lb.content or b""),
|
|
)
|
|
|
|
# The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is
|
|
# so the client shows a soft "try again shortly", not a hard error.
|
|
if lb.status_code == 503:
|
|
body = _safe_json(lb)
|
|
if body.get("state") == "at_capacity":
|
|
logger.debug("[EXT][LB] session: at_capacity — queue full")
|
|
resp = JSONResponse({"state": "at_capacity"}, status_code=503)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
if lb.status_code != 200:
|
|
# The LB's error body may name the reason (e.g. capacity); it carries no
|
|
# secret, so relay a trimmed copy.
|
|
logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
|
|
raise HTTPException(status_code=502, detail=f"Session handshake failed ({lb.status_code}).")
|
|
|
|
data = lb.json()
|
|
state = data.get("state", "?")
|
|
connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120]
|
|
session_id = data.get("session_id") or data.get("sessionId")
|
|
logger.debug(
|
|
"[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s",
|
|
state, session_id, bool(connect_url), connect_url,
|
|
)
|
|
|
|
# Busy pool: the LB queued us. Relay the ticket untouched — crucially with NO
|
|
# reservation, so waiting in line never costs the day's budget.
|
|
if data.get("state") == "queued":
|
|
data["tier"] = tier
|
|
logger.debug("[EXT][LB] session: queued queueId=%s position=%s",
|
|
data.get("queue_id") or data.get("queueId"),
|
|
data.get("position"))
|
|
resp = JSONResponse(data)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
# A slot was free: reserve the first chunk now and return the grant.
|
|
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
|
|
|
|
|
|
@app.get("/api/queue/{queue_id}")
|
|
async def queue_status(queue_id: str, request: Request):
|
|
"""Poll a waiting ticket: relay the position, or — when the head of the line
|
|
claims a freed slot — reserve the budget now and return the grant. Re-checks the
|
|
daily budget at claim, since a multi-minute wait could have spent it elsewhere."""
|
|
client_ip = request.client.host if request.client else "?"
|
|
if not LOAD_BALANCER_URL:
|
|
raise HTTPException(status_code=404, detail="Not found.")
|
|
|
|
tier, keys, set_cookie = auth.resolve_identity(request)
|
|
tracked = LIMITER_ENABLED and limiter.budget_for(tier) is not None
|
|
logger.debug(
|
|
"[EXT][LB] queue poll: client=%s queueId=%s tier=%s",
|
|
client_ip, queue_id[:16], tier,
|
|
)
|
|
|
|
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
|
logger.debug("[EXT][LB] queue GET %s", url)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as http:
|
|
lb = await http.get(url)
|
|
except httpx.RequestError as exc:
|
|
logger.warning("Load balancer unreachable: %r", exc)
|
|
raise HTTPException(status_code=502, detail="Speech service unreachable.")
|
|
logger.debug(
|
|
"[EXT][LB] queue RESP status=%s len=%dB",
|
|
lb.status_code, len(lb.content or b""),
|
|
)
|
|
|
|
if lb.status_code == 404:
|
|
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
|
|
# to start over rather than spin.
|
|
logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
|
|
resp = JSONResponse({"state": "expired"}, status_code=404)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
if lb.status_code != 200:
|
|
logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
|
|
raise HTTPException(status_code=502, detail=f"Queue poll failed ({lb.status_code}).")
|
|
|
|
data = lb.json()
|
|
state = data.get("state", "?")
|
|
position = data.get("position")
|
|
logger.debug(
|
|
"[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s",
|
|
queue_id[:16], state, position,
|
|
data.get("session_id") or data.get("sessionId"),
|
|
)
|
|
|
|
if data.get("state") == "queued":
|
|
data["tier"] = tier
|
|
resp = JSONResponse(data)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
# Claimed a slot. Re-check the budget: it may have been spent in another tab
|
|
# during the wait. If so, refuse — the just-claimed slot is now a pending
|
|
# session on the LB and its pending-timeout reaper reclaims it shortly.
|
|
if tracked:
|
|
rem = await asyncio.to_thread(limiter.remaining, keys, tier)
|
|
logger.debug(
|
|
"[EXT][LB] queue CLAIMED queueId=%s budget remaining=%s",
|
|
queue_id[:16], rem,
|
|
)
|
|
if rem is not None and rem <= 0:
|
|
resp = JSONResponse(
|
|
{"tier": tier, "reason": "limit", "remainingSec": 0}, status_code=402
|
|
)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
return await _finalize_grant(data, keys, tier, tracked, set_cookie)
|
|
|
|
|
|
@app.delete("/api/queue/{queue_id}")
|
|
async def queue_leave(queue_id: str):
|
|
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
|
|
if not LOAD_BALANCER_URL:
|
|
raise HTTPException(status_code=404, detail="Not found.")
|
|
logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
|
|
await _lb_leave(queue_id)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/queue/end")
|
|
async def queue_end(request: Request):
|
|
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only
|
|
POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
|
|
if not LOAD_BALANCER_URL:
|
|
raise HTTPException(status_code=404, detail="Not found.")
|
|
qid = await _queue_id(request)
|
|
if qid:
|
|
logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
|
|
await _lb_leave(qid)
|
|
return {"ok": True}
|
|
|
|
|
|
async def _finalize_grant(data, keys, tier, tracked, set_cookie):
|
|
"""Shared grant tail (fast path or queue claim): reserve the first chunk, attach
|
|
the metering fields the client needs, and set the anon cookie."""
|
|
remaining = None
|
|
session_id = data.get("session_id")
|
|
if tracked and session_id:
|
|
logger.debug(
|
|
"[EXT][LB] grant FINALIZE sessionId=%s tier=%s tracked=%s",
|
|
session_id, tier, tracked,
|
|
)
|
|
await asyncio.to_thread(limiter.begin, session_id, keys, tier)
|
|
remaining = await asyncio.to_thread(limiter.remaining, keys, tier)
|
|
logger.debug(
|
|
"[EXT][LB] grant RESERVED sessionId=%s remaining=%s",
|
|
session_id, remaining,
|
|
)
|
|
|
|
data.update({
|
|
"tier": tier,
|
|
"limited": tracked,
|
|
"remainingSec": remaining,
|
|
"heartbeatSec": limiter.HEARTBEAT_SEC,
|
|
})
|
|
resp = JSONResponse(data)
|
|
if set_cookie:
|
|
auth.set_anon_cookie(resp, set_cookie)
|
|
return resp
|
|
|
|
|
|
async def _lb_leave(queue_id: str) -> None:
|
|
"""Best-effort: tell the LB to drop a waiting ticket."""
|
|
url = f"{LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
|
logger.debug("[EXT][LB] queue DELETE %s", url)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as http:
|
|
resp = await http.delete(url)
|
|
logger.debug(
|
|
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
|
|
queue_id[:16], resp.status_code,
|
|
)
|
|
except httpx.RequestError as exc:
|
|
logger.warning("Queue leave failed: %r", exc)
|
|
|
|
|
|
def _safe_json(response) -> dict:
|
|
try:
|
|
body = response.json()
|
|
except Exception:
|
|
return {}
|
|
return body if isinstance(body, dict) else {}
|
|
|
|
|
|
def _proxy_public_url(request: Request) -> str:
|
|
if not S2S_PROXY_TARGET:
|
|
return ""
|
|
forwarded_proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip()
|
|
forwarded_host = (request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc).split(",")[0].strip()
|
|
ws_scheme = "wss" if forwarded_proto in {"https", "wss"} else "ws"
|
|
root_path = (request.scope.get("root_path") or "").rstrip("/")
|
|
return f"{ws_scheme}://{forwarded_host}{root_path}/v1/realtime"
|
|
|
|
|
|
|
|
|
|
|
|
def _proxy_upstream_url(ws: WebSocket) -> str:
|
|
target = urlsplit(S2S_PROXY_TARGET)
|
|
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
|
|
merged_query.update(parse_qsl(ws.url.query, keep_blank_values=True))
|
|
return urlunsplit(
|
|
(
|
|
target.scheme,
|
|
target.netloc,
|
|
target.path or "/v1/realtime",
|
|
urlencode(merged_query, doseq=True),
|
|
target.fragment,
|
|
)
|
|
)
|
|
|
|
|
|
def _subprotocols(ws: WebSocket) -> list[str]:
|
|
raw = ws.headers.get("sec-websocket-protocol", "")
|
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
|
|
|
|
async def _client_to_upstream(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None:
|
|
while True:
|
|
message = await ws.receive()
|
|
if message["type"] == "websocket.disconnect":
|
|
# #region debug-point B:client-disconnect
|
|
await _report_server_debug(
|
|
"B",
|
|
"server.py:_client_to_upstream",
|
|
"client websocket disconnected",
|
|
{"code": message.get("code")},
|
|
)
|
|
# #endregion
|
|
logger.debug(
|
|
"[EXT][S2S-PROXY] client=%s client→upstream DISCONNECT code=%s",
|
|
client_ip, message.get("code"),
|
|
)
|
|
await upstream.close()
|
|
return
|
|
if message.get("text") is not None:
|
|
# #region debug-point B:client-text-forward
|
|
summary = _realtime_event_summary(message["text"])
|
|
if summary:
|
|
await _report_server_debug(
|
|
"B",
|
|
"server.py:_client_to_upstream",
|
|
"forwarding client event to upstream",
|
|
summary,
|
|
)
|
|
event_type = summary.get("type")
|
|
# --- INPUT UTENTE lato client (text typed / inserted items) ---
|
|
user_text = ""
|
|
if event_type == "conversation.item.created" and summary.get("role") == "user":
|
|
user_text = (summary.get("text") or "").strip()
|
|
if user_text:
|
|
logger.info(
|
|
"[INPUT UTENTE] client=%s text=%s",
|
|
client_ip, user_text,
|
|
)
|
|
if (event_type not in _NOISY_CLIENT_EVENT_TYPES
|
|
and event_type not in _NOISY_REALTIME_EVENT_TYPES):
|
|
logger.debug(
|
|
"[EXT][S2S-PROXY] client=%s →UPSTREAM text event=%s summary=%s",
|
|
client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300],
|
|
)
|
|
# #endregion
|
|
# --- S2S_INJECT_PROFILE: if this is a session.update client message
|
|
# and we have a resolved caller profile / podcast episode / latest caller summary,
|
|
# patch instructions to include them before forwarding upstream.
|
|
# Pure no-op when S2S_INJECT_PROFILE=false (original pure
|
|
# passthrough behaviour).
|
|
ctx_episode_context = session_ctx.get("injected_episode_context") if isinstance(session_ctx, dict) else ""
|
|
ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else ""
|
|
ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else ""
|
|
ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else ""
|
|
ctx_apipilot_pass1 = session_ctx.get("injected_apipilot_pass1") if isinstance(session_ctx, dict) else ""
|
|
ctx_profile_obj = session_ctx.get("profile_obj") if isinstance(session_ctx, dict) else None
|
|
forward_text = _patch_client_session_update_with_profile(
|
|
message["text"], ctx_profile_context or "", ctx_reports_context or "", ctx_summary_context or "", ctx_episode_context or "",
|
|
profile_obj=ctx_profile_obj,
|
|
apipilot_context_text_pass1=ctx_apipilot_pass1 or "",
|
|
)
|
|
await upstream.send(forward_text)
|
|
elif message.get("bytes") is not None:
|
|
# #region debug-point B:client-bytes-forward
|
|
await _report_server_debug(
|
|
"B",
|
|
"server.py:_client_to_upstream",
|
|
"forwarding client binary frame to upstream",
|
|
{"bytes": len(message["bytes"])},
|
|
)
|
|
logger.debug(
|
|
"[EXT][S2S-PROXY] client=%s →UPSTREAM bytes=%dB",
|
|
client_ip, len(message["bytes"]),
|
|
)
|
|
# #endregion
|
|
await upstream.send(message["bytes"])
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# 2026-09-11 POST-PROCESSING SANITIZZAZIONE OUTPUT AGENTE (server-side, strato 7)
|
|
#
|
|
# Qwen2.5 GPTQ quantizzato continua a sforare anche con 6 strati di prompt lock:
|
|
# • [rightspeak] ... [endspeak] → marker bracket non richiesti
|
|
# • [-]: checking_* → "pensieri interni"
|
|
# • {'reportDetails': '...'} → JSON literal inline nel TTS
|
|
# • Alessandro → Alexander → nome proper normalizzato all'inglese
|
|
#
|
|
# Nessun prompt è sufficiente al 100% con GPTQ. Introduciamo uno strato 7
|
|
# server-side di post-processing: TUTTO il testo in uscita dall'upstream
|
|
# viene filtrato PRIMA di essere inoltrato al client e PRIMA del log.
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
def _extract_profile_for_session(session_ctx: dict) -> dict:
|
|
"""Return the profile dict stored in session_ctx (phone auth result)."""
|
|
p = session_ctx.get("profile_obj") or session_ctx.get("user_profile") or {}
|
|
if isinstance(p, dict) and isinstance(p.get("user"), dict):
|
|
return p["user"]
|
|
if isinstance(p, dict):
|
|
return p
|
|
return {}
|
|
|
|
|
|
def _common_name_variants(original_name: str) -> list[str]:
|
|
"""For a user's first/last name, return known anglicized / adapted variants
|
|
that the model may produce (e.g. Alessandro → Alexander). Used by
|
|
sanitizer to replace variant → original.
|
|
|
|
Map is conservative (only common names we have observed in the wild so
|
|
far). Any new name can be added here without touching other code.
|
|
"""
|
|
variants_map: dict[str, tuple[str, ...]] = {
|
|
# Western / Romance masculine first names — anglicized / cognates
|
|
"Alessandro": ("Alexander", "Alexandre", "Aleksander", "Alexsander",
|
|
"Alessander", "Aleksandr", "Alexander", "Alejandro",
|
|
"Alesandro", "Allessandro", "Alex"),
|
|
"Alessandra": ("Alexandra", "Aleksandra", "Alejandra", "Alex"),
|
|
"Valenti": ("Valentini", "Valentine", "Valentin", "Valente",
|
|
"Valentín", "Valentí"),
|
|
"Giovanni": ("John", "Juan", "Giovani", "Gianni", "Joan", "Ivan"),
|
|
"Paolo": ("Paul", "Pablo", "Paulo", "Paul"),
|
|
"Marco": ("Marc", "Mark", "Marcos"),
|
|
"Luca": ("Lucas", "Luke", "Lukas"),
|
|
"Matteo": ("Matthew", "Matías", "Mateo", "Matt"),
|
|
"Francesco": ("Francis", "Francisco", "Francesc"),
|
|
"Leonardo": ("Leonard", "Leonhard"),
|
|
"Giuseppe": ("Joseph", "José", "Giuseppi", "Pepe"),
|
|
"Mario": ("Maryo", "Mari", "Marion"),
|
|
# Feminine
|
|
"Sofia": ("Sophia", "Sophie", "Sofía", "Sofie"),
|
|
"Giulia": ("Julia", "Juliana", "Julie", "Giuliana"),
|
|
"Anna": ("Ana", "Anne", "Hannah", "Ann"),
|
|
}
|
|
out: list[str] = []
|
|
if not original_name:
|
|
return out
|
|
o = original_name.strip()
|
|
if o in variants_map:
|
|
out.extend(variants_map[o])
|
|
# Also check case-insensitive match as safety net
|
|
lower_map = {k.lower(): v for k, v in variants_map.items()}
|
|
if o.lower() in lower_map:
|
|
for v in lower_map[o.lower()]:
|
|
if v not in out:
|
|
out.append(v)
|
|
return out
|
|
|
|
|
|
def _sanitize_agent_output(text, session_ctx=None) -> str:
|
|
"""Server-side FINAL filter applied to ALL assistant output text.
|
|
|
|
Guarantees (belt-and-suspenders over the 6 prompt layers):
|
|
1. No [...][xxx] bracket markers of any kind.
|
|
2. No inline { ... } JSON / dict literals.
|
|
3. No [-]: <internal action name> pseudo-markers.
|
|
4. No name anglicization: Alessandro → restored to Alessandro.
|
|
5. Trim + collapse multiple spaces.
|
|
"""
|
|
import re
|
|
s = str(text or "")
|
|
if not s:
|
|
return s
|
|
# (0) Remove "action line" pseudo-markers FIRST, BEFORE stripping all
|
|
# other brackets, because the pattern uses the literal "[-]" prefix
|
|
# which would get silently swallowed by step (1) otherwise. Match:
|
|
# [-]: checking_podcast_report (bracket literal then colon)
|
|
# []: action_name (empty bracket variant)
|
|
# [ - ] : do_stuff (spaces inside & around)
|
|
# Pattern is intentionally TIGHT: after the colon we only take up to 6
|
|
# underscore/hyphen words so we cannot accidentally eat the following
|
|
# real user speech (bug: greedy class with ,. and 200-char upper bound ate
|
|
# the entire "Claro, revisaré el reporte." clause on 2026-09-11).
|
|
s = re.sub(
|
|
r"\[[\s\-]{0,6}\]\s*:\s*[a-z0-9_\-]+(?:[ \t]+[a-z0-9_\-]+){0,5}",
|
|
" ",
|
|
s,
|
|
)
|
|
# Also catch dangling residual patterns if brackets got normalized to a
|
|
# single char already — handles cases where upstream writes the "colon"
|
|
# action marker without explicit brackets.
|
|
s = re.sub(r"(?<!\w):\s*checking_[A-Za-z0-9_\-]{1,120}", " ", s)
|
|
# (1) Remove ALL bracket-markers — [rightspeak], [endspeak], [thinking],
|
|
# [anything_else01]. We also remove stray [ / ] that don't form a
|
|
# complete pair (e.g. leftover "hello] there").
|
|
s = re.sub(r"\[[^\]\n]{0,80}\]", " ", s)
|
|
# (2) Remove inline dict / JSON literals. Pattern: { ... }, possibly
|
|
# split across a few \n (not greedy). Conservative: matches balanced
|
|
# pairs up to a reasonable length, otherwise fallback.
|
|
_max_iter = 5
|
|
for _ in range(_max_iter):
|
|
new_s = re.sub(r"\{[^{}]{0,1600}\}", " ", s)
|
|
if new_s == s:
|
|
break
|
|
s = new_s
|
|
# (3) Name anglicization. Iterate profile name's known variants →
|
|
# replace with original (case-insensitive match).
|
|
if session_ctx is not None and isinstance(session_ctx, dict):
|
|
profile = _extract_profile_for_session(session_ctx)
|
|
if profile:
|
|
full_name = str(profile.get("name") or "").strip()
|
|
if full_name:
|
|
# Split into parts and check each
|
|
parts = [p for p in re.split(r"[\s\-_]+", full_name) if p]
|
|
for part in parts:
|
|
variants = _common_name_variants(part)
|
|
orig_cap = part[0].upper() + part[1:] if part else part
|
|
for variant in variants:
|
|
# Case-insensitive global replace → original case
|
|
s = re.sub(
|
|
r"\b" + re.escape(variant) + r"\b",
|
|
orig_cap,
|
|
s,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
# Also try full-name variants (combined)
|
|
combined = full_name.replace(" ", "")
|
|
# (5) Collapse whitespace. Re-punctuation cleanup: avoid normalizing
|
|
# legitimate terminal "?" / "!" into nothing; only collapse doubled
|
|
# or space-separated punctuation that resulted from earlier deletes.
|
|
s = re.sub(r"[ \t\u00a0]+", " ", s)
|
|
s = re.sub(r"\n{3,}", "\n\n", s)
|
|
# Interior punctuation: normalize spaces, but keep the char itself.
|
|
# Example: "hello , world" → "hello, world"
|
|
s = re.sub(r"\s+([,.;:])\s+", r"\1 ", s)
|
|
# Terminal / exclamation / interrogation: only fix spacing, never strip.
|
|
s = re.sub(r"\s+([!?])", r"\1", s)
|
|
s = re.sub(r"([!?])\s{2,}", r"\1 ", s)
|
|
# Strip leftover whitespace. Do NOT strip the legitimate terminal
|
|
# punctuation chars ?, !, ., because they are part of the real speech.
|
|
# Only strip repeated trailing commas / semicolons that appear because
|
|
# we deleted bracket/JSON fragments that separated clauses.
|
|
s = s.strip()
|
|
# Strip 2+ identical terminal punctuation chars (e.g. "hello.." → "hello.")
|
|
s = re.sub(r"([,.;:!?])\1{1,}$", r"\1", s)
|
|
# Strip terminal comma / semicolon / colon that now dangle (clause joins)
|
|
# but preserve ., ?, !.
|
|
s = re.sub(r"[,;:]$", "", s).strip()
|
|
return s
|
|
|
|
|
|
def _sanitize_realtime_event_raw(raw_message, session_ctx=None) -> tuple:
|
|
"""If the raw upstream event contains assistant OUTPUT text, sanitize it
|
|
in-place and return (new_json_str, True) — otherwise return (raw, False).
|
|
|
|
Mirrors exactly the same traversal used by _realtime_event_summary so we
|
|
touch every field that would become summary["text"].
|
|
"""
|
|
import json as _json
|
|
if not isinstance(raw_message, str) or not raw_message.strip():
|
|
return raw_message, False
|
|
try:
|
|
payload = _json.loads(raw_message)
|
|
except Exception:
|
|
return raw_message, False
|
|
if not isinstance(payload, dict):
|
|
return raw_message, False
|
|
event_type = payload.get("type")
|
|
if not isinstance(event_type, str):
|
|
return raw_message, False
|
|
|
|
changed = False
|
|
|
|
def _sanitize_value(v):
|
|
nonlocal changed
|
|
if isinstance(v, str) and v.strip():
|
|
cleaned = _sanitize_agent_output(v, session_ctx)
|
|
# 2026-09-25 STRATO 8: Rimuovi marker CALL intern ApiPilot
|
|
# prima che il testo arrivi al client (UI chat + TTS audio).
|
|
# L'accumulatore transcript per PASS-2 marker parser USA l'originale
|
|
# quindi non perdiamo l'abilità di triggerare execute.
|
|
cleaned = _sanitize_text_strip_apipilot_markers(cleaned)
|
|
if cleaned != v:
|
|
changed = True
|
|
return cleaned
|
|
return v
|
|
|
|
# --- Event dispatch (same shape as _realtime_event_summary) -------------
|
|
# --- 2026-09-25: ORA SANITIZZIAMO ANCHE TUTTI I DELTA STREAMING oltre ai .done ---
|
|
# Perche' Qwen3-TTS (start_pipeline_rag) legge internamente i delta PRIMA di
|
|
# generare l'audio binario. Se non sanitizziamo i delta → TTS pronuncia il marker.
|
|
if event_type in {"response.audio_transcript.done",
|
|
"response.audio_transcript.delta"}:
|
|
if "transcript" in payload:
|
|
payload["transcript"] = _sanitize_value(payload["transcript"])
|
|
if "delta" in payload and isinstance(payload.get("delta"), str):
|
|
payload["delta"] = _sanitize_value(payload["delta"])
|
|
for container in ("item", "part", "content", "output", "audio"):
|
|
sub = payload.get(container)
|
|
if isinstance(sub, dict):
|
|
for key in ("transcript", "text", "delta"):
|
|
if key in sub and isinstance(sub.get(key), str):
|
|
sub[key] = _sanitize_value(sub[key])
|
|
elif event_type in {"response.output_audio_transcript.done",
|
|
"response.output_audio_transcript.delta"}:
|
|
# Top-level keys
|
|
for key in ("transcript", "text", "output_text", "audio_text",
|
|
"output_transcript", "final_transcript", "output_audio_text",
|
|
"content", "message", "utterance", "delta"):
|
|
if key in payload and isinstance(payload.get(key), str):
|
|
payload[key] = _sanitize_value(payload[key])
|
|
# Sub-containers
|
|
for container in ("item", "part", "content", "output", "delta", "audio"):
|
|
sub = payload.get(container)
|
|
if isinstance(sub, dict):
|
|
for key in ("transcript", "text", "output_text", "delta"):
|
|
if key in sub and isinstance(sub.get(key), str):
|
|
sub[key] = _sanitize_value(sub[key])
|
|
# content[] array
|
|
if isinstance(sub.get("content"), list):
|
|
for c in sub["content"]:
|
|
if isinstance(c, dict):
|
|
for k2 in ("transcript", "text", "delta"):
|
|
if k2 in c and isinstance(c.get(k2), str):
|
|
c[k2] = _sanitize_value(c[k2])
|
|
if isinstance(payload.get("content"), list):
|
|
for c in payload["content"]:
|
|
if isinstance(c, dict):
|
|
for k2 in ("transcript", "text", "delta"):
|
|
if k2 in c and isinstance(c.get(k2), str):
|
|
c[k2] = _sanitize_value(c[k2])
|
|
elif event_type in {"response.text.delta", "response.text.done",
|
|
"response.output_text.delta", "response.output_text.done"}:
|
|
for key in ("text", "output_text", "delta", "final_text", "content"):
|
|
if key in payload and isinstance(payload.get(key), str):
|
|
payload[key] = _sanitize_value(payload[key])
|
|
for container in ("item", "part", "output", "content", "text"):
|
|
sub = payload.get(container)
|
|
if isinstance(sub, dict):
|
|
for key in ("text", "output_text", "delta"):
|
|
if key in sub and isinstance(sub.get(key), str):
|
|
sub[key] = _sanitize_value(sub[key])
|
|
if isinstance(sub.get("content"), list):
|
|
for c in sub["content"]:
|
|
if isinstance(c, dict):
|
|
for k2 in ("text", "transcript", "delta"):
|
|
if k2 in c and isinstance(c.get(k2), str):
|
|
c[k2] = _sanitize_value(c[k2])
|
|
if isinstance(payload.get("content"), list):
|
|
for c in payload["content"]:
|
|
if isinstance(c, dict):
|
|
for k2 in ("text", "transcript", "delta"):
|
|
if k2 in c and isinstance(c.get(k2), str):
|
|
c[k2] = _sanitize_value(c[k2])
|
|
elif event_type == "response.content_part.done":
|
|
part = payload.get("part")
|
|
if isinstance(part, dict):
|
|
if "text" in part and isinstance(part.get("text"), str):
|
|
part["text"] = _sanitize_value(part["text"])
|
|
if "transcript" in part and isinstance(part.get("transcript"), str):
|
|
part["transcript"] = _sanitize_value(part["transcript"])
|
|
elif event_type == "response.content_part.added":
|
|
part = payload.get("part")
|
|
if isinstance(part, dict):
|
|
if "text" in part and isinstance(part.get("text"), str):
|
|
part["text"] = _sanitize_value(part["text"])
|
|
if "transcript" in part and isinstance(part.get("transcript"), str):
|
|
part["transcript"] = _sanitize_value(part["transcript"])
|
|
elif event_type in {"response.content_part.text.delta",
|
|
"response.output_content.text.delta",
|
|
"response.content.text.delta"}:
|
|
for key in ("text", "delta"):
|
|
if key in payload and isinstance(payload.get(key), str):
|
|
payload[key] = _sanitize_value(payload[key])
|
|
elif event_type in {"response.output_item.done",
|
|
"response.output_item.added"}:
|
|
item = payload.get("item")
|
|
if isinstance(item, dict):
|
|
for c in (item.get("content") or []):
|
|
if isinstance(c, dict):
|
|
if "text" in c and isinstance(c["text"], str):
|
|
c["text"] = _sanitize_value(c["text"])
|
|
if "transcript" in c and isinstance(c["transcript"], str):
|
|
c["transcript"] = _sanitize_value(c["transcript"])
|
|
elif event_type == "conversation.item.created":
|
|
item = payload.get("item")
|
|
if isinstance(item, dict) and item.get("role") == "assistant":
|
|
for c in (item.get("content") or []):
|
|
if isinstance(c, dict):
|
|
if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str):
|
|
c["text"] = _sanitize_value(c["text"])
|
|
if c.get("type") == "transcript" and isinstance(c.get("transcript"), str):
|
|
c["transcript"] = _sanitize_value(c["transcript"])
|
|
|
|
if not changed:
|
|
return raw_message, False
|
|
try:
|
|
new_raw = _json.dumps(payload, ensure_ascii=False)
|
|
except Exception:
|
|
return raw_message, False
|
|
return new_raw, True
|
|
|
|
|
|
async def _upstream_to_client(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None:
|
|
async for message in upstream:
|
|
if isinstance(message, bytes):
|
|
# 2026-09-25 GATE AUDIO BYTES: se response_id corrente è stato marcato
|
|
# come "contiene marker CALL" → DROPPARE tutti i binari audio (Qwen3
|
|
# ha gia generato audio con marker pronuciato, non lo mandiamo al client).
|
|
# IMPORTANTE oggi: MATCH STRETTAMENTE per response_id CORRENTE. Nessun
|
|
# fallback "qualsiasi id" (troppo largo: droppava anche audio PASS-2 buono).
|
|
drop_now = False
|
|
matched_rid = ""
|
|
drop_ids = session_ctx.get("_ws_drop_audio_for_response_ids")
|
|
if isinstance(drop_ids, dict) and drop_ids:
|
|
rid_ctx = session_ctx.get("_ws_current_response_id")
|
|
if isinstance(rid_ctx, str) and rid_ctx and drop_ids.get(rid_ctx):
|
|
drop_now = True
|
|
matched_rid = rid_ctx
|
|
elif (not isinstance(rid_ctx, str) or not rid_ctx) and len(drop_ids) == 1:
|
|
# Caso safety: nessun response_id corrente disponibile ma
|
|
# abbiamo 1 solo entry nel dict (probabilmente quella risposta).
|
|
only_rid = next(iter(drop_ids.keys()))
|
|
if drop_ids.get(only_rid):
|
|
drop_now = True
|
|
matched_rid = only_rid
|
|
if drop_now:
|
|
logger.info("[APIPILOT-AUDIO-DROP] FRAME DROP bytes=%dB matchedResponse=%s (marker CALL in questa risposta → Qwen3 audio sporco SCARTATO)", len(message), (matched_rid or session_ctx.get("_ws_current_response_id") or "-"))
|
|
continue
|
|
else:
|
|
logger.debug(
|
|
"[APIPILOT-AUDIO-ALLOW] FRAME PASS bytes=%dB responseCtx=%s dropIds=%s",
|
|
len(message),
|
|
(session_ctx.get("_ws_current_response_id") or "-"),
|
|
(sorted(list(drop_ids.keys())) if isinstance(drop_ids, dict) else "none"),
|
|
)
|
|
# #region debug-point B:upstream-bytes-forward
|
|
await _report_server_debug(
|
|
"B",
|
|
"server.py:_upstream_to_client",
|
|
"forwarding upstream binary frame to client",
|
|
{"bytes": len(message)},
|
|
)
|
|
logger.debug(
|
|
"[EXT][S2S-PROXY] client=%s UPSTREAM→ bytes=%dB",
|
|
client_ip, len(message),
|
|
)
|
|
# #endregion
|
|
await ws.send_bytes(message)
|
|
else:
|
|
# ── PRIMA DEL SANITIZE: estrai ORIGINALE per PASSATA-2 marker parser ──
|
|
# (marker sarebbe cancellato da STRATO 7)
|
|
pre_sanitize_summary = _realtime_event_summary(message)
|
|
pre_event_type = pre_sanitize_summary.get("type") if isinstance(pre_sanitize_summary, dict) else None
|
|
pre_response_id: str | None = None
|
|
if isinstance(pre_sanitize_summary, dict):
|
|
if isinstance(pre_sanitize_summary.get("response_id"), str):
|
|
pre_response_id = pre_sanitize_summary["response_id"]
|
|
elif isinstance(pre_sanitize_summary.get("id"), str):
|
|
pre_response_id = pre_sanitize_summary["id"]
|
|
# 2026-09-25: memorizza response_id corrente per il gate bytes audio
|
|
if isinstance(pre_response_id, str) and pre_response_id:
|
|
session_ctx["_ws_current_response_id"] = pre_response_id
|
|
# ── MARKER DETECTION PER EVENTO SINGOLO (non solo response.done) ──
|
|
# Se in QUALSIASI evento testo di questa risposta troviamo marker CALL
|
|
# → flag gate DROP bytes audio per questo response_id + sanitize anche i prossimi
|
|
if (
|
|
isinstance(pre_sanitize_summary, dict)
|
|
and isinstance(pre_event_type, str)
|
|
and pre_event_type not in {"response.done"}
|
|
):
|
|
quick_text_parts: list[str] = []
|
|
for _k in ("text", "transcript", "output_text", "audio_text",
|
|
"output_transcript", "final_transcript", "output_audio_text",
|
|
"content", "message", "utterance", "delta", "_raw_preview"):
|
|
_v = pre_sanitize_summary.get(_k)
|
|
if isinstance(_v, str) and _v.strip():
|
|
quick_text_parts.append(_v)
|
|
for _cname in ("item", "part", "output", "audio"):
|
|
_sub = pre_sanitize_summary.get(_cname)
|
|
if isinstance(_sub, dict):
|
|
for _k2 in ("text", "transcript", "output_text"):
|
|
_v2 = _sub.get(_k2)
|
|
if isinstance(_v2, str) and _v2.strip():
|
|
quick_text_parts.append(_v2)
|
|
quick_text = (" ".join(quick_text_parts)).strip()
|
|
if quick_text:
|
|
_detected_name, _detected_args = _apipilot_parse_call_marker(quick_text)
|
|
if isinstance(_detected_name, str) and _detected_name:
|
|
drop_ids2 = session_ctx.get("_ws_drop_audio_for_response_ids")
|
|
if not isinstance(drop_ids2, dict):
|
|
drop_ids2 = {}
|
|
session_ctx["_ws_drop_audio_for_response_ids"] = drop_ids2
|
|
_flag_rid = (pre_response_id if isinstance(pre_response_id, str) and pre_response_id
|
|
else f"auto-{id(session_ctx)}")
|
|
drop_ids2[_flag_rid] = True
|
|
# cleanup vecchi id se > 10
|
|
if len(drop_ids2) > 10:
|
|
try:
|
|
for _old_k in list(drop_ids2.keys())[:-10]:
|
|
drop_ids2.pop(_old_k, None)
|
|
except Exception:
|
|
pass
|
|
logger.info("[APIPILOT-SANITIZE] marker CALL in event=%s response=%s name=%s → GATE AUDIO DROP attivo per questa risposta (Qwen3 generera' audio sporco, sarà scartato)", pre_event_type, _flag_rid, _detected_name)
|
|
# 1a) PASSATA-2 pre-sanitize transcript accumulatore TTS audio
|
|
if (
|
|
isinstance(pre_sanitize_summary, dict)
|
|
and isinstance(pre_event_type, str)
|
|
and pre_event_type in {"response.audio_transcript.done",
|
|
"response.output_audio_transcript.done"}
|
|
):
|
|
pre_agent_text = (pre_sanitize_summary.get("text") if isinstance(pre_sanitize_summary.get("text"), str) else "").strip()
|
|
rid_key = (
|
|
pre_response_id if isinstance(pre_response_id, str) and pre_response_id
|
|
else f"auto-{id(session_ctx)}"
|
|
)
|
|
if rid_key and pre_agent_text:
|
|
acc = session_ctx.get("_ws_apipilot_asst_transcript_acc")
|
|
if not isinstance(acc, dict):
|
|
acc = {}
|
|
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc
|
|
prev = acc.get(rid_key, "")
|
|
if prev:
|
|
acc[rid_key] = f"{prev} {pre_agent_text}".strip()
|
|
else:
|
|
acc[rid_key] = pre_agent_text
|
|
# 1b) PASSATA-2 pre-sanitize accumulatore ANCHE da conversation.item.created (assistant)
|
|
# LLM emette il marker anche nel messaggio di testo conversation (non solo TTS!)
|
|
elif (
|
|
isinstance(pre_sanitize_summary, dict)
|
|
and isinstance(pre_event_type, str)
|
|
and pre_event_type == "conversation.item.created"
|
|
and pre_sanitize_summary.get("role") == "assistant"
|
|
):
|
|
pre_conv_text = (pre_sanitize_summary.get("text") if isinstance(pre_sanitize_summary.get("text"), str) else "").strip()
|
|
rid_key2 = (
|
|
pre_response_id if isinstance(pre_response_id, str) and pre_response_id
|
|
else f"auto-{id(session_ctx)}"
|
|
)
|
|
if rid_key2 and pre_conv_text:
|
|
acc2 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
|
|
if not isinstance(acc2, dict):
|
|
acc2 = {}
|
|
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc2
|
|
prev2 = acc2.get(rid_key2, "")
|
|
if prev2:
|
|
acc2[rid_key2] = f"{prev2} {pre_conv_text}".strip()
|
|
else:
|
|
acc2[rid_key2] = pre_conv_text
|
|
# 2) PASSATA-2 pre-sanitize response.done trigger marker
|
|
if (
|
|
isinstance(pre_sanitize_summary, dict)
|
|
and isinstance(pre_event_type, str)
|
|
and pre_event_type == "response.done"
|
|
):
|
|
rid_val: str = (
|
|
pre_response_id if isinstance(pre_response_id, str)
|
|
else (pre_sanitize_summary.get("id") if isinstance(pre_sanitize_summary.get("id"), str) else f"auto-{id(session_ctx)}")
|
|
)
|
|
full_text = ""
|
|
acc3 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
|
|
if rid_val and isinstance(acc3, dict):
|
|
full_text = (acc3.pop(rid_val, "") if isinstance(acc3, dict) else "") or ""
|
|
# Fallback: se response_id non matcha ma abbiamo un solo accumulo rimasto → usiamo
|
|
# quello (alcuni upstream generano response_id diversi tra eventi)
|
|
if (not isinstance(full_text, str) or not full_text.strip()) and isinstance(acc3, dict) and len(acc3) == 1:
|
|
_only_key = next(iter(acc3.keys()))
|
|
full_text = (acc3.pop(_only_key, "") if isinstance(acc3, dict) else "") or ""
|
|
marker_name, marker_args = None, None
|
|
if isinstance(full_text, str) and full_text.strip():
|
|
_p1_defaults = session_ctx.get("apipilot_defaults") if isinstance(session_ctx.get("apipilot_defaults"), dict) else {}
|
|
marker_name, marker_args = _apipilot_parse_call_marker(full_text, defaults=_p1_defaults)
|
|
logger.info("[APIPILOT-CALL-NAME] USE=WS_RESPONSE_DONE_DETECTION markerFound=%s name=%r argsKeys=%s", ("Y" if isinstance(marker_name, str) and marker_name else "N"), (marker_name or ""), list((marker_args or {}).keys()))
|
|
# ── LOG ALWAYS-ON diagnostico PASS-2 marker detection ────────
|
|
try:
|
|
_preview = full_text.replace("\n", " ")
|
|
if len(_preview) > 500:
|
|
_preview = _preview[:500] + "…"
|
|
except Exception:
|
|
_preview = str(full_text)[:500]
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=%d markerFound=%s markerName=%s argsKeys=%s defaultsAutoFill=%s acc3Remaining=%d fullTextPreview=%r",
|
|
len(full_text or ""),
|
|
("Y" if isinstance(marker_name, str) and marker_name else "N"),
|
|
(marker_name if isinstance(marker_name, str) and marker_name else ""),
|
|
(list(marker_args.keys()) if isinstance(marker_args, dict) else []),
|
|
(list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none"),
|
|
(len(acc3) if isinstance(acc3, dict) else -1),
|
|
_preview,
|
|
)
|
|
elif isinstance(full_text, str):
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=0 EMPTY markerFound=N response_id=%r acc3Remaining=%d",
|
|
rid_val,
|
|
(len(acc3) if isinstance(acc3, dict) else -1),
|
|
)
|
|
else:
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullText is not str type=%r acc3=%r",
|
|
type(full_text).__name__,
|
|
(list(acc3.keys()) if isinstance(acc3, dict) else "?"),
|
|
)
|
|
needs_pass2 = (
|
|
isinstance(marker_name, str) and bool(marker_name)
|
|
and not bool(session_ctx.get("_ws_apipilot_pass2_active"))
|
|
and (int(session_ctx.get("_ws_apipilot_pass2_count") or 0) < 3)
|
|
)
|
|
if needs_pass2:
|
|
# 2026-09-25: PULIZIA GATE AUDIO DROP.
|
|
# La risposta 1 (marker CALL) ha l'audio sporco (gate attivo DROP bytes).
|
|
# PASS-2 e' la risposta 2 con dati veri dell'utente → audio buono,
|
|
# quindi CANCELLIAMO il gate DROP per gli ID attivi.
|
|
drop_ids_clean = session_ctx.get("_ws_drop_audio_for_response_ids")
|
|
if isinstance(drop_ids_clean, dict) and drop_ids_clean:
|
|
drop_ids_clean.clear()
|
|
logger.info("[APIPILOT-AUDIO-DROP] PASS-2 start: pulisco gate audio drop → audio PASS-2 rielaborazione briefing verra' inoltrato normalmente")
|
|
args = marker_args if isinstance(marker_args, dict) else {}
|
|
# ── SAFETY: over-ride parametri con valori REALI da defaults ──────
|
|
# 1) Se manca la chiave -> usiamo defaults.
|
|
# 2) Se il valore di args è UN VALORE DI ESEMPIO fittizio del prompt
|
|
# (regola #7: "+56912345678", altri placeholder tipo "+56XXXXXX",
|
|
# "+1234567890", "+390000000000", stringa vuota, +56XXXX) →
|
|
# sostituiamo SEMPRE con defaults reale se disponibile.
|
|
# Questo evita bug oggi: AI usa "+56912345678" (esempio regola 7)
|
|
# invece di "+447432477433" (vero Alessandro).
|
|
# 3) FALLBACK 2026-09-25: se defaults è vuoto ma c'è
|
|
# session_ctx["inject_phone"] da querystring → usiamo quello.
|
|
_p1_defaults_use = session_ctx.get("apipilot_defaults") if isinstance(session_ctx.get("apipilot_defaults"), dict) else {}
|
|
_fallback_inject_phone = session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else ""
|
|
# Placeholder noti che appaiono nel prompt come esempio.
|
|
_FAKE_PHONE_PLACEHOLDERS: set[str] = {
|
|
"+56912345678", # nostro esempio regola 7
|
|
"56912345678", # variante senza +
|
|
"+5612345678",
|
|
"+1234567890",
|
|
"+12345678",
|
|
"+56XXXXXXX",
|
|
"+56xxxxxxx",
|
|
"",
|
|
}
|
|
def _is_phone_fake(v: str) -> bool:
|
|
c = str(v or "").strip()
|
|
if c in _FAKE_PHONE_PLACEHOLDERS:
|
|
return True
|
|
if len(c) < 7:
|
|
return True
|
|
if c.endswith("00000"):
|
|
return True
|
|
if "123456" in c or "xxxxxx" in c.lower() or "xxxx" in c.lower():
|
|
return True
|
|
if (c.startswith("+56") and len(c) <= 9):
|
|
return True
|
|
return False
|
|
# Applichiamo defaults + fallback su TUTTE le chiavi disponibili
|
|
if isinstance(_p1_defaults_use, dict) and _p1_defaults_use:
|
|
for k, default_v in _p1_defaults_use.items():
|
|
if default_v in (None, "", []):
|
|
continue
|
|
if k not in args or args[k] in (None, "", []):
|
|
args[k] = default_v
|
|
elif k == "phone" and isinstance(args.get("phone"), str):
|
|
if _is_phone_fake(str(args.get("phone"))):
|
|
args[k] = default_v
|
|
# Fallback phone diretto se è fake ma defaults non aveva chiave phone
|
|
if (
|
|
isinstance(args.get("phone"), str)
|
|
and _is_phone_fake(str(args.get("phone")))
|
|
and isinstance(_fallback_inject_phone, str)
|
|
and _fallback_inject_phone.strip()
|
|
):
|
|
args["phone"] = _fallback_inject_phone.strip()
|
|
# Aggiorniamo _p1_defaults per i log dopo
|
|
_p1_defaults = _p1_defaults_use
|
|
session_ctx["_ws_apipilot_pass2_count"] = int(session_ctx.get("_ws_apipilot_pass2_count") or 0) + 1
|
|
session_ctx["_ws_apipilot_pass2_active"] = True
|
|
# 2026-09-25 PRIORITÀ auth e demo ESATTI della DISCOVERY (anon-ws-...)
|
|
# invece di crearne uno nuovo (ws-pass2-...) al volo.
|
|
# AquaPortal fa il binding tra strumenti autorizzati e la sessione
|
|
# x-demo-session che ha fatto GET /tools.
|
|
_exec_auth = session_ctx.get("apipilot_exec_auth") if isinstance(session_ctx.get("apipilot_exec_auth"), str) else None
|
|
_exec_demo = session_ctx.get("apipilot_exec_demo") if isinstance(session_ctx.get("apipilot_exec_demo"), str) else None
|
|
# Fallback se per caso vuoti (safety backward-compat):
|
|
if not isinstance(_exec_auth, str) or not _exec_auth.strip():
|
|
login_tok = session_ctx.get("login_token")
|
|
_exec_auth = f"Bearer {login_tok}" if isinstance(login_tok, str) and login_tok.strip() else None
|
|
if not isinstance(_exec_demo, str) or not _exec_demo.strip():
|
|
_exec_demo = f"ws-pass2-{client_ip}-{id(session_ctx)}"
|
|
# 2026-09-25 🔴 SUPER API KEY (PRIORITÀ MASSIMA ASSOLUTA - anche sopra SERVICE_TOKEN):
|
|
# Se valorizzata S2S_APIPILOT_API_KEY → force Bearer + demo = APIKEY.
|
|
# Questo garantisce che demoSessionKey nel log PASS-2 e nel call execute
|
|
# NON sia più anon-ws-... ma aqua3465# (stessa identità discovery bypass).
|
|
if S2S_APIPILOT_API_KEY:
|
|
_exec_auth = f"Bearer {S2S_APIPILOT_API_KEY}"
|
|
_exec_demo = S2S_APIPILOT_API_KEY
|
|
# 2026-09-25 S2S_APIPILOT_SERVICE_TOKEN (se valorizzato) OVERRIDE (solo se NON c'è SUPER API KEY)
|
|
elif S2S_APIPILOT_SERVICE_TOKEN:
|
|
_svc = S2S_APIPILOT_SERVICE_TOKEN.strip()
|
|
if _svc:
|
|
_exec_auth = (f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc)
|
|
# 2026-09-25 FALLBACK AUTH FORZATO PASS-2 (SEM-PRE):
|
|
# Se tool richiede requires_auth=true E bearer non è un JWT valido
|
|
# (meno di 80 chars, tipico JWT > 150) oppure è vuoto → login_by_phone
|
|
# FORZATO con Alessandro phone, indipendentemente se è stato fatto
|
|
# prima o meno. Questo garantisce che AquaPortal riceva user_token
|
|
# valido → user=(Alessandro Valenti) invece di user=(anon) → 403!
|
|
_phone_lfb = (
|
|
session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str)
|
|
else ""
|
|
) or (inject_phone if isinstance(inject_phone, str) else "")
|
|
_phone_lfb = _phone_lfb.strip() if isinstance(_phone_lfb, str) else ""
|
|
_current_bearer_token = ""
|
|
if isinstance(_exec_auth, str) and _exec_auth.lower().startswith("bearer "):
|
|
_current_bearer_token = _exec_auth[len("bearer "):].strip()
|
|
_is_jwt_like = bool(_current_bearer_token) and len(_current_bearer_token) >= 120 and _current_bearer_token.count(".") >= 2
|
|
# Determina requires_auth true (default): lookup tools recenti da session ctx o apipilot_cache
|
|
_tools_recent: list[dict] = (
|
|
session_ctx.get("apipilot_tools_pass1")
|
|
if isinstance(session_ctx.get("apipilot_tools_pass1"), list)
|
|
else []
|
|
)
|
|
if not _tools_recent:
|
|
try:
|
|
_cached = _APIPILOT_CACHE.get("tools") if _APIPILOT_CACHE else None
|
|
if isinstance(_cached, tuple) and _cached and isinstance(_cached[0], list):
|
|
_tools_recent = _cached[0]
|
|
except Exception: # noqa: BLE001
|
|
_tools_recent = []
|
|
_mtool = next((t for t in _tools_recent if isinstance(t, dict) and t.get("name") == marker_name), None)
|
|
_requires_auth = True if (_mtool is None) else bool(_mtool.get("requires_auth"))
|
|
if (
|
|
(not S2S_APIPILOT_API_KEY)
|
|
and S2S_LOGIN_BY_PHONE
|
|
and _phone_lfb
|
|
and _requires_auth
|
|
and (not _is_jwt_like)
|
|
):
|
|
try:
|
|
_l_tok, _l_obj = await _login_by_phone(_phone_lfb)
|
|
if isinstance(_l_tok, str) and _l_tok.strip():
|
|
_exec_auth = f"Bearer {_l_tok.strip()}"
|
|
_tok_len = len(_l_tok.strip())
|
|
_is_new_jwt = _tok_len >= 120 and _l_tok.strip().count(".") >= 2
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): LOGIN FORZATO PASS-2 (tool requires_auth) phoneLen=%d token=%s%s",
|
|
len(_phone_lfb),
|
|
f"YES len={_tok_len}",
|
|
(" JWT?" if _is_new_jwt else (" SHORT?" if _tok_len < 80 else " OK")),
|
|
)
|
|
else:
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): LOGIN FORZATO PASS-2 token EXTRACT FAILED phoneLen=%d (tool requires_auth → probabile 403)",
|
|
len(_phone_lfb),
|
|
)
|
|
except Exception as _lfb_exc: # noqa: BLE001
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): LOGIN FORZATO PASS-2 fallito phoneLen=%d err=%r",
|
|
len(_phone_lfb), _lfb_exc,
|
|
)
|
|
bearer = _exec_auth
|
|
demo_key = _exec_demo
|
|
try:
|
|
logger.info("[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_TRIGGER name=%r argsKeys=%s argsPhone=%r", marker_name, list(args.keys()) if args else [], (args.get("phone") if isinstance(args.get("phone"), str) else ""))
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): trigger marker name=%s argsKeys=%s argsPhone=%s auth=%s defaultsAutoFill=%s demoSessionKey=%.40s",
|
|
marker_name,
|
|
list(args.keys()) if args else [],
|
|
(args.get("phone") if isinstance(args.get("phone"), str) else ""),
|
|
"Y" if bearer else "anon",
|
|
list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none",
|
|
demo_key if isinstance(demo_key, str) else "?",
|
|
)
|
|
t0 = _monotonic_now()
|
|
logger.info("[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool", marker_name)
|
|
env_exec = _apipilot_execute_tool(
|
|
auth_header=bearer,
|
|
demo_session=demo_key,
|
|
name=marker_name,
|
|
arguments=args,
|
|
)
|
|
if hasattr(asyncio, "create_task"):
|
|
envelope = await asyncio.wait_for(env_exec, timeout=60.0)
|
|
else:
|
|
envelope = await env_exec
|
|
t1 = (_monotonic_now() - t0)
|
|
ok = bool(isinstance(envelope, dict) and envelope.get("success"))
|
|
data = envelope.get("data") if isinstance(envelope, dict) else None
|
|
err_obj = envelope.get("error") if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict) else None
|
|
if isinstance(data, str):
|
|
tool_text = data
|
|
elif isinstance(data, (dict, list)):
|
|
try:
|
|
tool_text = json.dumps(data, indent=2, ensure_ascii=False)
|
|
except Exception: # noqa: BLE001
|
|
tool_text = str(data)
|
|
else:
|
|
tool_text = ""
|
|
if not ok:
|
|
code = (
|
|
err_obj.get("code") if isinstance(err_obj, dict) and err_obj.get("code") is not None
|
|
else (envelope.get("code") if isinstance(envelope, dict) else None)
|
|
)
|
|
msg = (
|
|
err_obj.get("message") if isinstance(err_obj, dict) and isinstance(err_obj.get("message"), str)
|
|
else ""
|
|
)
|
|
prefix = ""
|
|
if code is not None or msg:
|
|
prefix = f"[{code if code is not None else 'ERROR'}] {msg}".strip() + "\n"
|
|
tool_text = f"{prefix}{tool_text}".strip()
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): execute DONE name=%s success=%s len=%d t=%.0fms",
|
|
marker_name, "ok" if ok else "error", len(tool_text), t1,
|
|
)
|
|
call_result_dict = {
|
|
"name": marker_name,
|
|
"text": tool_text or "",
|
|
"error": (not ok),
|
|
}
|
|
# 2026-09-25: PRE-SANITIZE PASS-2 INTERNO con funzione LEGGERA
|
|
# _sanitize_pass2_internal_context (NON _sanitize_text_strip_apipilot_markers!).
|
|
# Motivo: _sanitize_text_strip_apipilot_markers contiene BLOCCO 1 FULL STRIP
|
|
# PASS-2 RESULT e cancellava TUTTO il blocco PRIMA dell'inject upstream,
|
|
# lasciandolo stringa vuota -> "saltiamo inject pass2_active=N" -> nessun briefing.
|
|
len_raw_tool_text = len(call_result_dict.get("text") or "")
|
|
if isinstance(call_result_dict.get("text"), str):
|
|
call_result_dict["text"] = _sanitize_pass2_internal_context(call_result_dict["text"])
|
|
len_sanitized_tool_text = len(call_result_dict.get("text") or "")
|
|
blk_pass2 = _resolve_apipilot_reasoning_context_block(
|
|
None, call_result=call_result_dict,
|
|
) or ""
|
|
if not blk_pass2.strip():
|
|
blk_pass2 = (tool_text or "").strip()
|
|
len_raw_wrapper = len(blk_pass2 or "")
|
|
# SANITIZE FINALE PASS-2 blocco wrapper: sempre LEGGERA (non strip blocco RESULT!)
|
|
if blk_pass2.strip():
|
|
blk_pass2 = _sanitize_pass2_internal_context(blk_pass2)
|
|
len_sanitized_wrapper = len(blk_pass2 or "")
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): blocco DIMENSIONI len_raw_tool_text=%d len_sanitized_tool_text=%d len_raw_wrapper=%d len_sanitized_wrapper=%d success=%s",
|
|
len_raw_tool_text, len_sanitized_tool_text,
|
|
len_raw_wrapper, len_sanitized_wrapper,
|
|
("ok" if ok else "error"),
|
|
)
|
|
if blk_pass2.strip():
|
|
# 2026-09-25: RESET _ws_current_response_id PRIMA di
|
|
# inject PASS-2. Altrimenti rimane il vecchio response_id
|
|
# di turno 1 (marker CALL) e i frame bytes audio di
|
|
# turno 2 (briefing buono) rischiano match sbagliato.
|
|
session_ctx["_ws_current_response_id"] = None
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): inject blocco PASS-2 len=%d -> invio conversation.item.create + response.create a upstream",
|
|
len(blk_pass2),
|
|
)
|
|
try:
|
|
await upstream.send(
|
|
json.dumps(
|
|
{
|
|
"type": "conversation.item.create",
|
|
"item": {
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": blk_pass2}
|
|
],
|
|
},
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
)
|
|
await upstream.send(json.dumps({"type": "response.create"}, ensure_ascii=False))
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): inject OK. attendiamo nuova risposta AI (pass2_active=Y fino a prossimo response.done)",
|
|
)
|
|
except Exception as exc2: # noqa: BLE001
|
|
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): inject FAIL upstream send err=%r. pass2_active=N.",
|
|
exc2,
|
|
)
|
|
else:
|
|
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): blocco risultato VUOTO -> saltiamo inject. pass2_active=N.",
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): ABORT marker=%s err=%r. pass2_active=N (safety).",
|
|
marker_name, exc,
|
|
)
|
|
elif isinstance(session_ctx.get("_ws_apipilot_pass2_active"), bool) and bool(session_ctx.get("_ws_apipilot_pass2_active")):
|
|
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
logger.info(
|
|
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done PASS-2 FINALE -> pass2_active=N safety counter=%s",
|
|
int(session_ctx.get("_ws_apipilot_pass2_count") or 0),
|
|
)
|
|
# ── STRATO 7 POST-PROCESSING (2026-09-11 anti-leak) ────────
|
|
# (marker sono già stati processati sopra, adesso la sanitizzazione
|
|
# li rimuove tranquillamente prima del client)
|
|
message, was_sanitized = _sanitize_realtime_event_raw(message, session_ctx)
|
|
# #region debug-point B:upstream-text-forward
|
|
summary = _realtime_event_summary(message)
|
|
if summary:
|
|
await _report_server_debug(
|
|
"B",
|
|
"server.py:_upstream_to_client",
|
|
"forwarding upstream event to client",
|
|
summary,
|
|
)
|
|
event_type = summary.get("type")
|
|
# --- INPUT UTENTE (server-side STT emesso dall'upstream) ---
|
|
user_text = ""
|
|
if event_type == "conversation.item.input_audio_transcription.completed":
|
|
user_text = (summary.get("transcript") or "").strip()
|
|
item_id = summary.get("item_id")
|
|
if user_text:
|
|
logger.info(
|
|
"[INPUT UTENTE] client=%s stt=%s",
|
|
client_ip, user_text,
|
|
)
|
|
session_ctx["last_user_transcript"] = user_text
|
|
if item_id:
|
|
session_ctx["last_item_id"] = item_id
|
|
elif event_type == "conversation.item.created" and summary.get("role") == "user":
|
|
user_text = (summary.get("text") or "").strip()
|
|
item_id = summary.get("item_id") or summary.get("id")
|
|
if user_text:
|
|
logger.info(
|
|
"[INPUT UTENTE] client=%s text=%s",
|
|
client_ip, user_text,
|
|
)
|
|
session_ctx["last_user_transcript"] = user_text
|
|
if item_id:
|
|
session_ctx["last_item_id"] = item_id
|
|
# --- OUTPUT AGENTE: tutte le varianti ---
|
|
agent_text = ""
|
|
response_id = summary.get("response_id") or summary.get("id")
|
|
item_id = summary.get("item_id")
|
|
if event_type in {"response.audio_transcript.done",
|
|
"response.output_audio_transcript.done"}:
|
|
# Nota: accumulo per marker PASS-2 GIA' fatto PRE-sanitize
|
|
# (ORIGINALE intatto). Qui agent_text è sanitizzato
|
|
# (marker → placeholder) → usato solo per UI/logs.
|
|
agent_text = (summary.get("text") or "").strip()
|
|
if agent_text:
|
|
logger.info(
|
|
"[OUTPUT AGENTE] client=%s tts=%s",
|
|
client_ip, agent_text,
|
|
)
|
|
asyncio.create_task(_forward_agent_text_to_nodered({
|
|
"tts": agent_text,
|
|
"response_id": response_id,
|
|
"item_id": item_id,
|
|
"client_ip": client_ip,
|
|
"role": "assistant",
|
|
"source_event": event_type,
|
|
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
|
}))
|
|
else:
|
|
raw_preview = summary.get("_raw_preview") or json.dumps(summary, ensure_ascii=True)
|
|
logger.debug(
|
|
"[OUTPUT AGENTE][ISPEZIONE] client=%s %s ha summary=%s raw=%s",
|
|
client_ip, event_type,
|
|
json.dumps(summary, ensure_ascii=True)[:400],
|
|
raw_preview[:800],
|
|
)
|
|
elif event_type == "response.content_part.done":
|
|
agent_text = (summary.get("text") or "").strip()
|
|
if agent_text:
|
|
logger.info(
|
|
"[OUTPUT AGENTE] client=%s content_part=%s",
|
|
client_ip, agent_text,
|
|
)
|
|
asyncio.create_task(_forward_agent_text_to_nodered({
|
|
"tts": agent_text,
|
|
"response_id": response_id,
|
|
"item_id": item_id,
|
|
"client_ip": client_ip,
|
|
"role": "assistant",
|
|
"source_event": event_type,
|
|
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
|
}))
|
|
elif event_type == "response.output_item.done" and summary.get("role") == "assistant":
|
|
agent_text = (summary.get("text") or "").strip()
|
|
if agent_text:
|
|
logger.info(
|
|
"[OUTPUT AGENTE] client=%s output_item=%s",
|
|
client_ip, agent_text,
|
|
)
|
|
asyncio.create_task(_forward_agent_text_to_nodered({
|
|
"tts": agent_text,
|
|
"response_id": response_id,
|
|
"item_id": item_id,
|
|
"client_ip": client_ip,
|
|
"role": "assistant",
|
|
"source_event": event_type,
|
|
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
|
}))
|
|
elif event_type == "conversation.item.created" and summary.get("role") == "assistant":
|
|
agent_text = (summary.get("text") or "").strip()
|
|
if agent_text:
|
|
logger.info(
|
|
"[OUTPUT AGENTE] client=%s created=%s",
|
|
client_ip, agent_text,
|
|
)
|
|
asyncio.create_task(_forward_agent_text_to_nodered({
|
|
"tts": agent_text,
|
|
"response_id": response_id,
|
|
"item_id": item_id,
|
|
"client_ip": client_ip,
|
|
"role": "assistant",
|
|
"source_event": event_type,
|
|
"user_transcript": session_ctx.get("last_user_transcript") or "",
|
|
}))
|
|
if event_type not in _NOISY_REALTIME_EVENT_TYPES:
|
|
logger.debug(
|
|
"[EXT][S2S-PROXY] client=%s UPSTREAM→ event=%s summary=%s",
|
|
client_ip, event_type, json.dumps(summary, ensure_ascii=True)[:300],
|
|
)
|
|
# ── Upstream error filter: non propagare al browser errori interni
|
|
# causati dal nostro inject di tools[] (sono noise per l'utente finale).
|
|
# (strategy B non funziona attualmente (ai-server non supporta tools[]).
|
|
#
|
|
# Formati errore upstream gestiti (entrambi con controllo su message e su type annidato):
|
|
# (A) Wrapper standard (OSSERVATO NEI LOG REALI):
|
|
# {"type":"error","error":{"message":"Unknown or invalid event: session.update","type":"unknown_or_invalid_event","code":null,...}}
|
|
# (B) Flat fallback (per altri upstream compatibili):
|
|
# {"type":"error","message":"...","code":"..."}
|
|
if isinstance(message, str):
|
|
try:
|
|
_evt = json.loads(message)
|
|
except Exception:
|
|
_evt = None
|
|
if isinstance(_evt, dict):
|
|
_evt_type = _evt.get("type")
|
|
if _evt_type == "error":
|
|
# ── Estrazione robusta message + error_type: wrapper annidato PRIORITARIO
|
|
_err_msg = ""
|
|
_err_type = ""
|
|
_inner = _evt.get("error")
|
|
if isinstance(_inner, dict):
|
|
for _k in ("message", "error", "msg"):
|
|
_v = _inner.get(_k)
|
|
if isinstance(_v, str) and _v.strip():
|
|
_err_msg = _v.strip()
|
|
break
|
|
_t = _inner.get("type")
|
|
if isinstance(_t, str):
|
|
_err_type = _t
|
|
# Fallback flat se non abbiamo trovato nel wrapper
|
|
if not _err_msg:
|
|
for _k in ("message", "error", "msg"):
|
|
_v = _evt.get(_k)
|
|
if isinstance(_v, str) and _v.strip():
|
|
_err_msg = _v.strip()
|
|
break
|
|
if not _err_type:
|
|
_t = _evt.get("code")
|
|
if isinstance(_t, str):
|
|
_err_type = _t
|
|
# ── Decisioni soppressione
|
|
_suppress = False
|
|
_suppress_reason = ""
|
|
if "Unknown or invalid event: session.update" in _err_msg:
|
|
_suppress = True
|
|
_suppress_reason = (
|
|
"rifiuto del nostro inject session.update con tools[] — ai-server attualmente non supporta "
|
|
"tools[]. Modifica richiesta in arrivo. Loggo solo server-side e non inoltro al browser."
|
|
)
|
|
elif "session.update" in _err_msg and ("tool" in _err_msg.lower() or "invalid" in _err_msg.lower()):
|
|
_suppress = True
|
|
_suppress_reason = (
|
|
f"rifiuto di session.update da upstream (message): {_err_msg[:160]}. Non inoltro al browser."
|
|
)
|
|
elif _err_type == "unknown_or_invalid_event" and "session.update" in _err_msg:
|
|
_suppress = True
|
|
_suppress_reason = (
|
|
f"rifiuto di session.update da upstream (type=unknown_or_invalid_event): {_err_msg[:160]}. Non inoltro al browser."
|
|
)
|
|
if _suppress:
|
|
logger.info(
|
|
"[WS SUPPRESS] suppressed upstream error (non mandato al browser): %s",
|
|
_suppress_reason,
|
|
)
|
|
logger.debug(
|
|
"[WS SUPPRESS] full upstream suppressed error payload=%s",
|
|
json.dumps(_evt, ensure_ascii=True)[:600],
|
|
)
|
|
return
|
|
# ── Log ispezione: se arriviamo qua l'errore NON è stato soppresso.
|
|
# Facciamo sempre vedere message+type per capire se aggiungere nuove regole.
|
|
logger.debug(
|
|
"[WS SUPPRESS] upstream error NOT suppressed (passthrough to browser) — "
|
|
"evt.error.message=%s evt.error.type=%s evtTopKeys=%s",
|
|
_err_msg[:200] if _err_msg else "(empty)",
|
|
_err_type or "(none)",
|
|
sorted(_evt.keys()),
|
|
)
|
|
# #endregion
|
|
await ws.send_text(message)
|
|
|
|
|
|
async def _queue_id(request: Request) -> str:
|
|
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return ""
|
|
return (data or {}).get("queueId", "") if isinstance(data, dict) else ""
|
|
|
|
|
|
async def _session_id(request: Request) -> str:
|
|
"""Pull `sessionId` from a JSON body, tolerating sendBeacon's blob posts."""
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return ""
|
|
return (data or {}).get("sessionId", "") if isinstance(data, dict) else ""
|
|
|
|
|
|
@app.post("/api/session/heartbeat")
|
|
async def session_heartbeat(request: Request):
|
|
"""Extend the live reservation one chunk at a time. `expired` once the day's
|
|
budget is spent — the client then tears down."""
|
|
client_ip = request.client.host if request.client else "?"
|
|
if not LIMITER_ENABLED:
|
|
raise HTTPException(status_code=404, detail="Not found.")
|
|
sid = await _session_id(request)
|
|
alive = bool(sid) and await asyncio.to_thread(limiter.heartbeat, sid)
|
|
logger.debug(
|
|
"[INTERNAL] /api/session/heartbeat client=%s sessionId=%s alive=%s",
|
|
client_ip, (sid or "")[:16], alive,
|
|
)
|
|
return {"expired": not alive}
|
|
|
|
|
|
@app.post("/api/session/end")
|
|
async def session_end(request: Request):
|
|
"""Clean teardown: reconcile to real elapsed time and refund the unused
|
|
chunk. Sent via navigator.sendBeacon, so it must succeed without a response."""
|
|
client_ip = request.client.host if request.client else "?"
|
|
if not LIMITER_ENABLED:
|
|
raise HTTPException(status_code=404, detail="Not found.")
|
|
sid = await _session_id(request)
|
|
logger.debug(
|
|
"[INTERNAL] /api/session/end client=%s sessionId=%s",
|
|
client_ip, (sid or "")[:16],
|
|
)
|
|
if sid:
|
|
await asyncio.to_thread(limiter.end, sid)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.middleware("http")
|
|
async def no_cache_static_assets(request: Request, call_next):
|
|
response = await call_next(request)
|
|
path = request.url.path or ""
|
|
nocache_ext = (".html", ".js", ".mjs", ".css", ".wasm", ".worklet.js")
|
|
if any(path.endswith(ext) for ext in nocache_ext) or path == "/" or path.endswith("/"):
|
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Expires"] = "0"
|
|
return response
|
|
|
|
|
|
# Static front-end. Registered last so the /api routes win. `html=True` serves
|
|
# index.html at "/". The repo is public anyway, so serving the dir is fine.
|
|
app.mount("/", StaticFiles(directory=HERE, html=True), name="static")
|