diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1a2c428 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +minimum_pre_commit_version: "4.4.0" +default_install_hook_types: [pre-commit] +default_stages: [pre-commit] + +repos: + - repo: local + hooks: + - id: ruff-check + name: Ruff lint + entry: uv run --locked ruff check --force-exclude + language: unsupported + types_or: [python, pyi] + require_serial: true + - id: ruff-format + name: Ruff formatting + entry: uv run --locked ruff format --check --force-exclude + language: unsupported + types_or: [python, pyi] + require_serial: true + - id: mypy + name: Python types + entry: uv run --locked mypy + language: unsupported + pass_filenames: false + files: ^(s2s_client/|s2s-client/server\.py|pyproject\.toml|uv\.lock) diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33dce32 --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +UV ?= uv +UV_RUN = $(UV) run --locked + +.PHONY: help install lock lock-check format format-check lint lint-fix type-check test check dev hooks-install hooks-run test-launcher test-image + +help: + @printf '%s\n' \ + 'install Sync the uv environment, including development tools' \ + 'lock Resolve dependencies into uv.lock' \ + 'format Apply Ruff fixes and formatting' \ + 'check Check lock freshness, formatting, lint, types, and backend tests' \ + 'dev Start the existing React/FastAPI development launcher' \ + 'hooks-install Install optional local pre-commit checks' \ + 'test-launcher Exercise the Bash supervisor with local fixture processes' \ + 'test-image Build and smoke-test the backend Docker image' + +install: + $(UV) sync --locked + +lock: + $(UV) lock + +lock-check: + $(UV) lock --check + +format: + $(UV_RUN) ruff check --fix-only . + $(UV_RUN) ruff format . + +format-check: + $(UV_RUN) ruff format --check . + +lint: + $(UV_RUN) ruff check . + +lint-fix: + $(UV_RUN) ruff check --fix . + +type-check: + $(UV_RUN) mypy + +test: + $(UV_RUN) pytest + +check: lock-check format-check lint type-check test + +dev: + ./start_ui.sh + +hooks-install: + $(UV_RUN) pre-commit install --install-hooks + +hooks-run: + $(UV_RUN) pre-commit run --all-files + +test-launcher: + npm --prefix s2s-frontend run test:launcher + +test-image: + bash scripts/test_docker_image.sh diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cd8f58f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "aquaportal-s2s-client" +version = "0.1.0" +description = "FastAPI integration and realtime proxy for the Vocero frontend" +readme = "s2s-client/README.md" +requires-python = ">=3.12,<3.13" +dependencies = [ + "fastapi==0.115.6", + "uvicorn[standard]==0.34.0", + "httpx==0.28.1", + "python-dotenv==1.2.2", + "pydantic==2.13.5", + "websockets==17.2", +] + +[dependency-groups] +dev = [ + "mypy>=1.16,<2", + "pre-commit>=4.4,<5", + "pytest>=9,<10", + "ruff>=0.12,<1", +] + +[tool.uv] +required-version = "==0.12.5" + +[tool.setuptools.packages.find] +include = ["s2s_client*"] + +[tool.ruff] +target-version = "py312" +line-length = 120 +include = ["pyproject.toml", "s2s_client/**/*.py", "s2s-client/server.py", "tests/**/*.py", "scripts/**/*.py"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "B", "UP"] +ignore = ["UP040", "UP046"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" + +[tool.mypy] +python_version = "3.12" +plugins = ["pydantic.mypy"] +files = ["s2s_client", "s2s-client/server.py"] +check_untyped_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unused_configs = true +warn_unused_ignores = true +show_error_codes = true +pretty = true + +[tool.pydantic-mypy] +init_typed = true +warn_untyped_fields = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = [".", "s2s-client"] + diff --git a/s2s-client/CHANGELOG.md b/s2s-client/CHANGELOG.md index e06be9b..8d574dd 100644 --- a/s2s-client/CHANGELOG.md +++ b/s2s-client/CHANGELOG.md @@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in ### Changed +- Added a uv-managed Python 3.12 environment and Ruff, mypy, and pytest quality + checks. Split the FastAPI service into configuration, route, provider, + context/reasoning, and realtime modules while retaining `server:app` startup. - Removed the legacy browser site in favor of the React application in `s2s-frontend`. FastAPI now serves only backend and documentation routes. - Retired daily talk-time accounting and the associated Hugging Face account diff --git a/s2s-client/server.py b/s2s-client/server.py index d9c2a7e..1d4643c 100644 --- a/s2s-client/server.py +++ b/s2s-client/server.py @@ -1,6305 +1,9 @@ -""" -API and WebSocket proxy for the speech-to-speech frontend. +"""Compatibility entry point for existing ``uvicorn server:app`` deployments.""" -Vite serves the React application and forwards backend requests here. Search, -caller-profile, and tool credentials stay server-side. `/api/session` proxies -load-balancer allocation so the browser receives only the per-session compute -URL it must dial. A pinned direct URL or WebSocket proxy target disables the LB. +import sys +from pathlib import Path -Endpoints: - GET /api/config -> frontend settings and available integrations - POST /api/search -> { results, answer } Google via Serper.dev - POST /api/session -> proxies /session: a grant, or a queue ticket - GET /api/queue/{id} -> proxies /queue/{id}: position, or a grant on claim - DELETE /api/queue/{id} -> leave the queue (explicit "Leave queue" button) - POST /api/queue/end -> leave the queue (sendBeacon on teardown) - WS /v1/realtime -> optional speech-service WebSocket proxy - GET /docs/* -> shared project documentation +# Also support --app-dir /path/to/s2s-client when launched outside the checkout. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -When every compute slot is busy the load balancer hands back a queue ticket -instead of a grant; the browser polls /api/queue/{id} until it reaches the front -and a slot frees. Queue deadlines and pending-grant timeouts belong to the LB. -""" -from __future__ import annotations - -import asyncio -from datetime import datetime, timezone -import datetime as _dt_module -import json -import logging -import os -import re -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit - -import httpx -import websockets -from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import JSONResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel - -logger = logging.getLogger("s2s") -_logging_configured = False -if not _logging_configured: - _handler = logging.StreamHandler() - _handler.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-7s [%(name)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - )) - logging.basicConfig(level=logging.DEBUG, handlers=[_handler], force=True) - _lvl_env = (os.environ.get("LOG_LEVEL") or os.environ.get("S2S_LOG_LEVEL") or "INFO").strip().upper() - _is_debug = _lvl_env in {"DEBUG", "TRACE", "10"} - # Keep noisy loggers quiet unless LOG_LEVEL explicitly enables debug. - logging.getLogger("httpx").setLevel(logging.DEBUG if _is_debug else logging.WARNING) - logging.getLogger("websockets").setLevel(logging.DEBUG if _is_debug else logging.INFO) - logging.getLogger("uvicorn").setLevel(logging.DEBUG if _is_debug else logging.INFO) - logging.getLogger("uvicorn.access").setLevel(logging.DEBUG if _is_debug else logging.INFO) - logging.getLogger("httpcore").setLevel(logging.DEBUG if _is_debug else logging.WARNING) - logger.setLevel(logging.DEBUG if _is_debug else logging.DEBUG) # s2s always uses at least DEBUG in development - _logging_configured = True - logger.debug( - "LOG_LEVEL env=%s — noisy-logger levels: httpx=%s uvicorn=%s websockets=%s", - _lvl_env, - logging.getLevelName(logging.getLogger("httpx").level), - logging.getLevelName(logging.getLogger("uvicorn").level), - logging.getLevelName(logging.getLogger("websockets").level), - ) - -ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False) -logger.debug("Loaded .env from %s", ROOT_DIR) - - -def _normalize_reasoning_type(value: str) -> str: - normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper() - if normalized == "OPENAI": - return "OPENAI" - if normalized in {"NODE_RED", "NODERED"}: - return "NODE_RED" - return "PROXY" - -SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip() -S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip() -if S2S_PRIMARY_PROMPT: - S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n") -# External user-by-phone info endpoint. When set, GET /api/user/phone proxies -# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the -# browser so the UI can render caller identity / caller context. -VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/") -# Feature flag: when "true" (case-insensitive) => injects the resolved caller -# profile into the realtime session (patch session.update client->upstream, -# append profile to the LLM system instructions prompt instructions, and prepend it to the -# reasoning user_context for the /api/reasoning HTTP body. -# Default = disabled (generic deploy; pure passthrough; all characterization lives -# in the backend RAG per original architectural decision.) -S2S_INJECT_PROFILE = ( - (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() - in {"1", "true", "yes", "on"} -) -# Endpoint URL for fetching the latest per-caller conversation summary, used when -# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely -# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber= -# and expects JSON back (optionally wrapped as {"result": }). Empty -# string means the feature is off (no network call, nothing injected). -S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip() -# Static / hardcoded latest-summary text. When set (non-empty string), it takes -# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and -# injects this raw plain-text value directly into the prompt as the latest -# conversation summary. Designed for cases where the operator wants a clean, -# essential, pre-cleaned summary snippet without depending on an upstream API. -# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no -# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context -# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper). -S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip() -if S2S_LATEST_SUMMARY_TEXT: - # Interpret \n escape sequences so multi-line summaries can be written as a - # single env line (matches S2S_PRIMARY_PROMPT convention above). - S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n") -# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and -# phoneNumber is available), before fetching user profile / latest summary the proxy -# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body, -# extracts an auth token from the response (if any), and forwards it as -# Authorization: Bearer for subsequent profile / summary / reports HTTP calls. -S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/") -# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true) -# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}), -# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC, -# and injects the compiled block into the LLM instructions / user_context between -# the caller profile block and the latest conversation summary block. -S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/") -# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST -# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT) -# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and -# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue: -# "All reports are currently injected with their complete podcastScript." -# -# Historical note (2026-09-11): before this flag existed, if S2S_INJECT_PROFILE=true AND -# S2S_REPORT_LIST was set the server eagerly fetched every scheduled report for the -# caller, serialized all their full podcastScript fields into a single block (capped at -# 12000 chars) and appended it to every LLM turn. That was a huge prompt-bloat injection -# the user explicitly flagged as unwanted. The safe default is therefore DISABLED: to -# restore mass-report-injection, operators must opt in with S2S_INJECT_REPORTS=true. -S2S_INJECT_REPORTS = ( - (os.environ.get("S2S_INJECT_REPORTS", "") or "").strip().lower() - in {"1", "true", "yes", "on"} -) -# Max number of scheduled reports to surface in the QUICK-HINT line block (the TOC of -# label + date shown before the serialized JSON entries). Also applied as a hard cap to -# the final filtered+serialized entries list — so the wrapped prompt block never carries -# content for more than N reports, even when the upstream API returns 20+. Configurable -# from .env; value is integer-parsed and clamped to [1, 50] (invalid values fall back to -# the default = 5, which is the user's requested "max 3-5" default range). User request -# 2026-09-11: "Limit report quick hints to 3-5 entries, configurable through .env." -try: - _S2S_REPORTS_QUICK_HINT_MAX_RAW = int((os.environ.get("S2S_REPORTS_QUICK_HINT_MAX", "") or "5").strip()) -except Exception: - _S2S_REPORTS_QUICK_HINT_MAX_RAW = 5 -S2S_REPORTS_QUICK_HINT_MAX = max(1, min(50, _S2S_REPORTS_QUICK_HINT_MAX_RAW)) -del _S2S_REPORTS_QUICK_HINT_MAX_RAW -# Feature flag: when "true" → prepend a TODAY date + anti-hallucination guard to -# every LLM prompt and session instructions. Fixes user bug 3 2026-09-11: -# "Qwen2.5 has a training cutoff around mid-2024 and produces Chinese meta-comments -# about 2026 reports: 'Today is 2023; why are these reports dated 2026?'" -# Default = ON (safe even when empty/unset). Explicit S2S_INJECT_TODAY="false" to disable. -# Injected text contains: -# • The actual date: "TODAY is YYYY-MM-DD" (ISO, server local date). -# • Earlier reports are historical records; do not question their dates. -# • No internal meta-comments or prompt introspection. -# • No thinking leaks with GPTQ Int4: only words Vocero says aloud. -# • Retrieve report content through RAG rather than inventing it from summaries. -S2S_INJECT_TODAY = not ( - (os.environ.get("S2S_INJECT_TODAY", "") or "").strip().lower() - in {"0", "false", "no", "off"} -) -# Feature flag: S2S_REPORTS_INJECT_MODE controls what the server sends into the LLM -# prompt for scheduled reports. Values (case-insensitive, default="minimal"): -# • "minimal" → ONLY last N = S2S_REPORTS_QUICK_HINT_MAX items as a pure -# TOC (label + date ONLY). NO podcastScript is ever exposed. This -# the user's "Keep reports only as a MINIMAL summary" -# fix. Token budget: ~50 tokens instead of ~3200. -# • "full" → legacy behaviour: full JSON array with podcastScript -# (still capped to S2S_REPORTS_QUICK_HINT_MAX items). Used to be -# the default before 2026-09-11; kept only for explicit opt-in -# because of the user explicitly flagged the massive script injection -# as unwanted on this session. -# • "off" / "none" → no reports injection at all (same as S2S_INJECT_REPORTS=false) -# even when S2S_INJECT_REPORTS=true. Use this to keep the HTTP -# fetch running while keeping the prompt clean, or as a redundant kill. -S2S_REPORTS_INJECT_MODE = (os.environ.get("S2S_REPORTS_INJECT_MODE", "") or "").strip().lower() -if S2S_REPORTS_INJECT_MODE not in {"minimal", "full", "off", "none"}: - S2S_REPORTS_INJECT_MODE = "minimal" -# Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just -# lengths) going to the upstream LLM/realtime backends. Includes: -# - WS session.update session.instructions (before/after patch) -# - /api/reasoning NODE_RED instructions + user_context full text -# - /api/reasonING OPENAI full system message + user transcript -# Default = off; only length summaries are logged (current behaviour). -# WARNING: setting this on will log potentially PII / sensitive caller -# profile data / user transcripts. Use only in development / debugging. -S2S_DEBUG_PROMPT = ( - (os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower() - in {"1", "true", "yes", "on"} -) -S2S_START_SESSION_MODE = (os.environ.get("S2S_START_SESSION_MODE", "") or "").strip().upper() -S2S_THEME = (os.environ.get("S2S_THEME", "") or "").strip().lower() -if S2S_THEME and S2S_THEME not in {"white", "dark"}: - S2S_THEME = "" -S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY")) -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip() -OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/") -OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip() -OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip() -OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip() -# ────────────────────────────────────────────────────────────────────────────── -# APIPILOT — Backend AquaPortal dynamic-tool bridge (docs/client/tool-calling.md) -# ────────────────────────────────────────────────────────────────────────────── -# Base URL for ApiPilot dynamic tool discovery and execution. When configured, -# the proxy sends GET /tools with include_unauthorized=true (guide §2.1) -# to discover tools, and POST /execute (§2.2) to invoke one. The trailing -# slash is optional; the proxy normalizes it with rstrip("/") below. -# -# Example: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot -S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/") -# Explicit execution URL override (guide §2.2). When set, it takes precedence -# over S2S_APIPILOT_TOOLS_URL for POST execution calls. Remove trailing slashes -# and append "/execute" only if the URL does not already end with that suffix. -# Example 1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot -# → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute -# Example 2: S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools -# → POST https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute -S2S_APIPILOT_TOOLS_EXECUTE_URL = os.environ.get("S2S_APIPILOT_TOOLS_EXECUTE_URL", "").strip().rstrip("/") -# Optional fixed bearer token, used only when the caller supplies no session -# token (Authorization / x-demo-session). Useful for staging service tokens. -S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip() -# 2026-09-25 SUPER API KEY (TEMPORARY AQUAPORTAL AUTHENTICATION BYPASS). -# When set, send all three header forms for maximum compatibility: -# 1) X-API-Key: -# 2) Authorization: Bearer -# 3) x-demo-session: -# Highest priority; completely disables the unnecessary login_by_phone call. -# Clear the value to restore standard login. -S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'") -# Tool discovery cache TTL in seconds. The guide recommends a five-minute -# client cache; the server uses the same default: 300 seconds. -try: - _APIPILOT_TTL_RAW = int((os.environ.get("S2S_APIPILOT_TTL_SEC", "") or "300").strip()) -except Exception: - _APIPILOT_TTL_RAW = 300 -APIPILOT_CACHE_TTL_SEC = max(15, min(3600, _APIPILOT_TTL_RAW)) -del _APIPILOT_TTL_RAW -# HTTP timeout for ApiPilot discovery and execution calls, in seconds. -try: - _APIPILOT_TO_RAW = float((os.environ.get("S2S_APIPILOT_TIMEOUT_SEC", "") or "15.0").strip()) -except Exception: - _APIPILOT_TO_RAW = 15.0 -APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW)) -del _APIPILOT_TO_RAW - -# In-process cache and asynchronous lock: one caller refreshes while others -# read the stale entry. `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`. -_APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {} -_APIPILOT_CACHE_LOCK = asyncio.Lock() - -# Monotonic time helper for duration measurements unaffected by NTP drift. -import time as _time_mod_gbl -_monotonic_now: callable = _time_mod_gbl.monotonic -del _time_mod_gbl - - -def _apipilot_auth_fingerprint(auth_header: str | None, demo_session: str | None) -> str: - """Return a short string used as cache key scoped to the caller identity. - - We don't want to share cached tool-lists across users with different auth - tokens (requires_auth flags can be token-dependent via /tools?include_unauthorized=true - because the back-end may still attach per-role metadata).""" - parts: list[str] = [] - if auth_header: - # Don't cache the raw bearer. SHA-256 truncated 12 chars = safe fingerprint. - import hashlib - parts.append("h:" + hashlib.sha256(auth_header.encode("utf-8")).hexdigest()[:12]) - if demo_session: - parts.append("d:" + demo_session) - return "|".join(parts) if parts else "anon" - - -def _apipilot_parse_authorization(request: Request | None) -> tuple[str | None, str | None]: - """Extract (Authorization header value, x-demo-session header value) from a FastAPI request. - - Returns a tuple of two optional strings. Both are forwarded to the upstream - ApiPilot backend verbatim when non-empty (see docs/client/tool-calling.md). - """ - if request is None: - return None, None - auth = None - demo = None - try: - raw_auth = request.headers.get("authorization") or request.headers.get("Authorization") - if isinstance(raw_auth, str) and raw_auth.strip(): - auth = raw_auth.strip() - raw_demo = request.headers.get("x-demo-session") or request.headers.get("X-Demo-Session") - if isinstance(raw_demo, str) and raw_demo.strip(): - demo = raw_demo.strip() - except Exception: - pass - return auth, demo - - -def _apipilot_resolve_execute_url() -> str: - """Resolve the final POST URL for ApiPilot tool execution. - - Priority (guide §2.2 and explicit environment override): - 1. When S2S_APIPILOT_TOOLS_EXECUTE_URL is set, append "/execute" if needed. - 2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute". - If neither URL is configured, the caller (usually _apipilot_execute_tool) - must return an APIPILOT_NOT_CONFIGURED error envelope before calling this. - """ - base = S2S_APIPILOT_TOOLS_EXECUTE_URL or S2S_APIPILOT_TOOLS_URL - base = base.strip().rstrip("/") - if not base: - return "" - if base.lower().endswith("/execute"): - return base - return f"{base}/execute" - - -def _apipilot_strip_tool_for_llm(tool_def: dict) -> dict: - """Return a tool definition that is safe for LLM function-calling providers. - - Guide §4 forbids passing raw /tools output directly to the model because - requires_auth is outside the function-calling standard and some providers - reject it. Project the native `ToolDefinition` into the OpenAI-compatible - `{type: "function", function: {name, description, parameters}}`. - """ - if not isinstance(tool_def, dict): - return {} - name = tool_def.get("name") if isinstance(tool_def.get("name"), str) else "" - description = tool_def.get("description") if isinstance(tool_def.get("description"), str) else "" - parameters = tool_def.get("parameters") - if not isinstance(parameters, dict): - parameters = {"type": "object", "properties": {}, "required": []} - out: dict = { - "type": "function", - "function": { - "name": name, - "description": description, - "parameters": parameters, - }, - } - return out - - -async def _apipilot_fetch_tools( - *, - auth_header: str | None, - demo_session: str | None, - include_unauthorized: bool = True, - force_refresh: bool = False, - refresh: bool | None = None, -) -> list[dict]: - """Fetch ApiPilot tool definitions with a short in-process TTL cache. - - Implements §2.1 (GET /tools?include_unauthorized=true). The cache key is - scoped by caller auth fingerprint (see `_apipilot_auth_fingerprint`) so - per-token variations in `requires_auth` or list length don't leak between - identities. - - Returns a list of tool definitions (parsed JSON, may be empty). Raises - `HTTPException` only when `S2S_APIPILOT_TOOLS_URL` is set but the backend - replied with a non-2xx / non-JSON response. When env is empty → returns - `[]` silently (passthrough). - """ - # 2026-09-25 compatibility alias: WS connect uses `refresh`; when present, - # apply it to force_refresh. - if refresh is not None: - force_refresh = bool(refresh) or bool(force_refresh) - if not S2S_APIPILOT_TOOLS_URL: - return [] - import time as _time_mod - # 2026-09-25 fingerprint consistency: determine effective auth/demo values - # first (SUPER API KEY takes precedence), then calculate the cache fingerprint. - # An anonymous fingerprint with APIKEY headers would mismatch the cache and - # demo session, causing Aquachile user_type=null and a 403 response. - if S2S_APIPILOT_API_KEY: - final_auth = "BYPASS-API-KEY" - _demo_used = S2S_APIPILOT_API_KEY - _effective_auth_for_fp = f"Bearer {S2S_APIPILOT_API_KEY}" - _effective_demo_for_fp = S2S_APIPILOT_API_KEY - else: - final_auth = auth_header or (f"Bearer {S2S_APIPILOT_SERVICE_TOKEN}" if S2S_APIPILOT_SERVICE_TOKEN else None) - _demo_used = demo_session - _effective_auth_for_fp = final_auth if isinstance(final_auth, str) and final_auth.strip() else (auth_header or "") - _effective_demo_for_fp = _demo_used if isinstance(_demo_used, str) and _demo_used.strip() else "" - fp = _apipilot_auth_fingerprint(_effective_auth_for_fp, _effective_demo_for_fp) - now = _time_mod.monotonic() - cached = _APIPILOT_CACHE.get(fp) - if (not force_refresh) and cached and (now - cached[0]) < APIPILOT_CACHE_TTL_SEC: - logger.debug( - "[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d", - APIPILOT_CACHE_TTL_SEC - (now - cached[0]), fp, len(cached[1]), - ) - return cached[1] - # Prefer to serve stale (if any) rather than block many callers. - stale_tools = cached[1] if cached else None - async with _APIPILOT_CACHE_LOCK: - # Double-check after acquiring lock (another coroutine may have refreshed). - now2 = _time_mod.monotonic() - recheck = _APIPILOT_CACHE.get(fp) - if (not force_refresh) and recheck and (now2 - recheck[0]) < APIPILOT_CACHE_TTL_SEC: - return recheck[1] - url = f"{S2S_APIPILOT_TOOLS_URL}/tools" - params = {"include_unauthorized": "true" if include_unauthorized else "false"} - headers = {"accept": "application/json"} - # 2026-09-25 SUPER API KEY overrides other authentication and sends all - # three header forms simultaneously for maximum compatibility. - if S2S_APIPILOT_API_KEY: - headers["X-API-Key"] = S2S_APIPILOT_API_KEY - headers["Authorization"] = f"Bearer {S2S_APIPILOT_API_KEY}" - headers["x-demo-session"] = S2S_APIPILOT_API_KEY - else: - if final_auth: - headers["Authorization"] = final_auth - if _demo_used: - headers["x-demo-session"] = _demo_used - logger.info( - "[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s", - url, params["include_unauthorized"], fp, - ("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")), - ("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")), - ) - try: - async with httpx.AsyncClient(timeout=APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http: - resp = await http.get(url, params=params, headers=headers) - except Exception as exc: - logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc) - if stale_tools is not None: - logger.warning("[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)) - return stale_tools - raise HTTPException( - status_code=502, - detail={"code": "APIPILOT_NETWORK_ERROR", "message": f"Error al contactar ApiPilot tools backend: {exc}"}, - ) - if resp.status_code >= 300: - logger.error("[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400]) - if stale_tools is not None: - logger.warning("[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)) - return stale_tools - raise HTTPException( - status_code=502, - detail={ - "code": "APIPILOT_BAD_UPSTREAM", - "message": f"Backend ApiPilot tools devolvió HTTP {resp.status_code}", - "status_code": resp.status_code, - }, - ) - try: - payload = resp.json() - except Exception: - logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500]) - if stale_tools is not None: - return stale_tools - raise HTTPException( - status_code=502, - detail={"code": "APIPILOT_BAD_JSON", "message": "La respuesta de ApiPilot tools no es JSON válido."}, - ) - # Backend might return: list OR {"result": [...]} OR {"tools": [...]} OR - # {"data": {"tools": [...]}}. Unwrap generic wrapper-style payloads. - tools: list[dict] = [] - if isinstance(payload, list): - tools = [t for t in payload if isinstance(t, dict)] - elif isinstance(payload, dict): - for key in ("tools", "result", "data"): - cand = payload.get(key) - if isinstance(cand, list): - tools = [t for t in cand if isinstance(t, dict)] - break - if isinstance(cand, dict): - sub = cand.get("tools") if isinstance(cand.get("tools"), list) else cand.get("result") - if isinstance(sub, list): - tools = [t for t in sub if isinstance(t, dict)] - break - # Validate minimal ToolDefinition contract §2.1: name + description + parameters. - sanitized: list[dict] = [] - for raw in tools: - name = raw.get("name") - if not isinstance(name, str) or not name.strip(): - continue - description = raw.get("description") if isinstance(raw.get("description"), str) else "" - parameters = raw.get("parameters") - if not isinstance(parameters, dict): - parameters = {"type": "object", "properties": {}, "required": []} - requires_auth = raw.get("requires_auth", False) - if not isinstance(requires_auth, bool): - # Some backends return 0/1; normalize to bool as required. - try: - requires_auth = bool(int(requires_auth)) - except Exception: - requires_auth = False - tool_def = { - "name": name.strip(), - "description": description, - "parameters": parameters, - "requires_auth": requires_auth, - } - sanitized.append(tool_def) - _APIPILOT_CACHE[fp] = (now2, sanitized) - logger.info( - "[APIPILOT-DISCOVERY] ← OK tools=%s fp=%s status=%s upstreamFmt=%s cachedTools=%d", - len(sanitized), fp, resp.status_code, - "list" if isinstance(payload, list) else "object", - len(sanitized), - ) - if S2S_DEBUG_PROMPT and sanitized: - logger.debug( - "[APIPILOT-DISCOVERY] raw first tool preview: %s", - json.dumps(sanitized[0], ensure_ascii=False)[:400], - ) - return sanitized - - -def _apipilot_validate_arguments(arguments: dict | None, tool_def: dict) -> tuple[dict, list[str]]: - """Validate / execute arguments strictly against the tool's JSON-Schema parameters. - - Implements §2.2 PRE-CHECK #3: - • Remove unknown keys only when parameters.properties is nonempty. - • AquaPortal tool.get_user_briefing has parameters.properties={} and - required=[]: no explicit schema, so arguments remain flexible. The - backend resolves phone from the token; preserve supplied arguments. - • Validate required parameters and return errors for missing values. - • Never invent parameters or add keys. - - Returns `(filtered_args, list_of_validation_errors_in_spanish)`. - If the returned errors list is non-empty, MUST block the execution (see - the `_apipilot_execute_tool` caller). - """ - errors: list[str] = [] - schema = tool_def.get("parameters") if isinstance(tool_def.get("parameters"), dict) else {} - properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} - required = schema.get("required") if isinstance(schema.get("required"), list) else [] - required = [x for x in required if isinstance(x, str)] - args = arguments if isinstance(arguments, dict) else {} - - # 2026-09-25 empty-schema flexibility: preserve all arguments when no - # properties are explicitly declared, as with AquaPortal get_user_briefing. - schema_is_open: bool = (not isinstance(properties, dict)) or len(properties) == 0 - requires_auth_flag = bool(tool_def.get("requires_auth")) - # 2026-09-25 auth-only tools: requires_auth=true, empty properties and - # required=[], with a valid-session description, resolve user data from - # the bearer JWT. Send arguments={} to avoid conflicts between a phone - # argument and the token's user, which would produce a 403 response. - auth_only_tool = False - if schema_is_open and requires_auth_flag and len(required) == 0: - _d = tool_def.get("description") if isinstance(tool_def.get("description"), str) else "" - if (not _d) or "valid session token" in _d.lower() or "authenticated" in _d.lower(): - auth_only_tool = True - if auth_only_tool: - cleaned = {} - logger.info( - "[APIPILOT-VALIDATE] tool=%s requires only an authenticated session → arguments forced to {} (avoid phone-argument/user-token conflicts)", - tool_def.get("name") or "?", - ) - elif schema_is_open: - cleaned = dict(args) - logger.info( - "[APIPILOT-VALIDATE] tool=%s open schema (empty parameters.properties) → preserve all arguments N=%d keys=%s", - tool_def.get("name") or "?", - len(cleaned), - sorted(list(cleaned.keys())), - ) - else: - cleaned: dict = {} - for key in args.keys(): - if key in properties: - cleaned[key] = args[key] - else: - logger.warning( - "[APIPILOT-VALIDATE] tool=%s removing unknown arg=%s", - tool_def.get("name") or "?", key, - ) - # Check required fields only for a closed schema; open required=[] is vacuous. - if (not schema_is_open): - for req in required: - if req not in cleaned: - errors.append(f"Falta el parámetro requerido: {req}") - return cleaned, errors - - -# --------------------------------------------------------------------------- -# APIPILOT — Reasoning engine integration (two passes) -# --------------------------------------------------------------------------- -# Deduplication/start/end markers used by _classify_context_block to avoid -# injecting a block already supplied by the client or found in base_instructions. -APIPILOT_PASS1_LIST_MARKER = "<<>>" -APIPILOT_PASS2_RESULT_MARKER = "<<]{1,3}", - re.IGNORECASE | re.DOTALL, -) -# 2026-09-25: observed inverted marker (incorrect LLM syntax): -# >>>APIPILOT_CALL:NAME=tool,ARGUMENTS=json<<< -_APIPILOT_CALL_MARKER_INVERTED_RE = re.compile( - r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}", - re.IGNORECASE | re.DOTALL, -) - - -def _apipilot_parse_call_marker(answer_text: str, *, defaults: dict | None = None) -> tuple[str | None, dict | None]: - """Parse the mandatory CALL marker emitted by the LLM in pass 1. - - Returns (tool_name, arguments_dict), or (None, None) when no marker exists. - - Supports: - (1) ARGUMENTS={"key":"val"}, valid JSON. - (2) ARGUMENTS={}, a valid empty object. - (3) ARGUMENTS= without braces, observed on realtime WebSocket sessions. - (4) key=val,key2=val2 strings through a manual parsing fallback. - (5) After parsing, fill missing required arguments from defaults such as - phone and episode_id. - """ - if not isinstance(answer_text, str) or not answer_text.strip(): - logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_INPUT name=None markerFound=N") - return None, None - m = _APIPILOT_CALL_MARKER_RE.search(answer_text.strip()) - inverted = False - if not m: - m = _APIPILOT_CALL_MARKER_INVERTED_RE.search(answer_text.strip()) - inverted = bool(m) - if not m: - logger.info("[APIPILOT-CALL-NAME] USE=PARSE_NO_MATCH name=None markerFound=N textHead=%r", answer_text.strip()[:80]) - return None, None - name_raw = m.group(1).strip() - args_raw = (m.group(2) if m.lastindex and m.lastindex >= 2 and isinstance(m.group(2), str) else "").strip() - if not name_raw: - logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_NAME name=None markerFound=Y argsRawHead=%r inverted=%s", args_raw[:60], ("Y" if inverted else "N")) - return None, None - logger.info("[APIPILOT-CALL-NAME] USE=PARSE_MARKER_OK name=%r argsRawLen=%d markerFound=Y inverted=%s", name_raw, len(args_raw), ("Y" if inverted else "N")) - args: dict = {} - # Parse args_raw into a dict, tolerating common formatting variations. - if args_raw == "" or args_raw in {"{}", "null", "None"}: - args = {} - else: - # Attempt 1: standard JSON, with or without braces. - _candidate = args_raw - try: - args = json.loads(_candidate) - except Exception: - try: - # Attempt 2: supply missing outer braces. - args = json.loads("{" + _candidate + "}") - except Exception: - try: - # Attempt 3: replace single quotes with double quotes. - args = json.loads(_candidate.replace("'", '"')) - except Exception: - try: - # Attempt 4: supply braces and replace single quotes. - args = json.loads("{" + _candidate.replace("'", '"') + "}") - except Exception: - # Manual fallback 5: equals signs and commas. - # Example: args_raw="phone=+56,name=Mario" → dict. - args = {} - for piece in args_raw.split(","): - kv = piece.strip() - if not kv or "=" not in kv: - continue - k, v = kv.split("=", 1) - k = k.strip().strip('"').strip("'") - v = v.strip().strip('"').strip("'") - if k: - args[k] = v - if not isinstance(args, dict): - args = {} - # Stage 2: fill missing arguments from defaults (phone, etc.). - if isinstance(defaults, dict) and defaults: - for dk, dv in defaults.items(): - if dk not in args or args[dk] in (None, "", []): - if dv not in (None, "", []): - args[dk] = dv - logger.info("[APIPILOT-CALL-NAME] USE=PARSE_RETURN_FINAL name=%r finalArgsKeys=%s defaultsApplied=%s", name_raw, sorted(list(args.keys())), ("Y" if isinstance(defaults, dict) and defaults else "N")) - return name_raw, args - - -def _resolve_apipilot_reasoning_context_block( - tools_list: list[dict] | None, - *, - call_result: dict | None = None, - context_defaults: dict | None = None, -) -> str: - """Build the ApiPilot context block for the reasoning prompt. - - With call_result=None, pass 1 supplies the tool list and marker syntax - the model must emit when a description matches. - - With call_result={'name':str, 'text':str, 'error':bool}, pass 2 injects the - execution result and requests a final spoken/text response for the user. - - The optional context_defaults dict adds known default values to pass 1. - The model must use these exact values for required arguments rather than - inventing them. Usually contains {"phone": "+56..."} from inject_phone. - """ - # Pass 2: the tool result is already available. - if isinstance(call_result, dict) and call_result.get("name"): - name = call_result.get("name") or "?" - text = (call_result.get("text") if isinstance(call_result.get("text"), str) else "") or "" - is_err = bool(call_result.get("error")) - status_label = "ERROR" if is_err else "OK" - title = f"<<>>" - trailer = ( - "Now reinterpret THE RESULT TEXT (above) in clear, concise natural language, " - "exclusively in the user's language (language field; Spanish by default). " - "Do not mention the internal tool or technical details. " - "Respond ONLY with the final content intended for the user." - ) - return ( - f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n" - "### Invoked ApiPilot tool result\n" - f"{title}\n" - f"\n```tool-output\n" - f"{text.rstrip() if text.strip() else '(empty output)'}\n" - f"```\n\n" - f"{trailer}\n" - ) - # Pass 1: available tools and marker rules. - tools = tools_list if isinstance(tools_list, list) else [] - # Limit to 100 tools, although the guide specifies at most 50 for the demo. - tools_cap = tools[:100] - if not tools_cap: - return "" - entries: list[str] = [] - for t in tools_cap: - if not isinstance(t, dict): - continue - name = t.get("name") if isinstance(t.get("name"), str) else "" - if not name: - continue - desc = t.get("description") if isinstance(t.get("description"), str) else "" - params = t.get("parameters") if isinstance(t.get("parameters"), dict) else None - requires_auth = bool(t.get("requires_auth")) - required_fields: list[str] = [] - if isinstance(params, dict) and isinstance(params.get("required"), list): - required_fields = [str(x) for x in params.get("required") if isinstance(x, str)] - # Limit descriptions to about 500 characters to control prompt size. - desc_cut = desc[:500].rstrip() - if len(desc) > 500: - desc_cut += "…" - snippet = f"- name: {name}\n requires_auth: {('true' if requires_auth else 'false')}" - if required_fields: - snippet += f"\n required_parameters: [{', '.join(required_fields[:40])}]" - if desc_cut: - snippet += f"\n description: {desc_cut}" - entries.append(snippet) - if not entries: - return "" - rule = ( - f"{APIPILOT_PASS1_LIST_MARKER}\n" - "### Available dynamic tools (ApiPilot AquaPortal)\n" - "\n" - "Each tool has a `name` field (a unique identifier: do NOT modify it, " - "use camelCase, or translate it) and a `description` describing its use case.\n" - "\n" - "MANDATORY EXECUTION RULES:\n" - "1. If the USER REQUEST (transcript) clearly matches the `description` of " - "EXACTLY one tool below, emit ONLY one line as the FIRST line of YOUR RESPONSE, " - "with this exact syntax:\n" - " <<>>\n" - "2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). " - "Include ONLY fields listed in `required_parameters:`, if present.\n" - "3. Add NO text after the marker line: no explanations or previews. The engine " - "will execute the tool and supply its result in a second call.\n" - "4. If NO description matches, emit NO marker and respond normally in Spanish.\n" - "5. If multiple descriptions match, choose the most specific one and use the marker.\n" - "6. 🔴 EXCLUSIVE RESPONSE MODE (HIGHEST priority, mandatory): when using a CALL " - "marker, YOUR ENTIRE RESPONSE MUST CONTAIN ONLY THE MARKER LINE. " - "Write NOTHING BEFORE it: no 'Claro que sí', 'Voy a revisar', " - "'Voy a generar tu briefing', greeting, or transition. " - "Write NOTHING AFTER it: no comments, previews, or explanations. " - "Exactly one line containing the marker. Additional text prevents the engine " - "from recognizing the marker and executing the tool.\n" - "8. INCORRECT examples (NEVER do this):\n" - " 'Claro que sí, ahora ejecuto'\n" - " 'Voy a generar tu briefing ejecutivo personal.'\n" - " 'Claro que sí.\\n<<>>'\n" - " '<<>>\\nAhora proceso el resultado.'\n" - "9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n" - " <<>> IS AN INTERNAL SYSTEM SIGNAL and MUST NEVER be spoken,\n" - " read aloud, or synthesized by TTS. When emitting the marker:\n" - " - INTERNAL REASONING (text/transcript) MUST contain the exact marker line.\n" - " - FINAL TTS AUDIO heard by the user MUST contain ONLY this placeholder:\n" - " \"Voy a consultar tus datos, un momento por favor.\"\n" - " - NEVER speak any of these technical terms in final TTS audio:\n" - " APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, function, function call,\n" - " marcador, marker, even to explain or describe the call.\n" - "\n" - ) - # 2026-09-25 known defaults: inject values already resolved from the URL or - # profile, such as phoneNumber, so tool arguments use those exact values. - if isinstance(context_defaults, dict) and context_defaults: - defaults_lines = [] - for dk, dv in context_defaults.items(): - if dv in (None, "", []): - continue - if isinstance(dv, (dict, list)): - try: - dv_repr = json.dumps(dv, ensure_ascii=False) - except Exception: - dv_repr = str(dv) - else: - dv_repr = str(dv) - defaults_lines.append(f"- {dk} = \"{dv_repr}\"") - if defaults_lines: - rule += ( - "KNOWN DEFAULT VALUES (use these exact values in ARGUMENTS={…} when " - "the corresponding tool lists these names in required_parameters; " - "do not invent, translate, or normalize them):\n" - f"{chr(10).join(defaults_lines)}\n" - "\n" - ) - rule += ( - "Tool list (name / description):\n" - f"{chr(10).join(entries)}\n" - "<<>>" - ) - return rule - - -def _sanitize_pass2_internal_context(text: str) -> str: - """Lightly sanitize the pass-2 block injected into the upstream model. - - Unlike _sanitize_text_strip_apipilot_markers, preserve the pass-2 wrapper: - the model needs it to recognize tool data. Limit sanitation to filesystem - path leaks, fragmented marker tokens, and contextual phone leaks. - """ - if not isinstance(text, str) or not text.strip(): - return text - # Block 3: Unix/Windows/TRAE path leaks, matching the user-output sanitizer. - import re as _re_pass2 - text = _re_pass2.sub(r"(?i)(?:/[A-Za-z0-9_.\-@~]+){2,}/(?:\.trae[^\s\"']*|workspaceStorage[^\s\"']*|User[^\s\"']*|home[^\s\"']*)", " ", text) - text = _re_pass2.sub(r"(?i)(?:[A-Z]:)?[\\/](?:Users|home|root|tmp|var|mnt|opt|srv)[\\/][^\s\"']{5,}", " ", text) - text = _re_pass2.sub(r"(?i)(?:\.trae-server|\.trae[^\s\"']{0,40}|workspaceStorage[0-9a-f]+)", " ", text) - text = _re_pass2.sub(r"\s{2,}", " ", text) - # Block 4: fragmented marker leaks, tier 4. - tier4_out_parts: list[str] = [] - for _line in text.splitlines(keepends=True): - _orig = _line - _low = _line.lower() - if ( - "apipilot" in _low - and ( - ("call" in _low and "name=" in _low) - or ("arguments=" in _low) - or ("result" in _low and "pass" in _low) - ) - ): - if "<<<" in _line or ">>>" in _line: - _line = _re_pass2.sub( - r"[<]{1,3}\s*APIPILOT_(?:CALL|RESULT)[^<>\n]{0,500}[>]{1,3}", - " ", _line, flags=_re_pass2.IGNORECASE, - ) - if "apipilot" in _line.lower(): - _line = _re_pass2.sub( - r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b", - " ", _line, - ) - _line = _re_pass2.sub( - r"(?i)\b(?:NAME|ARGUMENTS|STATUS|REV|PASS2)\s*[:=]", - " ", _line, - ) - tier4_out_parts.append(_line if _line.strip() else _orig) - text = "".join(tier4_out_parts) - text = _re_pass2.sub(r"\s{2,}", " ", text) - return text.strip() if isinstance(text, str) else text - - -def _sanitize_text_strip_apipilot_markers( - text: str, - *, - placeholder: str | None = None, -) -> str: - """Remove all internal ApiPilot markers from user-visible chat and TTS text. - - Replace pass-1 CALL markers with a Spanish placeholder. Silently remove - pass-2 RESULT markers, their heading, tool-output block and instruction - trailer. These server-only signals must never reach the user. - - Args: - text: Text to sanitize. - placeholder: Optional replacement; defaults to the Spanish UI/voice message. - """ - if not isinstance(text, str) or not text: - return text - if placeholder is None: - placeholder = "Voy a consultar tus datos, un momento por favor." - - # ================================================================ - # Block 1: silently remove pass-2 RESULT markers and wrappers from TTS/UI. - # ================================================================ - # (1a) Full pass-2 wrapper first: marker, heading, status, output, trailer. - pass2_full_block_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*" - r"<<>>[ \t]*(?:\r?\n|\r)+" - r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+" - r"<<>>[ \t]*(?:\r?\n|\r)*" - r"(?:\r?\n|\r)?" - r"```tool-output[ \t]*(?:\r?\n|\r)?" - r"(?P.*?)" - r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}" - r"(?:" - r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO|Now reinterpret THE RESULT TEXT)[\s\S]{0,1200}?" - r"(?:\r?\n|\r){0,2}" - r")?" - r"[ \t]*", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_full_block_pattern.sub("", text) - - # (1b) Standalone PASS2 markers when the wrapper is incomplete or fragmented. - pass2_marker_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*<<>>[ \t]*(?:\r?\n|\r)?[ \t]*", - re.IGNORECASE, - ) - text = pass2_marker_pattern.sub("", text) - - # (1c) Residual Markdown heading after the marker has been removed. - pass2_title_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)?[ \t]*", - re.IGNORECASE, - ) - text = pass2_title_pattern.sub("", text) - - # (1d) Standalone tool-output blocks without a PASS2 wrapper. - pass2_codeblock_pattern = re.compile( - r"```tool-output[ \t]*(?:\r?\n|\r)?.*?(?:\r?\n|\r)?```", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_codeblock_pattern.sub("", text) - - # (1e) Residual instruction trailers missed by the full-wrapper pattern. - pass2_trailer_pattern = re.compile( - r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario|Now reinterpret THE RESULT TEXT[\s\S]{0,1200}?final content intended for the user)\.", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_trailer_pattern.sub("", text) - # Broader fallback also handles trailers truncated before their closing text. - pass2_trailer_fallback = re.compile( - r"(?:Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)|Now reinterpret THE RESULT TEXT[\s\S]{0,1500}?(?:for the user\.|$))", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_trailer_fallback.sub("", text) - # Handle residual middle fragments after the trailer's opening was removed. - pass2_trailer_broad = re.compile( - r"(?:en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario|in clear, concise natural language[\s\S]{0,800}?final content intended for the user)\.?", - re.IGNORECASE | re.DOTALL, - ) - text = pass2_trailer_broad.sub("", text) - - # ================================================================ - # Block 2: pass-1 CALL marker replacement with a Spanish placeholder. - # ================================================================ - marker_full_pattern = re.compile( - r"<<>>", - re.IGNORECASE | re.DOTALL, - ) - line_wrapped_pattern = re.compile( - r"[ \t]*(?:\r?\n|\r)?[ \t]*<<>>[ \t]*(?:\r?\n|\r)?[ \t]*", - re.IGNORECASE | re.DOTALL, - ) - - def _replacement_full(_m: re.Match) -> str: - """Replace standalone or inline markers with the spoken placeholder.""" - return placeholder - - text = line_wrapped_pattern.sub(_replacement_full, text) - text = marker_full_pattern.sub(_replacement_full, text) - - # Remove truncated CALL markers such as <<\n]{0,500}?)?>>*", - re.IGNORECASE, - ) - text = trunc_call_pattern.sub(_replacement_full, text) - - # Make closing brackets optional to remove RESULT markers truncated by TTS, - # such as <<>>. - trunc_result_pattern = re.compile( - r"<<?>?>?", - re.IGNORECASE, - ) - text = trunc_result_pattern.sub("", text) - # Remove remaining APIPILOT_RESULT fragments after partial sanitation. - trunc_result_residual = re.compile( - r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}", - re.IGNORECASE, - ) - text = trunc_result_residual.sub("", text) - - # Collapse whitespace left by pass-2 block removal. - text = re.sub(r"[ \t]{2,}", " ", text) - text = re.sub(r"\n{3,}", "\n\n", text) - # Remove residual trailer fragments and empty parentheses before TTS. - text = re.sub(r"\s*\((?:arriba|above)\)\s*", " ", text, flags=re.IGNORECASE) - text = re.sub(r"\(\s*\)", "", text) - # ================================================================ - # Block 3: remove filesystem paths and host information leaked into TTS. - # Match long absolute Unix/Windows/mixed paths and known workspace tokens. - # ================================================================ - # (3a) Absolute Unix paths and nested paths with multiple separators. - unix_path_pattern = re.compile( - r"\b(?:/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/\w@.+\-:#$%^&()[\]{}!~=,]*){2,}[A-Za-z0-9_./\-+@#$%^&()[\]{}!~=,:]*\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif)\b" - r"|" - # Broad fallback for long absolute Unix paths under known directories, - # including paths without extensions; avoid short URLs such as /api/v1. - r"(?:^|\s+|\(|[,.;:!?])(/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/A-Za-z0-9_\-+@#$%^&()[\]{}!~=,.]{10,})(?=$|\s|\)|[,.;:!?])", - re.IGNORECASE, - ) - text = unix_path_pattern.sub("", text) - # (3b) Windows paths with single/double backslashes and mixed separators. - win_path_pattern = re.compile( - r"(?:^|\s|\(|[,.;:])" - r"([A-Za-z]:[\\/]" - r"|(?:[\\/]{1,2}(?:home|Users|Windows|Program|Program\sFiles|ProgramData|Users|trae|AppData)[\\/])" - r"|(?:[\\/]{1,2}\.(?:trae|trae-server|vscode|config|cache)[\\/])" - r")" - r"[A-Za-z0-9_@.+\-:#$%^&()[\]{}!~=,\\/\s]{10,}" - r"(?:\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif))?" - r"(?=$|\s|\)|[,.;:!?])", - re.IGNORECASE, - ) - text = win_path_pattern.sub("", text) - # (3c) TRAE workspace tokens, including fragments without complete paths. - trae_leak_tokens = re.compile( - r"\b(?:workspaceStorage|long-text|\.trae-server|\.trae|ckg_server|ai-agent)\b[^\s()'\"`,.]{0,80}", - re.IGNORECASE, - ) - text = trae_leak_tokens.sub("", text) - # (3d) Remove long tokens containing mixed separators or many path segments. - mixed_path_pattern = re.compile( - r"(?=\S{0,200})" - r"(?=\S*(?:[/\\].*){5,})" - r"(?:\S{10,})", - re.IGNORECASE, - ) - def _mixed_safe(m): - s = m.group(0) - has_slash = "/" in s - has_back = "\\" in s - if (has_slash and has_back) or s.count("/") + s.count("\\") >= 6: - return "" - return s - text = mixed_path_pattern.sub(_mixed_safe, text) - # ================================================================ - # Block 4: remove fragmented APIPILOT CALL text from TTS. The upstream - # pipeline (start_pipeline_rag / Qwen3-TTS) can remove <<< >>> and read: - # "APIPILOT CALL name get_user_briefing arguments phone +56912345678" - # The bracketed patterns cannot catch this, so use contextual fallback tiers. - # ================================================================ - # Tier 1: full normalized form without braces, through the sentence ending. - call_normalized_full = re.compile( - r"(?:^|\s+|\(|[,.;:!?\"\'])\s*" - r"(?:" - r"APIPILOT[\s_\-]+CALL\s+(?:marker\s+)?(?:de\s+)?(?:señal\s+)?(?:herramienta\s+)?(?:funci[oó]n\s+)?" - r"|marcador\s+APIPILOT(?:\s+CALL)?\s*" - r"|señal\s+de\s+llamada\s+APIPILOT\s*" - r"|llamada\s+(?:a\s+)?(?:la\s+)?(?:herramienta|funci[oó]n)\s+APIPILOT\s*" - r")" - r"(?:\s*(?:con\s+)?(?:identificador|ID|name|nombre)\s*[:=]?\s*[A-Za-z0-9_.-]+)?" - r"(?:\s*(?:y\s+)?(?:con\s+)?(?:argumentos|ARGUMENTS|params|par[áa]metros)\s*[:=]?\s*(?:\{[^{}]{0,800}\}|[\"'][^\"']{0,200}[\"']|[^\s.!,?;]{3,100}(?:\s*[,:]\s*[^\s.!,?;]{3,100})*\s*)?)?" - r"(?:\s*(?:phone|tel[ée]fono|correo|email)\s*[:=]?\s*[+0-9A-Za-z._@\-]{6,50})?" - r"(?=$|\s+|\)|[,.;:!?\"\'])", - re.IGNORECASE, - ) - text = call_normalized_full.sub(placeholder, text) - # Tier 2: shortened forms with APIPILOT_CALL or adjacent APIPILOT and CALL. - apipilot_call_token = re.compile( - r"(?:^|\s|\(|[,.;:!?\"\'])\s*" - r"APIPILOT(?:[\s_\-]+CALL|_CALL|\s+llamada|\s+marker)" - r"(?:[^.!,?;\n]{0,600})?" - r"(?=$|\s|\)|[,.;:!?\"\'])", - re.IGNORECASE, - ) - text = apipilot_call_token.sub(placeholder, text) - # Tier 3: standalone service tokens in call/name/argument contexts. - service_token_context = re.compile( - r"(?:" - r"ARGUMENTS\s*[:=]\s*(?:\{[^{}]{0,800}\}|[^\s,.;!?]{3,100})" - r"|\barguments\s+(?:de\s+)?(?:la\s+)?(?:llamada|herramienta|funci[oó]n)\b[^\n.]{0,200}" - r")", - re.IGNORECASE, - ) - text = service_token_context.sub("", text) - # Tier 4: remove sentences containing APIPILOT with technical call context. - def _apipilot_sentence(m): - sentence = m.group(0) - lowered = sentence.lower() - # Preserve a sentence already containing the Spanish CALL placeholder, - # including any natural text surrounding it. - if "voy a consultar tus datos" in lowered: - return sentence - ctx_kw = ("name", "argument", "arg", "herramienta", "tool", - "funci", "phone", "tel", "+56", "+44", "marker", - "call", "llamada", "señal", "marcador") - if any(k in lowered for k in ctx_kw) and "apipilot" in lowered: - return "" - return sentence - catchall_sentence = re.compile( - r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}", - re.IGNORECASE, - ) - text = catchall_sentence.sub(_apipilot_sentence, text) - # Tier 5: replace any remaining APIPILOT CALL tokens, even without context. - final_apipilot_leak = re.compile( - r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b", - re.IGNORECASE, - ) - text = final_apipilot_leak.sub(placeholder, text) - # Tier 6: remove residual fake/real phone numbers from internal call text. - # Match compact and spaced forms to tolerate TTS normalization. - fake_phone_pattern = re.compile( - r"(" - # Compact forms without spaces: - r"\+56912345678" - r"|\+1234567890" - r"|\+447432477433" - r"|56912345678" - r"|1234567890" - r"|447432477433" - # Spaced forms with punctuation: - r"|\+?\s*56\s*[\s\-()]?9\s*[\s\-()]?1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8" - r"|\+?\s*1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8\s*[\s\-()]?9\s*[\s\-()]?0" - r"|\+?\s*44\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?2\s*[\s\-()]?4\s*[\s\-()]?7\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?3" - # Generic labeled forms (English or Spanish phone labels): - r"|phone\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}" - r"|tel(?:éfono|efono)?\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}" - r")", - re.IGNORECASE, - ) - # Strip numbers only from lines with technical call context or the Spanish - # placeholder, indicating that the number belongs to a replaced CALL marker. - lines = text.splitlines(keepends=True) - _phone_ctx = ("apipilot", "call", "arguments", "marker", "herramienta", - "funcion", "función", "ejecutar", "llamada", "marcador", - "señal", "voy a consultar tus datos") - def _phone_line_safe(line: str) -> str: - lowered = line.lower() - has_ctx = any(c in lowered for c in _phone_ctx) - if has_ctx: - # Remove phone numbers in this line (strip also surrounding space) - return fake_phone_pattern.sub(" ", line) - return line - text = "".join(_phone_line_safe(l) for l in lines) - # Collapse whitespace left by marker and path removal. - text = re.sub(r"[ \t]{2,}", " ", text) - text = re.sub(r"\n{3,}", "\n\n", text) - return text.strip() - - -async def _apipilot_execute_tool( - *, - auth_header: str | None, - demo_session: str | None, - name: str, - arguments: dict | None, -) -> dict: - """Run a single ApiPilot tool via POST /execute. - - Implements the full PRE-CHECK flow (§2.2) BEFORE sending the HTTP - request: - 1. Fresh discovery (TTL cache; force_refresh=True if a tool is missing). - 2. requires_auth: return HTTP 401 when authentication is required but absent. - 3. Strict JSON-Schema validation: filter unknown parameters and check required. - - Always return the standard `{success, data, error}` envelope (guide §2.2). - Pre-check failures populate error.{code,message,status_code}, with success=false. - """ - name_clean = (name or "").strip() if isinstance(name, str) else "" - logger.info("[APIPILOT-CALL-NAME] USE=EXECUTE_ENTRYPOINT name=%r argsKeys=%s authHead=%.20s demo=%.20s", name_clean, sorted(list((arguments or {}).keys())) if isinstance(arguments, dict) else [], str(auth_header or "")[:20], str(demo_session or "")[:20]) - if not S2S_APIPILOT_TOOLS_URL: - return { - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", - "status_code": 501, - "raw": None, - }, - } - if not name_clean: - return { - "success": False, - "data": None, - "error": {"code": "TOOL_NAME_REQUIRED", "message": "Falta el campo obligatorio: name (nombre de la herramienta).", "status_code": 400, "raw": None}, - } - tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=False) - tool_def = next((t for t in tools if t.get("name") == name_clean), None) - if tool_def is None: - # Unknown tool: refresh discovery and retry once. - logger.info("[APIPILOT-EXEC] tool=%s not found → forced re-discovery", name_clean) - tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=True) - tool_def = next((t for t in tools if t.get("name") == name_clean), None) - if tool_def is None: - return { - "success": False, - "data": None, - "error": { - "code": "TOOL_NOT_FOUND", - "message": ( - f"Herramienta «{name_clean}» no encontrada en el catálogo ApiPilot. " - "Actualice la lista de herramientas y vuelva a intentarlo." - ), - "status_code": 404, - "raw": None, - }, - } - # ── PRE-CHECK 2: requires_auth ──────────────────────────────────────────── - requires_auth = bool(tool_def.get("requires_auth")) - has_token = bool(auth_header) or bool(demo_session) or bool(S2S_APIPILOT_SERVICE_TOKEN) - if requires_auth and not has_token: - logger.warning("[APIPILOT-EXEC] tool=%s requires_auth=true blocked (no token)", name_clean) - return { - "success": False, - "data": None, - "error": { - "code": "AUTH_REQUIRED", - "message": ( - f"La herramienta «{name_clean}» requiere autenticación. " - "Inicie sesión o proporcione un token para continuar." - ), - "status_code": 401, - "raw": None, - }, - } - # Pre-check 3: strict JSON-Schema argument validation. - cleaned_args, errors = _apipilot_validate_arguments(arguments, tool_def) - if errors: - return { - "success": False, - "data": None, - "error": { - "code": "INVALID_ARGUMENTS", - "message": " / ".join(errors), - "status_code": 400, - "raw": {"removed": sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []}, - }, - } - # ── HTTP: POST /execute ─────────────────────────────────────────────────── - url = _apipilot_resolve_execute_url() - if not url: - return { - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL y S2S_APIPILOT_TOOLS_EXECUTE_URL están vacíos).", - "status_code": 501, - "raw": None, - }, - } - headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"} - # 2026-09-25 SUPER API KEY (highest priority): bypass AquaPortal authentication. - # Send all three header forms simultaneously for maximum compatibility: - # - X-API-Key: - # - Authorization: Bearer - # - x-demo-session: - # Ignore service tokens and login_by_phone when the API KEY is set. - if S2S_APIPILOT_API_KEY: - headers["X-API-Key"] = S2S_APIPILOT_API_KEY - headers["Authorization"] = f"Bearer {S2S_APIPILOT_API_KEY}" - headers["x-demo-session"] = S2S_APIPILOT_API_KEY - final_auth = f"Bearer {S2S_APIPILOT_API_KEY}" - else: - # Authentication priority: service-token override, then caller auth_header - # from phone login/discovery/pass-2 fallback, then no authentication. - if S2S_APIPILOT_SERVICE_TOKEN: - _svc = S2S_APIPILOT_SERVICE_TOKEN.strip() - final_auth = (f"Bearer {_svc}" if (not _svc.lower().startswith("bearer ")) else _svc) if _svc else None - else: - final_auth = auth_header if isinstance(auth_header, str) and auth_header.strip() else None - if final_auth: - headers["Authorization"] = final_auth - if demo_session: - headers["x-demo-session"] = demo_session - payload = {"name": name_clean, "arguments": cleaned_args} - removed = sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else [] - logger.info( - "[APIPILOT-EXEC] → POST %s name=%s argsKeys=%s removedKeys=%s auth=%s demo=%s", - url, name_clean, sorted(cleaned_args.keys()), removed, - ("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")), - ("APIKEY" if S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")), - ) - try: - async with httpx.AsyncClient(timeout=APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http: - resp = await http.post(url, json=payload, headers=headers) - except Exception as exc: - logger.error("[APIPILOT-EXEC] ✗ network error: %s", exc) - return { - "success": False, - "data": None, - "error": {"code": "EXECUTION_ERROR", "message": f"Error de red al ejecutar «{name_clean}»: {exc}", "status_code": 502, "raw": None}, - } - # Parse a standard envelope; construct a matching error for non-JSON/5xx responses. - raw_text = resp.text - # Detailed diagnostics are always enabled for HTTP >= 400: request payload, - # unmasked demo session, response status and bounded raw response body. - if resp.status_code >= 400: - try: - payload_dump = __import__("json").dumps(payload, ensure_ascii=False) - except Exception: # noqa: BLE001 - payload_dump = str(payload) - logger.warning( - "[APIPILOT-EXEC][DIAGNOSTIC 4xx] HTTP=%d url=%s name=%s demoSession=%.60s AUTH=%s requestPAYLOAD=%s |RAW RESPONSE BODY (max 1800 chars)=|%s|", - resp.status_code, - url, - name_clean, - (demo_session if isinstance(demo_session, str) else "?"), - ("YES len="+str(len(final_auth)) if isinstance(final_auth, str) and final_auth.strip() else "-"), - payload_dump, - (raw_text or "")[:1800], - ) - try: - parsed = resp.json() - except Exception: - logger.error("[APIPILOT-EXEC] ✗ non-JSON body HTTP %s: %s", resp.status_code, raw_text[:500]) - return { - "success": False, - "data": None, - "error": { - "code": "EXECUTION_ERROR", - "message": f"La respuesta de ejecución «{name_clean}» no es JSON válido (HTTP {resp.status_code}).", - "status_code": resp.status_code, - "raw": raw_text[:2000] if raw_text else None, - }, - } - # Return standard {success,data,error} envelopes unchanged. - if isinstance(parsed, dict) and "success" in parsed and isinstance(parsed.get("success"), bool): - envelope_ok = parsed["success"] and parsed.get("error") in (None, {}) - logger.info( - "[APIPILOT-EXEC] ← %s name=%s success=%s http=%s dataKeys=%s", - "OK" if envelope_ok else "FAIL", name_clean, bool(parsed.get("success")), resp.status_code, - sorted(list(parsed.get("data").keys())) if isinstance(parsed.get("data"), dict) else "-", - ) - if (not envelope_ok) and isinstance(parsed.get("error"), dict): - err = parsed["error"] - logger.warning( - "[APIPILOT-EXEC] ERROR: code=%s status=%s msg=%s", - err.get("code"), err.get("status_code"), - (str(err.get("message"))[:200] if err.get("message") is not None else "-"), - ) - return parsed - # Wrap raw backend data in the required envelope (guide §2.2) for stable - # downstream behavior when an upstream response omits the wrapper. - logger.warning("[APIPILOT-EXEC] backend omitted standard envelope → wrapping response http=%s", resp.status_code) - return { - "success": 200 <= resp.status_code < 300, - "data": parsed if (200 <= resp.status_code < 300) else None, - "error": None if (200 <= resp.status_code < 300) else { - "code": "EXECUTION_ERROR", - "message": ( - f"Ejecución «{name_clean}» devolvió HTTP {resp.status_code} sin envelope estándar." - ), - "status_code": resp.status_code, - "raw": (raw_text[:2000] if raw_text else None), - }, - } - - -# Node-RED reasoning engine endpoint (NODE_RED mode). -# Examples: http://localhost:1881 or http://s2s-reasoning:1880 (Docker network). -NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/") -# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session -# (which proxies /session here, server-side) and connects to the URL the LB -# returns (the original flow). The LB address itself is never sent to the browser. -# When empty, the user may instead set a direct s2s server URL in Settings and the -# browser connects to it straight (no load balancer). -LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip() -# Direct s2s server URL pinned by the deploy. Takes priority over the load -# balancer: when set, LB allocation and queuing are disabled, and the browser -# connects to this URL directly. Unlike the LB address it is NOT a secret — -# /api/config sends it to the client, which -# shows it read-only in Settings. -SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip() -# Optional same-origin WebSocket relay target. When set, the browser dials this -# app over ws(s):///v1/realtime and the server relays frames to the -# upstream speech-to-speech endpoint, keeping the internal host private. -S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip() -if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET: - LOAD_BALANCER_URL = "" -SERPER_URL = "https://google.serper.dev/search" -# Cap results so the tool output stays small enough to feed back to the model. -MAX_RESULTS = 5 - -app = FastAPI(title="s2s-demo") - -# Resolve the execution URL for the startup banner using the same normalization -# as _apipilot_resolve_execute_url. -def _resolve_exec_url_for_startup_banner(): - base = (S2S_APIPILOT_TOOLS_EXECUTE_URL or S2S_APIPILOT_TOOLS_URL).strip().rstrip("/") - if not base: - return "" - if base.lower().endswith("/execute"): - return base - return f"{base}/execute" -_STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner() -del _resolve_exec_url_for_startup_banner - -logger.info( - "[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s", - S2S_REASONING_TYPE, - NODE_RED_REASONING_URL or "-", - bool(SERPER_KEY), - bool(LOAD_BALANCER_URL), - bool(S2S_PROXY_TARGET), - bool(SPEECH_TO_SPEECH_URL), - bool(VOCERO_USER_PHONE_AUTH_URL), - bool(S2S_REPORT_LIST), - S2S_INJECT_REPORTS, - S2S_REPORTS_QUICK_HINT_MAX, - S2S_REPORTS_INJECT_MODE, - S2S_INJECT_TODAY, - bool(S2S_LATEST_SUMMARY), - bool(S2S_LATEST_SUMMARY_TEXT), - bool(S2S_LOGIN_BY_PHONE), - S2S_INJECT_PROFILE, - S2S_DEBUG_PROMPT, - S2S_START_SESSION_MODE or "-", - S2S_THEME or "-", - S2S_APIPILOT_TOOLS_URL or "-", - (S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})") if S2S_APIPILOT_TOOLS_EXECUTE_URL else "-", - "Y" if bool(S2S_APIPILOT_SERVICE_TOKEN) else "-", - APIPILOT_CACHE_TTL_SEC, - APIPILOT_HTTP_TIMEOUT, - "Y" if (bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-", - ("YES head=" + (S2S_APIPILOT_API_KEY[:6] + "..." if len(S2S_APIPILOT_API_KEY) > 6 else S2S_APIPILOT_API_KEY) + " (3 FORMS!)") if S2S_APIPILOT_API_KEY else "-", -) -del _STARTUP_APIPILOT_EXEC_URL - - -async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None: - return - - -def _realtime_event_summary(raw: str) -> dict | None: - try: - payload = json.loads(raw) - except Exception: - return None - if not isinstance(payload, dict): - return None - event_type = payload.get("type") - if not isinstance(event_type, str): - return None - summary = {"type": event_type} - if event_type == "session.update": - session = payload.get("session") if isinstance(payload.get("session"), dict) else {} - tools = session.get("tools") if isinstance(session.get("tools"), list) else [] - summary["toolCount"] = len(tools) - summary["toolNames"] = [tool.get("name") for tool in tools if isinstance(tool, dict)] - summary["toolChoice"] = session.get("tool_choice") - summary["instructionLength"] = len(session.get("instructions") or "") - elif event_type in {"response.function_call_arguments.done", "error", "session.updated"}: - for key in ("name", "call_id", "code", "message", "response_id"): - if key in payload: - summary[key] = payload.get(key) - # --- INPUT (user speech → STT transcript) --- - # Can arrive from upstream (server-side STT) — match either direction. - elif event_type == "conversation.item.input_audio_transcription.completed": - summary["transcript"] = payload.get("transcript", "") - summary["item_id"] = payload.get("item_id") - elif event_type == "conversation.item.created": - item = payload.get("item") if isinstance(payload.get("item"), dict) else None - if isinstance(item, dict): - summary["item_id"] = item.get("id") - summary["role"] = item.get("role") - summary["item_type"] = item.get("type") - summary["status"] = item.get("status") - content_parts = [] - for c in (item.get("content") or []): - if isinstance(c, dict): - if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str): - content_parts.append(c["text"]) - elif c.get("type") == "transcript" and isinstance(c.get("transcript"), str): - content_parts.append(c["transcript"]) - if content_parts: - summary["text"] = " ".join(content_parts) - # --- OUTPUT (agent text/speech generation) --- - elif event_type == "response.output_item.done": - item = payload.get("item") if isinstance(payload.get("item"), dict) else None - if isinstance(item, dict): - summary["item_id"] = item.get("id") - summary["role"] = item.get("role") - summary["status"] = item.get("status") - content_parts = [] - for c in (item.get("content") or []): - if isinstance(c, dict): - if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"]: - content_parts.append(c["text"]) - elif c.get("type") == "audio" and isinstance(c.get("transcript"), str) and c["transcript"]: - content_parts.append(c["transcript"]) - if content_parts: - summary["text"] = " ".join(content_parts) - elif event_type == "response.content_part.done": - part = payload.get("part") if isinstance(payload.get("part"), dict) else None - if isinstance(part, dict): - summary["response_id"] = payload.get("response_id") - summary["item_id"] = payload.get("item_id") - summary["output_index"] = payload.get("output_index") - summary["content_index"] = payload.get("content_index") - if part.get("type") == "text": - summary["text"] = part.get("text", "") - elif part.get("type") == "audio": - if isinstance(part.get("transcript"), str) and part["transcript"]: - summary["text"] = part["transcript"] - # Standard OpenAI variant: - elif event_type == "response.audio_transcript.done": - summary["response_id"] = payload.get("response_id") - summary["item_id"] = payload.get("item_id") - summary["text"] = payload.get("transcript", "") - # Custom upstream variant observed in user logs: - elif event_type == "response.output_audio_transcript.done": - summary["response_id"] = payload.get("response_id") - summary["item_id"] = payload.get("item_id") - summary["output_index"] = payload.get("output_index") - # Try field names in order of likelihood - text = "" - for key in ("transcript", "text", "output_text", "audio_text", - "output_transcript", "final_transcript", "output_audio_text", - "content", "message", "utterance"): - v = payload.get(key) - if isinstance(v, str) and v.strip(): - text = v.strip() - break - # If text is still empty, search known nested objects. - if not text: - for container in ("item", "part", "content", "output", "delta", "audio"): - sub = payload.get(container) - if isinstance(sub, dict): - for key in ("transcript", "text", "output_text"): - v = sub.get(key) - if isinstance(v, str) and v.strip(): - text = v.strip() - break - if text: - break - if not text: - # Last resort: the content[] array. - arr = payload.get("content") - if isinstance(arr, list): - parts = [] - for c in arr: - if isinstance(c, dict): - for key in ("transcript", "text"): - v = c.get(key) - if isinstance(v, str) and v.strip(): - parts.append(v.strip()) - if parts: - text = " ".join(parts) - summary["text"] = text - # If no text was found, retain a raw preview to inspect the custom format. - if not text: - preview = raw[:800] - try: - pretty = json.dumps(payload, ensure_ascii=True)[:800] - except Exception: - pretty = preview - summary["_raw_preview"] = pretty - elif event_type == "response.function_call_arguments.done": - summary["arguments"] = payload.get("arguments") - # This event confirms strategy B: ai-server accepted native tools[]. - _tool_name = payload.get("name") if isinstance(payload.get("name"), str) else "" - _call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else "" - _args_json = payload.get("arguments") if isinstance(payload.get("arguments"), str) else "{}" - logger.info( - "[TOOLCALL] ✓ UPSTREAM emitted response.function_call_arguments.done! " - "tool=%s callId=%s argsLen=%d argsPreview=%s", - _tool_name or "(empty)", - _call_id or "-", - len(_args_json), - _args_json[:200], - ) - try: - _args_parsed = json.loads(_args_json or "{}") - logger.info( - "[TOOLCALL] ✓ Tool call parsed argsKeys=%s", - sorted(_args_parsed.keys()) if isinstance(_args_parsed, dict) else "N/A", - ) - except Exception: - pass - return summary - - -# Suppress noisy high-frequency events in detailed logs. Internal debug -# reporting still tracks them when enabled. -_NOISY_REALTIME_EVENT_TYPES = frozenset({ - "response.output_audio.delta", - "response.audio.delta", - "conversation.item.input_audio_transcription.delta", - "response.text.delta", - "response.output_text.delta", - "rate_limits.updated", -}) - -# Suppress client-side high-frequency events such as input_audio_buffer.append. -_NOISY_CLIENT_EVENT_TYPES = frozenset({ - "input_audio_buffer.append", - "input_audio_buffer.commit", - "input_audio_buffer.clear", - "input_audio_buffer.speech_started", - "input_audio_buffer.speech_stopped", -}) - - -class SearchRequest(BaseModel): - query: str - # Optional user-supplied key (fallback when the deploy has no server key). - # Used for this request only; never stored. - key: str | None = None - - -VOCERO_USER_CONTEXT_HINT = ( - " Caller profile resolved from the page phoneNumber: " -) -REPORTS_LIST_HINT = ( - " Scheduled reports / podcast scripts available for this caller before the current session. " - "Each entry is a scheduled report; use the podcastScript contents as trusted reference material when the user asks about scheduled topics, reports, or prior content." - " NOTE (2026-09-11 request to remove mass injection of scheduled reports): " - "S2S_INJECT_REPORTS defaults to FALSE. When FALSE the injection below NEVER happens. " - "Operators must explicitly set S2S_INJECT_REPORTS=true to opt back into mass scheduled-report prompt injection. " -) -LATEST_SUMMARY_HINT = ( - " Latest conversation summary for this caller (most recent before the current session): " -) -PODCAST_EPISODE_HINT = ( - " Podcast episode context loaded from the page episode_id query param (current episode the user is referencing). " - "Treat the episode fields (title, description, transcript, script, show notes, guests, audio metadata, etc.) as the PRIMARY trusted reference material for this session. " -) -# ────────────────────────────────────────────────────────────────────────── -# User fix 2026-09-11 Bug 3: "Qwen2.5 has training cutoff mid-2024, when it -# sees 39 reports dated 2026-09-10 it hallucinates meta-comments in Chinese -# like 'today is 2023, why are you simulating 2026 reports?'" -# -# Resolved with S2S_INJECT_TODAY default ON + S2S_REPORTS_INJECT_MODE=minimal: -# • TODAY_BLOCK (marker <<>>): -# - Fixed reference: "TODAY is YYYY-MM-DD". -# - Earlier reports are historical records; do not question their dates. -# - No internal commentary about prompts, dates, data validity or instructions. -# - Clean output: only words Vocero says aloud; no reasoning or prompt checks. -# - Retrieve report details through RAG rather than inventing from summaries. -# • MINIMAL_REPORTS_BLOCK (marker <<>>): only latest N -# label + date lines. NO podcastScript text, NO full JSON entries. Uses the -# pattern the user explicitly requested: -# Recent scheduled reports (latest 3): -# 1. "Mortality Percentage Report" — 2026-09-10 -# 2. ... -# (Retrieve full content through knowledge search.) -# • We register both blocks with the dedupe layer via the new TYPE IDs -# "today_fixed" and "reports_minimal" so they also can never get duplicated. -# -# IMPORTANT MARKER CHANGE 2026-09-11: markers used to be human-readable -# strings like "<<>>" which got written into the LLM -# prompt verbatim. Qwen2.5 (especially GPTQ quantized variants) would -# sometimes *leak those markers back into the output* alongside meta-comment -# hallucinations (e.g. emitting "[-]: checking_podcast_report" or literal -# "<<<...>>>"). To fix this we now use INVISIBLE markers: a SHA-1 hash of -# stable strings. These hashes are NEVER seen by the LLM as text because -# they are NOT written into the prompt. They exist ONLY as Python-side -# substring sentinels prepended to the *Python string* returned by each -# block builder — stripped out at the very end with .replace() just before -# the string is assembled into the prompt. Result: the dedupe logic still -# works perfectly, but the LLM NEVER sees any marker / guard token. -# ────────────────────────────────────────────────────────────────────────── -def _s2s_marker(label: str) -> str: - """Return a short, unique, invisible sentinel for dedup classification. - - Sentinel is 40 hex chars (SHA1) of a stable string; it is NEVER written - into the prompt — we strip all markers at the end of each builder. - """ - import hashlib - return hashlib.sha1(f"S2S-ctx-marker/{label}/v1".encode("utf-8")).hexdigest() - - -TODAY_BLOCK_MARKER = _s2s_marker("today_fixed_block") -MINIMAL_REPORTS_MARKER = _s2s_marker("reports_minimal_toc") -VOICE_RULES_HEAD_MARKER = _s2s_marker("voice_rules_head_lock") -REPORTS_FULL_MARKER = _s2s_marker("reports_full_block") -SUMMARY_FULL_MARKER = _s2s_marker("summary_full_block") -PROFILE_FULL_MARKER = _s2s_marker("profile_full_block") -EPISODE_FULL_MARKER = _s2s_marker("episode_full_block") -ALL_S2S_INTERNAL_MARKERS: tuple = ( - TODAY_BLOCK_MARKER, - MINIMAL_REPORTS_MARKER, - VOICE_RULES_HEAD_MARKER, - REPORTS_FULL_MARKER, - SUMMARY_FULL_MARKER, - PROFILE_FULL_MARKER, - EPISODE_FULL_MARKER, -) - - -def _strip_internal_markers(text) -> str: - """Strip every internal S2S dedup marker from the final prompt text. - - Must be called right before a block is joined to the final instructions / - user_context so the LLM NEVER sees any of our sentinel strings. This is - the final guarantee that prevents marker leakage into spoken output. - """ - s = str(text or "") - if not s: - return s - for m in ALL_S2S_INTERNAL_MARKERS: - if m and m in s: - s = s.replace(m, "") - return s.strip() - - -def _extract_user_name_parts(profile) -> list[str]: - """Given a profile dict (possibly wrapped in {"user": ...} or {"result": ...}) - return the list of non-empty first/last name parts in order (e.g. - ["Alessandro", "Valenti"]). Returns [] when profile has no name. - - Used by: - • _build_voice_rules_head_lock_block — prompt rule #5, exact name. - • _resolve_user_context_text — dynamic profile note against name translation. - • _sanitize_agent_output — layer 7 final whole-word regex replacement. - """ - if not profile or not isinstance(profile, dict): - return [] - # Unwrap common container keys (server returns both shapes depending on - # the auth source path — direct /phone/ endpoint vs login proxy wrapper). - nested = profile - for wrapper in ("user", "result", "data"): - inner = nested.get(wrapper) - if isinstance(inner, dict): - nested = inner - # Prefer explicit firstName+lastName when available (structured fields) - first = str(nested.get("firstName") or nested.get("first_name") or "").strip() - last = str(nested.get("lastName") or nested.get("last_name") or "").strip() - if first or last: - out: list[str] = [] - for token in (first, last): - token_clean = token.strip() - if token_clean: - for sub in token_clean.split(): - if sub: - out.append(sub) - return out - # Fallback: split the combined "name" string by whitespace - full_name = str(nested.get("name") or "").strip() - if not full_name: - return [] - return [p for p in full_name.split() if p] - - -def _build_voice_rules_head_lock_block(profile=None) -> str: - """Return the #1 PRIORITY block prepended to EVERY prompt: voice lock. - - Fixes 6 hallucination categories seen in user log 2026-09-11 07:57: - (1) [-]: checking_podcast_report — internal "action thinking" leak - (2) English 'Sure, here's my best focused expression while I look into it' - (3) JSON literal {'reportDetails': '...'} inside output - (4) Unexpected Italian phrases. - (5) leak markers <<<...>>> in TTS output. - (6) Invented content about nonexistent podcasts. - - This block contains short English instructions requiring Spanish output. We put - it as the VERY FIRST thing in the prompt (even before the caller profile), - because quantized models obey the first ~400 tokens way more strongly - than rules buried at the end. - - 2026-09-11: NEW rule #5 — PROPER NAME LOCK. Model keeps writing "Alexander" - for the Italian caller "Alessandro Valenti". We now emit an explicit, - non-ambiguous "Exact name: Alessandro. NEVER Alexander/Alexandre/..." - that the sanitizer's _common_name_variants map can also repair. - - INVISIBLE marker: prefixes VOICE_RULES_HEAD_MARKER (stripped before prompt - join, never seen by LLM) to enable dedupe so we never double-prepend. - """ - base = ( - "PRIMARY VOICE OUTPUT RULES (always apply; override all other instructions):\n" - "1) EXCLUSIVE LANGUAGE: ALWAYS respond ONLY in Spanish (Castilian). No Italian, English, Portuguese or other language. " - "Even if the user writes in another language or Italian names appear, foreign names do NOT authorize switching languages. " - "No words, phrases or confirmations in another language.\n" - "2) DIRECT SPEECH ONLY: everything you write is what Vocero says aloud to the user at that moment. " - "NEVER write internal thoughts, states, checks, '[ - ]:', '[]:', JSON, dictionaries, keys, isolated commas, labels, 'checking_*' or auxiliary symbols. " - "No '[rightspeak]', '[endspeak]', '[thinking]', '[endthinking]' or bracketed labels. No meta-comments about prompts or data.\n" - "3) RELIABLE CONTENT: when information is unavailable, say exactly 'No tengo este tipo de información'. Do not invent, assume or generalize. " - "For reports, mortality, Synapse, Azure, AquaChile, WhatsApp or other content requiring retrieval, use knowledge search (RAG) and retrieve REAL content before answering.\n" - "4) SHORT NATURAL RESPONSES: speak like a real person on the phone. No lists, bullet points, quotations or long text blocks. " - "Use a couple of short sentences, then ask whether the user wants more details.\n" - ) - # Dynamic proper-name lock based on the profile. - name_lines = "" - parts = _extract_user_name_parts(profile) - if parts: - full_name = " ".join(parts) - variants_all: list[str] = [] - for part in parts: - vs = _common_name_variants(part) - for v in vs: - if v not in variants_all and v != part: - variants_all.append(v) - cleaned_variants = variants_all[:8] - if cleaned_variants: - name_lines = ( - "5) PROPER NAME (mandatory for this session): " - f"the user's exact name, WRITTEN AND PRONOUNCED AS GIVEN, is: {full_name}. " - f"NEVER adapt, translate or normalize it: " - + ", ".join(f'NEVER "{v}"' for v in cleaned_variants) - + ". Write and pronounce only the exact profile name.\n" - ) - body = base + name_lines - return VOICE_RULES_HEAD_MARKER + "\n" + body - - -def _build_today_fixed_context_block( - today_override=None, -) -> str: - """Return the TODAY + anti-hallucination guard block. - - 2026-09-11 rewrite: - • English internal instructions; user responses remain Spanish. - Earlier mixed Italian/Spanish prompts caused output language leaks. - • Only 3 bullet sentences, nothing fancy, nothing that triggers meta. - • Invisible dedup marker TODAY_BLOCK_MARKER is prefixed and will be - stripped before prompt join → LLM never sees. - """ - if not S2S_INJECT_TODAY: - return "" - today = today_override if today_override is not None else _dt_module.date.today() - iso_date = today.strftime("%Y-%m-%d") - weekday = [ - "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", - ][today.weekday()] - body = ( - f"Today's date (mandatory reference): {iso_date} ({weekday}).\n" - "Reports dated BEFORE today are HISTORICAL RECORDS, not future dates. Do not comment on this.\n" - "A period '.' separates decimals; a comma ',' separates thousands (the standard international format used by this system).\n" - ) - return TODAY_BLOCK_MARKER + "\n" + body - - -def _build_minimal_reports_toc_block( - filtered_sorted, - cap=None, -) -> str: - """Return the user-requested MINIMAL scheduled-reports TOC block (NO script). - - 2026-09-11 rewrite: - • Marker <<<...>>> removed because it was leaking into output. Now the - invisible internal sentinel MINIMAL_REPORTS_MARKER (stripped before - joining) is used for dedupe classification. - • Footer is now the strict RAG rule, not a soft note. - """ - items = list(filtered_sorted or []) - if cap is None: - cap = max(1, int(S2S_REPORTS_QUICK_HINT_MAX or 3)) - if len(items) > cap: - items = items[:cap] - if not items: - return "" - lines: list[str] = [] - lines.append( - f"Recent scheduled reports (latest {len(items)}):" - ) - for idx, r in enumerate(items, start=1): - label = (str(r.get("sourceScheduleLabel") or "").strip() - or "(untitled)") - raw_d = r.get("date") - date_str = "" - try: - if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool): - ts_sec = float(raw_d) - if ts_sec > 1e12: - ts_sec = ts_sec / 1000.0 - dt = _dt_module.datetime.fromtimestamp(ts_sec, tz=_dt_module.timezone.utc) - date_str = dt.strftime("%Y-%m-%d") - elif raw_d is not None: - s = str(raw_d).strip() - if "T" in s and len(s) >= 10: - date_str = s[:10] - else: - date_str = s - except Exception: - date_str = str(raw_d)[:10] if raw_d else "" - if date_str: - lines.append(f' {idx}. "{label}" — {date_str}') - else: - lines.append(f' {idx}. "{label}"') - lines.append( - "ALWAYS retrieve full report content through knowledge search (RAG). Do not invent details you have not retrieved." - ) - return MINIMAL_REPORTS_MARKER + "\n" + "\n".join(lines) + "\n" - -# ────────────────────────────────────────────────────────────────────────── -# 2026-09-11 user fix: remove duplicated caller profiles. -# Each context block returned by the _resolve_* helpers above starts with a -# unique, stable, documented HINT prefix (see VOCERO_USER_CONTEXT_HINT / -# PODCAST_EPISODE_HINT / REPORTS_LIST_HINT / LATEST_SUMMARY_HINT). We use -# these prefixes as logical "block type IDs" so a single context type is -# NEVER appended twice to the same prompt / session.instructions / user_context -# — even if two different call paths (phoneNumber fetch + identity / -# client-side supplied user_context + server-side fetch) both carry the same -# block type. -# -# Historical duplicate reported by the user: -# Caller profile resolved from the page phoneNumber: {name:"Alessandro.."} -# Caller profile resolved from the page phoneNumber: {name:"Alessandro.."} -# ↑ IDENTICAL SECOND COPY ↑ -# The most likely cause is a client-side reasoning / profile fetch supplying -# the profile as part of `req.user_context` (or two overlapping server -# injection channels) while the server re-fetches it again for the -# instructions augment. Instead of trying to hunt every single call site, we -# add TWO dedupe layers below that are immune to future regressions: -# -# 1) _dedup_context_blocks_by_type([A, B, C, ...]) — given a list of -# candidate context strings, returns a new list where only the FIRST -# occurrence of each logical block TYPE (profile / episode / reports / -# summary) survives. All HINT prefixes are treated as block-type IDs. -# Used inside _augment_instructions_with_profile and the -# effective_user_context join list. -# 2) _strip_already_injected_types_from_base(base_text, [candidates]) — -# before appending new candidates to an existing base prompt string, -# scan the base for each HINT prefix. If a prefix is already present -# somewhere inside base_text, that candidate is DROPPED (skipped). -# Catches the case where session.instructions was ALREADY patched on a -# previous session.update frame, or where the client sent us the -# profile already baked into req.instructions / req.user_context. -# -# Together these two layers guarantee: per prompt / per LLM call, each block -# type appears AT MOST ONCE (zero or one). -# ────────────────────────────────────────────────────────────────────────── -_CONTEXT_DEDUPE_MARKERS: tuple = ( - # (logical_type_id, unique_prefix_marker_in_text) - ("voice_rules_head", VOICE_RULES_HEAD_MARKER), # NEW: 2026-09-11 #1 PRIORITY voice-language lock - ("episode", PODCAST_EPISODE_HINT.strip()), - ("profile", VOCERO_USER_CONTEXT_HINT.strip()), - ("reports_full", REPORTS_FULL_MARKER), - ("reports", "<<>>"), # legacy back-compat marker - ("summary", "<<>>"), # legacy back-compat marker - ("summary_full", SUMMARY_FULL_MARKER), - ("today_fixed", TODAY_BLOCK_MARKER), - ("reports_minimal", MINIMAL_REPORTS_MARKER), - ("apipilot_tools_list", APIPILOT_PASS1_LIST_MARKER), - ("apipilot_result", APIPILOT_PASS2_RESULT_MARKER), -) - - -def _classify_context_block(block: str) -> str | None: - """Return the logical type of block or None for unrecognised text. - - Uses the HINT prefixes / block begin markers above. Uses substring - containment (not startswith) because the augmented prompt may carry the - markers after 1-2 leading spaces. Uses the STRIPPED version of each HINT - to avoid missing matches due to leading whitespace. - """ - if not block: - return None - haystack = (block or "").strip() - if not haystack: - return None - for type_id, marker in _CONTEXT_DEDUPE_MARKERS: - if marker and marker in haystack: - return type_id - return None - - -def _dedup_context_blocks_by_type(candidates: list[str]) -> list[str]: - """Keep only the FIRST occurrence of each logical block TYPE in order. - - Drops duplicated block types silently and logs + returns a new list with - only the surviving blocks. Also drops empty / whitespace-only candidates - which would otherwise be a no-op append. This is layer 1 of the dedupe. - """ - if not candidates: - return [] - seen_types: set[str] = set() - out: list[str] = [] - dropped_types: dict[str, int] = {} - for candidate in candidates: - c = (candidate or "").strip() - if not c: - continue - type_id = _classify_context_block(c) - if type_id is None: - # Unknown type (free-form text, plain user_context, ...) is NEVER - # deduped — we only dedupe the 4 well-known S2S injected block - # types so user-supplied arbitrary context is always preserved. - out.append(candidate) - continue - if type_id in seen_types: - dropped_types[type_id] = dropped_types.get(type_id, 0) + 1 - continue - seen_types.add(type_id) - out.append(candidate) - if dropped_types and S2S_DEBUG_PROMPT: - # Log only when dedupe actually fired (avoids spamming). Use level - # DEBUG / info level so the admin can see "profile duplicate was - # dropped" when troubleshooting prompt size issues. - logger.info( - "[INJECT-DEDUPE] dropped duplicate injected block types=%s kept=%d candidates=%d", - ", ".join(f"{k}x{v}" for k, v in dropped_types.items()), - len(out), - len(candidates), - ) - return out - - -def _strip_already_injected_types_from_base( - base_text: str, candidates: list[str] -) -> list[str]: - """Layer 2: for each candidate, check if its HINT prefix ALREADY appears - anywhere inside base_text (i.e. was injected by a previous patch / baked - into the instructions sent by the browser). If yes, drop that candidate - entirely (keep only candidates whose types are NOT already present in - base). Works even when base_text contains the profile twice — after this - filter we won't add a third copy; layer 1 handles any further duplicates - among the candidates themselves.""" - b = (base_text or "") - if not b: - return list(candidates) - out: list[str] = [] - dropped_base: list[str] = [] - for candidate in candidates: - c = (candidate or "").strip() - if not c: - continue - type_id = _classify_context_block(c) - if type_id is None: - # Unknown context blocks → never dedupe, always append. - out.append(candidate) - continue - # Does base already contain ANY marker for this type? Use _ALL_ - # markers for this type, not just the single canonical one, in case - # an older deploy used a different stringification (though prefix - # stability is guaranteed by the module-level constants here). - already = False - for marker_tid, marker in _CONTEXT_DEDUPE_MARKERS: - if marker_tid == type_id and marker and marker in b: - already = True - break - if already: - dropped_base.append(type_id) - continue - out.append(candidate) - if dropped_base and S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-DEDUPE] dropped candidate block types already present in base instructions=%s baseLen=%d", - ", ".join(dropped_base), - len(b or ""), - ) - return out - - -def _resolve_user_context_text(phone_number, profile_obj, loaded: bool = True) -> str: - """Given a phone+profile object -> the short context string appended to - LLM system prompt / reasoning user_context when S2S_INJECT_PROFILE is on. - - Generic (deploy-agnostic): profile_obj is the JSON returned by - VOCERO_USER_PHONE_AUTH_URL (optionally nested under {"result": ...}). - - 2026-09-11 anti-language-leak rule: names like "Alessandro Valenti" are - Italian/Luxembourgish names but our voice assistant uses Castilian Spanish, so - we append an EXPLICIT NOTE that the model sees alongside the caller info. - - 2026-09-11 anti-anglicization rule (Alexander → Alessandro): when the - profile contains a known given-name (e.g. Alessandro) we emit a DYNAMIC - list of forbidden anglicizations so the model cannot "normalize" it. - """ - if not loaded or not profile_obj or not phone_number: - return "" - try: - serialized = json.dumps(profile_obj, ensure_ascii=False) - except Exception: - serialized = str(profile_obj) - if len(serialized) > 1600: - serialized = serialized[:1600] + "..." - - # ─── anti-anglicization dynamic note ───────────────────────────────── - parts = _extract_user_name_parts(profile_obj) or [] - forbidden_lines: list[str] = [] - for name_part in parts: - variants = _common_name_variants(name_part) - if variants: - # keep the first 6 variants max so the prompt stays tight - short = variants[:6] - quoted = ', '.join('"' + v + '"' for v in short) - forbidden_lines.append( - f"For '{name_part}': NEVER write or pronounce {quoted} or similar English/French/Russian variants. " - f"Use only '{name_part}' exactly as given." - ) - if forbidden_lines: - proper_name_note = ( - "PROPER NAME (mandatory for this session): " - + " ".join(forbidden_lines) - + " " - ) - else: - # fallback to generic example so the rule's existence is still taught - proper_name_note = ( - "PROPER NAME: never adapt, translate or normalize proper names " - "(for example, never change 'Alessandro' to 'Alexander'). Write and pronounce only the exact profile name. " - ) - - # We build with invisible internal PROFILE_FULL_MARKER so dedup + strip - # works consistently with today/voice_rules_head blocks. - body = ( - f"{VOCERO_USER_CONTEXT_HINT}{serialized}. Use this as trusted caller context when relevant. " - "IMPORTANT LANGUAGE RULE: even if the user has an Italian name (such as 'Alessandro Valenti') " - "or a name from any other language, respond ONLY in SPANISH (Castilian). " - "Foreign names do NOT authorize switching languages. " - "Never write phrases, words, questions or confirmations in Italian, English or another language. " - "Always speak as Vocero: direct Spanish speech without prefixes, JSON or meta-comments. " - f"{proper_name_note}" - ) - return PROFILE_FULL_MARKER + "\n" + body - - -def _resolve_latest_summary_context(summary_value) -> str: - """Normalize the payload returned by S2S_LATEST_SUMMARY endpoint into a - single context string to be appended next to the profile block. - - Accepts plain string / object (dumped JSON) / None. Capped at 1600 chars. - 2026-09-11: uses INVISIBLE SUMMARY_FULL_MARKER (never seen by LLM) so the - dedupe layer can still classify it; human <<<...>>> markers removed to - stop leakage into output. - Returns "" when no summary is available. - """ - if summary_value is None: - return "" - if isinstance(summary_value, dict) or isinstance(summary_value, list): - try: - serialized = json.dumps(summary_value, ensure_ascii=False) - except Exception: - serialized = str(summary_value) - else: - serialized = str(summary_value) - trimmed = serialized.strip() - if not trimmed: - return "" - if len(trimmed) > 1600: - trimmed = trimmed[:1600] + "..." - body = f"{LATEST_SUMMARY_HINT}\n{trimmed}\n. Use this as trusted history when the user references prior calls." - return SUMMARY_FULL_MARKER + "\n" + body - - -def _resolve_episode_context(raw_value) -> str: - """Normalize the podcast episode API response into a prompt block injected - at the TOP of the contexts (most specific / current reference). - - Accepts a dict (or {"result": {...}} wrapped) with any arbitrary episode - fields (title, description, transcript, script, showNotes, guests, - publishedAt, audioUrl, duration, author, etc.). Capped at 24000 chars - (episodes can have long transcripts). - 2026-09-11: uses INVISIBLE EPISODE_FULL_MARKER so dedupe still classifies - it without leaking <<<...>>> wrappers into TTS. - Returns "" if empty / fetch failed / None. - """ - if raw_value is None: - return "" - if isinstance(raw_value, dict) and len(raw_value) == 1 and isinstance(raw_value.get("result"), (dict, list)): - raw_value = raw_value["result"] - if isinstance(raw_value, dict) or isinstance(raw_value, list): - try: - serialized = json.dumps(raw_value, ensure_ascii=False) - except Exception: - serialized = str(raw_value) - else: - serialized = str(raw_value) - trimmed = serialized.strip() - if not trimmed: - return "" - if len(trimmed) > 24000: - trimmed = trimmed[:24000] + "...(episode truncated)" - body = f"{PODCAST_EPISODE_HINT}\n{trimmed}\n. Treat as PRIMARY trusted reference for this session." - return EPISODE_FULL_MARKER + "\n" + body - - -def _parse_date_key(value) -> tuple: - """Return a sortable tuple for an arbitrary date-like value. - - Sort order is DESC (newest first). Non-dates / empty sort to the end (lowest priority). - Accepts ISO strings ("YYYY-MM-DD", "YYYY-MM-DDTHH:MM:SS"), Python datetime objects, - int/float unix timestamps, numbers. - """ - import datetime as _dt - if value is None: - return (1, 0) - if isinstance(value, _dt.datetime): - try: - return (0, -int(value.timestamp())) - except Exception: - return (1, 0) - if isinstance(value, _dt.date): - try: - ts = int(_dt.datetime.combine(value, _dt.time.min).timestamp()) - return (0, -ts) - except Exception: - return (1, 0) - if isinstance(value, (int, float)): - try: - return (0, -int(value)) - except Exception: - return (1, 0) - if isinstance(value, str): - s = value.strip() - if not s: - return (1, 0) - # Try ISO parse first (handles most formats) - try: - dt = _dt.datetime.fromisoformat(s.replace("Z", "+00:00")) - return (0, -int(dt.timestamp())) - except Exception: - pass - # Fallback: date-only YYYY-MM-DD - try: - dt = _dt.datetime.strptime(s[:10], "%Y-%m-%d") - return (0, -int(dt.timestamp())) - except Exception: - pass - # Last resort: lexicographic sort DESC (newest first still works for ISO-ish) - return (0, tuple(-ord(c) for c in s[:32])) - return (1, 0) - - -def _resolve_reports_context(raw_value: object) -> str: - """Normalize / filter the S2S_REPORT_LIST response into a prompt block. - - Expects an array/list (or dict {"result": [...]} unwrapped by the fetcher). - For each dict item keeps only fields: sourceScheduleLabel, date, podcastScript. - Also supports nested shape: sourceSchedule.label / sourceSchedule.date. - Sorts by date DESC (newest first) via _parse_date_key. Capped at 12000 chars - (podcastScript can be long). Wrapped with <<>> markers. - Returns "" if no valid items / empty list. - - USER FIX 2026-09-11: remove mass injection of scheduled reports. - • If the new env flag S2S_INJECT_REPORTS is FALSE (DEFAULT), this function - ALWAYS returns "" — NO scheduled reports of any kind are ever injected - into the LLM prompt, not even a TOC line with label+date. The function - returns the empty string at the very top before touching the input, so - we also skip JSON processing / sorting / serialization work. - • If S2S_INJECT_REPORTS=TRUE, behaviour is unchanged: full injection of - podcastScript text for every report, just as before. - """ - # ── MASS-INJECTION KILL SWITCH (user request) ──────────────────────── - if not S2S_INJECT_REPORTS: - return "" - # ───────────────────────────────────────────────────────────────────── - if raw_value is None: - return "" - items_list: list - if isinstance(raw_value, list): - items_list = raw_value - elif isinstance(raw_value, dict) and isinstance(raw_value.get("result"), list): - items_list = raw_value["result"] - else: - return "" - # Keep only dict items; for each project ONLY the three required fields (in order). - filtered: list[dict] = [] - for item in items_list: - if not isinstance(item, dict): - continue - cleaned: dict = {} - # Field extraction with fallback to nested sourceSchedule.* shape. - # 1. Label: prefer flat sourceScheduleLabel, else nested sourceSchedule.label - label_val: object = None - if item.get("sourceScheduleLabel") is not None: - label_val = item["sourceScheduleLabel"] - elif isinstance(item.get("sourceSchedule"), dict): - ss = item["sourceSchedule"] - if ss.get("label") is not None: - label_val = ss["label"] - if label_val is None and ss.get("name") is not None: - label_val = ss["name"] - if label_val is None and ss.get("title") is not None: - label_val = ss["title"] - if label_val is not None: - cleaned["sourceScheduleLabel"] = label_val - # 2. Date: prefer flat "date", else nested sourceSchedule.date - date_val: object = None - if item.get("date") is not None: - date_val = item["date"] - elif isinstance(item.get("sourceSchedule"), dict): - ss = item["sourceSchedule"] - if ss.get("date") is not None: - date_val = ss["date"] - if date_val is None and ss.get("scheduledAt") is not None: - date_val = ss["scheduledAt"] - if date_val is None and ss.get("publishedAt") is not None: - date_val = ss["publishedAt"] - if date_val is not None: - cleaned["date"] = date_val - # 3. Script: prefer flat podcastScript, else nested podcastScript / script / text - script_val: object = None - if item.get("podcastScript") is not None: - script_val = item["podcastScript"] - elif item.get("script") is not None: - script_val = item["script"] - elif item.get("text") is not None: - script_val = item["text"] - elif item.get("content") is not None: - script_val = item["content"] - if script_val is not None: - cleaned["podcastScript"] = script_val - if cleaned: - filtered.append(cleaned) - if not filtered: - return "" - # Sort by date DESC (newest first) - filtered.sort(key=lambda r: _parse_date_key(r.get("date"))) - # 2026-09-11 user fix: limit report quick hints through .env (default 3-5). - # after sort by newest → keep ONLY the first S2S_REPORTS_QUICK_HINT_MAX reports. - # Applies cap to BOTH the quick-hint TOC lines AND the serialized JSON entries, - # so the final wrapped block stays small and contains no more than N scripts. - total_raw = len(filtered) - cap = max(1, int(S2S_REPORTS_QUICK_HINT_MAX or 5)) - if total_raw > cap: - filtered = filtered[:cap] - truncated_note = "" - if total_raw > cap: - truncated_note = ( - f"\n(Showing the {cap} newest scheduled reports of {total_raw} total " - f"returned upstream; older reports omitted. Set S2S_REPORTS_QUICK_HINT_MAX " - f"in .env to raise or lower this window.)\n" - ) - # ────────────────────────────────────────────────────────────────────── - # USER FIX BUG 3 2026-09-11: Qwen2.5 cutoff mid-2024 hallucinates meta on 2026 dates. - # Route output through the new tri-state S2S_REPORTS_INJECT_MODE: - # • "off"/"none" → skip entirely (no reports block in prompt, 0 tokens). - # • "minimal" → ONLY last-N label + date TOC. NO podcastScript text, no JSON entries, - # just the requested recent scheduled reports block. - # ~50 tokens instead of ~3200. - # • "full" → legacy mode with hints + full JSON entries + podcastScript. - # Kept only for explicit opt-in because user explicitly asked - # to eliminate massive script injection. - mode = (S2S_REPORTS_INJECT_MODE or "minimal").lower() - if mode in {"off", "none"}: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] mode=%s → no scheduled reports injected into prompt (cap=%d totalRaw=%d)", - mode, cap, total_raw, - ) - return "" - if mode == "minimal": - minimal_block = _build_minimal_reports_toc_block(filtered, cap=cap) - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] mode=minimal blockLen=%d (no podcastScript injected) cap=%d totalRaw=%d", - len(minimal_block), cap, total_raw, - ) - return minimal_block - # ───── mode == "full" — legacy behaviour (opt-in only) ─────────────── - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] mode=full (LEGACY, podcastScript included) cap=%d totalRaw=%d", - cap, total_raw, - ) - # Build a quick HINT LINE per report (label + date) so the LLM can see at a - # glance which scheduled topics are available before diving into scripts. - hint_lines = [] - import datetime as _dt - for idx, r in enumerate(filtered, start=1): - label_piece = "" - if r.get("sourceScheduleLabel"): - label_piece = str(r["sourceScheduleLabel"]).strip() - date_piece = "" - if r.get("date") is not None: - raw_d = r["date"] - try: - # Integer / float timestamp → render as YYYY-MM-DD HH:MM (local) - if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool): - ts_sec = float(raw_d) - if ts_sec > 1e12: # ms → seconds - ts_sec = ts_sec / 1000.0 - dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.timezone.utc) - date_piece = dt.strftime("%Y-%m-%d %H:%M UTC") - else: - s = str(raw_d).strip() - if "T" in s and len(s) >= 16: - date_piece = s.replace("T", " ")[:16] - else: - date_piece = s - except Exception: - date_piece = str(raw_d)[:32] - if label_piece and date_piece: - hint_lines.append( - f" • Report #{idx}: '{label_piece}' — scheduled on {date_piece}" - ) - elif label_piece: - hint_lines.append(f" • Report #{idx}: '{label_piece}'") - elif date_piece: - hint_lines.append(f" • Report #{idx}: (no label) — scheduled on {date_piece}") - # Serialize: compact JSON (pretty-ish but one line per report saves space) - entries_lines = [] - for idx, r in enumerate(filtered, start=1): - try: - entries_lines.append(f" [{idx}] {json.dumps(r, ensure_ascii=False)}") - except Exception: - entries_lines.append(f" [{idx}] {str(r)[:500]}") - hints_block = "" - if hint_lines: - hints_block = "\n".join(hint_lines) + "\n" - body = truncated_note + hints_block + "\n--- Full JSON entries ---\n" + "\n".join(entries_lines) - wrapped = ( - f"{REPORTS_LIST_HINT}\n" - f"Quick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n" - f"{body}\n" - f"<<>>. Use these scheduled reports as trusted prior " - "content relevant to the caller's company topics. Reference individual " - "reports by their label when answering." - ) - # Build wrapped with HINT BLOCK before the JSON. We prepend a summary line per - # report so the LLM has a quick table-of-contents of what's available. - if len(wrapped) > 12000: - # Truncate body preserving markers + hint - budget = ( - 12000 - - len(REPORTS_LIST_HINT) - - len(f"\nQuick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n") - - len("\n--- Full JSON entries ---\n") - - len("\n<<>>...truncated.") - - 200 # reserve for wrap sentence - ) - if budget > 0: - body = body[:budget] + "..." - wrapped = ( - f"{REPORTS_LIST_HINT}\n" - f"Quick hints (available scheduled reports / podcasts by label — cap {cap} newest):\n" - f"{body}\n" - f"<<>>...truncated (cap 12000 chars). Use these " - "scheduled reports as trusted prior content. Reference reports by label when relevant." - ) - if total_raw and total_raw > cap and len(truncated_note) and S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] quick-hint+entries cap applied: kept=%d upstreamTotal=%d cap=%d", - len(filtered), total_raw, cap, - ) - return wrapped - - -async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None: - """Fetch scheduled reports list from S2S_REPORT_LIST. - - Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends - ?phoneNumber= if a phone is provided. Accepts JSON (optional {"result": [...]} - wrapper) and returns the list or unwrapped list. On any error logs and returns None - (never fails the proxy / reasoning tunnel). - - USER FIX 2026-09-11: remove mass injection of scheduled reports. - • If S2S_INJECT_REPORTS is FALSE (DEFAULT) we short-circuit to None at the - very top — NO HTTP call is made to S2S_REPORT_LIST at all. The upstream - endpoint is never hit, not even for a "cheap empty list"; combined with - _resolve_reports_context → "" above, this guarantees ZERO scheduled-report - contents reach any LLM prompt anywhere (WS session instructions patch, - /api/reasoning user_context, NODE_RED instructions augment, quick reports - lookup response). - • S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND - the full injection into every prompt path). - """ - # ── MASS-INJECTION KILL SWITCH (user request) ───────────────────────── - if not S2S_INJECT_REPORTS: - return None - # ─────────────────────────────────────────────────────────────────────── - if not S2S_REPORT_LIST: - return None - from urllib.parse import urlencode - if phone_number: - sep = "&" if "?" in S2S_REPORT_LIST else "?" - target_url = f"{S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}" - else: - target_url = S2S_REPORT_LIST - logger.info( - "[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s", - S2S_REPORT_LIST, - len(phone_number) if phone_number else "none", - "yes" if auth_token else "no", - ) - try: - headers = {} - if auth_token: - headers["Authorization"] = f"Bearer {auth_token}" - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: - resp = await http.get(target_url, headers=headers or None) - if resp.status_code != 200: - logger.warning( - "[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s", - resp.status_code, resp.text, - ) - return None - text = (resp.text or "").strip() - if not text: - logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.") - return None - try: - payload = resp.json() - except Exception: - logger.warning( - "[INJECT-REPORTS] ⛔ upstream returned non-JSON body len=%d; cannot parse list. response=%.250s", - len(text), text, - ) - return None - except Exception as exc: - logger.warning( - "[INJECT-REPORTS] ⛔ fetch unreachable error=%r", - exc, - ) - return None - # Unwrap {"result": [...]} or other common list-wrapping dict keys - # (e.g. {"data": [...], "reports": [...], "items": [...], "content": [...], "value": [...]}). - unwrapped: object = payload - if isinstance(payload, dict): - # Log full structure for debugging if enabled - if S2S_DEBUG_PROMPT: - try: - keys_preview = ",".join(sorted(str(k) for k in payload.keys())) - preview = json.dumps(payload, ensure_ascii=False) - preview = preview[:4000] - logger.info( - "[DEBUG-REPORTS][fetch] upstream response dict keys=[%s] full PREVIEW (len=%d):\n%s\n", - keys_preview, len(preview), preview, - ) - except Exception: - pass - # Preferred explicit "result" key first - if "result" in payload and isinstance(payload["result"], list): - unwrapped = payload["result"] - else: - # Scan all common list-wrapper keys (case-insensitive match on the key) - wrapper_keys = {k.lower() for k in ( - "data", "reports", "reportList", "report_list", "report", - "items", "itemList", "item_list", "list", "content", - "scheduled", "scheduledReports", "scheduled_reports", - "podcastList", "podcast_list", "podcasts", "entries", - "records", "rows", "pageContent", "page_content", "value", - )} - for k, v in payload.items(): - if isinstance(k, str) and isinstance(v, list) and k.lower() in wrapper_keys: - unwrapped = v - break - # Fallback: if it's still a dict with only ONE list-valued key, - # assume that's the list (common pattern like {"data": [...]} with - # unusual key name we don't hardcode). - if not isinstance(unwrapped, list): - list_valued_keys = [ - k for k, v in payload.items() if isinstance(v, list) and len(v) > 0 - ] - if len(list_valued_keys) == 1: - unwrapped = payload[list_valued_keys[0]] - # Validate array shape - if isinstance(unwrapped, list): - n_items = len(unwrapped) - n_dict = sum(1 for x in unwrapped if isinstance(x, dict)) - logger.info( - "[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)", - n_items, n_dict, - ) - return unwrapped - logger.info( - "[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.", - type(unwrapped).__name__, - ) - if S2S_DEBUG_PROMPT and isinstance(payload, dict): - # Log final top-level keys to help diagnose wrapper shape - logger.info( - "[DEBUG-REPORTS][fetch] wrapper not detected — top-level keys of dict response: [%s] " - "(list values under keys: %s)", - ",".join(sorted(str(k) for k in payload.keys())), - ",".join( - sorted(str(k) + "=list[" + str(len(v)) + "]" for k, v in payload.items() if isinstance(v, list)) - ) or "none", - ) - return None - - -def _extract_login_token(login_payload: dict | None) -> str | None: - """Given the parsed response from the S2S_LOGIN_BY_PHONE endpoint (already - {"result":…}-unwrapped), try to extract an auth / session token. - Looks under common names (case-insensitive). Returns None if nothing found. - """ - if not login_payload or not isinstance(login_payload, dict): - return None - # Unwrap one level of nesting if result is still an dict: handled by caller already. - candidates = {c.lower() for c in ( - "token", "jwt", "access_token", "accessToken", "auth_token", - "authToken", "session_token", "sessionToken", "id_token", "idToken", - "bearer", "bearer_token", "bearerToken", "authentication_token", - )} - for k, v in login_payload.items(): - if isinstance(k, str) and k.lower() in candidates and isinstance(v, str) and v.strip(): - return v.strip() - # Second pass: nested dict {"data": {"token": …}} or {"auth": {"jwt": …}} - for k, v in login_payload.items(): - if isinstance(v, dict): - nested = _extract_login_token(v) - if nested: - return nested - # Third pass: list like [{"token": ...}] (uncommon but safe) - for k, v in login_payload.items(): - if isinstance(v, list) and v: - for elem in v: - if isinstance(elem, dict): - nested = _extract_login_token(elem) - if nested: - return nested - return None - - -async def _login_by_phone(phone_number: str) -> tuple[str | None, dict | None]: - """Perform login-by-phone: POST S2S_LOGIN_BY_PHONE with JSON - {"phoneNumber": phone_number}. Returns (bearer_token_or_None, response_obj_or_None). - - Never fails: on any HTTP/parse error logs a warning and returns (None, None) - so subsequent profile/summary calls simply proceed without a Bearer token. - """ - if not S2S_LOGIN_BY_PHONE or not phone_number: - return None, None - body = {"phoneNumber": phone_number} - logger.info( - "[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d", - S2S_LOGIN_BY_PHONE, - len(phone_number), - ) - try: - async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: - resp = await http.post( - S2S_LOGIN_BY_PHONE, - json=body, - headers={"Content-Type": "application/json"}, - ) - if resp.status_code != 200: - logger.warning( - "[INJECT-LOGIN] ⛔ upstream HTTP %s phoneLen=%d response=%.250s", - resp.status_code, len(phone_number), resp.text, - ) - return None, None - text = (resp.text or "").strip() - if not text: - logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.") - return None, None - try: - payload = resp.json() - except Exception: - logger.warning( - "[INJECT-LOGIN] ⛔ upstream returned non-JSON body len=%d; cannot extract token. response=%.250s", - len(text), text, - ) - return None, None - except Exception as exc: - logger.warning( - "[INJECT-LOGIN] ⛔ fetch unreachable phoneLen=%d error=%r", - len(phone_number), exc, - ) - return None, None - - # Unwrap {"result": …} OR {"data": …} if present (common wrapper conventions). - unwrapped: dict | None = None - if isinstance(payload, dict): - for _wrap_key in ("result", "data", "payload"): - if isinstance(payload.get(_wrap_key), dict): - unwrapped = payload[_wrap_key] - break - if unwrapped is None: - unwrapped = payload - token = _extract_login_token(unwrapped) if isinstance(unwrapped, dict) else _extract_login_token( - payload if isinstance(payload, dict) else None - ) - # Always log login-token diagnostics, even without S2S_DEBUG_PROMPT: - # token length, first eight characters, response keys and wrapper layer. - _all_keys: list[str] = [] - if isinstance(payload, dict): - _all_keys.extend(str(k) for k in payload.keys()) - _unwrap_keys: list[str] = [] - if isinstance(unwrapped, dict): - _unwrap_keys.extend(str(k) for k in unwrapped.keys()) - if isinstance(unwrapped, dict): - _t_preview = "" - _t_len = 0 - if isinstance(token, str) and token.strip(): - _t_len = len(token.strip()) - if _t_len > 8: - _t_preview = token.strip()[:8] + "..." - else: - _t_preview = token.strip() - _fmt_note = "" - if _t_len >= 120: - _fmt_note = " [JWT-like]" - elif _t_len > 0: - _fmt_note = " [SHORT]" - _wrap_layer = "ROOT" - if isinstance(payload, dict): - for _wk in ("result", "data", "payload"): - if isinstance(payload.get(_wk), dict): - _wrap_layer = "result/data/payload" - break - _all_keys_s = ",".join(sorted(_all_keys)) if _all_keys else "-" - _unwrap_keys_s = ",".join(sorted(_unwrap_keys)) if _unwrap_keys else "-" - _t_info = "MISSING" - if _t_len: - _t_info = "YES len=" + str(_t_len) + " head=" + _t_preview - msg = "[INJECT-LOGIN] ✅ login OK: wrapLayer=%s topKeys=%s unwrappedKeys=%s token=%s%s" % (_wrap_layer, _all_keys_s, _unwrap_keys_s, _t_info, _fmt_note) - logger.info("%s", msg) - return token, unwrapped - # Fallback: non-dict payload (string/list/number) — still log it, but no token expected. - logger.info( - "[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.", - type(payload).__name__, - ) - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n", - len(str(payload)), payload, - ) - return None, None - - -async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | None = None) -> str | dict | None: - """Fetch the latest caller summary via S2S_LATEST_SUMMARY URL. - - Generic / completely deploy-agnostic: plain GET - S2S_LATEST_SUMMARY?phoneNumber=; accepts JSON (optional {"result": …} - wrapper) or plain text. Never fails the proxy / reasoning; returns None on - any error and logs it. If `auth_token` is provided it is sent as - Authorization: Bearer . - - OVERRIDE (2026-09-23): when S2S_LATEST_SUMMARY_TEXT is set (non-empty) it - takes absolute priority over S2S_LATEST_SUMMARY. No HTTP call is made at all; - the raw plain-text value is returned directly. Designed for operators who want - a clean, pre-curated, essential summary snippet without any upstream API - dependency. Works even without a phone_number (static snippet). - """ - if S2S_LATEST_SUMMARY_TEXT: - override_len = len(S2S_LATEST_SUMMARY_TEXT) - logger.info( - "[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=%d chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.", - override_len, - ) - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s", - S2S_LATEST_SUMMARY_TEXT[:800], - ) - return S2S_LATEST_SUMMARY_TEXT - if not S2S_LATEST_SUMMARY or not phone_number: - return None - from urllib.parse import urlencode - target_url = f"{S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}" - logger.info( - "[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s", - S2S_LATEST_SUMMARY, - len(phone_number), - "yes" if auth_token else "no", - ) - try: - headers = {} - if auth_token: - headers["Authorization"] = f"Bearer {auth_token}" - async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: - resp = await http.get(target_url, headers=headers or None) - if resp.status_code != 200: - logger.warning( - "[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s", - resp.status_code, len(phone_number), resp.text, - ) - return None - # Accept JSON first, then fall back to plain text (some summary APIs - # return text/plain). - text = (resp.text or "").strip() - if not text: - return "" - try: - payload = resp.json() - except Exception: - logger.info( - "[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.", - len(text), - ) - return text - except Exception as exc: - logger.warning( - "[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r", - len(phone_number), exc, - ) - return None - # Unwrap {"result": …} just like profile fetch (common wrapper pattern). - if isinstance(payload, dict) and "result" in payload: - payload = payload["result"] - # Filter out empty dict/list / empty string after unwrap. - empty = ( - payload is None - or (isinstance(payload, str) and not payload.strip()) - or (isinstance(payload, dict) and len(payload) == 0) - or (isinstance(payload, list) and len(payload) == 0) - ) - if empty: - logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.") - return None - if isinstance(payload, dict): - logger.info( - "[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s", - ",".join(sorted(str(k) for k in payload.keys())), - ) - elif isinstance(payload, list): - logger.info( - "[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d", - len(payload), - ) - else: - logger.info( - "[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d", - len(str(payload)), - ) - return payload - - -async def _fetch_podcast_episode(episode_id: str) -> dict | str | None: - """Fetch a single podcast episode by ID from the Aquachile AzurianLab API. - - Completely generic / deploy-agnostic: plain GET - https://aquachile.azurianlab.com/api/v1/podcast/episodes/ - Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails - the proxy / reasoning; returns None on any error and logs it. - """ - if not episode_id: - return None - target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}" - logger.info( - "[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s", - target_url, - episode_id, - ) - try: - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: - resp = await http.get(target_url) - if resp.status_code != 200: - logger.warning( - "[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s", - resp.status_code, episode_id, resp.text, - ) - return None - text = (resp.text or "").strip() - if not text: - return "" - try: - payload = resp.json() - except Exception: - logger.info( - "[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.", - len(text), - ) - return text - except Exception as exc: - logger.warning( - "[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r", - episode_id, exc, - ) - return None - if isinstance(payload, dict) and "result" in payload: - payload = payload["result"] - empty = ( - payload is None - or (isinstance(payload, str) and not payload.strip()) - or (isinstance(payload, dict) and len(payload) == 0) - or (isinstance(payload, list) and len(payload) == 0) - ) - if empty: - logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.") - return None - if isinstance(payload, dict): - logger.info( - "[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s", - ",".join(sorted(str(k) for k in payload.keys())), - ) - elif isinstance(payload, list): - logger.info( - "[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d", - len(payload), - ) - else: - logger.info( - "[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d", - len(str(payload)), - ) - return payload - - -async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None: - """Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone. - Returns the raw {"result": ...}-unwrapped object or None. Errors are logged - then suppressed (never fail the proxy / reasoning because of this). - - When `auth_token` is provided it is forwarded as Authorization: Bearer - to the upstream profile endpoint. - """ - if not VOCERO_USER_PHONE_AUTH_URL or not phone_number: - return None - from urllib.parse import urlencode - target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}" - logger.info( - "[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s", - VOCERO_USER_PHONE_AUTH_URL, - len(phone_number), - "yes" if auth_token else "no", - ) - try: - headers = {} - if auth_token: - headers["Authorization"] = f"Bearer {auth_token}" - async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http: - resp = await http.get(target_url, headers=headers or None) - if resp.status_code != 200: - logger.info( - "[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s", - resp.status_code, len(phone_number), resp.text, - ) - return None - payload = resp.json() - except Exception as exc: - logger.warning( - "[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r", - len(phone_number), exc, - ) - return None - if (isinstance(payload, dict) - and "result" in payload - and isinstance(payload["result"], dict)): - profile_obj = payload["result"] - elif isinstance(payload, dict) and not isinstance(payload, list): - profile_obj = payload - else: - logger.info( - "[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?", - type(payload).__name__, - ) - return None - logger.info( - "[INJECT-PROFILE] ✅ profile loaded OK keys=%s", - ",".join(sorted(profile_obj.keys())), - ) - return profile_obj - - -def _augment_instructions_with_profile(base_instructions, user_context_text, reports_context_text=None, summary_context_text=None, episode_context_text=None, profile_obj=None) -> str: - """When S2S_INJECT_PROFILE is true: prepend TODAY fixed context block (Bug 3 fix) - then append the episode + profile + (optional) reports list + (optional) latest - summary context strings to the LLM system/instructions prompt. - - Append order (most specific → most generic / chronologically newest → oldest): - 0. VOICE_RULES_HEAD_LOCK (absolute language lock + #1 priority) - 1. TODAY_block (date of today + anti-hallucination date rules) - 2. episode_ctx (CURRENT podcast episode the user is interacting with) - 3. base instructions (primary prompt / user settings / default) - 4. profile_ctx (caller identity block) - 5. reports_ctx (scheduled reports / podcast scripts list, sorted by date DESC) - 6. summary_ctx (latest conversation summary) - Preserves the existing instructions text. - - USER FIX 2026-09-11: remove duplicate caller profiles with TWO deduplication layers - so the same block type (profile / episode / reports / summary) NEVER appears twice - in the final augmented instructions, even if the caller passed it both in - base_instructions (e.g. client-supplied profile baked into req.instructions) AND in the - explicit profile_ctx parameter: - 1) layer 2 — _strip_already_injected_types_from_base removes from parameters - if their marker already appears in base_instructions. - 2) layer 1 — _dedup_context_blocks_by_type then removes any remaining - duplicates among the parameters themselves. - - 2026-09-11: NEW keyword profile_obj → forwarded to _build_voice_rules_head_lock_block - so the prompt lock can emit rule #5 "Exact name: Alessandro Valenti. NEVER - Alexander" for models that keep anglicizing proper names (Qwen2.5-GPTQ). - """ - base = (base_instructions or "").strip() - # ─── USER FIX BUG 3: TODAY fixed block prepended (HEAD of prompt) ───── - voice_lock_block = _build_voice_rules_head_lock_block(profile=profile_obj) # NEW #1 priority: absolute language+output lock - today_block = _build_today_fixed_context_block() # #2: date guard for Spanish responses - episode_ctx = (episode_context_text or "").strip() - profile_ctx = (user_context_text or "").strip() - reports_ctx = (reports_context_text or "").strip() - summary_ctx = (summary_context_text or "").strip() - # raw_candidates is ORDERED by prompt-priority! voice_lock_block (#0) is - # inserted FIRST so it becomes position 0 of the final instructions and - # gets highest LLM attention, then today, then episode, etc. - raw_candidates = [voice_lock_block, today_block, episode_ctx, profile_ctx, reports_ctx, summary_ctx] - # Layer 2: strip candidates whose HINT is ALREADY in base instructions - # (e.g. client sent profile baked in, we won't re-add it). - candidates_against_base = _strip_already_injected_types_from_base(base, raw_candidates) - # Layer 1: dedupe among themselves (e.g. episode was both episode_ctx passed in the same raw_candidates twice via identity + raw). - contexts = _dedup_context_blocks_by_type(candidates_against_base) - if not contexts: - return base_instructions or "" - augmented = base - for ctx in contexts: - augmented = (augmented + " " + ctx).strip() if augmented else ctx - # ─── 2026-09-11 MARKER LEAK GUARD: STRIP INTERNAL SENTINELS ────────── - # All invisible dedup markers are NOW stripped from the final prompt text - # before leaving this function → LLM NEVER sees ANY <<>> / hash string. - augmented = _strip_internal_markers(augmented) - changed = augmented != base - # Compute per-type lengths for log (based on what actually survived dedupe) - final_lock_len = sum(len(c) for c in contexts if _classify_context_block(c) == "voice_rules_head") - final_today_len = sum(len(c) for c in contexts if _classify_context_block(c) == "today_fixed") - final_ep_len = sum(len(c) for c in contexts if _classify_context_block(c) == "episode") - final_pf_len = sum(len(c) for c in contexts if _classify_context_block(c) == "profile") - final_rp_len = sum(len(c) for c in contexts if _classify_context_block(c) in {"reports", "reports_minimal", "reports_full"}) - final_sm_len = sum(len(c) for c in contexts if _classify_context_block(c) in {"summary", "summary_full"}) - logger.info( - "[INJECT-PROFILE] ✏️ prepended VOICE_LOCK + TODAY_block + appended episode/profile/reports/summary to LLM instructions. " - "baseLen=%d voiceLockLen=%d todayCtxLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d newLen=%d changed=%s dedupedCandidates=%d", - len(base), final_lock_len, final_today_len, final_ep_len, final_pf_len, final_rp_len, final_sm_len, len(augmented), changed, len(contexts), - ) - if S2S_DEBUG_PROMPT and final_lock_len: - for c in contexts: - if _classify_context_block(c) == "voice_rules_head": - logger.info( - "[DEBUG-VOICELOCK][instructions augment] FULL voice lock block (len=%d):\n%s\n", - len(c), _strip_internal_markers(c), - ) - if S2S_DEBUG_PROMPT and final_today_len: - for c in contexts: - if _classify_context_block(c) == "today_fixed": - logger.info( - "[DEBUG-TODAY][instructions augment] FULL today+anti-hallucination block (len=%d):\n%s\n", - len(c), _strip_internal_markers(c), - ) - if S2S_DEBUG_PROMPT and final_ep_len: - for c in contexts: - if _classify_context_block(c) == "episode": - logger.info( - "[DEBUG-EPISODE][instructions augment] FULL podcast episode block (len=%d):\n%s\n", - len(c), _strip_internal_markers(c), - ) - if S2S_DEBUG_PROMPT and final_rp_len: - for c in contexts: - if _classify_context_block(c) in {"reports", "reports_minimal", "reports_full"}: - logger.info( - "[DEBUG-REPORTS][instructions augment] FULL reports block (len=%d mode=%s):\n%s\n", - len(c), _classify_context_block(c), _strip_internal_markers(c), - ) - if S2S_DEBUG_PROMPT and final_sm_len: - for c in contexts: - if _classify_context_block(c) in {"summary", "summary_full"}: - logger.info( - "[DEBUG-SUMMARY][instructions augment] FULL latest summary block (len=%d):\n%s\n", - len(c), _strip_internal_markers(c), - ) - return augmented - - -def _patch_client_session_update_with_profile(raw_text, user_context_text, reports_context_text=None, summary_context_text=None, episode_context_text=None, profile_obj=None, apipilot_context_text_pass1=None) -> str: - """If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is - true, append episode_context_text + user_context_text + (optional) reports_context_text + - (optional) summary_context_text + (optional) apipilot_context_text_pass1 to session.instructions. - Returns the modified JSON string, or the original string unchanged if: - - it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG) - - episode_context_text AND user_context_text AND reports_context_text AND summary_context_text AND apipilot_context_text_pass1 are all empty - - S2S_INJECT_PROFILE is False (but if we have apipilot_context_text_pass1 -> PATCH even if S2S_INJECT_PROFILE=false, tools injection is independent.) - - parse fails - - 2026-09-11: NEW keyword profile_obj. Forwarded to - _augment_instructions_with_profile so the prompt lock can emit the proper-name - anti-anglicization rule for this caller (e.g. Alessandro never Alexander). - - 2026-09-25: NEW keyword apipilot_context_text_pass1. Injected IN ADDITION so WS - Realtime LLM sees the tools list match description / marker rules (same as the - /api/reasoning HTTP endpoint). Independent gate: even S2S_INJECT_PROFILE=false, - if apipilot_context_text_pass1 has content -> PATCH instructions. - """ - # Step 1: only consider session.update frames. All other frame types - # (response.create, conversation.item.*, audio_* etc.) pass through - # SILENTLY — we never emit logs for them, which avoids spamming the - # console with 6-20 identical "SKIPPED" lines per session. - try: - payload = json.loads(raw_text) - except Exception: - return raw_text - if not isinstance(payload, dict) or payload.get("type") != "session.update": - return raw_text - - # From here on: we know this is a real session.update (typically emitted - # just once or twice per realtime session). We can log safely — 1 line, - # not 20 repeated lines. - has_apipilot_pass1 = bool((apipilot_context_text_pass1 or "").strip()) - if not S2S_INJECT_PROFILE and not has_apipilot_pass1: - logger.info( - "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false and no apipilot list pass1). " - "WS stays pure passthrough." - ) - return raw_text - has_episode = bool((episode_context_text or "").strip()) - has_profile = bool((user_context_text or "").strip()) - has_reports = bool((reports_context_text or "").strip()) - has_summary = bool((summary_context_text or "").strip()) - if not (has_episode or has_profile or has_reports or has_summary or has_apipilot_pass1): - logger.info( - "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary, no apipilot tools list pass1). " - "Probably phoneNumber/episode_id query param missing / fetch failed upstream / " - "endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result." - ) - return raw_text - if has_episode and not has_profile: - logger.info( - "[INJECT-EPISODE] profile missing, but PODCAST EPISODE block loaded. " - "Will inject episode alone into session.update." - ) - if has_summary and not has_profile and not has_episode: - logger.info( - "[INJECT-SUMMARY] profile missing, but LATEST SUMMARY block loaded. " - "Will inject summary alone into session.update." - ) - if has_reports and not has_profile and not has_episode: - logger.info( - "[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. " - "Will inject reports alone into session.update." - ) - if has_apipilot_pass1 and not has_profile and not has_episode and not has_reports and not has_summary: - logger.info( - "[INJECT-APIPILOT] profile/episode/reports/summary missing, but APIPILOT TOOLS list PASS-1 block loaded (%d chars). " - "Will inject tools list alone into session.update.", - len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0, - ) - top_session = payload.get("session") - if not isinstance(top_session, dict): - return raw_text - current_instructions = ( - top_session.get("instructions") - if isinstance(top_session.get("instructions"), str) - else "" - ) - augmented = _augment_instructions_with_profile( - current_instructions, user_context_text, reports_context_text, summary_context_text, episode_context_text, - profile_obj=profile_obj, - ) - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-PROMPT][WS session.update] BEFORE instructions (len=%d):\n%s\n", - len(current_instructions), current_instructions, - ) - # Append ApiPilot pass-1 tools and marker rules after profile/report/episode/ - # summary context, matching /api/reasoning. If a second client session.update - # overwrites instructions without the tool-list marker, append the list again. - already_has_apipilot_pass1 = ( - isinstance(current_instructions, str) - and APIPILOT_PASS1_LIST_MARKER in current_instructions - ) - if has_apipilot_pass1 and not already_has_apipilot_pass1: - augmented = ( - (augmented + "\n\n" if augmented.strip() else "") - + (apipilot_context_text_pass1 if isinstance(apipilot_context_text_pass1, str) else "") - ) - # Do not skip a tool-list repair even if profile augmentation was unchanged. - if augmented == current_instructions and not (has_apipilot_pass1 and not already_has_apipilot_pass1): - logger.info( - "[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; " - "episode/profile already injected or context empty or apipilot same)" - ) - if S2S_DEBUG_PROMPT and augmented.strip(): - logger.info( - "[DEBUG-PROMPT][WS session.update] FINAL unchanged (len=%d):\n%s\n", - len(augmented), augmented, - ) - return raw_text - top_session["instructions"] = augmented - logger.info( - "[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. " - "origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s apipilotPass1=%s(len=%d, wasAlreadyPresent=%s)", - len(current_instructions), - len(augmented), - (len(augmented) - len(current_instructions)), - has_episode, has_profile, has_reports, has_summary, - has_apipilot_pass1, - (len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0), - ("true" if already_has_apipilot_pass1 else "false"), - ) - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-PROMPT][WS session.update] AFTER patch (len=%d):\n%s\n", - len(augmented), augmented, - ) - if S2S_DEBUG_PROMPT and episode_context_text and episode_context_text.strip(): - logger.info( - "[DEBUG-EPISODE][WS session.update] FULL podcast episode block (len=%d):\n%s\n", - len(episode_context_text.strip()), episode_context_text.strip(), - ) - if S2S_DEBUG_PROMPT and reports_context_text and reports_context_text.strip(): - logger.info( - "[DEBUG-REPORTS][WS session.update] FULL reports list block (len=%d):\n%s\n", - len(reports_context_text.strip()), reports_context_text.strip(), - ) - if S2S_DEBUG_PROMPT and isinstance(apipilot_context_text_pass1, str) and apipilot_context_text_pass1.strip(): - logger.info( - "[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=%d):\n%s\n", - len(apipilot_context_text_pass1.strip()), _strip_internal_markers(apipilot_context_text_pass1), - ) - # Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and - # this is a session.update (so we see the EXACT text reaching the model even - # when S2S_INJECT_PROFILE is off and no patch happened.) - if (not S2S_INJECT_PROFILE) and S2S_DEBUG_PROMPT and current_instructions.strip(): - logger.info( - "[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n", - len(current_instructions), current_instructions, - ) - try: - return json.dumps(payload) - except Exception: - logger.info( - "[INJECT-PROFILE] ⛔ JSON re-serialize failed on session.update. " - "Forwarding unpatched." - ) - return raw_text - - -class ReasoningRequest(BaseModel): - transcript: str - instructions: str = "" - language: str = "" - user_context: str = "" - # Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches - # the caller profile from VOCERO_USER_PHONE_AUTH_URL and: - # 1. prepends the serialized profile to user_context before feeding the - # engine (NODE_RED or OPENAI); - # 2. appends the profile context to the LLM instructions prompt. - phoneNumber: str | None = None - # Optional podcast episode ID. When set the proxy fetches the episode from - # the Aquachile AzurianLab API and injects it as the PRIMARY context into - # both user_context (prepended) and LLM instructions prompt (appended first). - episode_id: str | None = None - - - - - - - - - - - - - - -def _url_with_query(url: str, **params: str) -> str: - target = urlsplit(url) - merged_query = dict(parse_qsl(target.query, keep_blank_values=True)) - for key, value in params.items(): - if value: - merged_query[key] = value - return urlunsplit( - ( - target.scheme, - target.netloc, - target.path, - urlencode(merged_query, doseq=True), - target.fragment, - ) - ) - - -def _openai_headers() -> dict[str, str]: - if not OPENAI_API_KEY: - raise RuntimeError("OPENAI_API_KEY is not configured.") - headers = { - "Authorization": f"Bearer {OPENAI_API_KEY}", - "Content-Type": "application/json", - } - if OPENAI_ORGANIZATION: - headers["OpenAI-Organization"] = OPENAI_ORGANIZATION - if OPENAI_PROJECT: - headers["OpenAI-Project"] = OPENAI_PROJECT - return headers - - -def _effective_s2s_url(request: Request) -> str: - return SPEECH_TO_SPEECH_URL or _proxy_public_url(request) - - -@app.get("/api/config") -async def config(request: Request): - """Client bootstrap: whether web search is available, whether the deploy runs - behind a load balancer (so the browser uses the /api/session proxy), - and whether the user may instead set a direct - s2s server URL. The LB address itself is intentionally NOT included. - - 2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the - new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see - docs/client/tool-calling.md) that the browser may call directly instead of - hardcoding upstream tool URLs / names.""" - logger.debug("[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?") - primary_prompt = S2S_PRIMARY_PROMPT - out = { - "search": bool(SERPER_KEY), - "lb": bool(LOAD_BALANCER_URL), - "allowDirect": not LOAD_BALANCER_URL, - "s2sUrl": _effective_s2s_url(request), - "primaryPrompt": primary_prompt, - "reasoningType": S2S_REASONING_TYPE, - "injectProfile": S2S_INJECT_PROFILE, - "debugPrompt": S2S_DEBUG_PROMPT, - "startSessionMode": S2S_START_SESSION_MODE, - "theme": S2S_THEME, - "apipilot": bool(S2S_APIPILOT_TOOLS_URL), - } - logger.debug( - "[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s", - len(primary_prompt), - S2S_REASONING_TYPE, - S2S_START_SESSION_MODE or "-", - S2S_THEME or "-", - out["apipilot"], - ) - return out - - -class ApipilotExecuteRequest(BaseModel): - """Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape.""" - name: str - arguments: dict = {} - - -@app.get("/api/apipilot/tools") -async def apipilot_list_tools( - request: Request, - include_unauthorized: bool = True, - refresh: bool = False, -): - """Public proxy for ApiPilot tool discovery (guide §2.1). - - Forwards Authorization / x-demo-session headers from the caller to the - upstream backend verbatim and returns the list of `ToolDefinition` records - exactly as documented: - - { "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false } - - A short in-process TTL cache is applied (default 5 min, configurable via - S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true` - (e.g. after a user just logged in and previously saw only requires_auth tools).""" - if not S2S_APIPILOT_TOOLS_URL: - return JSONResponse( - status_code=501, - content={ - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", - "status_code": 501, - "raw": None, - }, - }, - ) - auth, demo = _apipilot_parse_authorization(request) - try: - tools = await _apipilot_fetch_tools( - auth_header=auth, - demo_session=demo, - include_unauthorized=bool(include_unauthorized), - force_refresh=bool(refresh), - ) - except HTTPException as exc: - # Normalize to envelope-style payload so downstream code is uniform. - detail = exc.detail if isinstance(exc.detail, dict) else { - "code": "APIPILOT_ERROR", - "message": str(exc.detail) if exc.detail else "Error", - "status_code": exc.status_code, - "raw": None, - } - return JSONResponse( - status_code=exc.status_code, - content={"success": False, "data": None, "error": detail}, - ) - logger.info( - "[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s", - len(tools), bool(include_unauthorized), bool(refresh), - ) - return { - "success": True, - "data": { - "count": len(tools), - "include_unauthorized": bool(include_unauthorized), - "tools": tools, - }, - "error": None, - } - - -@app.post("/api/apipilot/execute") -async def apipilot_execute(request: Request, body: ApipilotExecuteRequest): - """Public proxy for ApiPilot /execute (guide §2.2). - - Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool - discovery, `requires_auth` gate, strict JSON-Schema argument validation), - forwards the request to the upstream backend, and returns the envelope - `{success, data, error}`. - - Show only `error.message` to the end user. `error.code`, `error.status_code` - and `error.raw` are for internal auditing (guide §2.2, recommended UI errors).""" - if not S2S_APIPILOT_TOOLS_URL: - return JSONResponse( - status_code=501, - content={ - "success": False, - "data": None, - "error": { - "code": "APIPILOT_NOT_CONFIGURED", - "message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).", - "status_code": 501, - "raw": None, - }, - }, - ) - auth, demo = _apipilot_parse_authorization(request) - logger.info("[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s", (body.name or ""), sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [], ("Y" if auth or demo else "none")) - envelope = await _apipilot_execute_tool( - auth_header=auth, - demo_session=demo, - name=body.name, - arguments=body.arguments if isinstance(body.arguments, dict) else {}, - ) - status_code = 200 - if (not envelope.get("success")) and isinstance(envelope.get("error"), dict): - code = envelope["error"].get("status_code") - try: - code_int = int(code) - if 400 <= code_int < 599: - status_code = code_int - except Exception: - pass - logger.info( - "[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s", - body.name, envelope.get("success"), status_code, - ) - return JSONResponse(status_code=status_code, content=envelope) - - - - -@app.post("/api/reasoning") -async def reasoning(req: ReasoningRequest): - if S2S_REASONING_TYPE == "PROXY": - raise HTTPException(status_code=409, detail="El servicio de razonamiento está deshabilitado en el modo PROXY.") - transcript = (req.transcript or "").strip() - if not transcript: - raise HTTPException(status_code=400, detail="Se requiere el campo transcript.") - - # ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ── - # (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context - # and instructions are taken verbatim from the request body.) - inject_episode_text = "" - inject_profile_text = "" - inject_reports_text = "" - inject_summary_text = "" - phone = (req.phoneNumber or "").strip() - episode_id = (req.episode_id or "").strip() - if not S2S_INJECT_PROFILE: - logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). " - "Using verbatim request body; no fetch, no augmentation." - ) - else: - needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip()) - # 2026-09-11 user fix: remove mass injection of scheduled reports. - # treats S2S_REPORT_LIST as "need to fetch reports" ONLY when BOTH the URL is - # configured AND the new opt-in S2S_INJECT_REPORTS flag is true. When - # S2S_INJECT_REPORTS=false (default) needs_reports stays False here → we skip - # the branch entirely, no HTTP call, no context string, no prompt bloat. - needs_reports = bool(S2S_REPORT_LIST.strip()) and S2S_INJECT_REPORTS - needs_summary = bool(S2S_LATEST_SUMMARY.strip()) or bool(S2S_LATEST_SUMMARY_TEXT) - needs_login = bool(S2S_LOGIN_BY_PHONE.strip()) - can_inject = ( - needs_phone_authed or needs_reports or needs_summary or needs_login - or bool(episode_id) - ) - if (not needs_phone_authed) and (not needs_reports) and (not needs_summary) and (not needs_login) and (not episode_id): - logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY/S2S_LATEST_SUMMARY_TEXT and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request; OR S2S_INJECT_REPORTS is false so reports injection is globally disabled). " - "Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable login / fetch episode/profile/reports/summary for REASONING endpoint." - ) - elif (not phone) and (not needs_reports) and (not episode_id) and (not S2S_LATEST_SUMMARY_TEXT): - logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). " - "Browser-side issue: check VoiceController passes phoneNumber and/or episode_id in the JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)" - ) - elif not can_inject: - logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)." - ) - else: - # ################################################################### - # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env set) - # Obtains a bearer token that will be forwarded for profile/reports/summary fetches. - # ################################################################### - login_token = None - if S2S_LOGIN_BY_PHONE and phone: - login_token, _login_resp = await _login_by_phone(phone) - # ################################################################### - # 0.5) Podcast episode fetch (independent of phone — uses req.episode_id) - # ################################################################### - if episode_id: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...", - episode_id, - ) - episode_raw = await _fetch_podcast_episode(episode_id) - inject_episode_text = _resolve_episode_context(episode_raw) - if inject_episode_text.strip(): - logger.info( - "[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", - episode_id, len(inject_episode_text), - ) - else: - logger.info( - "[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)." - ) - # ################################################################### - # 1) Profile fetch (optional — only if env is configured) - # ################################################################### - if needs_phone_authed and phone: - profile_obj = await _fetch_user_profile_for_phone(phone, auth_token=login_token) - inject_profile_text = _resolve_user_context_text(phone, profile_obj, loaded=True) - if inject_profile_text.strip(): - logger.info( - "[INJECT-PROFILE] ✅ REASONING: profile READY phoneLen=%d contextLen=%d -> augmenting instructions + user_context before sending upstream.", - len(phone), len(inject_profile_text), - ) - else: - logger.info( - "[INJECT-PROFILE] ⏭️ REASONING: profile fetch OK but resolved context empty." - ) - # ################################################################### - # 1.5) Scheduled reports / podcast scripts list (optional — S2S_REPORT_LIST) - # ################################################################### - if needs_reports: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] starting REASONING fetch S2S_REPORT_LIST (phoneLen=%s, bearer=%s)...", - len(phone) if phone else "none", - "yes" if login_token else "no", - ) - reports_raw = await _fetch_reports_list(phone_number=phone if phone else None, auth_token=login_token) - inject_reports_text = _resolve_reports_context(reports_raw) - if inject_reports_text.strip(): - logger.info( - "[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", - len(inject_reports_text), - ) - else: - logger.info( - "[INJECT-REPORTS] ⏭️ REASONING: reports list skipped (context empty / upstream returned empty list / fetch failed)." - ) - # ################################################################### - # 2) Latest summary fetch (optional — only if env is configured) - # ################################################################### - if needs_summary and (phone or S2S_LATEST_SUMMARY_TEXT): - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s.", - bool(S2S_LATEST_SUMMARY), - bool(S2S_LATEST_SUMMARY_TEXT), - len(phone) if phone else "none", - ) - latest_summary_raw = await _fetch_latest_summary_for_phone(phone, auth_token=login_token) - inject_summary_text = _resolve_latest_summary_context(latest_summary_raw) - if inject_summary_text.strip(): - logger.info( - "[INJECT-SUMMARY] ✅ REASONING: latest summary READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.", - len(phone) if phone else "none", - bool(S2S_LATEST_SUMMARY_TEXT), - len(inject_summary_text), - ) - else: - logger.info( - "[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)" - ) - # Load dynamic ApiPilot tools before building context, independently of - # S2S_INJECT_PROFILE. - needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL) - apipilot_tools_pass1: list[dict] = [] - apipilot_auth_header: str | None = None - if isinstance(login_token, str) and login_token.strip(): - tk = login_token.strip() - if tk.lower().startswith("bearer "): - apipilot_auth_header = tk - else: - apipilot_auth_header = f"Bearer {tk}" - # S2S_LOGIN_BY_PHONE resolves an AquaPortal JWT, used for requires_auth=true - # execution. S2S_APIPILOT_SERVICE_TOKEN overrides it when configured. - if S2S_APIPILOT_SERVICE_TOKEN: - _svc = S2S_APIPILOT_SERVICE_TOKEN.strip() - if _svc: - apipilot_auth_header = (f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc) - if needs_apipilot: - try: - apipilot_t0 = _monotonic_now() - apipilot_tools_pass1 = await _apipilot_fetch_tools( - auth_header=apipilot_auth_header, - demo_session=None, - force_refresh=False, - ) - logger.info( - "[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s", - len(apipilot_tools_pass1), - int((_monotonic_now() - apipilot_t0) * 1000), - "Y" if apipilot_auth_header else "-", - ) - except Exception as exc: - logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc) - apipilot_tools_pass1 = [] - inject_apipilot_text_pass1 = _resolve_apipilot_reasoning_context_block( - apipilot_tools_pass1, call_result=None, - ) - - user_context_parts: list[str] = [] - # ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too - # (the same anti-hallucination block gets prefixed in the SYSTEM instructions - # path via _augment_instructions_with_profile; we also prepend here so the - # OPENAI/ANYSCALE messages[] array (where caller context is user_message[0]) - # ALSO gets the same guard.) - # ─── USER FIX 2026-09-11 HALLUCINATION: voice_lock_block FIRST position - voice_lock_block = _build_voice_rules_head_lock_block(profile=profile_obj) - if voice_lock_block.strip(): - user_context_parts.append(voice_lock_block) - today_block = _build_today_fixed_context_block() - if today_block.strip(): - user_context_parts.append(today_block) - if inject_episode_text: - user_context_parts.append(inject_episode_text) - if inject_profile_text: - user_context_parts.append(inject_profile_text) - if inject_reports_text: - user_context_parts.append(inject_reports_text) - if inject_summary_text: - user_context_parts.append(inject_summary_text) - if inject_apipilot_text_pass1: - user_context_parts.append(inject_apipilot_text_pass1) - original_user_context = (req.user_context or "").strip() - if original_user_context: - user_context_parts.append(original_user_context) - user_context_parts = _dedup_context_blocks_by_type(user_context_parts) - if original_user_context: - server_injected_types_in_client_ctx: set = set() - for part in user_context_parts: - if original_user_context and (part or "").strip() == original_user_context: - continue - tid = _classify_context_block(part) - if tid is None: - continue - for marker_tid, marker in _CONTEXT_DEDUPE_MARKERS: - if marker_tid == tid and marker and marker in original_user_context: - server_injected_types_in_client_ctx.add(tid) - break - if server_injected_types_in_client_ctx: - new_parts: list[str] = [] - for part in user_context_parts: - if (part or "").strip() == original_user_context: - new_parts.append(part) - continue - tid = _classify_context_block(part) - if tid in server_injected_types_in_client_ctx: - continue - new_parts.append(part) - if S2S_DEBUG_PROMPT and new_parts != user_context_parts: - logger.info( - "[INJECT-DEDUPE] dropped server-injected profile/episode/reports/summary because the same block type was already present in req.user_context client-side context=%s kept=%d", - ", ".join(sorted(server_injected_types_in_client_ctx)), - len(new_parts), - ) - user_context_parts = new_parts - # ─── 2026-09-11 MARKER LEAK GUARD: strip invisible internal sentinels from EACH part. - stripped_parts: list[str] = [] - for p in user_context_parts: - sp = _strip_internal_markers(p) - if sp: - stripped_parts.append(sp) - user_context_parts = stripped_parts - effective_user_context = "\n\n".join(user_context_parts) - if S2S_INJECT_PROFILE: - any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text) - if any_injected: - logger.info( - "[INJECT-PROFILE] REASONING user_context final parts=%d serverEpisodeLen=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d", - len(user_context_parts), - len(inject_episode_text), - len(inject_profile_text), - len(inject_reports_text), - len(inject_summary_text), - len(original_user_context), - len(effective_user_context), - ) - if S2S_DEBUG_PROMPT and voice_lock_block.strip(): - logger.info( - "[DEBUG-VOICELOCK][REASONING augment] FULL voice lock block (len=%d):\n%s\n", - len(voice_lock_block), _strip_internal_markers(voice_lock_block), - ) - if S2S_DEBUG_PROMPT and inject_episode_text.strip(): - logger.info( - "[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n", - len(inject_episode_text), _strip_internal_markers(inject_episode_text), - ) - if S2S_DEBUG_PROMPT and today_block.strip(): - logger.info( - "[DEBUG-TODAY][REASONING augment] FULL today block (len=%d):\n%s\n", - len(today_block), _strip_internal_markers(today_block), - ) - if S2S_DEBUG_PROMPT and inject_reports_text.strip(): - logger.info( - "[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n", - len(inject_reports_text), _strip_internal_markers(inject_reports_text), - ) - if S2S_DEBUG_PROMPT and inject_summary_text.strip(): - logger.info( - "[DEBUG-SUMMARY][REASONING augment] FULL latest summary block (len=%d):\n%s\n", - len(inject_summary_text), _strip_internal_markers(inject_summary_text), - ) - if S2S_DEBUG_PROMPT and inject_profile_text.strip(): - logger.info( - "[DEBUG-PROFILE][REASONING augment] FULL profile block (len=%d):\n%s\n", - len(inject_profile_text), _strip_internal_markers(inject_profile_text), - ) - if S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip(): - logger.info( - "[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n", - len(inject_apipilot_text_pass1), _strip_internal_markers(inject_apipilot_text_pass1), - ) - if needs_apipilot and inject_apipilot_text_pass1.strip(): - logger.info( - "[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions and user_context.", - len(inject_apipilot_text_pass1), len(apipilot_tools_pass1), - ) - effective_instructions = _augment_instructions_with_profile( - req.instructions or "", inject_profile_text, inject_reports_text, inject_summary_text, inject_episode_text, - profile_obj=profile_obj, - ) - if inject_apipilot_text_pass1.strip(): - effective_instructions = ( - (effective_instructions + "\n\n" if effective_instructions else "") - + inject_apipilot_text_pass1 - ) - else: - effective_instructions = (req.instructions or "").strip() - if inject_apipilot_text_pass1.strip(): - logger.info( - "[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions (inject_profile=false).", - len(inject_apipilot_text_pass1), len(apipilot_tools_pass1), - ) - effective_instructions = ( - (effective_instructions + "\n\n" if effective_instructions else "") - + inject_apipilot_text_pass1 - ) - if S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip(): - logger.info( - "[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n", - len(inject_apipilot_text_pass1), _strip_internal_markers(inject_apipilot_text_pass1), - ) - - # ──────────────────────────────────────────────────────────────────── - # Mode A: NODE_RED forwards the complete payload; the flow decides behavior. - # Stage 1 (log-only) returns {answer: "", log_only: true}, allowing the browser - # to leave speech generation to the S2S backend. Later stages can return - # {answer: "...", log_only: false}. Expected response fields include answer, - # log_only, usage, debug and latency_ms. - # ──────────────────────────────────────────────────────────────────── - if S2S_REASONING_TYPE == "NODE_RED": - if not NODE_RED_REASONING_URL: - raise HTTPException( - status_code=503, - detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).", - ) - logger.info( - "[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", - len(effective_instructions), len(effective_user_context), len(phone), len(episode_id), - ) - upstream_body = { - "transcript": transcript, - "instructions": effective_instructions, - "language": (req.language or "").strip(), - "user_context": effective_user_context, - } - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n", - json.dumps(upstream_body, ensure_ascii=False, indent=2), - ) - logger.debug( - "[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d", - NODE_RED_REASONING_URL, - (req.language or "")[:20] or "auto", - len(transcript), - ) - try: - async with httpx.AsyncClient(timeout=120.0) as http: - resp = await http.post( - f"{NODE_RED_REASONING_URL}/api/reasoning", - json=upstream_body, - headers={ - "Content-Type": "application/json", - "X-S2S-Caller": "demo-server", - }, - ) - except httpx.RequestError as exc: - logger.warning("[EXT][NODE_RED] unreachable: %r", exc) - raise HTTPException(status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED.") - logger.debug( - "[EXT][NODE_RED] reasoning RESP status=%s len=%dB", - resp.status_code, len(resp.content or b""), - ) - if resp.status_code != 200: - logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500]) - raise HTTPException( - status_code=502, - detail=f"El razonamiento de Node-RED falló (HTTP {resp.status_code}).", - ) - try: - payload = resp.json() - except Exception: - logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500]) - raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido.") - answer = payload.get("answer") if isinstance(payload, dict) else None - log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True) - if not isinstance(answer, str): - logger.warning("[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800]) - raise HTTPException(status_code=502, detail="Node-RED devolvió un campo answer no válido.") - answer_stripped = answer.strip() - # An empty answer is intentional in log-only mode; forward the flag. - if not answer_stripped and not log_only: - logger.warning("[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]) - raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.") - usage = payload.get("usage") if isinstance(payload, dict) else None - if isinstance(usage, dict): - logger.debug( - "[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d", - usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), - ) - extra = {} - for key in ("latency_ms", "intent", "caller_profile_keys", "debug"): - if isinstance(payload, dict) and key in payload: - extra[key] = payload[key] - if log_only: - logger.info( - "[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s", - transcript[:300], - json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], - ) - else: - logger.info( - "[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s", - transcript[:300], answer_stripped[:500], - json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400], - ) - out = {"answer": answer_stripped} - if log_only: - out["log_only"] = True - if usage: - out["usage"] = usage - out.update({k: v for k, v in extra.items() if k != "debug" or logger.level <= logging.DEBUG}) - # Capture the ApiPilot marker, execute its tool and run reasoning pass 2. - tool_name_p1, tool_args_p1 = _apipilot_parse_call_marker(answer_stripped) - if tool_name_p1: - logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s", tool_name_p1, sorted(list((tool_args_p1 or {}).keys()))) - logger.info( - "[APIPILOT] REASONING PASS-2 trigger NODE_RED marker name=%s args=%s", - tool_name_p1, - json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300], - ) - logger.info("[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool", tool_name_p1) - tool_env_p1 = await _apipilot_execute_tool( - auth_header=apipilot_auth_header, - demo_session=None, - name=tool_name_p1, - arguments=tool_args_p1 or {}, - ) - is_tool_err = not bool(tool_env_p1.get("success")) - tool_data = tool_env_p1.get("data") - if isinstance(tool_data, str): - tool_text_p1 = tool_data - elif tool_data is None: - tool_text_p1 = "" - else: - try: - tool_text_p1 = json.dumps(tool_data, ensure_ascii=False, indent=2) - except Exception: - tool_text_p1 = str(tool_data) - if is_tool_err and isinstance(tool_env_p1.get("error"), dict): - err = tool_env_p1["error"] - err_head = f"[{err.get('code')}] {err.get('message')}" - tool_text_p1 = (err_head + ("\n" + tool_text_p1 if tool_text_p1.strip() else "")).strip() - inject_apipilot_text_pass2 = _resolve_apipilot_reasoning_context_block( - [], - call_result={ - "name": tool_name_p1, - "text": tool_text_p1, - "error": is_tool_err, - }, - ) - logger.info( - "[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger second Node-RED call", - tool_name_p1, - str(not is_tool_err).lower(), - len(tool_text_p1), - ) - # Pass-2 context replaces the tool list with the execution result, - # preserving voice/date/profile context. - user_ctx_parts_2: list[str] = [] - if voice_lock_block.strip(): - user_ctx_parts_2.append(voice_lock_block) - if today_block.strip(): - user_ctx_parts_2.append(today_block) - if inject_episode_text: - user_ctx_parts_2.append(inject_episode_text) - if inject_profile_text: - user_ctx_parts_2.append(inject_profile_text) - if inject_reports_text: - user_ctx_parts_2.append(inject_reports_text) - if inject_summary_text: - user_ctx_parts_2.append(inject_summary_text) - if inject_apipilot_text_pass2: - user_ctx_parts_2.append(inject_apipilot_text_pass2) - if original_user_context: - user_ctx_parts_2.append(original_user_context) - user_ctx_parts_2 = _dedup_context_blocks_by_type(user_ctx_parts_2) - stripped_2: list[str] = [] - for p in user_ctx_parts_2: - sp = _strip_internal_markers(p) - if sp: - stripped_2.append(sp) - user_ctx_parts_2 = stripped_2 - effective_user_ctx_2 = "\n\n".join(user_ctx_parts_2) - # Preserve augmentation without CALL-marker instructions; the result - # block supplies the pass-2 response instructions. - if S2S_INJECT_PROFILE: - eff_inst_2 = _augment_instructions_with_profile( - req.instructions or "", inject_profile_text, inject_reports_text, - inject_summary_text, inject_episode_text, profile_obj=profile_obj, - ) - else: - eff_inst_2 = (req.instructions or "").strip() - if inject_apipilot_text_pass2.strip(): - eff_inst_2 = ( - (eff_inst_2 + "\n\n" if eff_inst_2 else "") - + inject_apipilot_text_pass2 - ) - upstream_body_2 = { - "transcript": transcript, - "instructions": eff_inst_2, - "language": (req.language or "").strip(), - "user_context": effective_user_ctx_2, - } - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-PROMPT][REASONING → NODE_RED PASS-2] FULL upstream_body_2 JSON:\n%s\n", - json.dumps(upstream_body_2, ensure_ascii=False, indent=2), - ) - try: - async with httpx.AsyncClient(timeout=120.0) as http2: - resp2 = await http2.post( - f"{NODE_RED_REASONING_URL}/api/reasoning", - json=upstream_body_2, - headers={ - "Content-Type": "application/json", - "X-S2S-Caller": "demo-server-pass2", - }, - ) - except httpx.RequestError as exc: - logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc) - raise HTTPException(status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).") - if resp2.status_code != 200: - logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500]) - raise HTTPException(status_code=502, detail=f"La segunda pasada del razonamiento de Node-RED falló (HTTP {resp2.status_code}).") - try: - payload2 = resp2.json() - except Exception: - logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500]) - raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido (segunda pasada).") - final_ans = payload2.get("answer") if isinstance(payload2, dict) else None - if not isinstance(final_ans, str): - # Fallback: the first message and raw result. - logger.warning("[EXT][NODE_RED PASS-2] invalid answer, falling back to raw result text") - final_ans = tool_text_p1 or answer_stripped - final_stripped = final_ans.strip() - # Pass 2 always sets log_only=False: its final answer is ready for speech. - out2: dict = {"answer": final_stripped} - usage2 = payload2.get("usage") if isinstance(payload2, dict) else None - if usage2: - out2["usage_pass2"] = usage2 - out2["apipilot_invoked"] = { - "name": tool_name_p1, - "success": not is_tool_err, - "result_length": len(tool_text_p1), - } - logger.info( - "[Vocero REASONING] engine=NODE_RED PASS-2 transcript=%s finalAnswer=%s apipilot=%s", - transcript[:300], final_stripped[:500], - json.dumps(out2["apipilot_invoked"], ensure_ascii=True), - ) - return JSONResponse(out2) - # Without a marker, return the original pass-1 answer. - return JSONResponse(out) - - # ──────────────────────────────────────────────────────────────────── - # Mode B: OPENAI calls OpenAI Chat Completions directly. - # ──────────────────────────────────────────────────────────────────── - if not OPENAI_API_KEY: - raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.") - system_parts = [] - instructions = effective_instructions or ((req.instructions or "").strip()) - if instructions: - system_parts.append(instructions) - if effective_user_context.strip(): - system_parts.append(f"User context:\n{effective_user_context.strip()}") - if inject_apipilot_text_pass1: - system_parts.append(inject_apipilot_text_pass1) - if req.language.strip(): - system_parts.append( - "Reply only with the final assistant answer text. " - f"Keep the answer natural and concise in this language: {req.language.strip()}." - ) - else: - system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.") - logger.info( - "[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d", - len(system_parts), - len(effective_instructions), - len(effective_user_context), - len(phone), - len(episode_id), - ) - system_msg = "\n\n".join(system_parts) - body = { - "model": OPENAI_REASONING_MODEL, - "messages": [ - {"role": "system", "content": system_msg}, - {"role": "user", "content": transcript}, - ], - } - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n", - len(system_msg), system_msg, - ) - logger.info( - "[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n", - len(transcript), transcript, - ) - logger.debug( - "[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s", - OPENAI_BASE_URL, OPENAI_REASONING_MODEL, - len(system_parts[0]) if system_parts else 0, len(transcript), - (req.language or "")[:20] or "auto", - ) - try: - async with httpx.AsyncClient(timeout=30.0) as http: - resp = await http.post( - f"{OPENAI_BASE_URL}/chat/completions", - headers=_openai_headers(), - json=body, - ) - except httpx.RequestError as exc: - logger.warning("OpenAI reasoning unreachable: %r", exc) - raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI.") - logger.debug( - "[EXT][OPENAI] reasoning RESP status=%s len=%dB", - resp.status_code, len(resp.content or b""), - ) - if resp.status_code != 200: - logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400]) - raise HTTPException(status_code=502, detail=f"El razonamiento de OpenAI falló ({resp.status_code}).") - try: - payload = resp.json() - except Exception: - logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400]) - raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido.") - answer = "" - choices = payload.get("choices") - if isinstance(choices, list) and choices: - message = choices[0].get("message") if isinstance(choices[0], dict) else None - if isinstance(message, dict): - content = message.get("content") - if isinstance(content, str): - answer = content.strip() - usage = payload.get("usage") - if isinstance(usage, dict): - logger.debug( - "[EXT][OPENAI] reasoning USAGE prompt=%d completion=%d total=%d", - usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), usage.get("total_tokens", 0), - ) - if not answer: - logger.warning("OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800]) - raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.") - logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500]) - # Capture the ApiPilot marker, execute its tool and run OpenAI pass 2. - tool_name_p1_oai, tool_args_p1_oai = _apipilot_parse_call_marker(answer) - if tool_name_p1_oai: - logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s", tool_name_p1_oai, sorted(list((tool_args_p1_oai or {}).keys()))) - logger.info( - "[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s", - tool_name_p1_oai, - json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300], - ) - logger.info("[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool", tool_name_p1_oai) - tool_env_oai_p1 = await _apipilot_execute_tool( - auth_header=apipilot_auth_header, - demo_session=None, - name=tool_name_p1_oai, - arguments=tool_args_p1_oai or {}, - ) - is_tool_err_oai = not bool(tool_env_oai_p1.get("success")) - tool_data_oai = tool_env_oai_p1.get("data") - if isinstance(tool_data_oai, str): - tool_text_oai = tool_data_oai - elif tool_data_oai is None: - tool_text_oai = "" - else: - try: - tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2) - except Exception: - tool_text_oai = str(tool_data_oai) - if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict): - err_oai = tool_env_oai_p1["error"] - err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}" - tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip() - inject_apipilot_pass2_oai = _resolve_apipilot_reasoning_context_block( - [], - call_result={ - "name": tool_name_p1_oai, - "text": tool_text_oai, - "error": is_tool_err_oai, - }, - ) - logger.info( - "[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call", - tool_name_p1_oai, - str(not is_tool_err_oai).lower(), - len(tool_text_oai), - ) - # Build pass 2 with the same instructions/context structure, replacing - # inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai. - user_ctx_oai_2: list[str] = [] - if voice_lock_block.strip(): - user_ctx_oai_2.append(voice_lock_block) - if today_block.strip(): - user_ctx_oai_2.append(today_block) - if inject_episode_text: - user_ctx_oai_2.append(inject_episode_text) - if inject_profile_text: - user_ctx_oai_2.append(inject_profile_text) - if inject_reports_text: - user_ctx_oai_2.append(inject_reports_text) - if inject_summary_text: - user_ctx_oai_2.append(inject_summary_text) - if inject_apipilot_pass2_oai: - user_ctx_oai_2.append(inject_apipilot_pass2_oai) - if original_user_context: - user_ctx_oai_2.append(original_user_context) - user_ctx_oai_2 = _dedup_context_blocks_by_type(user_ctx_oai_2) - stripped_oai_2: list[str] = [] - for p in user_ctx_oai_2: - sp = _strip_internal_markers(p) - if sp: - stripped_oai_2.append(sp) - user_ctx_oai_2 = stripped_oai_2 - eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2) - if S2S_INJECT_PROFILE: - eff_inst_oai_2 = _augment_instructions_with_profile( - req.instructions or "", inject_profile_text, inject_reports_text, - inject_summary_text, inject_episode_text, profile_obj=profile_obj, - ) - else: - eff_inst_oai_2 = (req.instructions or "").strip() - sys_parts_oai_2: list[str] = [] - instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip()) - if instr_oai_2: - sys_parts_oai_2.append(instr_oai_2) - if eff_user_ctx_oai_2.strip(): - sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}") - if inject_apipilot_pass2_oai: - sys_parts_oai_2.append(inject_apipilot_pass2_oai) - if req.language.strip(): - sys_parts_oai_2.append( - "Reply only with the final assistant answer text. " - f"Keep the answer natural and concise in this language: {req.language.strip()}." - ) - else: - sys_parts_oai_2.append( - "Reply only with the final assistant answer text. Keep the answer natural and concise." - ) - system_msg_oai_2 = "\n\n".join(sys_parts_oai_2) - body_oai_2 = { - "model": OPENAI_REASONING_MODEL, - "messages": [ - {"role": "system", "content": system_msg_oai_2}, - {"role": "user", "content": transcript}, - ], - } - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n", - len(system_msg_oai_2), system_msg_oai_2, - ) - try: - async with httpx.AsyncClient(timeout=30.0) as http_oai2: - resp_oai_2 = await http_oai2.post( - f"{OPENAI_BASE_URL}/chat/completions", - headers=_openai_headers(), - json=body_oai_2, - ) - except httpx.RequestError as exc: - logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc) - raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).") - if resp_oai_2.status_code != 200: - logger.warning("[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400]) - raise HTTPException(status_code=502, detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).") - try: - payload_oai_2 = resp_oai_2.json() - except Exception: - logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400]) - raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).") - final_oai_ans = "" - choices_oai_2 = payload_oai_2.get("choices") - if isinstance(choices_oai_2, list) and choices_oai_2: - msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None - if isinstance(msg_oai_2, dict): - content_oai_2 = msg_oai_2.get("content") - if isinstance(content_oai_2, str): - final_oai_ans = content_oai_2.strip() - usage_oai_2 = payload_oai_2.get("usage") - if not final_oai_ans: - # Last-resort fallback: raw tool-result text. - logger.warning("[EXT][OPENAI PASS-2] empty answer, falling back to raw tool-result text") - final_oai_ans = (tool_text_oai or answer).strip() - final_out_oai: dict = {"answer": final_oai_ans} - if usage_oai_2: - final_out_oai["usage_pass2"] = usage_oai_2 - final_out_oai["apipilot_invoked"] = { - "name": tool_name_p1_oai, - "success": not is_tool_err_oai, - "result_length": len(tool_text_oai), - } - logger.info( - "[Vocero REASONING] engine=OPENAI PASS-2 transcript=%s finalAnswer=%s apipilot=%s", - transcript[:300], final_oai_ans[:500], - json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True), - ) - return JSONResponse(final_out_oai) - # Without a marker, return the original pass-1 answer. - return JSONResponse({"answer": answer}) - - -async def _forward_agent_text_to_nodered(payload: dict) -> None: - """Forward native-backend TTS responses to Node-RED's stage-1 logging endpoint. - - Ignore unavailable or unconfigured Node-RED so the realtime loop continues. - """ - if S2S_REASONING_TYPE != "NODE_RED" or not NODE_RED_REASONING_URL: - return - url = f"{NODE_RED_REASONING_URL}/api/log/system-response" - logger.debug( - "[EXT][NODE_RED] forward system-response POST %s len=%d source=%s", - NODE_RED_REASONING_URL, - len(str(payload.get("tts", "")) or 0), - payload.get("source_event") or "-", - ) - try: - async with httpx.AsyncClient(timeout=5.0) as http: - resp = await http.post( - url, - json=payload, - headers={ - "Content-Type": "application/json", - "X-S2S-Caller": "demo-server", - }, - ) - if resp.status_code != 200: - logger.debug( - "[EXT][NODE_RED] forward system-response non-200 status=%s body=%s", - resp.status_code, - (resp.text or "")[:300], - ) - except httpx.RequestError as exc: - logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc) - except Exception as exc: # noqa: BLE001 — never interrupt the realtime loop - logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc) - - -@app.websocket("/v1/realtime") -async def realtime_proxy(ws: WebSocket): - """Relay the browser's same-origin realtime WebSocket to the configured upstream.""" - client_ip = ws.client.host if ws.client else "?" - if not S2S_PROXY_TARGET: - logger.debug("[EXT][S2S-PROXY] client=%s rejected: S2S_PROXY_TARGET not configured", client_ip) - await ws.accept() - await ws.close(code=1013, reason="El proxy de tiempo real no está configurado.") - return - - await ws.accept() - upstream_url = _proxy_upstream_url(ws) - subprotocols = _subprotocols(ws) - - # ── S2S_INJECT_PROFILE: resolve caller profile + podcast episode + latest summary ── - # Query params (browser URL carries ?phoneNumber=...&episode_id=...) are forwarded by the - # client WS library: we read ?phoneNumber= and ?episode_id= from the incoming WS query. - # When S2S_INJECT_PROFILE=false or envs missing -> ctx fields stay empty - # and the tunnel stays 100% pure passthrough (original behaviour). - injected_episode_context = "" - injected_profile_context = "" - injected_reports_context = "" - injected_summary_context = "" - injected_apipilot_pass1 = "" - apipilot_tools_pass1: list = [] - inject_phone = "" - inject_episode_id = "" - client_query = {} - login_token: str | None = None - login_response_obj: dict | None = None - profile_obj: dict | None = None - needs_apipilot = bool(S2S_APIPILOT_TOOLS_URL) or bool(S2S_APIPILOT_TOOLS_EXECUTE_URL) - try: - client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True)) - except Exception: # noqa: BLE001 - client_query = {} - - # ApiPilot works independently of profile injection. When enabled, extract - # phone/episode query parameters, resolve the bearer for authenticated tools, - # and load the profile for email defaults when its endpoint is configured. - if needs_apipilot: - inject_phone = (client_query.get("phoneNumber") or "").strip() or inject_phone - inject_episode_id = (client_query.get("episode_id") or "").strip() or inject_episode_id - _needs_login_apipilot = bool(S2S_LOGIN_BY_PHONE.strip()) - _needs_profile_apipilot = bool(VOCERO_USER_PHONE_AUTH_URL.strip()) - # Always log initial WS state to diagnose missing phone, token or login - # configuration that could leave the AquaPortal user anonymous. - _s_init = { - "needs_apipilot": needs_apipilot, - "phoneLen": len(inject_phone or ""), - "phoneHead": (("+" + inject_phone.lstrip("+")[:3] + "...") if (inject_phone and len(inject_phone) > 6) else (inject_phone or "")), - "loginTokenLen": (len(login_token) if isinstance(login_token, str) and login_token.strip() else 0), - "loginByPhone": ("YES" if _needs_login_apipilot else "NO"), - "profileUrl": ("YES" if _needs_profile_apipilot else "NO"), - "S2S_INJECT_PROFILE": S2S_INJECT_PROFILE, - } - logger.info( - "[INJECT-APIPILOT][WS connect] INITIAL STATE: " + ", ".join("%s=%s" % (k, v) for k, v in sorted(_s_init.items())), - ) - # The API-key bypass makes login_by_phone unnecessary. - if not S2S_APIPILOT_API_KEY and _needs_login_apipilot and inject_phone and (not isinstance(login_token, str) or not login_token.strip() or (isinstance(login_token, str) and 0 < len(login_token.strip()) < 80)): - try: - login_token, _lresp = await _login_by_phone(inject_phone) - if isinstance(_lresp, dict) and _lresp: - login_response_obj = _lresp - logger.info( - "[INJECT-APIPILOT][WS connect] login_by_phone for authenticated ApiPilot execution phoneLen=%d token=%s", - len(inject_phone or ""), - ("YES len=%d" % len(login_token or "")) if isinstance(login_token, str) and login_token else "NONE (403!)", - ) - except Exception as _exc_login: # noqa: BLE001 - logger.info( - "[INJECT-APIPILOT][WS connect] login_by_phone failed (anonymous ApiPilot user, likely 403) phoneLen=%d err=%r", - len(inject_phone or ""), _exc_login, - ) - if _needs_profile_apipilot and inject_phone and (not isinstance(profile_obj, dict) or not profile_obj): - try: - profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token) - logger.info( - "[INJECT-APIPILOT][WS connect] profile fetch for email defaults phoneLen=%d profile=%s", - len(inject_phone or ""), - ("FOUND user=%s" % (((profile_obj or {}).get("user") or {}).get("name") or "?")) if isinstance(profile_obj, dict) else "NONE", - ) - except Exception as _exc_p: # noqa: BLE001 - logger.info( - "[INJECT-APIPILOT][WS connect] profile fetch failed phoneLen=%d err=%r", - len(inject_phone or ""), _exc_p, - ) - - if not needs_apipilot: - logger.info( - "[INJECT-APIPILOT] ⏭️ WS connect: APIPILOT tools injection DISABLED (S2S_APIPILOT_TOOLS_URL='%s' S2S_APIPILOT_TOOLS_EXECUTE_URL='%s'). " - "No ApiPilot fetch or session.update patch in this tunnel.", - S2S_APIPILOT_TOOLS_URL, - S2S_APIPILOT_TOOLS_EXECUTE_URL, - ) - if not S2S_INJECT_PROFILE: - logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). " - "No profile/episode fetch; tunnel stays pure passthrough for injects EXCEPT apipilot." - ) - else: - needs_phone_authed = bool(VOCERO_USER_PHONE_AUTH_URL.strip()) - # 2026-09-11 user fix: remove mass injection of scheduled reports. - # SAME gate as the REASONING endpoint above. "needs_reports" becomes True - # only when BOTH the URL env is configured AND the new opt-in S2S_INJECT_REPORTS - # flag is true. With the default (S2S_INJECT_REPORTS=false) this entire 1.5) - # branch of the WS connect path is skipped — reports-list fetch never runs. - needs_reports = bool(S2S_REPORT_LIST.strip()) and S2S_INJECT_REPORTS - needs_summary = bool(S2S_LATEST_SUMMARY.strip()) or bool(S2S_LATEST_SUMMARY_TEXT) - needs_login = bool(S2S_LOGIN_BY_PHONE.strip()) - inject_phone = (client_query.get("phoneNumber") or "").strip() - inject_episode_id = (client_query.get("episode_id") or "").strip() - can_inject = ( - needs_phone_authed or needs_reports or needs_summary or needs_login - or bool(inject_episode_id) - ) - if not can_inject: - logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY/S2S_LATEST_SUMMARY_TEXT and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in query; OR S2S_INJECT_REPORTS is false so scheduled report injection is globally disabled). " - "Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable caller context / reports / login / episode." - ) - elif (not inject_phone) and (not needs_reports) and (not inject_episode_id) and (not S2S_LATEST_SUMMARY_TEXT): - # reports list can be fetched without phone (generic list); episode can be fetched without phone; - # S2S_LATEST_SUMMARY_TEXT also works without phone (static text override) - logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (incoming WS query has NO ?phoneNumber= AND no ?episode_id=, AND S2S_REPORT_LIST+S2S_INJECT_REPORTS not enabled). " - "Make sure the browser opens the WS with the phone or episode_id query param, or set S2S_INJECT_REPORTS=true if you want scheduled reports without phone." - ) - else: - # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). - # Obtains a bearer token used on subsequent profile/reports/summary fetches. - if needs_login and inject_phone: - login_token, login_response_obj = await _login_by_phone(inject_phone) - # 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query) - if inject_episode_id: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-EPISODE] WS connect: starting podcast episode fetch episodeId=%.80s...", - inject_episode_id, - ) - episode_raw = await _fetch_podcast_episode(inject_episode_id) - injected_episode_context = _resolve_episode_context(episode_raw) - if injected_episode_context.strip(): - logger.info( - "[INJECT-EPISODE] ✅ WS connect: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> will patch session.update frames in this tunnel.", - inject_episode_id, len(injected_episode_context), - ) - else: - logger.info( - "[INJECT-EPISODE] ⏭️ WS connect: episode skipped (context empty / upstream returned empty / fetch failed)." - ) - # 1) Profile fetch (optional — only if env is set) - if needs_phone_authed and inject_phone: - profile_obj = await _fetch_user_profile_for_phone(inject_phone, auth_token=login_token) - injected_profile_context = _resolve_user_context_text( - inject_phone, profile_obj, loaded=True - ) - if injected_profile_context.strip(): - logger.info( - "[INJECT-PROFILE] ✅ WS connect: profile READY phoneLen=%d contextLen=%d -> will patch all client session.update frames in this tunnel.", - len(inject_phone), len(injected_profile_context), - ) - else: - logger.info( - "[INJECT-PROFILE] ⏭️ WS connect: profile fetch OK but resolved empty. Tunnel stays pure passthrough for profile." - ) - # 1.5) Reports list fetch (optional — only if env is set) - if needs_reports: - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-REPORTS] WS connect: starting S2S_REPORT_LIST fetch (phoneLen=%s bearer=%s)...", - len(inject_phone) if inject_phone else "none", - "yes" if login_token else "no", - ) - reports_raw = await _fetch_reports_list( - phone_number=inject_phone if inject_phone else None, - auth_token=login_token, - ) - injected_reports_context = _resolve_reports_context(reports_raw) - if injected_reports_context.strip(): - logger.info( - "[INJECT-REPORTS] ✅ WS connect: reports list READY wrappedLen=%d -> will patch session.update frames in this tunnel.", - len(injected_reports_context), - ) - else: - logger.info( - "[INJECT-REPORTS] ⏭️ WS connect: reports list skipped (context empty / upstream returned empty list / fetch failed)." - ) - # 2) Latest summary fetch (optional — only if env is set) - if needs_summary and (inject_phone or S2S_LATEST_SUMMARY_TEXT): - if S2S_DEBUG_PROMPT: - logger.info( - "[INJECT-SUMMARY] WS connect: starting summary load (S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s)...", - bool(S2S_LATEST_SUMMARY), - bool(S2S_LATEST_SUMMARY_TEXT), - len(inject_phone) if inject_phone else "none", - ) - latest_summary_raw = await _fetch_latest_summary_for_phone(inject_phone, auth_token=login_token) - injected_summary_context = _resolve_latest_summary_context(latest_summary_raw) - if injected_summary_context.strip(): - logger.info( - "[INJECT-SUMMARY] ✅ WS connect: latest SUMMARY READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> will patch session.update frames in this tunnel.", - len(inject_phone) if inject_phone else "none", - bool(S2S_LATEST_SUMMARY_TEXT), - len(injected_summary_context), - ) - else: - logger.info( - "[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)." - ) - # 3) Fetch the ApiPilot pass-1 tool list independently of S2S_INJECT_PROFILE, - # even without login/phone. Use the resolved token or anonymous fingerprint, - # matching the reasoning endpoint's server cache. - if needs_apipilot: - apipilot_auth_header = (f"Bearer {login_token}" if isinstance(login_token, str) and login_token.strip() else None) - # Phone login resolves the AquaPortal JWT for execution. The configured - # S2S_APIPILOT_SERVICE_TOKEN overrides it. - if S2S_APIPILOT_SERVICE_TOKEN: - _svc = S2S_APIPILOT_SERVICE_TOKEN.strip() - if _svc: - apipilot_auth_header = (f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc) - demo_session_key = client_query.get("x-demo-session") if isinstance(client_query, dict) else None - if not demo_session_key: - demo_session_key = f"anon-ws-{client_ip}-{id(ws)}" - try: - # Always log discovery, matching reasoning pass 1. - logger.info( - "[INJECT-APIPILOT] WS connect: fetching tools (auth=%s demoSession=%.40s...) upstream=%s...", - "yes" if apipilot_auth_header else "anon", - (demo_session_key[:40] if isinstance(demo_session_key, str) else "?"), - S2S_APIPILOT_TOOLS_URL or "", - ) - if S2S_DEBUG_PROMPT: - logger.info( - "[DEBUG-APIPILOT][WS connect] tools fetch detail toolsURL=%s executeURL=%s overrideExec=%s", - S2S_APIPILOT_TOOLS_URL, - S2S_APIPILOT_TOOLS_EXECUTE_URL, - _apipilot_resolve_execute_url(), - ) - _t0 = _monotonic_now() - apipilot_tools_pass1 = await _apipilot_fetch_tools( - auth_header=apipilot_auth_header, - demo_session=demo_session_key if isinstance(demo_session_key, str) else None, - refresh=False, - ) or [] - _el = (_monotonic_now() - _t0) - # Supply known phone/episode defaults to pass 1 so the model does - # not leave required arguments empty (observed ARGUMENTS=>>> bug). - _apipilot_defaults_p1: dict[str, str] = {} - if isinstance(inject_phone, str) and inject_phone.strip(): - _apipilot_defaults_p1["phone"] = inject_phone.strip() - if isinstance(inject_episode_id, str) and inject_episode_id.strip(): - _apipilot_defaults_p1["episode_id"] = inject_episode_id.strip() - # Also supply email when the resolved profile has a valid value. - if isinstance(profile_obj, dict) and profile_obj: - try: - u = profile_obj.get("user") if isinstance(profile_obj.get("user"), dict) else None - if isinstance(u, dict): - email = u.get("email") if isinstance(u.get("email"), str) else "" - if email.strip(): - _apipilot_defaults_p1["email"] = email.strip() - except Exception: # noqa: BLE001 - pass - session_ctx_apipilot_defaults = dict(_apipilot_defaults_p1) - injected_apipilot_pass1 = _resolve_apipilot_reasoning_context_block( - tools_list=apipilot_tools_pass1, context_defaults=_apipilot_defaults_p1, - ) or "" - if injected_apipilot_pass1.strip(): - logger.info( - "[APIPILOT] REASONING PASS-1 (WS realtime): tool list ready N=%d t=%.0fms auth=%s defaults=%s", - len(apipilot_tools_pass1), _el, - "Y" if isinstance(apipilot_auth_header, str) else "anon", - list(_apipilot_defaults_p1.keys()), - ) - logger.info( - "[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=%d wrappedLen=%d defaultsPhone=%s -> will patch session.update frames in this tunnel.", - len(apipilot_tools_pass1), len(injected_apipilot_pass1), - ("yes" if _apipilot_defaults_p1.get("phone") else "no"), - ) - else: - logger.info( - "[INJECT-APIPILOT] ⏭️ WS connect: apipilot fetch returned empty tools list; PASS-1 injection skipped for this tunnel.", - ) - except Exception as exc: # noqa: BLE001 - logger.info( - "[INJECT-APIPILOT] ⛔ WS connect: apipilot tools fetch failed. Skipping PASS-1 injection for tunnel. err=%r", - exc, - ) - injected_apipilot_pass1 = "" - apipilot_tools_pass1 = [] - session_ctx_apipilot_defaults = {} - # Preserve the exact discovery authentication and demo-session values. - # AquaPortal binds authorized tools to GET /tools' x-demo-session; a new - # ws-pass2 value would appear anonymous and produce a 403 response. - _ws_apipilot_auth_for_execute = apipilot_auth_header - _ws_apipilot_demo_for_execute = demo_session_key if isinstance(demo_session_key, str) else None - # Preserve API-key identity in every propagation variable, matching the - # discovery cache fingerprint and HTTP headers even during pass-2 fallback. - if S2S_APIPILOT_API_KEY: - _ws_apipilot_auth_for_execute = f"Bearer {S2S_APIPILOT_API_KEY}" - _ws_apipilot_demo_for_execute = S2S_APIPILOT_API_KEY - else: - # With ApiPilot unconfigured, keep defaults as an empty dict rather than None. - session_ctx_apipilot_defaults = {} - injected_apipilot_pass1 = "" - apipilot_tools_pass1 = [] - _ws_apipilot_auth_for_execute = None - _ws_apipilot_demo_for_execute = None - logger.info( - "[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s injectApipilotPass1=%s(Ntools=%d) login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d apipilotCtxLen=%d target=%s", - bool(injected_episode_context), - bool(injected_profile_context), - bool(injected_reports_context), - bool(injected_summary_context), - bool(injected_apipilot_pass1.strip()), - len(apipilot_tools_pass1), - ("token-present" if login_token else "skipped"), - len(inject_episode_id), - len(inject_phone), - len(injected_episode_context), - len(injected_profile_context), - len(injected_reports_context), - len(injected_summary_context), - len(injected_apipilot_pass1), - upstream_url, - ) - if S2S_DEBUG_PROMPT and injected_episode_context.strip(): - logger.info( - "[DEBUG-EPISODE][WS connect] FULL podcast episode block loaded (len=%d):\n%s\n", - len(injected_episode_context), injected_episode_context, - ) - if S2S_DEBUG_PROMPT and injected_reports_context.strip(): - logger.info( - "[DEBUG-REPORTS][WS connect] FULL reports list block loaded (len=%d):\n%s\n", - len(injected_reports_context), injected_reports_context, - ) - if S2S_DEBUG_PROMPT and injected_summary_context.strip(): - logger.info( - "[DEBUG-SUMMARY][WS connect] FULL latest summary block loaded (len=%d):\n%s\n", - len(injected_summary_context), injected_summary_context, - ) - try: - async with websockets.connect( - upstream_url, - subprotocols=subprotocols or None, - open_timeout=15, - max_size=None, - ) as upstream: - logger.info("[INJECT-PROFILE] WS tunnel ESTABLISHED client=%s", client_ip) - session_ctx = { - "last_user_transcript": "", - "last_item_id": None, - "injected_episode_context": injected_episode_context, - "injected_profile_context": injected_profile_context, - "injected_reports_context": injected_reports_context, - "injected_summary_context": injected_summary_context, - "injected_apipilot_pass1": injected_apipilot_pass1, - "inject_phone": inject_phone, - "inject_episode_id": inject_episode_id, - "login_token": login_token, - "login_response": login_response_obj, - # 2026-09-11 proper-name anglicization guard: keep the full - # upstream profile object so: - # (a) _build_voice_rules_head_lock_block can emit rule #5 - # "Exact name: Alessandro Valenti — NEVER Alexander" - # (b) layer 7 post-processing _sanitize_agent_output can - # deterministically replace Alexander/Alexandre back to - # Alessandro with word-boundary regex even when the - # prompt lock fails on GPTQ. - "profile_obj": profile_obj, - "apipilot_tools_pass1": apipilot_tools_pass1, - # Accumulate transcripts per response_id and prevent pass-2 - # injection loops after ApiPilot execution. - "_ws_apipilot_asst_transcript_acc": {}, # response_id -> str full transcript - "_ws_apipilot_pass2_active": False, # True during the second response; prevents infinite marker loops. - "_ws_apipilot_pass2_count": 0, # Session execution count, limited to three attempts. - # Defaults prepared by pass 1 fill missing pass-2 arguments. - "apipilot_defaults": (session_ctx_apipilot_defaults if needs_apipilot else {}), - # Pass-2 phone fallback also works when defaults are empty but - # the query string supplied phoneNumber. - "inject_phone": (inject_phone if isinstance(inject_phone, str) else ""), - # Bearer for authenticated execution, resolved by phone login - # independently of S2S_INJECT_PROFILE. - "login_token": (login_token if isinstance(login_token, str) else None), - # Execute with the exact auth/demo values used for WS discovery; - # AquaPortal binds the authorized tool list to that demo session. - "apipilot_exec_auth": _ws_apipilot_auth_for_execute, - "apipilot_exec_demo": _ws_apipilot_demo_for_execute, - } - client_to_upstream = asyncio.create_task( - _client_to_upstream(ws, upstream, client_ip, session_ctx) - ) - upstream_to_client = asyncio.create_task( - _upstream_to_client(ws, upstream, client_ip, session_ctx) - ) - done, pending = await asyncio.wait( - {client_to_upstream, upstream_to_client}, - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - await asyncio.gather(*pending, return_exceptions=True) - for task in done: - exc = task.exception() - if exc: - raise exc - except WebSocketDisconnect: - logger.debug("[EXT][S2S-PROXY] client=%s disconnected (WSDisconnect)", client_ip) - return - except Exception as exc: - logger.warning("Realtime proxy failed: %r", exc) - logger.debug("[EXT][S2S-PROXY] client=%s ERROR %s", client_ip, type(exc).__name__) - try: - await ws.close(code=1011, reason="El servicio de tiempo real no está disponible.") - except Exception: - pass - finally: - logger.debug("[EXT][S2S-PROXY] client=%s TUNNEL CLOSED", client_ip) - - -@app.get("/api/user/phone") -async def user_by_phone(phoneNumber: str): - """HTTP passthrough: fetches caller identity / profile from the external - info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw - JSON to the browser. - - The proxy NEVER inspects this payload nor injects it into the realtime - WebSocket session. It is only used by: - - the browser UI to render a caller name header (#ident-user-name) - - the /api/reasoning HTTP body user_context field (browser's choice). - - When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can - silently skip identity loading (backward compatible with generic deploys). - - When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone - POST with the caller phone and (if a bearer token is extracted) forwards - it as Authorization: Bearer on the subsequent profile GET. - """ - from urllib.parse import urlencode - - if not VOCERO_USER_PHONE_AUTH_URL: - raise HTTPException( - status_code=503, - detail="VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.", - ) - phone = (phoneNumber or "").strip() - if not phone: - raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.") - - # 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set). - login_token = None - if S2S_LOGIN_BY_PHONE: - login_token, _login_obj = await _login_by_phone(phone) - - target_url = f"{VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}" - logger.debug( - "[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s", - VOCERO_USER_PHONE_AUTH_URL, - len(phone), - "yes" if login_token else "no", - ) - try: - headers = {} - if login_token: - headers["Authorization"] = f"Bearer {login_token}" - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http: - resp = await http.get(target_url, headers=headers or None) - except httpx.RequestError as exc: - logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc) - raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de información del usuario.") - # Forward the upstream status + JSON verbatim; never rewrite the payload. - if resp.status_code >= 500: - logger.warning( - "[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s", - resp.status_code, resp.text, - ) - try: - payload = resp.json() - except Exception: - raise HTTPException( - status_code=502, - detail="El servicio de información del usuario devolvió un JSON no válido.", - ) - return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload) - - -@app.get("/api/user/reports") -async def user_reports(phoneNumber: str | None = None): - """Fetch + normalize the scheduled reports list for the caller UI. - - Mirrors the same sequence used in the WebSocket connect handler and - /api/reasoning: - 0. login-by-phone (optional) → bearer token - 1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth - 2. run the same resolver used for prompt injection: filter 3 fields, - sort DESC, build hints + entries → JSON returned verbatim so the - browser can render a "reports loaded" summary in the UI header. - - Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no - reports feature enabled) so the browser silently skips UI rendering. - Response shape on success: - { - "count": N, - "wrappedLen": X, - "hints": [ "• Report #1: '