""" 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") # External user-by-phone info endpoint. When set, GET /api/user/phone proxies # to this URL (passing ?phoneNumber=) and forwards the JSON payload to the # browser so the UI can render caller identity / caller context. VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/") # Feature flag: when "true" (case-insensitive) => injects the resolved caller # profile into the realtime session (patch session.update client->upstream, # append profile to the LLM system instructions prompt instructions, and prepend it to the # reasoning user_context for the /api/reasoning HTTP body. # Default = disabled (generic deploy; pure passthrough; all characterization lives # in the backend RAG per original architectural decision.) S2S_INJECT_PROFILE = ( (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() in {"1", "true", "yes", "on"} ) # Endpoint URL for fetching the latest per-caller conversation summary, used when # both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely # generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber= # and expects JSON back (optionally wrapped as {"result": }). Empty # string means the feature is off (no network call, nothing injected). S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip() # Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and # phoneNumber is available), before fetching user profile / latest summary the proxy # first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body, # extracts an auth token from the response (if any), and forwards it as # Authorization: Bearer for subsequent profile / summary / reports HTTP calls. S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/") # Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true) # the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}), # filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC, # and injects the compiled block into the LLM instructions / user_context between # the caller profile block and the latest conversation summary block. S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/") # Feature flag: when "true" 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_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.info( "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s summaryApi=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s auth=%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), bool(S2S_LATEST_SUMMARY), bool(S2S_LOGIN_BY_PHONE), S2S_INJECT_PROFILE, S2S_DEBUG_PROMPT, S2S_START_SESSION_MODE or "-", 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 (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." ) 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. " ) def _resolve_user_context_text(phone_number: str, profile_obj: dict | None, 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": ...}). """ 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] + "..." return f"{VOCERO_USER_CONTEXT_HINT}{serialized}. Use this as trusted caller context when relevant." def _resolve_latest_summary_context(summary_value: str | dict | None) -> 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. Wrapped with LATEST_SUMMARY_BEGIN/END markers so log inspection is easy. 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] + "..." return f"{LATEST_SUMMARY_HINT}<<>>\n{trimmed}\n<<>>. Use this as trusted history when the user references prior calls." def _resolve_episode_context(raw_value: object) -> 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). Wrapped with <<>> markers. Returns "" if empty / fetch failed / None. """ if raw_value is None: return "" episode_obj: object = raw_value if isinstance(raw_value, dict) and isinstance(raw_value.get("result"), dict): episode_obj = raw_value["result"] if isinstance(episode_obj, dict) and len(episode_obj) == 0: return "" if isinstance(episode_obj, list) and len(episode_obj) == 0: return "" try: serialized = json.dumps(episode_obj, ensure_ascii=False) except Exception: serialized = str(episode_obj) trimmed = serialized.strip() if not trimmed: return "" if len(trimmed) > 24000: trimmed = trimmed[:24000] + "..." return f"{PODCAST_EPISODE_HINT}<<>>\n{trimmed}\n<<>>. This is the episode the caller is currently interacting with — answer with reference to it first." def _parse_date_key(value: object) -> tuple: """Return a sortable tuple for an arbitrary date-like value. Sort order is DESC (newest first). Non-dates / empty sort to the end (lowest priority). Accepts ISO strings ("YYYY-MM-DD", "YYYY-MM-DDTHH:MM:SS"), Python datetime objects, int/float unix timestamps, numbers. """ import datetime as _dt if value is None: return (1, 0) if isinstance(value, _dt.datetime): try: return (0, -int(value.timestamp())) except Exception: return (1, 0) if isinstance(value, _dt.date): try: ts = int(_dt.datetime.combine(value, _dt.time.min).timestamp()) return (0, -ts) except Exception: return (1, 0) if isinstance(value, (int, float)): try: return (0, -int(value)) except Exception: return (1, 0) if isinstance(value, str): s = value.strip() if not s: return (1, 0) # Try ISO parse first (handles most formats) try: dt = _dt.datetime.fromisoformat(s.replace("Z", "+00:00")) return (0, -int(dt.timestamp())) except Exception: pass # Fallback: date-only YYYY-MM-DD try: dt = _dt.datetime.strptime(s[:10], "%Y-%m-%d") return (0, -int(dt.timestamp())) except Exception: pass # Last resort: lexicographic sort DESC (newest first still works for ISO-ish) return (0, tuple(-ord(c) for c in s[:32])) return (1, 0) def _resolve_reports_context(raw_value: object) -> str: """Normalize / filter the S2S_REPORT_LIST response into a prompt block. Expects an array/list (or dict {"result": [...]} unwrapped by the fetcher). For each dict item keeps only fields: sourceScheduleLabel, date, podcastScript. Also supports nested shape: sourceSchedule.label / sourceSchedule.date. Sorts by date DESC (newest first) via _parse_date_key. Capped at 12000 chars (podcastScript can be long). Wrapped with <<>> markers. Returns "" if no valid items / empty list. """ 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"))) # 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 = hints_block + "\n--- Full JSON entries ---\n" + "\n".join(entries_lines) wrapped = ( f"{REPORTS_LIST_HINT}\n" "Quick hints (available scheduled reports / podcasts by label):\n" f"{body}\n" f"<<>>. Use these scheduled reports as trusted prior " "content relevant to the caller's company topics. Reference individual " "reports by their label when answering." ) # Build wrapped with HINT BLOCK before the JSON. We prepend a summary line per # report so the LLM has a quick table-of-contents of what's available. if len(wrapped) > 12000: # Truncate body preserving markers + hint budget = ( 12000 - len(REPORTS_LIST_HINT) - len("\nQuick hints (available scheduled reports / podcasts by label):\n") - len("\n--- Full JSON entries ---\n") - len("\n<<>>...truncated.") - 200 # reserve for wrap sentence ) if budget > 0: body = body[:budget] + "..." wrapped = ( f"{REPORTS_LIST_HINT}\n" "Quick hints (available scheduled reports / podcasts by label):\n" f"{body}\n" f"<<>>...truncated (cap 12000 chars). Use these " "scheduled reports as trusted prior content. Reference reports by label when relevant." ) return wrapped async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None: """Fetch scheduled reports list from S2S_REPORT_LIST. Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends ?phoneNumber= if a phone is provided. Accepts JSON (optional {"result": [...]} wrapper) and returns the list or unwrapped list. On any error logs and returns None (never fails the proxy / reasoning tunnel). """ 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 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": …} if present (common wrapper convention). if isinstance(payload, dict) and isinstance(payload.get("result"), dict): unwrapped = payload["result"] else: unwrapped = payload if isinstance(payload, dict) else None token = _extract_login_token(unwrapped) if isinstance(unwrapped, dict) else _extract_login_token( payload if isinstance(payload, dict) else None ) if isinstance(unwrapped, dict): logger.info( "[INJECT-LOGIN] ✅ login response OK keys=%s token=%s", ",".join(sorted(str(k) for k in unwrapped.keys())), "present" if token else "missing", ) if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n", len(json.dumps(unwrapped, ensure_ascii=False)), json.dumps(unwrapped, ensure_ascii=False, indent=2), ) return token, unwrapped # Fallback: non-dict payload (string/list/number) — still log it, but no token expected. logger.info( "[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.", type(payload).__name__, ) if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n", len(str(payload)), payload, ) return None, None async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | None = None) -> str | dict | None: """Fetch the latest caller summary via S2S_LATEST_SUMMARY URL. Generic / completely deploy-agnostic: plain GET S2S_LATEST_SUMMARY?phoneNumber=; accepts JSON (optional {"result": …} wrapper) or plain text. Never fails the proxy / reasoning; returns None on any error and logs it. If `auth_token` is provided it is sent as Authorization: Bearer . """ if not S2S_LATEST_SUMMARY or not phone_number: return None from urllib.parse import urlencode target_url = f"{S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}" logger.info( "[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s", S2S_LATEST_SUMMARY, len(phone_number), "yes" if auth_token else "no", ) try: headers = {} if auth_token: headers["Authorization"] = f"Bearer {auth_token}" async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: resp = await http.get(target_url, headers=headers or None) if resp.status_code != 200: logger.warning( "[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s", resp.status_code, len(phone_number), resp.text, ) return None # Accept JSON first, then fall back to plain text (some summary APIs # return text/plain). text = (resp.text or "").strip() if not text: return "" try: payload = resp.json() except Exception: logger.info( "[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.", len(text), ) return text except Exception as exc: logger.warning( "[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r", len(phone_number), exc, ) return None # Unwrap {"result": …} just like profile fetch (common wrapper pattern). if isinstance(payload, dict) and "result" in payload: payload = payload["result"] # Filter out empty dict/list / empty string after unwrap. empty = ( payload is None or (isinstance(payload, str) and not payload.strip()) or (isinstance(payload, dict) and len(payload) == 0) or (isinstance(payload, list) and len(payload) == 0) ) if empty: logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.") return None if isinstance(payload, dict): logger.info( "[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s", ",".join(sorted(str(k) for k in payload.keys())), ) elif isinstance(payload, list): logger.info( "[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d", len(payload), ) else: logger.info( "[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d", len(str(payload)), ) return payload async def _fetch_podcast_episode(episode_id: str) -> dict | str | None: """Fetch a single podcast episode by ID from the Aquachile AzurianLab API. Completely generic / deploy-agnostic: plain GET https://aquachile.azurianlab.com/api/v1/podcast/episodes/ Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails the proxy / reasoning; returns None on any error and logs it. """ if not episode_id: return None target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}" logger.info( "[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s", target_url, episode_id, ) try: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: resp = await http.get(target_url) if resp.status_code != 200: logger.warning( "[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s", resp.status_code, episode_id, resp.text, ) return None text = (resp.text or "").strip() if not text: return "" try: payload = resp.json() except Exception: logger.info( "[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.", len(text), ) return text except Exception as exc: logger.warning( "[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r", episode_id, exc, ) return None if isinstance(payload, dict) and "result" in payload: payload = payload["result"] empty = ( payload is None or (isinstance(payload, str) and not payload.strip()) or (isinstance(payload, dict) and len(payload) == 0) or (isinstance(payload, list) and len(payload) == 0) ) if empty: logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.") return None if isinstance(payload, dict): logger.info( "[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s", ",".join(sorted(str(k) for k in payload.keys())), ) elif isinstance(payload, list): logger.info( "[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d", len(payload), ) else: logger.info( "[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d", len(str(payload)), ) return payload async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None: """Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone. Returns the raw {"result": ...}-unwrapped object or None. Errors are logged then suppressed (never fail the proxy / reasoning because of this). When `auth_token` is provided it is forwarded as Authorization: Bearer to the upstream profile endpoint. """ if not VOCERO_USER_PHONE_AUTH_URL or not phone_number: return None from urllib.parse import urlencode target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}" logger.info( "[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s", VOCERO_USER_PHONE_AUTH_URL, len(phone_number), "yes" if auth_token else "no", ) try: headers = {} if auth_token: headers["Authorization"] = f"Bearer {auth_token}" async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: resp = await http.get(target_url, headers=headers or None) if resp.status_code != 200: logger.info( "[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s", resp.status_code, len(phone_number), resp.text, ) return None payload = resp.json() except Exception as exc: logger.warning( "[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r", len(phone_number), exc, ) return None if (isinstance(payload, dict) and "result" in payload and isinstance(payload["result"], dict)): profile_obj = payload["result"] elif isinstance(payload, dict) and not isinstance(payload, list): profile_obj = payload else: logger.info( "[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?", type(payload).__name__, ) return None logger.info( "[INJECT-PROFILE] ✅ profile loaded OK keys=%s", ",".join(sorted(profile_obj.keys())), ) return profile_obj def _augment_instructions_with_profile(base_instructions: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None, episode_context_text: str | None = None) -> str: """When S2S_INJECT_PROFILE is true: appends 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): 1. episode_ctx (CURRENT podcast episode the user is interacting with) 2. base instructions (primary prompt / user settings / default) 3. profile_ctx (caller identity block) 4. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC) 5. summary_ctx (latest conversation summary) Preserves the existing instructions text. """ base = (base_instructions or "").strip() 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() contexts = [c for c in [episode_ctx, profile_ctx, reports_ctx, summary_ctx] if c] if not contexts: return base_instructions or "" augmented = base for ctx in contexts: augmented = (augmented + " " + ctx).strip() if augmented else ctx changed = augmented != base logger.info( "[INJECT-PROFILE] ✏️ appended episode/profile/reports/summary to LLM instructions. " "baseLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s", len(base), len(episode_ctx), len(profile_ctx), len(reports_ctx), len(summary_ctx), len(augmented), changed, ) if S2S_DEBUG_PROMPT and episode_ctx: logger.info( "[DEBUG-EPISODE][instructions augment] FULL podcast episode block (len=%d):\n%s\n", len(episode_ctx), episode_ctx, ) if S2S_DEBUG_PROMPT and reports_ctx: logger.info( "[DEBUG-REPORTS][instructions augment] FULL reports list block (len=%d):\n%s\n", len(reports_ctx), reports_ctx, ) if S2S_DEBUG_PROMPT and summary_ctx: logger.info( "[DEBUG-SUMMARY][instructions augment] FULL latest summary block (len=%d):\n%s\n", len(summary_ctx), summary_ctx, ) return augmented def _patch_client_session_update_with_profile(raw_text: str, user_context_text: str, reports_context_text: str | None = None, summary_context_text: str | None = None, episode_context_text: str | None = 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 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 are all empty - S2S_INJECT_PROFILE is False - parse fails """ # 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. if not S2S_INJECT_PROFILE: logger.info( "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false). " "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): logger.info( "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary). " "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." ) 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 ) if S2S_DEBUG_PROMPT: logger.info( "[DEBUG-PROMPT][WS session.update] BEFORE instructions (len=%d):\n%s\n", len(current_instructions), current_instructions, ) if augmented == current_instructions: logger.info( "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; " "episode/profile already injected or context empty)" ) 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", len(current_instructions), len(augmented), (len(augmented) - len(current_instructions)), has_episode, has_profile, has_reports, has_summary, ) 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(), ) # 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.""" 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, } logger.debug( "[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s", len(primary_prompt), S2S_REASONING_TYPE, S2S_START_SESSION_MODE or "-", ) return out @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()) needs_reports = bool(S2S_REPORT_LIST.strip()) needs_summary = bool(S2S_LATEST_SUMMARY.strip()) 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 and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request). " "Set at least one to enable login / fetch episode/profile/reports/summary for REASONING endpoint." ) elif (not phone) and (not needs_reports) and (not episode_id): 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: if S2S_DEBUG_PROMPT: logger.info( "[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY configured; phoneLen=%d.", len(phone), ) 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=%d wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", len(phone), len(inject_summary_text), ) else: logger.info( "[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)" ) user_context_parts: list[str] = [] 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) original_user_context = (req.user_context or "").strip() if original_user_context: user_context_parts.append(original_user_context) 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 inject_episode_text.strip(): logger.info( "[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n", len(inject_episode_text), inject_episode_text, ) 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), 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), inject_summary_text, ) effective_instructions = _augment_instructions_with_profile( req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text, inject_episode_text ) else: effective_instructions = (req.instructions or "").strip() # ──────────────────────────────────────────────────────────────────── # 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}) 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 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]) 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 = "" inject_phone = "" inject_episode_id = "" login_token: str | None = None login_response_obj: dict | None = None if not S2S_INJECT_PROFILE: logger.info( "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). " "No profile/episode fetch; tunnel stays pure passthrough." ) else: needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip()) needs_reports = bool(S2S_REPORT_LIST.strip()) needs_summary = bool(S2S_LATEST_SUMMARY.strip()) needs_login = bool(S2S_LOGIN_BY_PHONE.strip()) client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True)) 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 and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in query). " "Set at least one with S2S_INJECT_PROFILE=true to enable caller context / reports / login / episode." ) elif (not inject_phone) and (not needs_reports) and (not inject_episode_id): # reports list can be fetched without phone (generic list); episode can be fetched without phone logger.info( "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber= AND no ?episode_id=, AND S2S_REPORT_LIST not set). " "Make sure the browser opens the WS with the phone or episode_id query param." ) 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: if S2S_DEBUG_PROMPT: logger.info( "[INJECT-SUMMARY] WS connect: starting S2S_LATEST_SUMMARY fetch for phoneLen=%d...", len(inject_phone), ) 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=%d wrappedLen=%d -> will patch session.update frames in this tunnel.", len(inject_phone), len(injected_summary_context), ) else: logger.info( "[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)." ) logger.info( "[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s", bool(injected_episode_context), bool(injected_profile_context), bool(injected_reports_context), bool(injected_summary_context), ("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), 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, "inject_phone": inject_phone, "inject_episode_id": inject_episode_id, "login_token": login_token, "login_response": login_response_obj, } 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: '