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= 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=; 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 . 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/ 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 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