""" HTTP client for the Speech Analytics API. Responsibilities: - OAuth2 authentication (client_credentials) with in-memory token caching for the Prediction endpoint. - Google service-account ID Token authentication (with in-memory caching) for the History endpoint, which is exposed behind a private Cloud Run. - POST /prediction-canais-recursais-api/predictions-canais-recursais to obtain NLP insights for the current complaint. - GET /v1/reclamacoes for complaint history. All errors are re-raised as SpeechClientError so that the caller (speech_enrichment_node) can implement the graceful-degradation policy without knowing the HTTP internals. """ import os import time import asyncio import logging import httpx from typing import Tuple, Dict, Any from src.core.config import settings from src.utils.observer import trace_tool from src.utils.http import traced_async_client from src.components.clients.exceptions.speech_exceptions import SpeechClientError logger = logging.getLogger(__name__) _PREDICTION_VARIABLES = [ "RESUME", "SUBMOTIVO", "MOTIVO", "SOLUCAO_PROPOSTA_CLIENTE", "CAUSA_RAIZ", "SENTIMENTO_CLIENTE", "DESCORTESIA_CLIENTE", ] _OAUTH_PATH = "/oauth-admin/v1/oauth2/token" _PREDICTION_PATH = "/prediction-canais-recursais-api/predictions-canais-recursais" _HISTORY_PATH = "/v1/reclamacoes" class SpeechAnalyticsClient: """ Thin async HTTP client for the Speech Analytics gateway. Token lifecycles: - OAuth2 access token (Prediction API): cached at class level until 60s before expiry. - Google ID Token (History API): cached at class level until 60s before expiry. """ _cached_token: str | None = None _token_expires_at: float = 0.0 _cached_history_id_token: str | None = None _history_id_token_expires_at: float = 0.0 _history_auth_unavailable: bool = False def __init__(self) -> None: self.base_url = settings.SPEECH_PREDICTION_BASE_URL @trace_tool async def _get_token(self) -> str: """ Return a valid OAuth2 Bearer token for the Prediction API, refreshing it when necessary. """ if SpeechAnalyticsClient._cached_token and time.time() < SpeechAnalyticsClient._token_expires_at - 60: return SpeechAnalyticsClient._cached_token base_url = settings.SPEECH_PREDICTION_BASE_URL client_id = settings.SPEECH_PREDICTION_CLIENT_ID client_secret = settings.SPEECH_PREDICTION_CLIENT_SECRET if not all([base_url, client_id, client_secret]): raise SpeechClientError( "Speech Prediction OAuth2 credentials not configured " "(SPEECH_PREDICTION_BASE_URL, SPEECH_PREDICTION_CLIENT_ID, SPEECH_PREDICTION_CLIENT_SECRET)." ) url = f"{base_url.rstrip('/')}{_OAUTH_PATH}" logger.info("Requesting new Speech Prediction OAuth2 token.") def _sanitize_token_response(body): if not isinstance(body, dict): return body return { "api_product_list": body.get("api_product_list"), "organization_name": body.get("organization_name"), "developer.email": body.get("developer.email"), "expires_in": body.get("expires_in"), "status": body.get("status"), } try: async with traced_async_client( timeout=settings.SPEECH_TIMEOUT, response_sanitizer=_sanitize_token_response, ) as client: response = await client.post( url, headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"}, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, }, ) except httpx.TimeoutException as exc: raise SpeechClientError(f"Timeout during Speech Analytics authentication: {exc}") from exc except httpx.RequestError as exc: raise SpeechClientError(f"Network error during Speech Analytics authentication: {exc}") from exc if response.status_code != 200: raise SpeechClientError( f"Speech Analytics authentication failed: {response.text}", status_code=response.status_code, ) payload = response.json() SpeechAnalyticsClient._cached_token = payload["access_token"] expires_in = int(payload.get("expires_in", 3600)) SpeechAnalyticsClient._token_expires_at = time.time() + expires_in logger.info("Speech Analytics token obtained. Expires in %d s.", expires_in) return SpeechAnalyticsClient._cached_token @trace_tool async def get_prediction( self, reclamacao_id: str, raw_text: str, customer_segment: str, ) -> Tuple[dict, Dict[str, Any]]: """ Call the Prediction API and return the raw response dict with metadata. Returns: Tuple of (response_body, http_meta) where: - response_body: dict - The prediction API response. - http_meta: Dict with keys url, status_code, response_text, latency_ms. """ token = await self._get_token() base_url = settings.SPEECH_PREDICTION_BASE_URL url = f"{base_url.rstrip('/')}{_PREDICTION_PATH}" body = { "customer_segment": customer_segment, "prediction_variables": _PREDICTION_VARIABLES, "raw_text": raw_text, "reclamacao_id": reclamacao_id, } logger.info( "Calling Speech Analytics Prediction API. reclamacao_id=%s customer_segment=%s", reclamacao_id, customer_segment, ) start = time.perf_counter() try: async with traced_async_client(timeout=settings.SPEECH_TIMEOUT) as client: response = await client.post( url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", }, json=body, ) except httpx.TimeoutException as exc: latency_ms = int((time.perf_counter() - start) * 1000) raise SpeechClientError(f"Timeout calling Speech Analytics Prediction: {exc}", url=url, latency_ms=latency_ms) from exc except httpx.RequestError as exc: latency_ms = int((time.perf_counter() - start) * 1000) raise SpeechClientError(f"Network error calling Speech Analytics Prediction: {exc}", url=url, latency_ms=latency_ms) from exc latency_ms = int((time.perf_counter() - start) * 1000) if response.status_code != 200: raise SpeechClientError( f"Speech Analytics Prediction API error: {response.text}", status_code=response.status_code, url=url, response_text=response.text, latency_ms=latency_ms, ) http_meta = { "url": url, "status_code": response.status_code, "response_text": response.text, "latency_ms": latency_ms, } return response.json(), http_meta def _refresh_history_id_token(self) -> tuple[str, float]: """ Synchronously fetch a Google service-account ID Token for the Speech History Cloud Run service. Returns (token, expires_at_epoch). Reads GOOGLE_APPLICATION_CREDENTIALS env var for the service-account JSON file path, and uses SPEECH_HISTORY_AUDIENCE as target_audience. """ from google.oauth2 import service_account import google.auth.transport.requests sa_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") audience = settings.SPEECH_HISTORY_AUDIENCE # Local development: return dummy token if file doesn't exist or DEBUG=True if settings.DEBUG and (not sa_path or not os.path.exists(sa_path)): logger.warning("Running in DEBUG mode — using dummy Google ID Token for Speech History API") return "dummy-id-token-local-dev", time.time() + 3600 if not sa_path: raise SpeechClientError( "GOOGLE_APPLICATION_CREDENTIALS env var not set — " "cannot authenticate to Speech History API." ) if not audience: raise SpeechClientError( "SPEECH_HISTORY_AUDIENCE not configured — " "cannot mint Google ID Token for Speech History API." ) creds = service_account.IDTokenCredentials.from_service_account_file( sa_path, target_audience=audience, ) creds.refresh(google.auth.transport.requests.Request()) # creds.expiry is a naive UTC datetime; convert to epoch seconds expires_at = creds.expiry.timestamp() if creds.expiry else time.time() + 3600 return creds.token, expires_at async def _get_history_id_token(self) -> str: """Return a valid Google ID Token, refreshing it when needed.""" if SpeechAnalyticsClient._history_auth_unavailable: raise SpeechClientError( "Speech History API unavailable: Google credentials not configured or invalid." ) if ( SpeechAnalyticsClient._cached_history_id_token and time.time() < SpeechAnalyticsClient._history_id_token_expires_at - 60 ): return SpeechAnalyticsClient._cached_history_id_token logger.info("Requesting new Google ID Token for Speech History API.") try: token, expires_at = await asyncio.to_thread(self._refresh_history_id_token) except SpeechClientError as exc: SpeechAnalyticsClient._history_auth_unavailable = True logger.warning("Speech History API will be skipped: %s", exc) raise SpeechAnalyticsClient._cached_history_id_token = token SpeechAnalyticsClient._history_id_token_expires_at = expires_at logger.info( "Speech History ID Token obtained. Expires at epoch %.0f.", expires_at ) return token @trace_tool async def get_history(self, cpf_cnpj: str, acesso_gsm: str | None = None) -> Tuple[list, Dict[str, Any]]: """ Call the complaint history API authenticated via Google service-account ID Token. Returns: Tuple of (response_body, http_meta) where: - response_body: list - The history API response. - http_meta: Dict with keys url, status_code, response_text, latency_ms. Raises: SpeechClientError: on auth failure, network error, or non-2xx HTTP response. """ base_url = settings.SPEECH_HISTORY_BASE_URL if not base_url: raise SpeechClientError( "SPEECH_HISTORY_BASE_URL not configured — cannot call Speech History API." ) url = f"{base_url.rstrip('/')}{_HISTORY_PATH}" params = {"cpf_cnpj": cpf_cnpj} if acesso_gsm: params["acesso_gsm"] = acesso_gsm logger.info( "Calling Speech Analytics History API. cpf_cnpj=%s acesso_gsm=%s", cpf_cnpj, acesso_gsm, ) token = await self._get_history_id_token() headers = {"Authorization": f"Bearer {token}"} if settings.SPEECH_HISTORY_HOST: headers["Host"] = settings.SPEECH_HISTORY_HOST start = time.perf_counter() try: async with traced_async_client(timeout=settings.SPEECH_TIMEOUT) as client: response = await client.get(url, params=params, headers=headers) except httpx.TimeoutException as exc: latency_ms = int((time.perf_counter() - start) * 1000) raise SpeechClientError(f"Timeout calling Speech Analytics History: {exc}", url=url, latency_ms=latency_ms) from exc except httpx.RequestError as exc: latency_ms = int((time.perf_counter() - start) * 1000) raise SpeechClientError(f"Network error calling Speech Analytics History: {exc}", url=url, latency_ms=latency_ms) from exc latency_ms = int((time.perf_counter() - start) * 1000) if response.status_code != 200: raise SpeechClientError( f"Speech Analytics History API error: {response.text}", status_code=response.status_code, url=url, response_text=response.text, latency_ms=latency_ms, ) http_meta = { "url": url, "status_code": response.status_code, "response_text": response.text, "latency_ms": latency_ms, } return response.json(), http_meta