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
+558
View File
@@ -0,0 +1,558 @@
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlencode
import httpx
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
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=<x> 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, reasoning 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 settings.S2S_INJECT_REPORTS:
return None
# ───────────────────────────────────────────────────────────────────────
if not settings.S2S_REPORT_LIST:
return None
if phone_number:
sep = "&" if "?" in settings.S2S_REPORT_LIST else "?"
target_url = f"{settings.S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}"
else:
target_url = settings.S2S_REPORT_LIST
app_logging.logger.info(
"[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s",
settings.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:
app_logging.logger.warning(
"[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s",
resp.status_code,
resp.text,
)
return None
text = (resp.text or "").strip()
if not text:
app_logging.logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.")
return None
try:
payload = resp.json()
except Exception:
app_logging.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:
app_logging.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 settings.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]
app_logging.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))
app_logging.logger.info(
"[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)",
n_items,
n_dict,
)
return unwrapped
app_logging.logger.info(
"[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.",
type(unwrapped).__name__,
)
if settings.S2S_DEBUG_PROMPT and isinstance(payload, dict):
# Log final top-level keys to help diagnose wrapper shape
app_logging.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 settings.S2S_LOGIN_BY_PHONE or not phone_number:
return None, None
body = {"phoneNumber": phone_number}
app_logging.logger.info(
"[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d",
settings.S2S_LOGIN_BY_PHONE,
len(phone_number),
)
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
resp = await http.post(
settings.S2S_LOGIN_BY_PHONE,
json=body,
headers={"Content-Type": "application/json"},
)
if resp.status_code != 200:
app_logging.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:
app_logging.logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.")
return None, None
try:
payload = resp.json()
except Exception:
app_logging.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:
app_logging.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 = f"[INJECT-LOGIN] ✅ login OK: wrapLayer={_wrap_layer} topKeys={_all_keys_s} unwrappedKeys={_unwrap_keys_s} token={_t_info}{_fmt_note}"
app_logging.logger.info("%s", msg)
return token, unwrapped
# Fallback: non-dict payload (string/list/number) — still log it, but no token expected.
app_logging.logger.info(
"[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.",
type(payload).__name__,
)
if settings.S2S_DEBUG_PROMPT:
app_logging.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) -> Any:
"""Fetch the latest caller summary via S2S_LATEST_SUMMARY URL.
Generic / completely deploy-agnostic: plain GET
S2S_LATEST_SUMMARY?phoneNumber=<phone>; 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 <auth_token>.
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 settings.S2S_LATEST_SUMMARY_TEXT:
override_len = len(settings.S2S_LATEST_SUMMARY_TEXT)
app_logging.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 settings.S2S_DEBUG_PROMPT:
app_logging.logger.info(
"[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s",
settings.S2S_LATEST_SUMMARY_TEXT[:800],
)
return settings.S2S_LATEST_SUMMARY_TEXT
if not settings.S2S_LATEST_SUMMARY or not phone_number:
return None
target_url = f"{settings.S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}"
app_logging.logger.info(
"[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s",
settings.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:
app_logging.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:
app_logging.logger.info(
"[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.",
len(text),
)
return text
except Exception as exc:
app_logging.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:
app_logging.logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.")
return None
if isinstance(payload, dict):
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s",
",".join(sorted(str(k) for k in payload.keys())),
)
elif isinstance(payload, list):
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d",
len(payload),
)
else:
app_logging.logger.info(
"[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d",
len(str(payload)),
)
return payload
async def _fetch_podcast_episode(episode_id: str) -> Any:
"""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/<episode_id>
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}"
app_logging.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:
app_logging.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:
app_logging.logger.info(
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
len(text),
)
return text
except Exception as exc:
app_logging.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:
app_logging.logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
return None
if isinstance(payload, dict):
app_logging.logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
",".join(sorted(str(k) for k in payload.keys())),
)
elif isinstance(payload, list):
app_logging.logger.info(
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
len(payload),
)
else:
app_logging.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 <auth_token>
to the upstream profile endpoint.
"""
if not settings.VOCERO_USER_PHONE_AUTH_URL or not phone_number:
return None
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}"
app_logging.logger.info(
"[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s",
settings.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:
app_logging.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:
app_logging.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:
app_logging.logger.info(
"[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?",
type(payload).__name__,
)
return None
app_logging.logger.info(
"[INJECT-PROFILE] ✅ profile loaded OK keys=%s",
",".join(sorted(profile_obj.keys())),
)
return profile_obj