refactor(api): scope uv to s2s-client and validate compatible responses
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from s2s_client.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"
|
||||
if normalized in {"NODE_RED", "NODERED"}:
|
||||
return "NODE_RED"
|
||||
return "PROXY"
|
||||
|
||||
|
||||
SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
|
||||
S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip()
|
||||
if S2S_PRIMARY_PROMPT:
|
||||
S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n")
|
||||
# External user-by-phone info endpoint. When set, GET /api/user/phone proxies
|
||||
# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the
|
||||
# browser so the UI can render caller identity / caller context.
|
||||
VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/")
|
||||
# Feature flag: when "true" (case-insensitive) => injects the resolved caller
|
||||
# profile into the realtime session (patch session.update client->upstream,
|
||||
# append profile to the LLM system instructions prompt instructions, and prepend it to the
|
||||
# reasoning user_context for the /api/reasoning HTTP body.
|
||||
# Default = disabled (generic deploy; pure passthrough; all characterization lives
|
||||
# in the backend RAG per original architectural decision.)
|
||||
S2S_INJECT_PROFILE = (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
# Endpoint URL for fetching the latest per-caller conversation summary, used when
|
||||
# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely
|
||||
# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber=<x>
|
||||
# and expects JSON back (optionally wrapped as {"result": <value>}). Empty
|
||||
# string means the feature is off (no network call, nothing injected).
|
||||
S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip()
|
||||
# Static / hardcoded latest-summary text. When set (non-empty string), it takes
|
||||
# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and
|
||||
# injects this raw plain-text value directly into the prompt as the latest
|
||||
# conversation summary. Designed for cases where the operator wants a clean,
|
||||
# essential, pre-cleaned summary snippet without depending on an upstream API.
|
||||
# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no
|
||||
# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context
|
||||
# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper).
|
||||
S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip()
|
||||
if S2S_LATEST_SUMMARY_TEXT:
|
||||
# Interpret \n escape sequences so multi-line summaries can be written as a
|
||||
# single env line (matches S2S_PRIMARY_PROMPT convention above).
|
||||
S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n")
|
||||
# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and
|
||||
# phoneNumber is available), before fetching user profile / latest summary the proxy
|
||||
# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body,
|
||||
# extracts an auth token from the response (if any), and forwards it as
|
||||
# Authorization: Bearer <token> for subsequent profile / summary / reports HTTP calls.
|
||||
S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/")
|
||||
# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true)
|
||||
# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}),
|
||||
# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC,
|
||||
# and injects the compiled block into the LLM instructions / user_context between
|
||||
# the caller profile block and the latest conversation summary block.
|
||||
S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/")
|
||||
# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST
|
||||
# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT)
|
||||
# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and
|
||||
# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue:
|
||||
# "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 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 <url>/tools with include_unauthorized=true (guide §2.1)
|
||||
# to discover tools, and POST <url>/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: <key>
|
||||
# 2) Authorization: Bearer <key>
|
||||
# 3) x-demo-session: <key>
|
||||
# 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
|
||||
|
||||
|
||||
# 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 <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
|
||||
Reference in New Issue
Block a user