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:
@@ -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 vocero_api.core import config as settings
|
||||
from vocero_api.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),
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user