Files
compass_backoffice/src/components/clients/speech_analytics_client.py
2026-06-13 08:23:21 -03:00

333 lines
13 KiB
Python

"""
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