refactor(server): adopt uv and modular typed FastAPI structure

This commit is contained in:
2026-10-06 18:18:36 -03:00
parent 34f0d1b125
commit 92c53dc7ca
38 changed files with 8363 additions and 6622 deletions
View File
+597
View File
@@ -0,0 +1,597 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import Callable
from time import monotonic
from typing import Any, cast
import httpx
from fastapi import HTTPException, Request
from s2s_client.core import config as settings
from s2s_client.core import logging as app_logging
# 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.
_monotonic_now: Callable[[], float] = monotonic
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 = settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.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 settings.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 settings.S2S_APIPILOT_API_KEY:
final_auth: str | None = "BYPASS-API-KEY"
_demo_used: str | None = settings.S2S_APIPILOT_API_KEY
_effective_auth_for_fp = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
_effective_demo_for_fp = settings.S2S_APIPILOT_API_KEY
else:
final_auth = auth_header or (
f"Bearer {settings.S2S_APIPILOT_SERVICE_TOKEN}" if settings.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]) < settings.APIPILOT_CACHE_TTL_SEC:
app_logging.logger.debug(
"[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d",
settings.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]) < settings.APIPILOT_CACHE_TTL_SEC:
return recheck[1]
url = f"{settings.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 settings.S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
else:
if final_auth:
headers["Authorization"] = final_auth
if _demo_used:
headers["x-demo-session"] = _demo_used
app_logging.logger.info(
"[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s",
url,
params["include_unauthorized"],
fp,
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")),
)
try:
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.get(url, params=params, headers=headers)
except Exception as exc:
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc)
if stale_tools is not None:
app_logging.logger.warning(
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
)
return stale_tools
raise HTTPException( # noqa: B904
status_code=502,
detail={
"code": "APIPILOT_NETWORK_ERROR",
"message": f"Error al contactar ApiPilot tools backend: {exc}",
},
)
if resp.status_code >= 300:
app_logging.logger.error(
"[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400]
)
if stale_tools is not None:
app_logging.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:
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500])
if stale_tools is not None:
return stale_tools
raise HTTPException( # noqa: B904
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)
app_logging.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 settings.S2S_DEBUG_PROMPT and sanitized:
app_logging.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 = cast(dict[str, Any], tool_def.get("parameters")) if isinstance(tool_def.get("parameters"), dict) else {}
properties = cast(dict[str, Any], schema.get("properties")) if isinstance(schema.get("properties"), dict) else {}
required = cast(list[Any], 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 = {}
app_logging.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)
app_logging.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 = {}
for key in args.keys():
if key in properties:
cleaned[key] = args[key]
else:
app_logging.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
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 ""
app_logging.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 settings.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.
app_logging.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(settings.S2S_APIPILOT_SERVICE_TOKEN)
if requires_auth and not has_token:
app_logging.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: <key>
# - Authorization: Bearer <key>
# - x-demo-session: <key>
# Ignore service tokens and login_by_phone when the API KEY is set.
if settings.S2S_APIPILOT_API_KEY:
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
final_auth: str | None = f"Bearer {settings.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 settings.S2S_APIPILOT_SERVICE_TOKEN:
_svc = settings.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 []
app_logging.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 settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")),
)
try:
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
resp = await http.post(url, json=payload, headers=headers)
except Exception as exc:
app_logging.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)
app_logging.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:
app_logging.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, {})
app_logging.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(cast(dict, parsed.get("data")).keys())) if isinstance(parsed.get("data"), dict) else "-",
)
if (not envelope_ok) and isinstance(parsed.get("error"), dict):
err = parsed["error"]
app_logging.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.
app_logging.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),
},
}
+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 s2s_client.core import config as settings
from s2s_client.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, 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 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