""" Tiny server for the speech-to-speech demo. The demo used to ship as a `sdk: static` Space, but the web-search tool needs a search key the browser must NOT see. A static Space has no runtime process, so it can't hold a secret the front-end uses. This server fixes that: it serves the unchanged front-end AND exposes a same-origin `/api/search` proxy that holds the Serper key server-side (see docs/adr/0001). Everything lives in one container; the speech-to-speech backend stays a separate, load-balanced service the browser talks to over WebSocket as before. The load balancer's address is a secret too (like the Serper key): the browser never sees it. `/api/session` proxies the session handshake server-side so only the per-session compute URL the LB hands back (which the browser must dial) is exposed. On the deployed Space the server also meters conversation time by HF login tier (anonymous / signed-in / PRO) — see `limiter.py` and `auth.py`. That whole feature is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on the live Space, never locally (even with the LB exported for testing). `SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is disabled entirely (no session proxy, no queue, no metering, no sign-in) and the browser connects directly to that URL, shown read-only in Settings. Endpoints: GET /api/config -> { search, lb, allowDirect, s2sUrl, auth } GET /api/me -> login + tier + remaining budget (LB mode only) POST /api/search -> { results, answer } Google via Serper.dev POST /api/session -> proxies /session: a grant, or a queue ticket GET /api/queue/{id} -> proxies /queue/{id}: position, or a grant on claim DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button) POST /api/queue/end -> leave the queue (sendBeacon on teardown) POST /api/session/heartbeat-> extend the reservation; { expired } POST /api/session/end -> reconcile + refund (sendBeacon on teardown) /* -> static files (index.html, main.js, ...) When every compute slot is busy the load balancer hands back a queue ticket instead of a grant; the browser polls /api/queue/{id} until it reaches the front and a slot frees. Waiting reserves nothing — the daily budget is only reserved at the moment a slot is actually claimed (a grant), never while queued. """ import asyncio from datetime import datetime, timezone 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") S2S_AGENT_PROFILE = os.environ.get("S2S_AGENT_PROFILE", "").strip() S2S_TOOL_EXECUTION_MCP_URL = os.environ.get("S2S_TOOL_EXECUTION_MCP_URL", "").strip() S2S_TOOL_EXECUTION_MCP_API_KEY = os.environ.get("S2S_TOOL_EXECUTION_MCP_API_KEY", "").strip() S2S_TOOL_EXECUTION_MCP_API_SECRET = os.environ.get("S2S_TOOL_EXECUTION_MCP_API_SECRET", "").strip() # ⚙️ MASTER SWITCH MCP EXECUTION: Disabilita TUTTA la funzionalità MCP quando # S2S_TOOL_EXECUTION_MCP_URL non è presente / è commentata / è vuota in .env. # - Non viene caricato il catalogo 79 tool dal profilo agente Aquachile # - Non viene injettato tools[] / tool_choice nel session.update WS # - POST /api/mcp/tool risponde 503 con log [MCP DISABLED] MCP_EXEC_ENABLED = bool(S2S_TOOL_EXECUTION_MCP_URL) VOCERO_USER_PHONE_AUTH_URL = os.environ.get( "VOCERO_USER_PHONE_AUTH_URL", os.environ.get("S2S_USER_PHONE_AUTH_URL", ""), ).strip() S2S_INSIGHTS_URL = os.environ.get("S2S_INSIGHTS_URL", "").strip() # Credenziali opzionali per endpoint insights (stesso pattern di MCP execution; # endpoint Aquachile attualmente è pubblico ma lasciamo predisposto per auth futura). S2S_INSIGHTS_API_KEY = os.environ.get("S2S_INSIGHTS_API_KEY", "").strip() or os.environ.get("S2S_TOOL_EXECUTION_MCP_API_KEY", "").strip() S2S_INSIGHTS_API_SECRET = os.environ.get("S2S_INSIGHTS_API_SECRET", "").strip() or os.environ.get("S2S_TOOL_EXECUTION_MCP_API_SECRET", "").strip() # 🧠 Master switch per caricamento insights diario all'inizio della sessione. # Se S2S_INSIGHTS_URL è valorizzato → GET oggi e append blocco a instructions agente. INSIGHTS_ENABLED = bool(S2S_INSIGHTS_URL) # Numero massimo di insights serializzati nel prompt (evita esplosione contesto). MAX_INSIGHTS_IN_CONTEXT = 20 S2S_SEARCH_MCP = os.environ.get("S2S_SEARCH_MCP", "").strip().lower() == "true" 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() # Node-RED reasoning engine endpoint (modalità NODE_RED). # Es.: http://localhost:1881 oppure http://s2s-reasoning:1880 (rete docker) NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/") # Speech-to-speech load balancer URL. When set, the browser POSTs /api/session # (which proxies /session here, server-side) and connects to the URL the LB # returns (the original flow). The LB address itself is never sent to the browser. # When empty, the user may instead set a direct s2s server URL in Settings and the # browser connects to it straight (no load balancer). LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip() # Direct s2s server URL pinned by the deploy. Takes priority over the load # balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue, # no limiter, no sign-in) and the browser connects to this URL directly. Unlike # the LB address it is NOT a secret — /api/config sends it to the client, which # shows it read-only in Settings. SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip() # Optional same-origin WebSocket relay target. When set, the browser dials this # app over ws(s):///v1/realtime and the server relays frames to the # upstream speech-to-speech endpoint, keeping the internal host private. S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip() if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET: LOAD_BALANCER_URL = "" # HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent # locally and on a plain `docker run`. We meter conversation time ONLY on the # deployed Space — i.e. when BOTH the LB is configured AND we're on a Space. # Off-Space (local dev, even with the LB exported) the app still proxies the LB, # but nothing is metered: no budget, no reservations, no sign-in gating. SPACE_ID = os.environ.get("SPACE_ID", "").strip() LIMITER_ENABLED = bool(LOAD_BALANCER_URL) and bool(SPACE_ID) SERPER_URL = "https://google.serper.dev/search" # Cap results so the tool output stays small enough to feed back to the model. MAX_RESULTS = 5 HERE = os.path.dirname(os.path.abspath(__file__)) app = FastAPI(title="s2s-demo") # Wire HF OAuth before the app serves (no-op unless the OAuth env is present). # Sign-in only matters when we're metering (prod Space), so gate it on that. AUTH_ENABLED = LIMITER_ENABLED and auth.attach(app) logger.debug( "[STARTUP] Aquachile endpoints configured — profile=%s mcp=%s userAuth=%s powerbiCatalog=%s", bool(S2S_AGENT_PROFILE), MCP_EXEC_ENABLED, bool(VOCERO_USER_PHONE_AUTH_URL), bool(os.environ.get("S2S_PRELOAD_DATA_ENDPOINT", "").strip()), ) if MCP_EXEC_ENABLED: logger.info( "[MCP MASTER] ENABLED — S2S_TOOL_EXECUTION_MCP_URL=%s; tool catalog will be injected via WS session.update tools[] and /api/mcp/tool proxy is active.", S2S_TOOL_EXECUTION_MCP_URL, ) else: logger.warning( "[MCP MASTER] ⛔ DISABLED — S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env. " "Tool execution is OFF: no tools[] injection in WS session.update, /api/mcp/tool returns 503. " "To enable, uncomment or set S2S_TOOL_EXECUTION_MCP_URL and restart the container.", ) if INSIGHTS_ENABLED: logger.info( "[INSIGHTS MASTER] ENABLED — S2S_INSIGHTS_URL=%s. At every new WS realtime session we will GET today's insights (start_date=end_date=YYYY-MM-DD today) and append a summary block to the agent instructions so the assistant has today's operational context. MAX_INSIGHTS_IN_CONTEXT=%d apiKey=%s", S2S_INSIGHTS_URL, MAX_INSIGHTS_IN_CONTEXT, "set" if S2S_INSIGHTS_API_KEY else "not-set (public endpoint OK)", ) else: logger.debug( "[INSIGHTS MASTER] DISABLED — S2S_INSIGHTS_URL is empty/commented in .env. " "Agent instructions will NOT include today's insights context." ) logger.debug( "[STARTUP] Other externals — searchMcp=%s reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s auth=%s", S2S_SEARCH_MCP, S2S_REASONING_TYPE, NODE_RED_REASONING_URL or "-", bool(SERPER_KEY), bool(LOAD_BALANCER_URL), bool(S2S_PROXY_TARGET), bool(SPEECH_TO_SPEECH_URL), AUTH_ENABLED, ) 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 e chiama MCP Aquachile via protocollo. _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( "[MCP INJECT] ✓ 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( "[MCP INJECT] ✓ 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 class McpToolCallRequest(BaseModel): name: str arguments: dict = {} class ReasoningRequest(BaseModel): transcript: str instructions: str = "" language: str = "" user_context: str = "" # Phone number of the caller (same as URL param). When present and the # reasoning mode is NODE_RED, it is forwarded to Node-RED so that flows # can fetch the caller profile via Aquachile /auth/user-by-phone. phoneNumber: str | None = None _agent_profile_lock = asyncio.Lock() _agent_profile_cached: tuple[float, str, list[dict]] = (0.0, "", []) _insights_lock = asyncio.Lock() # cache: (cached_at_seconds, cache_date_str_YYYY_MM_DD, list[dict] | None, formatted_prompt_block_str) _insights_cached: tuple[float, str, list[dict] | None, str] = (0.0, "", None, "") async def _agent_profile_data() -> tuple[str, list[dict]]: if not S2S_AGENT_PROFILE: logger.debug("[EXT][AQUACHILE] agent_profile: endpoint not configured, skipping") return "", [] now = asyncio.get_running_loop().time() cached_at, cached_prompt, cached_tools = _agent_profile_cached if (cached_prompt or cached_tools) and (now - cached_at) < 60: logger.debug( "[EXT][AQUACHILE] agent_profile: cache HIT age=%.1fs promptLen=%d toolCount=%d", now - cached_at, len(cached_prompt), len(cached_tools), ) return cached_prompt, cached_tools async with _agent_profile_lock: cached_at, cached_prompt, cached_tools = _agent_profile_cached if (cached_prompt or cached_tools) and (now - cached_at) < 60: return cached_prompt, cached_tools logger.debug("[EXT][AQUACHILE] agent_profile GET %s (cache miss)", S2S_AGENT_PROFILE) try: async with httpx.AsyncClient(timeout=10.0) as http: resp = await http.get(S2S_AGENT_PROFILE) logger.debug( "[EXT][AQUACHILE] agent_profile RESP status=%s len=%dB", resp.status_code, len(resp.content or b""), ) if resp.status_code != 200: logger.warning("Agent profile fetch failed %s: %s", resp.status_code, resp.text[:300]) _set_agent_profile_cache(now, "", []) return "", [] data = resp.json() except Exception as exc: logger.warning("Agent profile fetch failed: %r", exc) _set_agent_profile_cache(now, "", []) return "", [] prompt = _extract_agent_prompt(data) tools = _extract_agent_tools(data) logger.debug( "[EXT][AQUACHILE] agent_profile PARSED promptLen=%d toolCount=%d toolNames=%s", len(prompt), len(tools), [t.get("name") for t in tools if isinstance(t, dict)], ) _set_agent_profile_cache(now, prompt, tools) return prompt, tools def _set_agent_profile_cache(ts: float, prompt: str, tools: list[dict]) -> None: global _agent_profile_cached _agent_profile_cached = (ts, prompt, tools) def _set_insights_cache(ts: float, date_str: str, entries: list[dict] | None, formatted_block: str) -> None: global _insights_cached _insights_cached = (ts, date_str, entries, formatted_block) def _format_insights_for_prompt(entries: list[dict]) -> str: """ Serializza una lista di insight Aquachile in formato testo leggibile da appendere alle agent instructions. Priorità visuale: HIGH → MEDIUM → LOW. Es. output: ### Contexto operativo del día (2026-08-25) — 18 insights: 1. [HIGH · Operations · Center: Arica · Jaula: C-07] Título breve - Descrizione del insight (trunc 200c). - Recomendación: azione consigliata (trunc 200c). ... """ if not entries: return "" today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") _priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} try: sorted_entries = sorted( entries, key=lambda e: ( _priority_order.get((e.get("priority") or "").upper(), 99), e.get("createdAt") or "", ), reverse=False, ) except Exception: sorted_entries = list(entries) lines: list[str] = [] total = len(sorted_entries) lines.append(f"### Contexto operativo del día ({today_str}) — {total} insights:") for idx, e in enumerate(sorted_entries[:MAX_INSIGHTS_IN_CONTEXT], start=1): if not isinstance(e, dict): continue priority = str(e.get("priority") or "UNKNOWN").upper() audience = e.get("audience") if isinstance(audience, list): audience_str = "/".join(str(a) for a in audience if a)[:40] elif isinstance(audience, str): audience_str = audience[:40] else: audience_str = "General" center = str(e.get("center") or "—")[:40] jaula = str(e.get("jaula") or "—")[:24] title = (e.get("title") or "(No title)").strip() description = (e.get("description") or "").strip() recommendation = (e.get("recommendation") or "").strip() header = f"{idx}. [{priority} · {audience_str} · Centro: {center} · Jaula: {jaula}] {title}" lines.append(header) if description: desc_trunc = description if len(description) <= 240 else description[:237] + "..." lines.append(f" • Situación: {desc_trunc}") if recommendation: rec_trunc = recommendation if len(recommendation) <= 200 else recommendation[:197] + "..." lines.append(f" • Recomendación: {rec_trunc}") if total > MAX_INSIGHTS_IN_CONTEXT: lines.append( f" … ({total - MAX_INSIGHTS_IN_CONTEXT} insights adicionales omitidos para mantener el contexto compacto. " f"Si el usuario necesita más detalles, puede consultarlos explícitamente via tool call list_recent_reports.)" ) return "\n".join(lines).strip() async def _fetch_insights_for_today() -> tuple[list[dict] | None, str]: """ Fetch diario degli insights Aquachile via GET S2S_INSIGHTS_URL con start_date == end_date == oggi (UTC). Cache 60s TTL con anti-thundering-herd lock, esattamente come _agent_profile_data. Ritorna (entries_list o None, formatted_block_str). Se fallisce → ritorna (None, "") e logga WARNING ma NON solleva eccezioni (never-block-audio). """ if not INSIGHTS_ENABLED: return None, "" today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") now = asyncio.get_running_loop().time() cached_at, cached_date, cached_entries, cached_block = _insights_cached # Cache HIT solo se: stessa data di oggi e age < 60s if cached_date == today_str and cached_block and (now - cached_at) < 60: logger.debug( "[INSIGHTS FETCH] cache HIT date=%s age=%.1fs entries=%d blockLen=%d", cached_date, now - cached_at, len(cached_entries or []), len(cached_block), ) return cached_entries, cached_block async with _insights_lock: cached_at, cached_date, cached_entries, cached_block = _insights_cached if cached_date == today_str and cached_block and (now - cached_at) < 60: return cached_entries, cached_block url = S2S_INSIGHTS_URL.rstrip("/") params = {"start_date": today_str, "end_date": today_str} headers = {"Accept": "application/json"} if S2S_INSIGHTS_API_KEY: headers["X-API-KEY"] = S2S_INSIGHTS_API_KEY if S2S_INSIGHTS_API_SECRET: headers["X-API-SECRET"] = S2S_INSIGHTS_API_SECRET logger.info( "[INSIGHTS FETCH] → GET today=%s url=%s params=%s apiKey=%s", today_str, url, json.dumps(params, ensure_ascii=True), "set" if S2S_INSIGHTS_API_KEY else "not-set (public OK)", ) try: async with httpx.AsyncClient(timeout=15.0) as http: resp = await http.get(url, params=params, headers=headers) except Exception as exc: logger.warning("[INSIGHTS FETCH] ERROR (continuo senza insights, non blocco audio): %r", exc) _set_insights_cache(now, today_str, None, "") return None, "" logger.debug( "[INSIGHTS FETCH] ← RESP status=%s len=%dB", resp.status_code, len(resp.content or b""), ) if resp.status_code != 200: logger.warning( "[INSIGHTS FETCH] HTTP %s (non 200, skip — non blocco audio). Preview body: %s", resp.status_code, (resp.text or "")[:300], ) _set_insights_cache(now, today_str, None, "") return None, "" try: data = resp.json() except Exception as exc: logger.warning("[INSIGHTS FETCH] JSON parse fallito: %r. Body preview=%s", exc, (resp.text or "")[:300]) _set_insights_cache(now, today_str, None, "") return None, "" if isinstance(data, dict) and isinstance(data.get("insights"), list): entries = [e for e in data["insights"] if isinstance(e, dict)] elif isinstance(data, list): entries = [e for e in data if isinstance(e, dict)] else: logger.warning( "[INSIGHTS FETCH] formato response inatteso (keys=%s). Skip senza errori.", sorted(data.keys()) if isinstance(data, dict) else type(data).__name__, ) _set_insights_cache(now, today_str, None, "") return None, "" # Ordina e formatta per injection nel prompt. priority_counts: dict[str, int] = {} for e in entries: p = str(e.get("priority") or "UNKNOWN").upper() priority_counts[p] = priority_counts.get(p, 0) + 1 formatted_block = _format_insights_for_prompt(entries) logger.info( "[INSIGHTS FETCH] ✓ PARSED OK today=%s total=%d byPriority=%s formattedLen=%d maxCap=%d (truncated=%s)", today_str, len(entries), json.dumps(priority_counts, ensure_ascii=True), len(formatted_block), MAX_INSIGHTS_IN_CONTEXT, "YES" if len(entries) > MAX_INSIGHTS_IN_CONTEXT else "NO", ) # Log dettaglio anteprima lista insights (title/priority/center) per auditoria # INFO level: sempre visibile nel container (audit quali insights sono stati considerati). _preview_cap = min(10, len(entries)) _preview_list = [] for e in entries[:_preview_cap]: _preview_list.append( { "priority": str(e.get("priority") or "?").upper(), "center": str(e.get("center") or "?")[:30], "jaula": str(e.get("jaula") or "?")[:20], "title": (str(e.get("title") or "?")[:80]), } ) # 👉 Pattern singolo %s + f-string: nessun rischio che % dentro agli insights # (es. -1.65%) vengano interpretati come placeholder da logging — BUG FIX. _msg_preview = ( f"[INSIGHTS FETCH] PREVIEW lista primi {_preview_cap}/{len(entries)} insights " f"JSON={json.dumps(_preview_list, ensure_ascii=True)}" ) logger.info("%s", _msg_preview) _set_insights_cache(now, today_str, entries, formatted_block) return entries, formatted_block def _extract_agent_prompt(data) -> str: try: if isinstance(data, dict): agent = data.get("agent") if isinstance(agent, dict): desc = agent.get("instructions") if isinstance(desc, str): return desc.strip() desc = agent.get("description") if isinstance(desc, str): return desc.strip() desc = data.get("instructions") if isinstance(desc, str): return desc.strip() desc = data.get("description") if isinstance(desc, str): return desc.strip() except Exception: return "" return "" def _extract_agent_tools(data) -> list[dict]: try: if isinstance(data, dict): agent = data.get("agent") if isinstance(agent, dict): tools = agent.get("tools") if isinstance(tools, list): return [tool for tool in tools if isinstance(tool, dict) and tool.get("enabled", True)] except Exception: return [] return [] def _normalize_tools_for_realtime(raw_tools: list[dict]) -> list[dict]: """ Converte tool definitions Aquachile (qualsiasi variante di struttura) in formato OpenAI Realtime API GA standard, valido per session.update -> tools[]. Formato atteso Realtime API (ogni tool): { "type": "function", # fisso, unico supportato oggi "name": "snake_case_tool_name", # <64 chars, a-z0-9_ "description": "frase che spiega quando usarlo", # stringa, NOT null "parameters": { # JSON Schema Object, MAY BE {} vuoto "type": "object", "properties": { "arg1": {"type":"string","description":"…"}, … }, "required": ["arg1"] # (opzionale, array di stringhe) } } Ritorna SEMPRE list[dict] (non torna mai None, non alza eccezioni). Tool invalidi vengono saltati con logger.warning ma non bloccano gli altri. """ normalized: list[dict] = [] seen_names: set[str] = set() if not isinstance(raw_tools, list): return normalized for idx, tool in enumerate(raw_tools): if not isinstance(tool, dict): logger.warning("[MCP TOOLS] skip tool at index %s: not a dict (got %s)", idx, type(tool).__name__) continue name = "" for key in ("name", "function_name", "toolName", "id"): val = tool.get(key) if isinstance(val, str) and val.strip(): name = val.strip() break if not name: logger.warning("[MCP TOOLS] skip tool at index %s: missing name keys=%s", idx, sorted(tool.keys())) continue # sanitize nome -> solo [a-z0-9_] (regex OpenAI) clean_name = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower().strip("_") if not clean_name: clean_name = f"tool_{idx}" if clean_name in seen_names: clean_name = f"{clean_name}_{idx}" seen_names.add(clean_name) description = "" for key in ("description", "desc", "summary", "instructions", "purpose"): val = tool.get(key) if isinstance(val, str) and val.strip(): description = val.strip() break if not description: description = f"Tool {clean_name}" # parameters JSON Schema raw_params = None for key in ("parameters", "params", "input_schema", "schema", "argument_schema"): val = tool.get(key) if isinstance(val, dict) and val: raw_params = val break parameters: dict if raw_params: parameters = dict(raw_params) parameters.setdefault("type", "object") if "properties" not in parameters or not isinstance(parameters["properties"], dict): parameters["properties"] = {} if "required" in parameters and not isinstance(parameters["required"], list): parameters["required"] = [] else: # parametri assenti: oggetto vuoto permesso da Realtime API parameters = {"type": "object", "properties": {}} normalized.append({ "type": "function", "name": clean_name, "description": description, "parameters": parameters, }) logger.debug( "[MCP TOOLS] normalized count=%d raw_count=%d names=%s", len(normalized), len(raw_tools), [t["name"] for t in normalized][:10], ) return normalized def _mcp_headers() -> dict[str, str]: headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", } if S2S_TOOL_EXECUTION_MCP_API_KEY: headers["X-API-KEY"] = S2S_TOOL_EXECUTION_MCP_API_KEY if S2S_TOOL_EXECUTION_MCP_API_SECRET: headers["X-API-SECRET"] = S2S_TOOL_EXECUTION_MCP_API_SECRET return headers def _parse_mcp_response(body: str) -> dict: text = (body or "").strip() if not text: return {} if text.startswith("{"): try: parsed = json.loads(text) return parsed if isinstance(parsed, dict) else {} except Exception: return {} for chunk in text.split("\n\n"): for line in chunk.splitlines(): if line.startswith("data: "): payload = line[6:].strip() try: parsed = json.loads(payload) return parsed if isinstance(parsed, dict) else {} except Exception: return {} return {} 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.""" logger.debug("[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?") agent_prompt, agent_tools_raw = await _agent_profile_data() # ⛔ MCP MASTER DISABLE: se S2S_TOOL_EXECUTION_MCP_URL non è in .env # nascondi catalogo 79 tool ANCHE lato client (evita ranking/pulsanti che fallirebbero 503) if MCP_EXEC_ENABLED: agent_tools_for_client = agent_tools_raw search_mcp_for_client = bool(S2S_SEARCH_MCP) else: agent_tools_for_client = [] search_mcp_for_client = False primary_prompt = agent_prompt or S2S_PRIMARY_PROMPT out = { "search": bool(SERPER_KEY), "lb": bool(LOAD_BALANCER_URL), "allowDirect": not LOAD_BALANCER_URL, # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the # browser dials it itself, and Settings shows it locked. "s2sUrl": _effective_s2s_url(request), "primaryPrompt": primary_prompt, "agentProfile": S2S_AGENT_PROFILE, "agentTools": agent_tools_for_client, "searchMcp": search_mcp_for_client, "reasoningType": S2S_REASONING_TYPE, "auth": AUTH_ENABLED, } logger.debug( "[INTERNAL] /api/config returned promptLen=%d toolCount=%d (raw=%d before master gate) reasoning=%s searchMcp=%s MCP_EXEC_ENABLED=%s", len(primary_prompt), len(agent_tools_for_client), len(agent_tools_raw), S2S_REASONING_TYPE, search_mcp_for_client, MCP_EXEC_ENABLED, ) return out @app.get("/api/user/phone") async def user_by_phone(phoneNumber: str = ""): phone_number = phoneNumber.strip() if not phone_number: raise HTTPException(status_code=400, detail="phoneNumber is required.") if not VOCERO_USER_PHONE_AUTH_URL: raise HTTPException(status_code=503, detail="VOCERO_USER_PHONE_AUTH_URL is not configured.") target_url = _url_with_query(VOCERO_USER_PHONE_AUTH_URL, phoneNumber=phone_number) logger.info("[Vocero USER AUTH] phoneNumber=%s target=%s", phone_number, target_url) logger.debug("[EXT][AQUACHILE] user_by_phone GET %s", target_url) try: async with httpx.AsyncClient(timeout=20.0) as http: resp = await http.get(target_url, headers={"Accept": "application/json"}) except httpx.RequestError as exc: logger.warning("User phone auth failed: %r", exc) raise HTTPException(status_code=502, detail="User phone auth unreachable.") logger.debug( "[EXT][AQUACHILE] user_by_phone RESP status=%s len=%dB", resp.status_code, len(resp.content or b""), ) if resp.status_code != 200: logger.warning("User phone auth failed %s: %s", resp.status_code, resp.text[:400]) raise HTTPException(status_code=502, detail=f"User phone auth failed ({resp.status_code}).") try: payload = resp.json() except Exception: logger.warning("User phone auth returned invalid JSON: %s", resp.text[:400]) raise HTTPException(status_code=502, detail="User phone auth returned invalid JSON.") logger.info("[Vocero USER AUTH] phoneNumber=%s preview=%s", phone_number, json.dumps(payload, ensure_ascii=True)[:800]) if isinstance(payload, dict): logger.debug( "[EXT][AQUACHILE] user_by_phone PARSED keys=%s", sorted(payload.keys()), ) return JSONResponse({"result": payload}) @app.post("/api/mcp/tool") async def mcp_tool(req: McpToolCallRequest): if not MCP_EXEC_ENABLED: logger.warning( "[MCP DISABLED] ⛔ /api/mcp/tool called but S2S_TOOL_EXECUTION_MCP_URL is empty/commented in .env. " "Rejecting with 503. name=%s args=%s", req.name, json.dumps(req.arguments or {}, ensure_ascii=True)[:200], ) raise HTTPException( status_code=503, detail="MCP tool execution is disabled: S2S_TOOL_EXECUTION_MCP_URL is not configured in .env.", ) target = S2S_TOOL_EXECUTION_MCP_URL.rstrip("/") + "/" body = { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": req.name, "arguments": req.arguments or {}, }, } args_json = json.dumps(req.arguments or {}, ensure_ascii=True) logger.info( "[Vocero MCP CALL] name=%s target=%s args=%s", req.name, target, args_json[:800], ) logger.debug( "[EXT][AQUACHILE] mcp_tool POST %s method=%s tool=%s argsLen=%dB body=%s", target, body["method"], req.name, len(args_json), json.dumps(body, ensure_ascii=True)[:800], ) try: async with httpx.AsyncClient(timeout=45.0) as http: resp = await http.post( target, headers=_mcp_headers(), json=body, ) except httpx.RequestError as exc: logger.warning("MCP tool call failed: %r", exc) raise HTTPException(status_code=502, detail="MCP tool execution unreachable.") logger.info("[Vocero MCP HTTP] name=%s status=%s", req.name, resp.status_code) logger.debug( "[EXT][AQUACHILE] mcp_tool RESP tool=%s status=%s len=%dB raw=%s", req.name, resp.status_code, len(resp.content or b""), (resp.text or "")[:800], ) if resp.status_code != 200: logger.warning("MCP tool call failed %s: %s", resp.status_code, resp.text[:400]) raise HTTPException(status_code=502, detail=f"MCP tool execution failed ({resp.status_code}).") payload = _parse_mcp_response(resp.text) if payload.get("error"): message = payload["error"].get("message") if isinstance(payload["error"], dict) else "MCP tool execution failed." logger.warning("[Vocero MCP OUT] name=%s error=%s", req.name, message) raise HTTPException(status_code=502, detail=message or "MCP tool execution failed.") result = payload.get("result") result_json = json.dumps(result, ensure_ascii=True) if result is not None else "null" logger.info("[Vocero MCP OUT] name=%s preview=%s", req.name, result_json[:800]) logger.debug( "[EXT][AQUACHILE] mcp_tool PARSED tool=%s resultKeys=%s resultLen=%dB", req.name, sorted(result.keys()) if isinstance(result, dict) else "N/A", len(result_json), ) return JSONResponse({"result": result}) @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.") # ──────────────────────────────────────────────────────────────────── # 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).", ) upstream_body = { "transcript": transcript, "instructions": (req.instructions or "").strip(), "language": (req.language or "").strip(), "user_context": (req.user_context or "").strip(), "phoneNumber": (req.phoneNumber or "").strip() or None, } _instructions_str = upstream_body["instructions"] or "" _has_mcp_search_hint = "Available MCP tools:" in _instructions_str _has_ranking_hint = "Client-side ranking hint only:" in _instructions_str _top3_suggested = [] if _has_ranking_hint: _rank_chunk = None for _sep in [". Use this hint", " Use this hint", " — Use this hint", ". Use it only", "\nUser profile:", "\nCurrent date:", " Use "]: _pos = _instructions_str.find(_sep, _instructions_str.find("Client-side ranking hint only:")) if _pos > 0: _start = _instructions_str.find("Client-side ranking hint only:") + len("Client-side ranking hint only:") _rank_chunk = _instructions_str[_start:_pos].strip(" \n\t:-—.") break if _rank_chunk is None: _start = _instructions_str.find("Client-side ranking hint only:") + len("Client-side ranking hint only:") _rank_chunk = _instructions_str[_start:_start + 1500].strip() for _rm in re.finditer(r"\s*(\d+)\.\s*([^::]+?)[::]\s*", _rank_chunk): _rn = int(_rm.group(1)) _nm = _rm.group(2).strip(" \n\t`*•") _rest_start = _rm.end() _rest_chunk = _rank_chunk[_rest_start:_rest_start + 300] _next_match = re.search(r"\s*\d+\.\s*[^::]+?[::]\s*", _rest_chunk) if _next_match: _descr = _rest_chunk[:_next_match.start()].strip(" \n\t;.,—-") else: _descr = _rest_chunk.strip(" \n\t;.,—-") if _nm: _top3_suggested.append({"rank": _rn, "name": _nm, "description": _descr[:80]}) _mcp_catalog_len = 0 if _has_mcp_search_hint: _cat_start = _instructions_str.find("Available MCP tools:") + len("Available MCP tools:") _cat_end_candidates = [ _instructions_str.find(_s, _cat_start) for _s in [" Current user request:", "\nCurrent user request:", " Current user request\n", " User profile:", "\nUser profile:", " When the user's request", " Use "] ] _cat_end_candidates = [_p for _p in _cat_end_candidates if _p > _cat_start] _cat_end = min(_cat_end_candidates) if _cat_end_candidates else (_cat_start + 20000) _cat_chunk = _instructions_str[_cat_start:_cat_end].strip(" \n\t:.,—-") _semicolon_count = _cat_chunk.count("; ") if _semicolon_count: _mcp_catalog_len = _semicolon_count + 1 else: _tool_mentions = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]{2,50}\b\s*:\s", _cat_chunk) _mcp_catalog_len = len(_tool_mentions) logger.info( "[MCP RANK] reasoning payload transcript=%s instructionsLen=%d hasMcpCatalogHint=%s hasRankingHint=%s mcpCatalogTools=%d top3Suggested=%s", transcript[:200], len(_instructions_str), _has_mcp_search_hint, _has_ranking_hint, _mcp_catalog_len, json.dumps(_top3_suggested, ensure_ascii=True) if _top3_suggested else "none", ) logger.debug( "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d phone=%s", NODE_RED_REASONING_URL, (req.language or "")[:20] or "auto", len(transcript), (req.phoneNumber or "")[:16] or "none", ) 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", "mcp_tools_used", "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}) 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 = (req.instructions or "").strip() if instructions: system_parts.append(instructions) if req.user_context.strip(): system_parts.append(f"User context:\n{req.user_context.strip()}") 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.") body = { "model": OPENAI_REASONING_MODEL, "messages": [ {"role": "system", "content": "\n\n".join(system_parts)}, {"role": "user", "content": 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]) 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) logger.debug( "[EXT][S2S-PROXY] client=%s CONNECT upstream=%s subprotocols=%s", client_ip, upstream_url, subprotocols, ) try: async with websockets.connect( upstream_url, subprotocols=subprotocols or None, open_timeout=15, max_size=None, ) as upstream: logger.debug("[EXT][S2S-PROXY] client=%s upstream TUNNEL ESTABLISHED", client_ip) # ── Inject strumenti MCP Aquachile + istruzioni agente a S2S upstream ── # Prima dei task di streaming: session.update con tools[] e instructions # native. Se l'upstream supporta il formato → emette response.function_call_arguments.done # e la catena toolcall nativa del client si attiva automaticamente. # # ★ FORMATO CORRETTO OpenAI Realtime GA Standard: # {"type":"session.update","session":{"instructions":"…","tools":[…],"tool_choice":"auto"}} # I campi sono SEMPRE annidati dentro "session": Pydantic strict reject extra fields # se li metti alla radice, causando "Unknown or invalid event: session.update". try: _agent_prompt, _agent_raw_tools = await _agent_profile_data() if MCP_EXEC_ENABLED: _realtime_tools = _normalize_tools_for_realtime(_agent_raw_tools) else: _realtime_tools = [] logger.info( "[MCP INJECT] ⛔ SKIPPED tool injection (MCP_EXEC_ENABLED=False — S2S_TOOL_EXECUTION_MCP_URL not set in .env). " "Sending only instructions, no tools[]. Agent prompt length=%d.", len(_agent_prompt or ""), ) # --- Insight diario Aquachile → append a instructions --- _insights_entries: list[dict] | None = None _insights_block: str = "" if INSIGHTS_ENABLED: try: _insights_entries, _insights_block = await _fetch_insights_for_today() except Exception as _insights_err: # NON bloccare mai la sessione audio per fallimento insights fetch logger.warning( "[INSIGHTS APPEND] eccezione in _fetch_insights_for_today (ignorata, continuo senza insights): %r", _insights_err, ) _insights_entries, _insights_block = None, "" else: logger.debug( "[INSIGHTS APPEND] skipped (INSIGHTS_ENABLED=False — S2S_INSIGHTS_URL vuoto/non settato in .env)." ) # --- Merge finale instructions: profilo agente + insights diario --- if _agent_prompt and _insights_block: _final_instructions = ( _agent_prompt.rstrip() + "\n\n" + _insights_block + "\n" ) elif _agent_prompt: _final_instructions = _agent_prompt.strip() elif _insights_block: _final_instructions = _insights_block else: _final_instructions = "" _insights_count_for_log = len(_insights_entries) if _insights_entries else 0 if _insights_block: logger.info( "[INSIGHTS APPEND] ✓ aggiunti %d insights al contesto agente (blockLen=%d, truncated=%s). " "Ora l'assistente dispone del contexto operativo del día UTC=%s.", _insights_count_for_log, len(_insights_block), "YES" if _insights_entries and len(_insights_entries) > MAX_INSIGHTS_IN_CONTEXT else "NO", datetime.now(timezone.utc).strftime("%Y-%m-%d"), ) # 📝 Log ESATTO del blocco che viene passato nel session.instructions # (prime ~45 righe o ~3500 chars per auditoria; non tronca MAI l'header). _all_ins_lines = _insights_block.splitlines() _cap_chars = 3500 _cap_lines = min(45, len(_all_ins_lines)) _truncated_preview = False _joined = "\n".join(_all_ins_lines[:_cap_lines]) if len(_joined) > _cap_chars: _joined = _joined[:_cap_chars].rsplit("\n", 1)[0] if len(_all_ins_lines) > _cap_lines or len(_insights_block) > _cap_chars: _truncated_preview = True _shown_lines = _cap_lines if _truncated_preview else len(_all_ins_lines) _shown_chars = len(_joined) if _truncated_preview else len(_insights_block) _trunc_msg = ( " [TRUNCATED — per vedere tutto imposta MAX_INSIGHTS_IN_CONTEXT maggiore " "oppure consulta endpoint /api/v1/insights]" if _truncated_preview else "" ) # 👉 Pattern singolo %s + f-string: ELIMINA DEFUNTIVAMENTE il bug # TypeError: not enough arguments for format string (mismatch args/placeholder) # e protegge da qualsiasi % dentro gli insights (es. "-1.65%" -36.34%"). _msg_append = ( f"[INSIGHTS APPEND] 📋 TESTO INJETTATO NEL PROMPT agent instructions" f"{_trunc_msg} " f"(lines={_shown_lines}/{len(_all_ins_lines)}, " f"chars={_shown_chars}/{len(_insights_block)}, " f"capChunks=45lines/3500chars):" f"\n{_joined}" ) logger.info("%s", _msg_append) elif INSIGHTS_ENABLED: logger.info( "[INSIGHTS APPEND] SKIP (INSIGHTS_ENABLED=True ma nessun insight disponibile oggi " "oppure fetch fallito per endpoint non raggiungibile. Non è un errore bloccante: " "le istruzioni agente contengono solo il profilo standard)." ) # unisci instructions: profilo agente Aquachile + default esistente da client # (il client manderà un secondo session.update con le proprie; # upstream le mergea. Per sicurezza mandiamo prima il profilo server-side) _inject_payload: dict = {"type": "session.update", "session": {}} _session_inner = _inject_payload["session"] if _final_instructions.strip(): _session_inner["instructions"] = _final_instructions.strip() if _realtime_tools: _session_inner["tools"] = _realtime_tools _session_inner["tool_choice"] = "auto" # cap: se 100+ tool upstream potrebbe rifiutare: log warning ma non falliamo if len(_realtime_tools) > 80: logger.warning( "[MCP INJECT] troppi tools (%d) — upstream potrebbe rifiutare. " "Se non vedi response.function_call_arguments.done prova a ridurre con S2S_REASONING_NODE_RED_MAX_TOOLS o filtro lato Aquachile.", len(_realtime_tools), ) logger.info( "[MCP INJECT] session.update upstream → client=%s tools=%d hasAgentPrompt=%s tool_choice=%s insights=%d fmt=nested_session", client_ip, len(_realtime_tools), bool(_agent_prompt.strip()), _session_inner.get("tool_choice", "none"), _insights_count_for_log, ) if _realtime_tools: logger.debug( "[MCP INJECT] tool names preview=%s", json.dumps([t["name"] for t in _realtime_tools][:20], ensure_ascii=True), ) await upstream.send(json.dumps(_inject_payload, ensure_ascii=True)) logger.info("[MCP INJECT] ✓ session.update upstream inviato (tools=%d, insights=%d, fmt=nested_session OK)", len(_realtime_tools), _insights_count_for_log) except Exception as _inject_err: # NON blocchiamo mai l'audio per fallimento inject tools logger.warning( "[MCP INJECT] FAILED (ignored, continuo senza tools nativi): %r", _inject_err, ) session_ctx = { "last_user_transcript": "", "last_item_id": None, # 🔥 FIX RACE CONDITION session.update: servono a _client_to_upstream # per mergiare profile+insights+tools IN OGNI session.update CHE # ARRIVA DAL CLIENT (invece di fare solo inject upfront che # viene SOVRASCRITTO dal 2ndo session.update del browser 200ms dopo). "mcp_agent_prompt": _agent_prompt or "", "mcp_insights_block": _insights_block or "", "mcp_realtime_tools": _realtime_tools, } 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/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 /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 _augment_client_session_update( event: dict, agent_prompt: str, insights_block: str, realtime_tools: list[dict], ) -> tuple[dict, bool, dict]: """ 🔥 FIX RACE: intercetta ogni session.update proveniente dal browser client PRIMA che venga mandato a ai-server e ne MENGIA istruzioni lato client con: server profile Aquachile + insights diario oggi + tools catalog MCP. Regola merge per instructions (ordine significativo, prima = più basso peso in LLM): [1] istruzioni client originali ("Eres el asistente de voz oficial de Aquachile..." + Voice Rules) [2] "\n\n## Aquachile Agent Profile Server-Side" + profile [3] "\n\n" + insights_block già formattato con ### Contexto operativo del día Tools + tool_choice: imposto solo se realtime_tools non vuoto (MCP_EXEC_ENABLED=True e 79 tool disponibili). Ritorna: (event_patched, was_patched, stats_dict) - event_patched: dict modificato, o lo stesso oggetto originario se niente da mergiare - was_patched: True solo se abbiamo toccato il dict """ stats = {"merged_instructions_len": 0, "appended_profile": False, "appended_insights": False, "tools_injected_count": 0} if not isinstance(event, dict) or event.get("type") != "session.update": return event, False, stats _has_anything = bool(agent_prompt or insights_block or realtime_tools) if not _has_anything: return event, False, stats # Assicura session wrapper esista (standard GA nested) if not isinstance(event.get("session"), dict): event["session"] = {} sess = event["session"] base_instructions = sess.get("instructions") or "" if isinstance(base_instructions, str): base = base_instructions.strip() else: base = "" extra_parts: list[str] = [] if agent_prompt and agent_prompt.strip(): extra_parts.append( "## Aquachile Agent Profile (injected by S2S proxy server-side)\n" + agent_prompt.strip() ) stats["appended_profile"] = True if insights_block and insights_block.strip(): extra_parts.append(insights_block.strip()) stats["appended_insights"] = True if base and extra_parts: merged = base + "\n\n" + "\n\n".join(extra_parts) elif base: merged = base elif extra_parts: merged = "\n\n".join(extra_parts) else: merged = "" merged = merged.strip() if merged: sess["instructions"] = merged stats["merged_instructions_len"] = len(merged) if realtime_tools: sess["tools"] = realtime_tools sess["tool_choice"] = "auto" stats["tools_injected_count"] = len(realtime_tools) was_patched = bool(stats["merged_instructions_len"] or stats["tools_injected_count"]) return event, was_patched, stats 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 # --- 🔥 FIX RACE: PATCH SESSION.UPDATE CLIENT PRIMA DI INOLTRE A UPSTREAM --- # Il browser manda un suo session.update (con instructions base + voice) # POCHI ms DOPO il nostro inject upfront. Senza questo patch, quello # del client SOVRASCRIVE instructions e tools completamente (handle_session_update # usa deep merge: instructions campo scalare viene sovrascritto, non concatenato). _raw_text = message["text"] _forward_this = _raw_text _was_patched = False try: _evt_client = json.loads(_raw_text) if ( isinstance(_evt_client, dict) and _evt_client.get("type") == "session.update" ): _a_prompt = session_ctx.get("mcp_agent_prompt", "") or "" _a_insights = session_ctx.get("mcp_insights_block", "") or "" _a_tools: list[dict] = session_ctx.get("mcp_realtime_tools") or [] _evt_patched, _was_patched, _patch_stats = _augment_client_session_update( _evt_client, _a_prompt, _a_insights, _a_tools ) if _was_patched: _forward_this = json.dumps(_evt_patched, ensure_ascii=True) logger.info( "[MCP INJECT] PATCHED client session.update →UPSTREAM: " "mergedInstructions=%dB appendedProfile=%s appendedInsights=%s " "toolsInjected=%s (prima era senza insights/tools — era la causa del " "PROMPT LLM senza insights in ai-server log).", _patch_stats["merged_instructions_len"], bool(_patch_stats["appended_profile"]), bool(_patch_stats["appended_insights"]), _patch_stats["tools_injected_count"] or "NONE", ) else: logger.info( "[MCP INJECT] PASSTHROUGH client session.update →UPSTREAM: " "no server data to merge (nothing enabled / empty profile / insights disabled).", ) except Exception as _patch_err: # NEVER-BREAK-AUDIO: qualunque errore nel parsing o nel patching, # inoltro il messaggio originale così com'era. logger.warning( "[MCP INJECT] errore non bloccante in patching client session.update " "(fallback to passthrough original message): %r", _patch_err, ) _forward_this = _raw_text _was_patched = False await upstream.send(_forward_this) 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"]) async def _upstream_to_client(ws: WebSocket, upstream, client_ip: str, session_ctx: dict) -> None: async for message in upstream: if isinstance(message, bytes): # #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: # #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"}: agent_text = (summary.get("text") or "").strip() if agent_text: logger.info( "[OUTPUT AGENTE] client=%s tts=%s", client_ip, agent_text, ) # ── Forward fire-and-forget a Node-RED (FASE 1 LOG-ONLY) ── 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: # Ispezione: questo evento custom non emette testo nel campo atteso 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 "", })) # Log dettagliato evento solo se non è un tipo rumoroso 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( "[MCP INJECT] suppressed upstream error (non mandato al browser): %s", _suppress_reason, ) logger.debug( "[MCP INJECT] 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( "[MCP INJECT] 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")