mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
333 lines
13 KiB
Python
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
|