Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline. Release notes: - Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution. - Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery. - Updated regression coverage and operating documentation for the retired routes and settings. Reviewed-on: AquaPortal/s2s#3
180 lines
11 KiB
Python
180 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
from vocero_api.core import logging as app_logging
|
|
|
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
|
load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False)
|
|
app_logging.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"
|
|
return "PROXY"
|
|
|
|
|
|
SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
|
|
S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip()
|
|
if S2S_PRIMARY_PROMPT:
|
|
S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n")
|
|
# External user-by-phone info endpoint. When set, GET /api/user/phone proxies
|
|
# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the
|
|
# browser so the UI can render caller identity / caller context.
|
|
VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/")
|
|
# Feature flag: when "true" (case-insensitive) => injects the resolved caller
|
|
# profile into the realtime session (patch session.update client->upstream,
|
|
# append profile to the LLM system instructions prompt instructions, and prepend it to the
|
|
# reasoning user_context for the /api/reasoning HTTP body.
|
|
# Default = disabled (generic deploy; pure passthrough; all characterization lives
|
|
# in the backend RAG per original architectural decision.)
|
|
S2S_INJECT_PROFILE = (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
# Endpoint URL for fetching the latest per-caller conversation summary, used when
|
|
# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely
|
|
# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber=<x>
|
|
# and expects JSON back (optionally wrapped as {"result": <value>}). Empty
|
|
# string means the feature is off (no network call, nothing injected).
|
|
S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip()
|
|
# Static / hardcoded latest-summary text. When set (non-empty string), it takes
|
|
# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and
|
|
# injects this raw plain-text value directly into the prompt as the latest
|
|
# conversation summary. Designed for cases where the operator wants a clean,
|
|
# essential, pre-cleaned summary snippet without depending on an upstream API.
|
|
# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no
|
|
# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context
|
|
# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper).
|
|
S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip()
|
|
if S2S_LATEST_SUMMARY_TEXT:
|
|
# Interpret \n escape sequences so multi-line summaries can be written as a
|
|
# single env line (matches S2S_PRIMARY_PROMPT convention above).
|
|
S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n")
|
|
# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and
|
|
# phoneNumber is available), before fetching user profile / latest summary the proxy
|
|
# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body,
|
|
# extracts an auth token from the response (if any), and forwards it as
|
|
# Authorization: Bearer <token> for subsequent profile / summary / reports HTTP calls.
|
|
S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/")
|
|
# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true)
|
|
# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}),
|
|
# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC,
|
|
# and injects the compiled block into the LLM instructions / user_context between
|
|
# the caller profile block and the latest conversation summary block.
|
|
S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/")
|
|
# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST
|
|
# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT)
|
|
# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and
|
|
# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue:
|
|
# "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 = (os.environ.get("S2S_INJECT_TODAY", "") or "").strip().lower() not 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 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()
|
|
|
|
|
|
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
|
|
# (which proxies <lb>/session here, server-side) and connects to the URL the LB
|
|
# returns (the original flow). The LB address itself is never sent to the browser.
|
|
# When empty, the user may instead set a direct s2s server URL in Settings and the
|
|
# browser connects to it straight (no load balancer).
|
|
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
|
|
# Direct s2s server URL pinned by the deploy. Takes priority over the load
|
|
# balancer: when set, 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)://<this-app>/v1/realtime and the server relays frames to the
|
|
# upstream speech-to-speech endpoint, keeping the internal host private.
|
|
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
|
|
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
|
|
LOAD_BALANCER_URL = ""
|
|
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
|
|
|
|
# Optional authenticated AquaPortal MCP integration. Legacy deployments remain unchanged.
|
|
S2S_MCP_URL = os.environ.get("S2S_MCP_URL", "").strip()
|
|
|
|
|
|
def _bounded_mcp_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
|
try:
|
|
value = int(os.environ.get(name, str(default)))
|
|
except ValueError:
|
|
value = default
|
|
return max(minimum, min(maximum, value))
|
|
|
|
|
|
S2S_MCP_TIMEOUT_SEC = _bounded_mcp_int("S2S_MCP_TIMEOUT_SEC", 20, 1, 120)
|
|
S2S_MCP_MAX_CALLS_PER_TURN = _bounded_mcp_int("S2S_MCP_MAX_CALLS_PER_TURN", 6, 1, 20)
|
|
S2S_CONVERSATION_TTL_SEC = _bounded_mcp_int("S2S_CONVERSATION_TTL_SEC", 3600, 60, 86400)
|
|
S2S_MAX_CONVERSATIONS = _bounded_mcp_int("S2S_MAX_CONVERSATIONS", 1000, 1, 10000)
|