refactor: rename API and frontend projects to Vocero

Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts.

Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
2026-10-06 19:26:55 -03:00
parent e0304b2bdb
commit abd03ed07d
105 changed files with 353 additions and 312 deletions
+206
View File
@@ -0,0 +1,206 @@
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()
# ──────────────────────────────────────────────────────────────────────────────
# 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
# 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