mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-10 06:14:19 +00:00
first commit
This commit is contained in:
BIN
src/components/clients/__pycache__/abrt_client.cpython-313.pyc
Normal file
BIN
src/components/clients/__pycache__/abrt_client.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/components/clients/__pycache__/imdb_client.cpython-313.pyc
Normal file
BIN
src/components/clients/__pycache__/imdb_client.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/components/clients/__pycache__/siebel_client.cpython-313.pyc
Normal file
BIN
src/components/clients/__pycache__/siebel_client.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
233
src/components/clients/abrt_client.py
Normal file
233
src/components/clients/abrt_client.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import json
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
from httpx import Timeout
|
||||
from typing import Optional, Tuple, Any
|
||||
from src.core.config import settings
|
||||
from src.components.clients.exceptions.abrt_exceptions import (
|
||||
AbrtClientError,
|
||||
AbrtHttpError,
|
||||
AbrtConnectionError,
|
||||
AbrtTimeoutError,
|
||||
AbrtNoContentError,
|
||||
AbrtExceededRetriesError
|
||||
)
|
||||
from src.api.schemas.abrt_schemas import AbrtResponse
|
||||
from src.utils.observer import trace_tool
|
||||
from src.utils.http import traced_async_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbrtClient:
|
||||
"""
|
||||
Client for the ABRT API to retrieve access information
|
||||
based on social security number and MSISDN.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
authorization: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
):
|
||||
self.timeout = Timeout(5.0, connect=float(settings.ABRT_API_TIMEOUT))
|
||||
self.base_url = base_url or settings.ABRT_API_BASE_URL
|
||||
self.authorization = authorization or settings.ABRT_API_AUTHORIZATION
|
||||
self.token = token or settings.ABRT_API_TOKEN
|
||||
self.client_id = client_id or settings.ABRT_API_CLIENT_ID
|
||||
|
||||
self._RETRYABLE_NETWORK_ERRORS = (
|
||||
AbrtTimeoutError,
|
||||
AbrtConnectionError,
|
||||
)
|
||||
self._RETRYABLE_STATUS_CODES = {429, 503, 504}
|
||||
|
||||
def _is_retryable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Returns True only for transient errors that should be retried.
|
||||
"""
|
||||
if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS):
|
||||
return True
|
||||
|
||||
if isinstance(exc, AbrtHttpError):
|
||||
return exc.status_code in self._RETRYABLE_STATUS_CODES
|
||||
|
||||
return False
|
||||
|
||||
def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
|
||||
return random.uniform(0, min(cap, base * (2 ** attempt)))
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Builds headers for the API request."""
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"clientId": self.client_id,
|
||||
"Authorization": self.authorization,
|
||||
"Token": self.token
|
||||
}
|
||||
|
||||
def _build_body(self, social_sec_no: str, msisdn: str) -> dict:
|
||||
"""Builds the request body."""
|
||||
return {
|
||||
"socialSecNo": social_sec_no,
|
||||
"msisdn": msisdn,
|
||||
"flagPos": True,
|
||||
"flagPre": True
|
||||
}
|
||||
|
||||
def _parse_response(self, response: httpx.Response) -> AbrtResponse:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return AbrtResponse(**response_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse JSON from ABRT API.", exc_info=True)
|
||||
raise AbrtClientError("Invalid JSON response from ABRT API") from exc
|
||||
|
||||
@trace_tool
|
||||
async def get_abrt_data(
|
||||
self,
|
||||
social_sec_no: str,
|
||||
msisdn: str,
|
||||
) -> Tuple[AbrtResponse, dict[str, Any]]:
|
||||
"""Calls the ABRT API and returns (parsed_response, http_meta).
|
||||
|
||||
http_meta has the shape {url, status_code, response_text, latency_ms},
|
||||
same contract used by SiebelClient/ImdbClient/TaisKbClient so that
|
||||
callers can build IC payloads uniformly.
|
||||
|
||||
On error, raises AbrtClientError (or subclass) carrying the same
|
||||
attributes (url, status_code, response_text, latency_ms) so the
|
||||
caller can read them via `getattr(exc, "<field>", None)`.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
headers = self._build_headers()
|
||||
body = self._build_body(social_sec_no, msisdn)
|
||||
|
||||
async with traced_async_client(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
self.base_url,
|
||||
headers=headers,
|
||||
json=body,
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
response_text = response.text
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 204 or not response.content:
|
||||
logger.warning(
|
||||
f"ABRT API returned {status_code} with no content "
|
||||
f"for msisdn={msisdn}."
|
||||
)
|
||||
raise AbrtNoContentError(
|
||||
f"ABRT API returned {status_code} with no usable content.",
|
||||
url=self.base_url,
|
||||
status_code=status_code,
|
||||
response_text=response_text,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
message_id = response.headers.get("Messageid")
|
||||
logger.info(f"ABRT Transaction Message ID: {message_id}")
|
||||
|
||||
parsed = self._parse_response(response)
|
||||
http_meta = {
|
||||
"url": self.base_url,
|
||||
"status_code": status_code,
|
||||
"response_text": response_text[:500] if response_text else "",
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return parsed, http_meta
|
||||
|
||||
except AbrtNoContentError:
|
||||
raise
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
response_text = exc.response.text if exc.response is not None else ""
|
||||
logger.error(
|
||||
f"HTTP error {exc.response.status_code} when calling ABRT API"
|
||||
, exc_info=True)
|
||||
raise AbrtHttpError(
|
||||
exc.response.status_code,
|
||||
f"HTTP error {exc.response.status_code} when consulting ABRT API",
|
||||
url=self.base_url,
|
||||
response_text=response_text[:500] if response_text else "",
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
except httpx.TimeoutException as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
logger.error(
|
||||
f"Request to ABRT API timed out after {self.timeout.connect}s"
|
||||
, exc_info=True)
|
||||
raise AbrtTimeoutError(
|
||||
self.base_url,
|
||||
self.timeout.connect,
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
except httpx.RequestError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(exc) or repr(exc)
|
||||
logger.error(f"Connection error at ABRT API: {detail}", exc_info=True)
|
||||
raise AbrtConnectionError(
|
||||
self.base_url,
|
||||
reason=detail,
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
except Exception as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(exc) or repr(exc)
|
||||
logger.error(f"Unexpected error at ABRT API: {detail}", exc_info=True)
|
||||
raise AbrtClientError(
|
||||
f"Unexpected error at ABRT API: {detail}",
|
||||
url=self.base_url,
|
||||
response_text=detail[:500] if detail else "",
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
async def get_abrt_data_with_retry(
|
||||
self,
|
||||
social_sec_no: str,
|
||||
msisdn: str,
|
||||
max_retries: int = 3,
|
||||
) -> Tuple[AbrtResponse, dict[str, Any]]:
|
||||
"""Returns (parsed_response, http_meta) following the same contract as
|
||||
SiebelClient.open_service_request_with_retry. The http_meta includes
|
||||
a "retry_count" field with the number of retries performed.
|
||||
"""
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response, http_meta = await self.get_abrt_data(social_sec_no, msisdn)
|
||||
http_meta["retry_count"] = attempt
|
||||
return response, http_meta
|
||||
|
||||
except AbrtClientError as err:
|
||||
if not self._is_retryable(err):
|
||||
raise
|
||||
|
||||
if attempt == max_retries:
|
||||
raise AbrtExceededRetriesError(
|
||||
self.base_url,
|
||||
max_retries,
|
||||
err,
|
||||
latency_ms=getattr(err, "latency_ms", None),
|
||||
)
|
||||
|
||||
wait = self._backoff(attempt)
|
||||
logger.warning(
|
||||
f"Transient error (attempt {attempt + 1}/{max_retries}), "
|
||||
f"retrying ABRT API call. Last error: {err}"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
259
src/components/clients/emulator_rag_client.py
Normal file
259
src/components/clients/emulator_rag_client.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
DB/embedding client for the emulator RAG sources.
|
||||
|
||||
Generates the query embedding via OCI GenAI (Cohere multilingual) and runs a
|
||||
vector-similarity search against the emulator RAG tables in the same Oracle
|
||||
Autonomous Database used by TAIS. Mirrors the TAIS KB client pattern, but is
|
||||
a trimmed-down version (no query pre/postprocessing, no segment filters).
|
||||
|
||||
The two sources have different schemas, so the id/text/metadata columns are
|
||||
mapped per source. The embedding column is always `EMBEDDING` (VECTOR).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import oci
|
||||
import oci.exceptions
|
||||
import oci.generative_ai_inference.models
|
||||
import oci.retry
|
||||
import oracledb
|
||||
|
||||
from src.components.clients.exceptions.emulator_rag_exceptions import EmulatorRagClientError
|
||||
from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Return CLOBs as native strings instead of LOB handles to keep the search code simple.
|
||||
oracledb.defaults.fetch_lobs = False
|
||||
|
||||
|
||||
class EmulatorRagSource(str, Enum):
|
||||
"""Supported emulator RAG sources."""
|
||||
templates = "templates"
|
||||
anatel_resposta = "anatel_resposta"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SourceMapping:
|
||||
"""Maps an emulator RAG source to its table and relevant columns."""
|
||||
table_setting: str
|
||||
id_col: str
|
||||
text_col: str
|
||||
embedding_col: str = "EMBEDDING"
|
||||
metadata_cols: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
_SOURCE_MAPPINGS: dict[EmulatorRagSource, _SourceMapping] = {
|
||||
EmulatorRagSource.templates: _SourceMapping(
|
||||
table_setting="EMULATOR_RAG_TEMPLATES_CHUNKS",
|
||||
id_col="ID",
|
||||
text_col="EMBEDDING_TEXT",
|
||||
metadata_cols=(
|
||||
"ITEM",
|
||||
"QUANDO_USAR",
|
||||
"INFORMACOES_OBRIGATORIAS",
|
||||
"SUGESTAO_PARA_COMPOR_RESPOSTA",
|
||||
"MENU",
|
||||
),
|
||||
),
|
||||
EmulatorRagSource.anatel_resposta: _SourceMapping(
|
||||
table_setting="EMULATOR_RAG_ANATEL_NOTAS_RESPOSTA_CHUNKS",
|
||||
id_col="ID_CHUNK",
|
||||
text_col="CHUNK_RESPOSTA",
|
||||
metadata_cols=("ID", "NOTA"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class EmulatorRagClient:
|
||||
"""Async client for the emulator RAG tables (Oracle ADB + OCI embeddings)."""
|
||||
|
||||
_embed_client: oci.generative_ai_inference.GenerativeAiInferenceClient | None = None
|
||||
|
||||
@classmethod
|
||||
def _get_embed_client(cls) -> oci.generative_ai_inference.GenerativeAiInferenceClient:
|
||||
if cls._embed_client is not None:
|
||||
return cls._embed_client
|
||||
|
||||
if not settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT or not settings.EMULATOR_RAG_COMPARTMENT_ID:
|
||||
raise EmulatorRagClientError(
|
||||
"Emulator RAG GenAI not configured "
|
||||
"(EMULATOR_RAG_OCI_GENAI_ENDPOINT, EMULATOR_RAG_COMPARTMENT_ID)."
|
||||
)
|
||||
|
||||
import os
|
||||
from agent_framework.config.settings import settings as fw_settings
|
||||
|
||||
oci_config = oci.config.from_file(
|
||||
os.path.expanduser(getattr(fw_settings, "OCI_CONFIG_FILE", "~/.oci/config")),
|
||||
getattr(fw_settings, "OCI_PROFILE", None) or settings.OCI_CONFIG_PROFILE,
|
||||
)
|
||||
if getattr(fw_settings, "OCI_REGION", None):
|
||||
oci_config["region"] = fw_settings.OCI_REGION
|
||||
|
||||
cls._embed_client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||||
config=oci_config,
|
||||
service_endpoint=settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT,
|
||||
retry_strategy=oci.retry.NoneRetryStrategy(),
|
||||
timeout=settings.TAIS_DB_TIMEOUT,
|
||||
)
|
||||
return cls._embed_client
|
||||
|
||||
def _embed_sync(self, text: str) -> list[float]:
|
||||
client = self._get_embed_client()
|
||||
embed_detail = oci.generative_ai_inference.models.EmbedTextDetails()
|
||||
embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(
|
||||
model_id=settings.EMULATOR_RAG_EMBED_MODEL_ID
|
||||
)
|
||||
embed_detail.inputs = [text]
|
||||
embed_detail.truncate = "NONE"
|
||||
embed_detail.compartment_id = settings.EMULATOR_RAG_COMPARTMENT_ID
|
||||
embed_detail.input_type = "SEARCH_QUERY"
|
||||
|
||||
endpoint = settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = client.embed_text(embed_detail)
|
||||
except oci.exceptions.ServiceError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
raise EmulatorRagClientError(
|
||||
f"OCI embedding service error: {exc}",
|
||||
status_code=exc.status,
|
||||
url=endpoint,
|
||||
response_text=str(getattr(exc, "message", exc)),
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
except (oci.exceptions.RequestException, oci.exceptions.ConnectTimeout) as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
raise EmulatorRagClientError(
|
||||
f"OCI embedding request error: {exc}",
|
||||
url=endpoint,
|
||||
response_text=str(exc),
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
return response.data._embeddings[0]
|
||||
|
||||
async def _embed(self, text: str) -> list[float]:
|
||||
# OCI Python SDK has no async client; isolate the blocking call.
|
||||
return await asyncio.to_thread(self._embed_sync, text)
|
||||
|
||||
@staticmethod
|
||||
def _fill_sql_with_bind_params(sql: str, bind_params: dict[str, object]) -> str:
|
||||
"""Replace bind parameters in SQL with their values (for debugging). Vectors are masked."""
|
||||
filled_sql = sql
|
||||
for key, value in bind_params.items():
|
||||
if key == "query_embedding":
|
||||
filled_sql = filled_sql.replace(f":{key}", "[VECTOR]")
|
||||
elif isinstance(value, str):
|
||||
filled_sql = filled_sql.replace(f":{key}", f"'{value.replace(chr(39), chr(39) * 2)}'")
|
||||
elif isinstance(value, (int, float)):
|
||||
filled_sql = filled_sql.replace(f":{key}", str(value))
|
||||
elif value is None:
|
||||
filled_sql = filled_sql.replace(f":{key}", "NULL")
|
||||
else:
|
||||
filled_sql = filled_sql.replace(f":{key}", f"'{str(value)}'")
|
||||
return filled_sql
|
||||
|
||||
async def search(
|
||||
self,
|
||||
source: EmulatorRagSource,
|
||||
query: str,
|
||||
nota_min: int | None = 4,
|
||||
nota_max: int | None = None,
|
||||
top_k: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Embed `query` and return the closest chunks from the given source.
|
||||
|
||||
`nota_min`/`nota_max` only apply to anatel_resposta and bound the
|
||||
note range (inclusive): pass only `nota_min` for the high-score
|
||||
bucket, only `nota_max` for the low-score bucket, or both for a
|
||||
closed range. Either may be `None` to leave that side unbounded.
|
||||
Returns `{results: [{id, content, distance, metadata}], top_k, sql}`.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
raise ValueError("query must be a non-empty string")
|
||||
if not isinstance(source, EmulatorRagSource):
|
||||
raise ValueError(f"Invalid source: {source!r}. Valid: {[s.value for s in EmulatorRagSource]}")
|
||||
|
||||
mapping = _SOURCE_MAPPINGS[source]
|
||||
table = getattr(settings, mapping.table_setting)
|
||||
effective_top_k = top_k or settings.EMULATOR_RAG_TOP_K
|
||||
|
||||
embedding = await self._embed(query)
|
||||
embedding_str = json.dumps(embedding)
|
||||
|
||||
bind_params: dict[str, object] = {
|
||||
"query_embedding": embedding_str,
|
||||
"fetch_limit": effective_top_k,
|
||||
}
|
||||
|
||||
select_cols = [mapping.id_col, mapping.text_col, *mapping.metadata_cols]
|
||||
select_clause = ",\n ".join(select_cols)
|
||||
where_clauses = ""
|
||||
if source is EmulatorRagSource.anatel_resposta:
|
||||
# `nota` is a validated int (settings/Query, ge=0 le=5), interpolated
|
||||
# directly like before — never a user-supplied string.
|
||||
note_filters = []
|
||||
if nota_min is not None:
|
||||
note_filters.append(f"nota >= {int(nota_min)}")
|
||||
if nota_max is not None:
|
||||
note_filters.append(f"nota <= {int(nota_max)}")
|
||||
if note_filters:
|
||||
where_clauses = "WHERE " + " AND ".join(note_filters)
|
||||
sql = f"""
|
||||
SELECT
|
||||
{select_clause},
|
||||
VECTOR_DISTANCE({mapping.embedding_col}, TO_VECTOR(:query_embedding), COSINE) AS distance
|
||||
FROM {table}
|
||||
{where_clauses}
|
||||
ORDER BY distance ASC
|
||||
FETCH FIRST :fetch_limit ROWS ONLY
|
||||
"""
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with oracledb.connect_async(
|
||||
user=settings.MONGODB_DB_USER,
|
||||
password=settings.MONGODB_DB_PASSWORD,
|
||||
dsn=settings.TAIS_DB_DSN,
|
||||
tcp_connect_timeout=settings.TAIS_DB_TIMEOUT,
|
||||
) as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(sql, bind_params)
|
||||
rows = await cur.fetchall()
|
||||
cols = [c[0].lower() for c in cur.description]
|
||||
except oracledb.Error as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
raise EmulatorRagClientError(
|
||||
f"Emulator RAG DB error: {exc}",
|
||||
url=settings.TAIS_DB_DSN,
|
||||
response_text=str(exc),
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
id_key = mapping.id_col.lower()
|
||||
text_key = mapping.text_col.lower()
|
||||
metadata_keys = [c.lower() for c in mapping.metadata_cols]
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
record = dict(zip(cols, row))
|
||||
results.append({
|
||||
"id": str(record.get(id_key)) if record.get(id_key) is not None else "",
|
||||
"content": record.get(text_key) or "",
|
||||
"distance": float(record.get("distance", 0.0)),
|
||||
"metadata": {k: record.get(k) for k in metadata_keys} or None,
|
||||
})
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"top_k": effective_top_k,
|
||||
"sql": self._fill_sql_with_bind_params(sql, bind_params),
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
106
src/components/clients/exceptions/abrt_exceptions.py
Normal file
106
src/components/clients/exceptions/abrt_exceptions.py
Normal file
@@ -0,0 +1,106 @@
|
||||
class AbrtClientError(Exception):
|
||||
"""Base for all ABRT Client Errors.
|
||||
|
||||
Carries optional http_meta attributes so downstream callers can build
|
||||
consistent IC payloads via `getattr(exc, "<field>", None) or fallback`,
|
||||
same pattern used by SiebelClientError/ImdbClientError.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
url: str | None = None,
|
||||
status_code: int | None = None,
|
||||
response_text: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args)
|
||||
self.url = url
|
||||
self.status_code = status_code
|
||||
self.response_text = response_text
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
|
||||
class AbrtNoContentError(AbrtClientError):
|
||||
"""API got a response but with no content (ex: 204)."""
|
||||
pass
|
||||
|
||||
|
||||
class AbrtHttpError(AbrtClientError):
|
||||
"""API responded with an error status (4xx, 5xx)."""
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
*,
|
||||
url: str | None = None,
|
||||
response_text: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
message,
|
||||
url=url,
|
||||
status_code=status_code,
|
||||
response_text=response_text,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
class AbrtTimeoutError(AbrtClientError):
|
||||
"""Request timed out before the server responded."""
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout: float,
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
self.timeout = timeout
|
||||
super().__init__(
|
||||
f"Request to {url!r} timed out after {timeout}s",
|
||||
url=url,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
class AbrtConnectionError(AbrtClientError):
|
||||
"""Could not establish a connection to the API."""
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
reason: str = "",
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
self.reason = reason
|
||||
msg = f"Connection to {url!r} failed"
|
||||
if reason:
|
||||
msg += f": {reason}"
|
||||
super().__init__(
|
||||
msg,
|
||||
url=url,
|
||||
response_text=reason or None,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
class AbrtExceededRetriesError(AbrtClientError):
|
||||
"""All retry attempts were exhausted for a transient error."""
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
attempts: int,
|
||||
last_error: AbrtClientError,
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
self.attempts = attempts
|
||||
self.last_error = last_error
|
||||
super().__init__(
|
||||
f"Exceeded {attempts} retry attempt(s) for {url!r} "
|
||||
f"— last error: {last_error}",
|
||||
url=url,
|
||||
status_code=getattr(last_error, "status_code", None),
|
||||
response_text=getattr(last_error, "response_text", None) or str(last_error),
|
||||
latency_ms=latency_ms if latency_ms is not None else getattr(last_error, "latency_ms", None),
|
||||
)
|
||||
30
src/components/clients/exceptions/emulator_rag_exceptions.py
Normal file
30
src/components/clients/exceptions/emulator_rag_exceptions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
class EmulatorRagClientError(Exception):
|
||||
"""Raised when the emulator RAG lookup fails (DB error, embedding error, config missing).
|
||||
|
||||
Attributes:
|
||||
status_code: Optional HTTP status code (from OCI embedding errors).
|
||||
url: Optional URL/endpoint that was called.
|
||||
response_text: Optional response body.
|
||||
latency_ms: Optional operation latency in milliseconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
*,
|
||||
url: str | None = None,
|
||||
response_text: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.response_text = response_text
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
def __str__(self) -> str:
|
||||
base = super().__str__()
|
||||
if self.status_code:
|
||||
return f"[HTTP {self.status_code}] {base}"
|
||||
return base
|
||||
45
src/components/clients/exceptions/imdb_exceptions.py
Normal file
45
src/components/clients/exceptions/imdb_exceptions.py
Normal file
@@ -0,0 +1,45 @@
|
||||
class ImdbClientError(Exception):
|
||||
"""Base for all IMDB Client Errors."""
|
||||
def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.url = url
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
class ImdbHttpError(ImdbClientError):
|
||||
"""API responded with an error status (4xx, 5xx)."""
|
||||
def __init__(self, status_code: int, message: str, *, url: str | None = None, response_text: str | None = None, latency_ms: int | None = None):
|
||||
self.status_code = status_code
|
||||
self.response_text = response_text
|
||||
super().__init__(message, url=url, latency_ms=latency_ms)
|
||||
|
||||
class ImdbTimeoutError(ImdbClientError):
|
||||
"""Request timed out before the server responded."""
|
||||
def __init__(self, url: str, timeout: float, *, latency_ms: int | None = None):
|
||||
self.url = url
|
||||
self.timeout = timeout
|
||||
super().__init__(f"Request to {url!r} timed out after {timeout}s", url=url, latency_ms=latency_ms)
|
||||
|
||||
class ImdbConnectionError(ImdbClientError):
|
||||
"""Could not establish a connection to the API."""
|
||||
def __init__(self, url: str, reason: str = "", *, latency_ms: int | None = None):
|
||||
self.url = url
|
||||
self.reason = reason
|
||||
msg = f"Connection to {url!r} failed"
|
||||
if reason:
|
||||
msg += f": {reason}"
|
||||
super().__init__(msg, url=url, latency_ms=latency_ms)
|
||||
|
||||
class ImdbExceededRetriesError(ImdbClientError):
|
||||
"""All retry attempts were exhausted for a transient error."""
|
||||
def __init__(self, url: str, attempts: int, last_error: ImdbClientError):
|
||||
self.url = url
|
||||
self.attempts = attempts
|
||||
self.last_error = last_error
|
||||
# Propagate latency_ms from the last error if available
|
||||
self.latency_ms = getattr(last_error, 'latency_ms', None)
|
||||
super().__init__(
|
||||
f"Exceeded {attempts} retry attempt(s) for {url!r} "
|
||||
f"— last error: {last_error}",
|
||||
url=url,
|
||||
latency_ms=self.latency_ms
|
||||
)
|
||||
105
src/components/clients/exceptions/portability_exceptions.py
Normal file
105
src/components/clients/exceptions/portability_exceptions.py
Normal file
@@ -0,0 +1,105 @@
|
||||
class PortabilityClientError(Exception):
|
||||
"""Base for all Portability Client Errors.
|
||||
|
||||
Carries optional http_meta attributes so downstream callers can build
|
||||
consistent IC payloads via `getattr(exc, "<field>", None) or fallback`,
|
||||
same pattern used by SiebelClientError/ImdbClientError/AbrtClientError.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
url: str | None = None,
|
||||
status_code: int | None = None,
|
||||
response_text: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
super().__init__(*args)
|
||||
self.url = url
|
||||
self.status_code = status_code
|
||||
self.response_text = response_text
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
|
||||
class PortabilityNoContentError(PortabilityClientError):
|
||||
"""API got a response but with no content (ex: 204)."""
|
||||
pass
|
||||
|
||||
|
||||
class PortabilityHttpError(PortabilityClientError):
|
||||
"""API responded with an error status (4xx, 5xx)."""
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
*,
|
||||
url: str | None = None,
|
||||
response_text: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
message,
|
||||
url=url,
|
||||
status_code=status_code,
|
||||
response_text=response_text,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
class PortabilityTimeoutError(PortabilityClientError):
|
||||
"""Request timed out before the server responded."""
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout: float,
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
self.timeout = timeout
|
||||
super().__init__(
|
||||
f"Request to {url!r} timed out after {timeout}s",
|
||||
url=url,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
class PortabilityConnectionError(PortabilityClientError):
|
||||
"""Could not establish a connection to the API."""
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
reason: str = "",
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
self.reason = reason
|
||||
msg = f"Connection to {url!r} failed"
|
||||
if reason:
|
||||
msg += f": {reason}"
|
||||
super().__init__(
|
||||
msg,
|
||||
url=url,
|
||||
response_text=reason or None,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
class PortabilityExceededRetriesError(PortabilityClientError):
|
||||
"""All retry attempts were exhausted for a transient error."""
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
attempts: int,
|
||||
last_error: PortabilityClientError,
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
):
|
||||
self.attempts = attempts
|
||||
self.last_error = last_error
|
||||
last_error_text = getattr(last_error, "response_text", None) or str(last_error)
|
||||
super().__init__(
|
||||
f"Exceeded {attempts} retry attempt(s) for {url!r} — last error: {last_error}",
|
||||
url=url,
|
||||
status_code=getattr(last_error, "status_code", None),
|
||||
response_text=last_error_text,
|
||||
latency_ms=latency_ms if latency_ms is not None else getattr(last_error, "latency_ms", None),
|
||||
)
|
||||
80
src/components/clients/exceptions/siebel_exceptions.py
Normal file
80
src/components/clients/exceptions/siebel_exceptions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Exceptions for the SiebelClient.
|
||||
|
||||
Hierarchy:
|
||||
SiebelClientError (base)
|
||||
├── SiebelHttpError – Non-2xx HTTP response from the Siebel API
|
||||
├── SiebelConnectionError – Could not establish a connection to the Siebel API
|
||||
└── SiebelTimeoutError – Request to the Siebel API timed out
|
||||
"""
|
||||
|
||||
|
||||
class SiebelClientError(Exception):
|
||||
"""Base exception for all Siebel client errors."""
|
||||
def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.url = url
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
|
||||
class SiebelHttpError(SiebelClientError):
|
||||
"""
|
||||
Raised when the Siebel API returns a non-2xx HTTP status code.
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP status code returned by the API.
|
||||
response_text: Raw response body returned by the API.
|
||||
url: The URL that was called.
|
||||
latency_ms: The request latency in milliseconds.
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, response_text: str, *, url: str | None = None, latency_ms: int | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.response_text = response_text
|
||||
super().__init__(
|
||||
f"Siebel API returned {status_code}: {response_text}",
|
||||
url=url,
|
||||
latency_ms=latency_ms
|
||||
)
|
||||
|
||||
|
||||
class SiebelConnectionError(SiebelClientError):
|
||||
"""
|
||||
Raised when a connection to the Siebel API cannot be established.
|
||||
|
||||
This typically wraps lower-level network errors such as DNS failures
|
||||
or refused connections.
|
||||
|
||||
Attributes:
|
||||
url: The URL that was being connected to.
|
||||
latency_ms: The request latency in milliseconds.
|
||||
"""
|
||||
def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None:
|
||||
super().__init__(message, url=url, latency_ms=latency_ms)
|
||||
|
||||
|
||||
class SiebelTimeoutError(SiebelClientError):
|
||||
"""
|
||||
Raised when a request to the Siebel API exceeds the configured timeout.
|
||||
|
||||
Attributes:
|
||||
url: The URL that was being called.
|
||||
latency_ms: The request latency in milliseconds.
|
||||
"""
|
||||
def __init__(self, message: str, *, url: str | None = None, latency_ms: int | None = None) -> None:
|
||||
super().__init__(message, url=url, latency_ms=latency_ms)
|
||||
|
||||
class SiebelExceededRetriesError(SiebelClientError):
|
||||
"""All retry attempts were exhausted for a transient error."""
|
||||
def __init__(self, url: str, attempts: int, last_error: SiebelClientError):
|
||||
self.url = url
|
||||
self.attempts = attempts
|
||||
self.last_error = last_error
|
||||
# Propagate latency_ms from the last error if available
|
||||
self.latency_ms = getattr(last_error, 'latency_ms', None)
|
||||
super().__init__(
|
||||
f"Exceeded {attempts} retry attempt(s) for {url!r} "
|
||||
f"— last error: {last_error}",
|
||||
url=url,
|
||||
latency_ms=self.latency_ms
|
||||
)
|
||||
33
src/components/clients/exceptions/speech_exceptions.py
Normal file
33
src/components/clients/exceptions/speech_exceptions.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Custom exceptions for the Speech Analytics API client.
|
||||
"""
|
||||
|
||||
|
||||
class SpeechClientError(Exception):
|
||||
"""
|
||||
Raised when the Speech Analytics API call fails for any reason
|
||||
(authentication error, timeout, unexpected HTTP status, etc.).
|
||||
|
||||
The speech_enrichment_node catches this exception to implement the
|
||||
graceful-degradation policy: log a warning and continue the graph
|
||||
with null speech data instead of aborting the flow.
|
||||
|
||||
Attributes:
|
||||
status_code: Optional HTTP status code.
|
||||
url: Optional URL that was called.
|
||||
response_text: Optional response body.
|
||||
latency_ms: Optional request latency in milliseconds.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None, *, url: str | None = None, response_text: str | None = None, latency_ms: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.response_text = response_text
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
def __str__(self) -> str:
|
||||
base = super().__str__()
|
||||
if self.status_code:
|
||||
return f"[HTTP {self.status_code}] {base}"
|
||||
return base
|
||||
30
src/components/clients/exceptions/tais_kb_exceptions.py
Normal file
30
src/components/clients/exceptions/tais_kb_exceptions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
class TaisKbClientError(Exception):
|
||||
"""Raised when the TAIS knowledge base lookup fails (DB error, embedding error, config missing).
|
||||
|
||||
Attributes:
|
||||
status_code: Optional HTTP status code (from OCI embedding errors).
|
||||
url: Optional URL/endpoint that was called.
|
||||
response_text: Optional response body.
|
||||
latency_ms: Optional operation latency in milliseconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
*,
|
||||
url: str | None = None,
|
||||
response_text: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.response_text = response_text
|
||||
self.latency_ms = latency_ms
|
||||
|
||||
def __str__(self) -> str:
|
||||
base = super().__str__()
|
||||
if self.status_code:
|
||||
return f"[HTTP {self.status_code}] {base}"
|
||||
return base
|
||||
222
src/components/clients/imdb_client.py
Normal file
222
src/components/clients/imdb_client.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import json
|
||||
import asyncio
|
||||
import random
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
from src.utils.observer import trace_tool
|
||||
from src.utils.http import traced_async_client
|
||||
|
||||
from httpx import Timeout
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from src.core.config import settings
|
||||
from src.components.clients.exceptions.imdb_exceptions import (
|
||||
ImdbClientError,
|
||||
ImdbHttpError,
|
||||
ImdbConnectionError,
|
||||
ImdbTimeoutError,
|
||||
ImdbExceededRetriesError
|
||||
)
|
||||
from src.api.schemas.imdb_schemas import ImdbResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ImdbClient:
|
||||
"""
|
||||
Client for the IMDB API to retrieve IMDB access data.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
basic_token: Optional[str] = None
|
||||
):
|
||||
|
||||
self.timeout = Timeout(5.0,
|
||||
connect=float(settings.PMID_API_TIMEOUT)
|
||||
)
|
||||
self.base_url = base_url or settings.PMID_API_HOST
|
||||
self.basic_token = basic_token or settings.PMID_API_BASIC_TOKEN
|
||||
|
||||
self._RETRYABLE_NETWORK_ERRORS = (
|
||||
ImdbTimeoutError,
|
||||
ImdbConnectionError
|
||||
)
|
||||
self._RETRYABLE_STATUS_CODES = {429, 503, 504}
|
||||
|
||||
|
||||
def _is_retryable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Returns True only to transient erros that must be retried.
|
||||
"""
|
||||
if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS):
|
||||
return True
|
||||
|
||||
if isinstance(exc, ImdbHttpError):
|
||||
return exc.status_code in self._RETRYABLE_STATUS_CODES
|
||||
|
||||
return False
|
||||
|
||||
def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
|
||||
return random.uniform(0, min(cap, base * (2 ** attempt)))
|
||||
|
||||
def _build_headers(self, client_id: str) -> dict[str, str]:
|
||||
"""Builds headers for the API request."""
|
||||
headers = {
|
||||
"Authorization": self.basic_token,
|
||||
"clientId": client_id,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
return headers
|
||||
|
||||
def _build_url(self, msisdn: str) -> str:
|
||||
"""Builds the API endpoint URL."""
|
||||
return f"{self.base_url}/{msisdn}"
|
||||
|
||||
def _parse_response(self, response: httpx.Response) -> ImdbResponse:
|
||||
"""
|
||||
Parses and validates the API response for JSON exceptions.
|
||||
"""
|
||||
try:
|
||||
response_json = response.json()
|
||||
enrichment_fields = {
|
||||
"plan": response_json.get("plan"),
|
||||
"statusType": response_json.get("statusType"),
|
||||
"statusDescription": response_json.get("statusDescription"),
|
||||
"socialSecNo": response_json.get("socialSecNo"),
|
||||
}
|
||||
return ImdbResponse(**enrichment_fields)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse JSON from IMDB API.", exc_info=True)
|
||||
raise ImdbClientError("Invalid JSON response from IMDB API") from exc
|
||||
|
||||
@trace_tool
|
||||
async def get_imdb_access_data(
|
||||
self,
|
||||
msisdn: str,
|
||||
client_id: str
|
||||
) -> Tuple[Optional[ImdbResponse], Dict[str, Any]]:
|
||||
"""
|
||||
Fetches IMDB access data asynchronously.
|
||||
|
||||
Args:
|
||||
msisdn: Customer MSISDN.
|
||||
client_id: Client identification.
|
||||
|
||||
Returns:
|
||||
Tuple of (response, http_meta) where:
|
||||
- response: ImdbResponse with IMDB access information, or None for 204 (no TIM number).
|
||||
- http_meta: Dict with keys url, status_code, response_text, latency_ms.
|
||||
|
||||
Raises:
|
||||
ImdbClientError: For any API or connection issues.
|
||||
"""
|
||||
try:
|
||||
url = self._build_url(msisdn)
|
||||
headers = self._build_headers(client_id)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
async with traced_async_client(timeout=self.timeout) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
|
||||
if response.status_code == 204 or not response.content:
|
||||
logger.warning(f"IMDB API returned {response.status_code} with no content for the received msisdn.")
|
||||
http_meta = {
|
||||
"url": url,
|
||||
"status_code": response.status_code,
|
||||
"response_text": response.text,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return None, http_meta
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
message_id = response.headers.get("Messageid")
|
||||
logger.info(f"IMDB Transaction's Message ID: {message_id}")
|
||||
|
||||
http_meta = {
|
||||
"url": url,
|
||||
"status_code": response.status_code,
|
||||
"response_text": response.text,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
return self._parse_response(response), http_meta
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0
|
||||
logger.error(f"HTTP error {exc.response.status_code} when trying to fetch IMDB's API.", exc_info=True)
|
||||
raise ImdbHttpError(
|
||||
exc.response.status_code,
|
||||
f"HTTP error {exc.response.status_code} when consulting IMDB's API",
|
||||
url=url,
|
||||
response_text=exc.response.text,
|
||||
latency_ms=latency_ms
|
||||
) from exc
|
||||
|
||||
except httpx.TimeoutException as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0
|
||||
logger.error(f"Request to IMDB's API timed out after {self.timeout.connect}s", exc_info=True)
|
||||
|
||||
raise ImdbTimeoutError(
|
||||
self.base_url,
|
||||
self.timeout.connect,
|
||||
latency_ms=latency_ms
|
||||
) from exc
|
||||
|
||||
except httpx.RequestError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0
|
||||
detail = str(exc) or repr(exc)
|
||||
logger.error(f"Connection error at IMDB's API: {detail}", exc_info=True)
|
||||
|
||||
raise ImdbConnectionError(
|
||||
self.base_url,
|
||||
reason=detail,
|
||||
latency_ms=latency_ms
|
||||
) from exc
|
||||
|
||||
except Exception as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000) if 'start' in locals() else 0
|
||||
detail = str(exc) or repr(exc)
|
||||
logger.error(f"Unexpected error at IMDB API: {detail}", exc_info=True)
|
||||
raise ImdbClientError(f"Unexpected error at IMDB's API: {detail}", url=self.base_url, latency_ms=latency_ms) from exc
|
||||
|
||||
async def get_imdb_access_data_with_retry(self,
|
||||
msisdn: str,
|
||||
client_id: str,
|
||||
max_retries: int = 3
|
||||
) -> Tuple[Optional[ImdbResponse], Dict[str, Any]]:
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response, http_meta = await self.get_imdb_access_data(msisdn, client_id)
|
||||
http_meta["retry_count"] = attempt
|
||||
return response, http_meta
|
||||
except ImdbClientError as imdb_err:
|
||||
if not self._is_retryable(imdb_err):
|
||||
# 401, 403, 500, etc.
|
||||
raise
|
||||
|
||||
if attempt == max_retries:
|
||||
raise ImdbExceededRetriesError(
|
||||
self.base_url,
|
||||
max_retries,
|
||||
imdb_err
|
||||
)
|
||||
|
||||
wait = self._backoff(attempt)
|
||||
logger.warning(
|
||||
"IMDB transient error, retrying",
|
||||
extra={
|
||||
"operation": {
|
||||
"name": "get_imdb_access_data_with_retry",
|
||||
"status": "in_progress",
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"backoff_seconds": round(wait, 3),
|
||||
"last_error": str(imdb_err),
|
||||
},
|
||||
"component": "imdb_client",
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
226
src/components/clients/portability_client.py
Normal file
226
src/components/clients/portability_client.py
Normal file
@@ -0,0 +1,226 @@
|
||||
import json
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
from httpx import Timeout
|
||||
from typing import Optional, Tuple, Any
|
||||
from src.core.config import settings
|
||||
from src.components.clients.exceptions.portability_exceptions import (
|
||||
PortabilityClientError,
|
||||
PortabilityHttpError,
|
||||
PortabilityConnectionError,
|
||||
PortabilityTimeoutError,
|
||||
PortabilityNoContentError,
|
||||
PortabilityExceededRetriesError
|
||||
)
|
||||
from src.api.schemas.portability_schemas import PortabilityResponse
|
||||
from src.utils.observer import trace_tool
|
||||
from src.utils.http import traced_async_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortabilityClient:
|
||||
"""
|
||||
Client for the Portability API to retrieve portability history
|
||||
based on social security number and MSISDN.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
authorization: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
):
|
||||
self.timeout = Timeout(5.0, connect=float(settings.PORTABILITY_API_TIMEOUT))
|
||||
self.base_url = base_url or settings.PORTABILITY_API_URL
|
||||
self.authorization = authorization or settings.PORTABILITY_API_AUTHORIZATION
|
||||
self.client_id = client_id or settings.PORTABILITY_API_CLIENT_ID
|
||||
|
||||
self._RETRYABLE_NETWORK_ERRORS = (
|
||||
PortabilityTimeoutError,
|
||||
PortabilityConnectionError,
|
||||
)
|
||||
self._RETRYABLE_STATUS_CODES = {429, 503, 504}
|
||||
|
||||
def _is_retryable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Returns True only for transient errors that should be retried.
|
||||
"""
|
||||
if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS):
|
||||
return True
|
||||
|
||||
if isinstance(exc, PortabilityHttpError):
|
||||
return exc.status_code in self._RETRYABLE_STATUS_CODES
|
||||
|
||||
return False
|
||||
|
||||
def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
|
||||
return random.uniform(0, min(cap, base * (2 ** attempt)))
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
"""Builds headers for the API request."""
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"clientId": self.client_id,
|
||||
"Authorization": settings.PORTABILITY_API_AUTHORIZATION,
|
||||
}
|
||||
|
||||
def _build_params(self, social_sec_no: str, msisdn: str) -> dict[str, str]:
|
||||
"""Builds the query string parameters."""
|
||||
return {
|
||||
"msisdn": msisdn,
|
||||
"socialSecNo": social_sec_no,
|
||||
"monthsNumber": "120",
|
||||
}
|
||||
|
||||
def _parse_response(self, response: httpx.Response) -> PortabilityResponse:
|
||||
"""
|
||||
Parses and validates the API response.
|
||||
"""
|
||||
try:
|
||||
response_json = response.json()
|
||||
return PortabilityResponse(**response_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse JSON from Portability API.", exc_info=True)
|
||||
raise PortabilityClientError("Invalid JSON response from Portability API") from exc
|
||||
|
||||
@trace_tool
|
||||
async def get_portability_history(
|
||||
self,
|
||||
social_sec_no: str,
|
||||
msisdn: str,
|
||||
) -> Tuple[dict, dict[str, Any]]:
|
||||
"""Calls the Portability API and returns (response_dict, http_meta).
|
||||
|
||||
response_dict has the legacy shape {"status_code", "data"} kept for
|
||||
backward compatibility with the consumers in undefined_complaint_operator_node.
|
||||
http_meta has the shape {url, status_code, response_text, latency_ms},
|
||||
same contract used by SiebelClient/ImdbClient/AbrtClient/TaisKbClient
|
||||
so that callers can build IC payloads uniformly.
|
||||
|
||||
On error, raises PortabilityClientError (or subclass) carrying the same
|
||||
attributes (url, status_code, response_text, latency_ms) so the caller
|
||||
can read them via `getattr(exc, "<field>", None)`.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
headers = self._build_headers()
|
||||
params = self._build_params(social_sec_no, msisdn)
|
||||
|
||||
async with traced_async_client(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
self.base_url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
response_text = response.text
|
||||
status_code = response.status_code
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
message_id = response.headers.get("Messageid")
|
||||
logger.info(f"Portability Transaction's Message ID: {message_id}")
|
||||
|
||||
http_meta = {
|
||||
"url": self.base_url,
|
||||
"status_code": status_code,
|
||||
"response_text": response_text[:500] if response_text else "",
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
if not response.content:
|
||||
logger.warning(
|
||||
f"Portability API returned {status_code} with empty content "
|
||||
f"for msisdn={msisdn}."
|
||||
)
|
||||
return {"status_code": status_code, "data": None}, http_meta
|
||||
|
||||
parsed = self._parse_response(response)
|
||||
|
||||
return {"status_code": status_code, "data": parsed}, http_meta
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
response_text = exc.response.text if exc.response is not None else ""
|
||||
logger.error(
|
||||
f"HTTP error {exc.response.status_code} when trying to fetch Portability API."
|
||||
, exc_info=True)
|
||||
raise PortabilityHttpError(
|
||||
exc.response.status_code,
|
||||
f"HTTP error {exc.response.status_code} when consulting Portability API",
|
||||
url=self.base_url,
|
||||
response_text=response_text[:500] if response_text else "",
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
except httpx.TimeoutException as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
logger.error(
|
||||
f"Request to Portability API timed out after {self.timeout.connect}s"
|
||||
, exc_info=True)
|
||||
raise PortabilityTimeoutError(
|
||||
self.base_url,
|
||||
self.timeout.connect,
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
except httpx.RequestError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(exc) or repr(exc)
|
||||
logger.error(f"Connection error at Portability API: {detail}", exc_info=True)
|
||||
raise PortabilityConnectionError(
|
||||
self.base_url,
|
||||
reason=detail,
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
except Exception as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(exc) or repr(exc)
|
||||
logger.error(f"Unexpected error at Portability API: {detail}", exc_info=True)
|
||||
raise PortabilityClientError(
|
||||
f"Unexpected error at Portability API: {detail}",
|
||||
url=self.base_url,
|
||||
response_text=detail[:500] if detail else "",
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
async def get_portability_history_with_retry(
|
||||
self,
|
||||
social_sec_no: str,
|
||||
msisdn: str,
|
||||
max_retries: int = 3,
|
||||
) -> Tuple[dict, dict[str, Any]]:
|
||||
"""Returns (response_dict, http_meta) following the same contract as
|
||||
AbrtClient.get_abrt_data_with_retry. http_meta includes a "retry_count"
|
||||
field with the number of retries performed.
|
||||
"""
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response, http_meta = await self.get_portability_history(social_sec_no, msisdn)
|
||||
http_meta["retry_count"] = attempt
|
||||
return response, http_meta
|
||||
except PortabilityClientError as err:
|
||||
if not self._is_retryable(err):
|
||||
# Non-retryable errors: 401, 403, 500, etc.
|
||||
raise
|
||||
|
||||
if attempt == max_retries:
|
||||
raise PortabilityExceededRetriesError(
|
||||
self.base_url,
|
||||
max_retries,
|
||||
err,
|
||||
latency_ms=getattr(err, "latency_ms", None),
|
||||
)
|
||||
|
||||
wait = self._backoff(attempt)
|
||||
logger.warning(
|
||||
f"Transient error (attempt {attempt + 1}/{max_retries}), "
|
||||
f"retrying Portability API call. Last error: {err}"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
17
src/components/clients/rag/__init__.py
Normal file
17
src/components/clients/rag/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Clients dos RAGs especializados consumidos pelo grafo do Response Emulator.
|
||||
|
||||
Cada client encapsula uma coleção do Autonomous DB Vector Search. Por ora
|
||||
todos retornam dados mockados a partir de `src/utils/mocks/emulador/*.json`
|
||||
— quando o cliente entregar o formato real, troca-se apenas a implementação
|
||||
interna; a interface (`async def search`) e os nós que consomem permanecem
|
||||
inalterados.
|
||||
"""
|
||||
|
||||
from src.components.clients.rag.templates_rag_client import templates_rag_client
|
||||
from src.components.clients.rag.history_rag_client import history_rag_client
|
||||
|
||||
__all__ = [
|
||||
"templates_rag_client",
|
||||
"history_rag_client",
|
||||
]
|
||||
BIN
src/components/clients/rag/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/components/clients/rag/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
168
src/components/clients/rag/history_rag_client.py
Normal file
168
src/components/clients/rag/history_rag_client.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Client do RAG de Histórico Operacional (history_collection).
|
||||
|
||||
Adapter fino sobre `EmulatorRagClient`, fonte `anatel_resposta` (chunks de
|
||||
respostas Anatel anteriores). A busca é dividida em dois baldes por `nota`:
|
||||
- "high_score": `nota >= EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD` (referências positivas);
|
||||
- "low_score": `nota <= EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD` (exemplos a evitar).
|
||||
|
||||
`search_examples(query, top_k_high_score, top_k_low_score)` retorna
|
||||
`{"high_score": [...], "low_score": [...]}` e é a interface consumida pelo
|
||||
`retrieve_history_node`. `search(query, top_k)` segue disponível para buscas
|
||||
de balde único (ex.: rota de QA).
|
||||
|
||||
Fallback ao mock local em `src/utils/mocks/emulador/kb_history.json` é
|
||||
gated por `settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK` — só dispara em dev
|
||||
local. Em ambientes compartilhados (dev kube, fqa, prod) a flag fica em
|
||||
False e qualquer falha do backend real é propagada como
|
||||
`EmulatorRagClientError`; o `retrieve_history_node` já trata e segue com
|
||||
lista vazia.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.components.clients.emulator_rag_client import EmulatorRagClient, EmulatorRagSource
|
||||
from src.components.clients.exceptions.emulator_rag_exceptions import EmulatorRagClientError
|
||||
from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MOCK_PATH = (
|
||||
Path(__file__).resolve().parents[3] / "utils" / "mocks" / "emulador" / "kb_history.json"
|
||||
)
|
||||
|
||||
|
||||
def _real_client_configured() -> bool:
|
||||
return bool(
|
||||
settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT
|
||||
and settings.EMULATOR_RAG_COMPARTMENT_ID
|
||||
and settings.TAIS_DB_DSN
|
||||
)
|
||||
|
||||
|
||||
def _load_mock() -> list[dict[str, Any]]:
|
||||
try:
|
||||
with _MOCK_PATH.open(encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
return data.get("history", []) or []
|
||||
except Exception as exc:
|
||||
logger.warning("Falha ao carregar mock de histórico: %s", exc, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def _mock_filter_by_nota(
|
||||
items: list[dict[str, Any]], nota_min: int | None, nota_max: int | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Best-effort nota filter for the local mock (dev only).
|
||||
|
||||
Mock entries may not carry `nota`; those are kept so dev runs still see
|
||||
data instead of an empty bucket.
|
||||
"""
|
||||
filtered = []
|
||||
for item in items:
|
||||
nota = (item.get("metadata") or {}).get("nota", item.get("nota"))
|
||||
if nota is None:
|
||||
filtered.append(item)
|
||||
continue
|
||||
if nota_min is not None and nota < nota_min:
|
||||
continue
|
||||
if nota_max is not None and nota > nota_max:
|
||||
continue
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
|
||||
|
||||
class HistoryRagClient:
|
||||
"""Interface estável de busca semântica na base de histórico operacional."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = EmulatorRagClient()
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
nota_min: int | None = None,
|
||||
nota_max: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retorna até `top_k` históricos similares a `query` num único balde.
|
||||
|
||||
`nota_min`/`nota_max` delimitam a faixa de nota (inclusiva). Quando
|
||||
ambos são `None`, usa o threshold de nota alta como piso (compat).
|
||||
Levanta `EmulatorRagClientError` quando o backend real não está
|
||||
configurado e o fallback de mock não está habilitado.
|
||||
"""
|
||||
if nota_min is None and nota_max is None:
|
||||
nota_min = settings.EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD
|
||||
|
||||
allow_mock = settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK
|
||||
|
||||
if not _real_client_configured():
|
||||
if not allow_mock:
|
||||
raise EmulatorRagClientError(
|
||||
"HistoryRAG backend not configured and mock fallback disabled "
|
||||
"(EMULATOR_RAG_ALLOW_MOCK_FALLBACK=false)."
|
||||
)
|
||||
history = _mock_filter_by_nota(_load_mock(), nota_min, nota_max)[:top_k]
|
||||
logger.info(
|
||||
"HistoryRAG mock | query=%r | nota_min=%s | nota_max=%s | returned=%d",
|
||||
query, nota_min, nota_max, len(history),
|
||||
)
|
||||
return history
|
||||
|
||||
try:
|
||||
response = await self._client.search(
|
||||
source=EmulatorRagSource.anatel_resposta,
|
||||
query=query,
|
||||
nota_min=nota_min,
|
||||
nota_max=nota_max,
|
||||
top_k=top_k,
|
||||
)
|
||||
except EmulatorRagClientError:
|
||||
if not allow_mock:
|
||||
raise
|
||||
logger.warning(
|
||||
"HistoryRAG fell back to mock after backend error (local dev only)",
|
||||
exc_info=True,
|
||||
)
|
||||
return _mock_filter_by_nota(_load_mock(), nota_min, nota_max)[:top_k]
|
||||
|
||||
results = response.get("results", [])
|
||||
logger.info(
|
||||
"HistoryRAG | query=%r | nota_min=%s | nota_max=%s | returned=%d",
|
||||
query, nota_min, nota_max, len(results),
|
||||
)
|
||||
return results
|
||||
|
||||
async def search_examples(
|
||||
self, query: str, top_k_high_score: int, top_k_low_score: int
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Retorna respostas similares separadas em baldes high_score/low_score por nota.
|
||||
|
||||
- `high_score`: `nota >= EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD` (referências),
|
||||
limitado a `top_k_high_score`;
|
||||
- `low_score`: `nota <= EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD` (exemplos a evitar),
|
||||
limitado a `top_k_low_score`.
|
||||
|
||||
Cada balde é uma busca vetorial independente — top_ks distintos
|
||||
permitem, por exemplo, puxar mais referências de nota alta e menos
|
||||
exemplos de nota baixa sem inflar tokens à toa. Propaga
|
||||
`EmulatorRagClientError` — o nó consumidor trata.
|
||||
"""
|
||||
high_score = await self.search(
|
||||
query=query,
|
||||
top_k=top_k_high_score,
|
||||
nota_min=settings.EMULATOR_RAG_HISTORY_HIGH_SCORE_THRESHOLD,
|
||||
)
|
||||
low_score = await self.search(
|
||||
query=query,
|
||||
top_k=top_k_low_score,
|
||||
nota_max=settings.EMULATOR_RAG_HISTORY_LOW_SCORE_THRESHOLD,
|
||||
)
|
||||
return {"high_score": high_score, "low_score": low_score}
|
||||
|
||||
|
||||
history_rag_client = HistoryRagClient()
|
||||
96
src/components/clients/rag/templates_rag_client.py
Normal file
96
src/components/clients/rag/templates_rag_client.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Client do RAG de Templates (templates_collection).
|
||||
|
||||
Adapter fino sobre `EmulatorRagClient` (Oracle ADB + OCI Cohere). A interface
|
||||
`async def search(query, top_k)` é estável: os nodes consumidores não mudam.
|
||||
|
||||
Fallback ao mock local em `src/utils/mocks/emulador/kb_templates.json` é
|
||||
gated por `settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK` — só dispara em dev
|
||||
local. Em ambientes compartilhados (dev kube, fqa, prod) a flag fica em
|
||||
False e qualquer falha do backend real é propagada como
|
||||
`EmulatorRagClientError`; o `retrieve_templates_node` já trata e segue com
|
||||
lista vazia.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.components.clients.emulator_rag_client import EmulatorRagClient, EmulatorRagSource
|
||||
from src.components.clients.exceptions.emulator_rag_exceptions import EmulatorRagClientError
|
||||
from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MOCK_PATH = (
|
||||
Path(__file__).resolve().parents[3] / "utils" / "mocks" / "emulador" / "kb_templates.json"
|
||||
)
|
||||
|
||||
|
||||
def _real_client_configured() -> bool:
|
||||
return bool(
|
||||
settings.EMULATOR_RAG_OCI_GENAI_ENDPOINT
|
||||
and settings.EMULATOR_RAG_COMPARTMENT_ID
|
||||
and settings.TAIS_DB_DSN
|
||||
)
|
||||
|
||||
|
||||
def _load_mock() -> list[dict[str, Any]]:
|
||||
try:
|
||||
with _MOCK_PATH.open(encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
return data.get("templates", []) or []
|
||||
except Exception as exc:
|
||||
logger.warning("Falha ao carregar mock de templates: %s", exc, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
class TemplatesRagClient:
|
||||
"""Interface estável de busca semântica na base de templates IQI."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = EmulatorRagClient()
|
||||
|
||||
async def search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
|
||||
"""Retorna até `top_k` templates relevantes para `query`.
|
||||
|
||||
Levanta `EmulatorRagClientError` quando o backend real não está
|
||||
configurado e o fallback de mock não está habilitado.
|
||||
"""
|
||||
allow_mock = settings.EMULATOR_RAG_ALLOW_MOCK_FALLBACK
|
||||
|
||||
if not _real_client_configured():
|
||||
if not allow_mock:
|
||||
raise EmulatorRagClientError(
|
||||
"TemplatesRAG backend not configured and mock fallback disabled "
|
||||
"(EMULATOR_RAG_ALLOW_MOCK_FALLBACK=false)."
|
||||
)
|
||||
templates = _load_mock()
|
||||
logger.info(
|
||||
"TemplatesRAG mock | query=%r | returned=%d (top_k=%d ignored)",
|
||||
query, len(templates), top_k,
|
||||
)
|
||||
return templates
|
||||
|
||||
try:
|
||||
response = await self._client.search(
|
||||
source=EmulatorRagSource.templates,
|
||||
query=query,
|
||||
top_k=top_k,
|
||||
)
|
||||
except EmulatorRagClientError:
|
||||
if not allow_mock:
|
||||
raise
|
||||
logger.warning(
|
||||
"TemplatesRAG fell back to mock after backend error (local dev only)",
|
||||
exc_info=True,
|
||||
)
|
||||
return _load_mock()
|
||||
|
||||
results = response.get("results", [])
|
||||
logger.info("TemplatesRAG | query=%r | returned=%d", query, len(results))
|
||||
return results
|
||||
|
||||
|
||||
templates_rag_client = TemplatesRagClient()
|
||||
355
src/components/clients/siebel_client.py
Normal file
355
src/components/clients/siebel_client.py
Normal file
@@ -0,0 +1,355 @@
|
||||
import httpx
|
||||
import base64
|
||||
import asyncio
|
||||
import random
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from src.core.config import settings
|
||||
from src.components.clients.exceptions.siebel_exceptions import (
|
||||
SiebelClientError,
|
||||
SiebelHttpError,
|
||||
SiebelConnectionError,
|
||||
SiebelTimeoutError,
|
||||
SiebelExceededRetriesError,
|
||||
)
|
||||
from src.api.schemas.siebel_schemas import SiebelSRRequest
|
||||
from src.utils.observer import trace_tool
|
||||
from src.utils.http import traced_async_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SiebelClient:
|
||||
"""
|
||||
Client to interact with Siebel CRM for Service Request operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
route: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
verify_ssl: Optional[bool] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
client_id: Optional[str] = None
|
||||
):
|
||||
host = base_url or settings.SIEBEL_API_HOST or ""
|
||||
path = route if route is not None else settings.SIEBEL_API_ROUTE
|
||||
self.base_url = f"{host.rstrip('/')}{path}"
|
||||
self.timeout = timeout if timeout is not None else settings.SIEBEL_API_TIMEOUT
|
||||
self.verify_ssl = verify_ssl if verify_ssl is not None else settings.VERIFY_SSL
|
||||
self.username = username or settings.SIEBEL_API_USERNAME
|
||||
self.password = password or settings.SIEBEL_API_PASSWORD
|
||||
self.client_id = client_id or settings.SIEBEL_API_CLIENT_ID
|
||||
|
||||
prospect_host = settings.SIEBEL_PROSPECT_API_HOST or ""
|
||||
prospect_path = settings.SIEBEL_PROSPECT_API_ROUTE or ""
|
||||
self.prospect_auth = settings.SIEBEL_PROSPECT_API_AUTHORIZATION or None
|
||||
self.prospect_url = f"{prospect_host.rstrip('/')}{prospect_path}"
|
||||
|
||||
self._RETRYABLE_NETWORK_ERRORS = (
|
||||
SiebelTimeoutError,
|
||||
SiebelConnectionError,
|
||||
)
|
||||
self._RETRYABLE_STATUS_CODES = {429, 503, 504}
|
||||
|
||||
def _is_retryable(self, exc: Exception) -> bool:
|
||||
if isinstance(exc, self._RETRYABLE_NETWORK_ERRORS):
|
||||
return True
|
||||
if isinstance(exc, SiebelHttpError):
|
||||
return exc.status_code in self._RETRYABLE_STATUS_CODES
|
||||
return False
|
||||
|
||||
def _backoff(self, attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
|
||||
return random.uniform(0, min(cap, base * (2 ** attempt)))
|
||||
|
||||
@staticmethod
|
||||
def _ensure_absolute_url(url: str, env_var_name: str, endpoint_label: str) -> None:
|
||||
"""Garante que `url` é absoluta (http:// ou https://).
|
||||
|
||||
Caso contrário, levanta SiebelClientError indicando a env var faltante.
|
||||
Erro não-retryable: falha de configuração não se resolve com retentativas.
|
||||
"""
|
||||
if not url or not url.lower().startswith(("http://", "https://")):
|
||||
raise SiebelClientError(
|
||||
f"URL inválida para {endpoint_label}: {url!r}. "
|
||||
f"Verifique a variável de ambiente {env_var_name}.",
|
||||
url=url or "N/A",
|
||||
latency_ms=0,
|
||||
)
|
||||
|
||||
def _get_auth_header(self, username: Optional[str] = None, password: Optional[str] = None) -> str:
|
||||
"""Generates Basic Auth header. Defaults to instance credentials when no overrides given."""
|
||||
user = username if username is not None else self.username
|
||||
pwd = password if password is not None else self.password
|
||||
if not user or not pwd:
|
||||
return ""
|
||||
auth_str = f"{user}:{pwd}"
|
||||
encoded_auth = base64.b64encode(auth_str.encode()).decode()
|
||||
return f"Basic {encoded_auth}"
|
||||
|
||||
@trace_tool
|
||||
async def open_service_request(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
prospect: bool = False,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""
|
||||
Opens a Service Request in Siebel.
|
||||
|
||||
|
||||
|
||||
Args:
|
||||
payload: Serialized payload to open a Service Request in Siebel.
|
||||
prospect: When True, target the Siebel Prospect API (non-TIM / canceled
|
||||
customers) using its dedicated host, route and credentials.
|
||||
|
||||
Returns:
|
||||
Tuple of (response_body, http_meta) where:
|
||||
- response_body: Dict[str, Any] - Response from the Siebel API.
|
||||
- http_meta: Dict with keys url, status_code, response_text, latency_ms.
|
||||
|
||||
Tuple of (response_body, http_meta) where:
|
||||
- response_body: Dict[str, Any] - Response from the Siebel API.
|
||||
- http_meta: Dict with keys url, status_code, response_text, latency_ms.
|
||||
|
||||
Raises:
|
||||
SiebelClientError: If the Siebel API returns a non-2xx HTTP status code.
|
||||
SiebelConnectionError: If a connection to the Siebel API cannot be established.
|
||||
SiebelTimeoutError: If a request to the Siebel API exceeds the configured timeout.
|
||||
"""
|
||||
if prospect:
|
||||
url = self.prospect_url
|
||||
auth_header = self.prospect_auth
|
||||
label = "Siebel Prospect"
|
||||
else:
|
||||
url = self.base_url
|
||||
username = self.username
|
||||
password = self.password
|
||||
auth_header = self._get_auth_header(username, password)
|
||||
label = "Siebel"
|
||||
|
||||
client_id = self.client_id
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
if auth_header:
|
||||
headers["Authorization"] = auth_header
|
||||
|
||||
if client_id:
|
||||
headers["clientId"] = client_id
|
||||
|
||||
if prospect:
|
||||
self._ensure_absolute_url(url, "SIEBEL_PROSPECT_API_HOST", "abertura de SR (Siebel Prospect)")
|
||||
else:
|
||||
self._ensure_absolute_url(url, "SIEBEL_API_HOST", "abertura de SR (Siebel)")
|
||||
|
||||
logger.info(f"Opening {label} SR on endpoint {url}.")
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
try:
|
||||
async with traced_async_client(verify_ssl=self.verify_ssl, timeout=self.timeout) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
response.raise_for_status()
|
||||
|
||||
message_id = response.headers.get("MessageId")
|
||||
logger.info(f"{label} SR opened and recieved Message ID: {message_id}")
|
||||
|
||||
http_meta = {
|
||||
"url": self.base_url,
|
||||
"status_code": response.status_code,
|
||||
"response_text": response.text,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
if response.status_code == 204 or not (response.text or "").strip():
|
||||
raise SiebelClientError(
|
||||
f"Siebel returned {response.status_code} no content — couldn't get crmProtocol. Endpoint: {url}, Message ID: {message_id}",
|
||||
url=self.base_url,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
return response.json(), http_meta
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
logger.error(f"{label} API HTTP error {e.response.status_code}: {e.response.text}", exc_info=True)
|
||||
raise SiebelHttpError(e.response.status_code, e.response.text, url=self.base_url, latency_ms=latency_ms)
|
||||
except httpx.ConnectError as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(e) or repr(e)
|
||||
logger.error(f"{label} API connection error: {detail}", exc_info=True)
|
||||
raise SiebelConnectionError(detail, url=self.base_url, latency_ms=latency_ms)
|
||||
except httpx.TimeoutException as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(e) or f"{type(e).__name__} after {self.timeout}s"
|
||||
logger.error(f"{label} API timeout: {detail}", exc_info=True)
|
||||
raise SiebelTimeoutError(detail, url=self.base_url, latency_ms=latency_ms)
|
||||
except SiebelClientError:
|
||||
raise
|
||||
except Exception as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(e) or repr(e)
|
||||
logger.error(f"Unexpected error calling {label}: {detail}", exc_info=True)
|
||||
raise SiebelClientError(f"Unexpected error: {detail}", url=self.base_url, latency_ms=latency_ms)
|
||||
|
||||
@trace_tool
|
||||
async def update_service_request_status(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
plan_type: str = "pós-pago",
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""
|
||||
Updates the status of a Service Request in Siebel (SR closing).
|
||||
|
||||
Args:
|
||||
payload: Serialized payload from SiebelSRStatusRequestPosPago.to_payload() / SiebelSRStatusRequestPrePago.to_payload().
|
||||
plan_type: Customer plan type from IMDB (plan.Type). Only "pré-pago"/"express" route
|
||||
to the pré-pago endpoint — everything else uses pós-pago.
|
||||
|
||||
Returns:
|
||||
Tuple of (response_body, http_meta).
|
||||
|
||||
Raises:
|
||||
SiebelClientError: On HTTP error, connection failure, timeout, or missing config.
|
||||
"""
|
||||
is_prepago = (plan_type or "").strip().lower() in {"pré-pago", "express"}
|
||||
|
||||
if is_prepago:
|
||||
prepago_host = settings.SIEBEL_PREPAGO_STATUS_API_HOST or ""
|
||||
if not prepago_host:
|
||||
raise SiebelClientError(
|
||||
"Endpoint Siebel pré-pago não configurado (SIEBEL_PREPAGO_STATUS_API_HOST). "
|
||||
"Aguardando definição pela TIM.",
|
||||
url="N/A",
|
||||
latency_ms=0,
|
||||
)
|
||||
url = f"{prepago_host.rstrip('/')}{settings.SIEBEL_PREPAGO_STATUS_API_ROUTE}"
|
||||
label = "Siebel Pré-pago Status"
|
||||
self._ensure_absolute_url(url, "SIEBEL_PREPAGO_STATUS_API_HOST", "atualização status pré-pago")
|
||||
else:
|
||||
status_host = settings.SIEBEL_STATUS_API_HOST or settings.SIEBEL_API_HOST or ""
|
||||
url = f"{status_host.rstrip('/')}{settings.SIEBEL_STATUS_API_ROUTE}"
|
||||
label = "Siebel Pós-pago Status"
|
||||
self._ensure_absolute_url(url, "SIEBEL_STATUS_API_HOST", "atualização status pós-pago")
|
||||
|
||||
auth_header = self.prospect_auth
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if auth_header:
|
||||
headers["Authorization"] = auth_header
|
||||
if self.client_id:
|
||||
headers["clientId"] = self.client_id
|
||||
|
||||
logger.info(f"Updating SR status via {label} on endpoint {url}.")
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
try:
|
||||
async with traced_async_client(verify_ssl=self.verify_ssl, timeout=self.timeout) as client:
|
||||
if is_prepago:
|
||||
response = await client.patch(url, json=payload, headers=headers)
|
||||
else:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
response.raise_for_status()
|
||||
|
||||
http_meta = {
|
||||
"url": url,
|
||||
"status_code": response.status_code,
|
||||
"response_text": response.text,
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
logger.info(f"{label} SR status updated successfully.")
|
||||
try:
|
||||
return response.json(), http_meta
|
||||
except Exception:
|
||||
# 204 No Content (ou body vazio) é resposta válida para update de status.
|
||||
return {}, http_meta
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
logger.error(f"{label} HTTP error {e.response.status_code}: {e.response.text}", exc_info=True)
|
||||
raise SiebelHttpError(e.response.status_code, e.response.text, url=url, latency_ms=latency_ms)
|
||||
except httpx.ConnectError as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(e) or repr(e)
|
||||
logger.error(f"{label} connection error: {detail}", exc_info=True)
|
||||
raise SiebelConnectionError(detail, url=url, latency_ms=latency_ms)
|
||||
except httpx.TimeoutException as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(e) or f"{type(e).__name__} after {self.timeout}s"
|
||||
logger.error(f"{label} timeout: {detail}", exc_info=True)
|
||||
raise SiebelTimeoutError(detail, url=url, latency_ms=latency_ms)
|
||||
except SiebelClientError:
|
||||
raise
|
||||
except Exception as e:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
detail = str(e) or repr(e)
|
||||
logger.error(f"Unexpected error calling {label}: {detail}", exc_info=True)
|
||||
raise SiebelClientError(f"Unexpected error: {detail}", url=url, latency_ms=latency_ms)
|
||||
|
||||
async def update_service_request_status_with_retry(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
max_retries: int = 2,
|
||||
plan_type: str = "pós-pago",
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response, http_meta = await self.update_service_request_status(payload, plan_type)
|
||||
http_meta["retry_count"] = attempt
|
||||
return response, http_meta
|
||||
except SiebelClientError as siebel_err:
|
||||
if not self._is_retryable(siebel_err):
|
||||
raise
|
||||
|
||||
if attempt == max_retries:
|
||||
raise SiebelExceededRetriesError(
|
||||
getattr(siebel_err, "url", "N/A"),
|
||||
max_retries,
|
||||
siebel_err,
|
||||
)
|
||||
|
||||
wait = self._backoff(attempt)
|
||||
logger.warning(
|
||||
f"Transient error (attempt {attempt + 1}/{max_retries}), retrying SR status update. Last error: {siebel_err}"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
async def open_service_request_with_retry(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
max_retries: int = 3,
|
||||
prospect: bool = False,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
target_url = self.prospect_url if prospect else self.base_url
|
||||
label = "Siebel Prospect" if prospect else "Siebel"
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response, http_meta = await self.open_service_request(payload, prospect)
|
||||
http_meta["retry_count"] = attempt
|
||||
return response, http_meta
|
||||
except SiebelClientError as siebel_err:
|
||||
if not self._is_retryable(siebel_err):
|
||||
raise
|
||||
|
||||
if attempt == max_retries:
|
||||
raise SiebelExceededRetriesError(
|
||||
target_url,
|
||||
max_retries,
|
||||
siebel_err
|
||||
)
|
||||
|
||||
wait = self._backoff(attempt)
|
||||
logger.warning(
|
||||
f"Transient error (attempt {attempt + 1}/{max_retries}), retrying {label} API call. Last error: {siebel_err}"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
332
src/components/clients/speech_analytics_client.py
Normal file
332
src/components/clients/speech_analytics_client.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
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
|
||||
813
src/components/clients/tais_kb_client.py
Normal file
813
src/components/clients/tais_kb_client.py
Normal file
@@ -0,0 +1,813 @@
|
||||
"""
|
||||
HTTP/DB client for the TAIS knowledge base.
|
||||
|
||||
Generates the query embedding via OCI GenAI (Cohere multilingual) and runs a
|
||||
vector-similarity search against an Oracle Autonomous Database using the
|
||||
native async oracledb driver.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
import oci
|
||||
import oci.exceptions
|
||||
import oci.generative_ai_inference.models
|
||||
import oci.retry
|
||||
import oracledb
|
||||
|
||||
from src.agent.local_prompts.preprocess_tais_kb_query import preprocess_tais_kb_query_pt
|
||||
from src.agent.local_prompts.postprocess_tais_kb_query import postprocess_tais_kb_query_pt
|
||||
from src.core.config import settings
|
||||
from src.core.prompt_manager import get_prompt
|
||||
from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError
|
||||
from src.providers.llm_provider import chat_llm_with_usage, classification_llm, tais_kb_llm
|
||||
from src.utils.observer import trace_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Return CLOBs as native strings instead of LOB handles to keep the search code simple.
|
||||
oracledb.defaults.fetch_lobs = False
|
||||
|
||||
|
||||
class Product(str, Enum):
|
||||
"""Supported TAIS products."""
|
||||
MOVEL = "Móvel"
|
||||
FIBRA = "Fibra"
|
||||
|
||||
|
||||
# Segments allowed per product
|
||||
_ALLOWED_MOVEL_SEGMENTS: dict[str, list[str]] = {
|
||||
"corporativo": ["SMB", "Top Clients", "M2M", "IoT"],
|
||||
"pospago": ["Fatura", "Express"],
|
||||
"controle": ["Fatura", "Express"],
|
||||
"prepago": ["Pré-Pago"],
|
||||
"beta": ["Beta"],
|
||||
"fixogsm": ["Fixo (GSM)"],
|
||||
}
|
||||
|
||||
_ALLOWED_FIBRA_SEGMENTS: dict[str, list[str]] = {
|
||||
"bandalarga": ["Banda Larga"],
|
||||
"wttx": ["WTTX"],
|
||||
}
|
||||
|
||||
|
||||
class TaisKbClient:
|
||||
"""Async client for the TAIS knowledge base (Oracle ADB + OCI embeddings)."""
|
||||
|
||||
_embed_client: oci.generative_ai_inference.GenerativeAiInferenceClient | None = None
|
||||
|
||||
@classmethod
|
||||
def _get_embed_client(cls) -> oci.generative_ai_inference.GenerativeAiInferenceClient:
|
||||
if cls._embed_client is not None:
|
||||
return cls._embed_client
|
||||
|
||||
if not settings.TAIS_GENAI_ENDPOINT or not settings.TAIS_GENAI_COMPARTMENT_ID:
|
||||
raise TaisKbClientError(
|
||||
"TAIS GenAI not configured (TAIS_GENAI_ENDPOINT, TAIS_GENAI_COMPARTMENT_ID)."
|
||||
)
|
||||
|
||||
import os
|
||||
from agent_framework.config.settings import settings as fw_settings
|
||||
|
||||
oci_config = oci.config.from_file(
|
||||
os.path.expanduser(getattr(fw_settings, "OCI_CONFIG_FILE", "~/.oci/config")),
|
||||
getattr(fw_settings, "OCI_PROFILE", None) or settings.OCI_CONFIG_PROFILE,
|
||||
)
|
||||
if getattr(fw_settings, "OCI_REGION", None):
|
||||
oci_config["region"] = fw_settings.OCI_REGION
|
||||
|
||||
# OCI client treats `timeout` as a single int — tuple form is silently
|
||||
# reduced to its first value. Use the configured TAIS timeout directly.
|
||||
cls._embed_client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||||
config=oci_config,
|
||||
service_endpoint=settings.TAIS_GENAI_ENDPOINT,
|
||||
retry_strategy=oci.retry.NoneRetryStrategy(),
|
||||
timeout=settings.TAIS_DB_TIMEOUT,
|
||||
)
|
||||
return cls._embed_client
|
||||
|
||||
async def _preprocess_query(self, query_text: str) -> str:
|
||||
"""
|
||||
Preprocess a query using LLM to optimize it for semantic search.
|
||||
|
||||
Transforms the query into a semantically enriched form that maximizes
|
||||
similarity for RAG and embedding-based retrieval.
|
||||
|
||||
Args:
|
||||
query_text: Original query text to preprocess
|
||||
|
||||
Returns:
|
||||
Reformulated query optimized for semantic search
|
||||
|
||||
Raises:
|
||||
TaisKbClientError: If LLM call fails
|
||||
"""
|
||||
llm = classification_llm
|
||||
|
||||
# Get prompt from Langfuse with local fallback
|
||||
prompt = get_prompt("preprocess_tais_kb_query_pt", preprocess_tais_kb_query_pt)
|
||||
|
||||
# Format the message with the prompt and query
|
||||
message = f"{prompt}\n\nTranscrição original:\n{query_text}"
|
||||
|
||||
try:
|
||||
# Call LLM to preprocess the query (com retry de JSON decode)
|
||||
llm_resp = chat_llm_with_usage(llm, message, expect_json=True)
|
||||
content = llm_resp.content
|
||||
|
||||
logger.debug(f"Query preprocessing LLM response: {content}")
|
||||
|
||||
parsed_response = llm_resp.parsed_json or {}
|
||||
reformulated_query = parsed_response.get("reformulado", query_text)
|
||||
|
||||
logger.info(f"Query preprocessed successfully. Original: {query_text[:100]}... -> Reformulated: {reformulated_query[:100]}...")
|
||||
|
||||
return reformulated_query
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse query preprocessing response as JSON: {e}. Returning original query.")
|
||||
return query_text
|
||||
except Exception as e:
|
||||
logger.warning(f"Query preprocessing failed: {e}. Proceeding with original query.")
|
||||
return query_text
|
||||
|
||||
async def _postprocess_results(self, query_text: str, results: list[dict], reformulated_query: str | None = None) -> dict:
|
||||
"""
|
||||
Postprocess search results using LLM to synthesize an answer.
|
||||
|
||||
Sends the query and top results to the LLM using the postprocess prompt,
|
||||
which synthesizes a comprehensive answer based on the retrieved documents.
|
||||
The LLM returns a JSON response with 'conteudo' (content) and 'id_procs' (document IDs).
|
||||
|
||||
Args:
|
||||
query_text: Original query text
|
||||
results: List of search result documents with id_proc, title_proc, description_proc, content
|
||||
reformulated_query: Query after preprocessing (optional)
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
- content: Synthesized answer from the LLM (from JSON 'conteudo' field)
|
||||
- id_procs: List of document IDs used in the answer (from JSON 'id_procs' field)
|
||||
- filled_prompt: The complete prompt with all variables filled in
|
||||
- postprocessing_succeeded: True on full success, False on JSON parse error
|
||||
or LLM call failure (fail-open: caller still receives best-effort content)
|
||||
- postprocessing_failure_reason: short label of the failure (only when failed)
|
||||
- postprocessing_http_meta: url/status/response_text/latency_ms of the LLM call
|
||||
(only when failed; consumed by AGA.036 emission downstream)
|
||||
"""
|
||||
llm = tais_kb_llm
|
||||
|
||||
# Get prompt from Langfuse with local fallback
|
||||
prompt = get_prompt("postprocess_tais_kb_query_pt", postprocess_tais_kb_query_pt)
|
||||
|
||||
# Format documents for the prompt
|
||||
formatted_docs = []
|
||||
for doc in results:
|
||||
doc_text = f"""
|
||||
Documento: {doc.get('id_proc', 'N/A')}
|
||||
Título: {doc.get('title_proc', 'N/A')}
|
||||
Descrição: {doc.get('description_proc', 'N/A')}
|
||||
Conteúdo: {doc.get('content', 'N/A')}
|
||||
"""
|
||||
formatted_docs.append(doc_text)
|
||||
|
||||
docs_context = "\n".join(formatted_docs)
|
||||
pp_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
# Build query context with both original and reformulated (if available)
|
||||
query_context = f"Query: {query_text}"
|
||||
|
||||
# Format the message with the prompt, query, and documents
|
||||
filled_prompt = f"{prompt}\n\nPergunta do Operador:\n{query_context}\n\n---\n\nDocumentos de Referência:\n{docs_context}"
|
||||
|
||||
# Call LLM to postprocess the results (com retry de JSON decode)
|
||||
try:
|
||||
llm_resp = chat_llm_with_usage(llm, filled_prompt, expect_json=True)
|
||||
except json.JSONDecodeError as je:
|
||||
# Esgotou as tentativas de obter JSON válido — fail-open com raw content vazio.
|
||||
logger.warning(f"Failed to parse LLM JSON response after retries: {je}.")
|
||||
return {
|
||||
"content": "",
|
||||
"id_procs": [],
|
||||
"filled_prompt": filled_prompt,
|
||||
"postprocessing_succeeded": False,
|
||||
"postprocessing_failure_reason": "JSON parse error na resposta do LLM",
|
||||
"postprocessing_http_meta": {
|
||||
"url": settings.TAIS_GENAI_ENDPOINT or "N/A",
|
||||
"status_code": 200,
|
||||
"response_text": f"JSONDecodeError after retries: {str(je)[:200]}",
|
||||
"latency_ms": int((time.perf_counter() - pp_start) * 1000),
|
||||
},
|
||||
}
|
||||
|
||||
content = llm_resp.content
|
||||
logger.debug(f"Query postprocessing LLM response: {content[:200]}...")
|
||||
|
||||
response_json = llm_resp.parsed_json or {}
|
||||
postprocessing_content = response_json.get("conteudo", "")
|
||||
postprocessing_id_procs = response_json.get("id_procs", [])
|
||||
|
||||
logger.info(f"Results postprocessed successfully. Query: {query_text[:100]}... ID procs: {postprocessing_id_procs}")
|
||||
|
||||
return {
|
||||
"content": postprocessing_content,
|
||||
"id_procs": postprocessing_id_procs if isinstance(postprocessing_id_procs, list) else [],
|
||||
"filled_prompt": filled_prompt,
|
||||
"postprocessing_succeeded": True,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Results postprocessing failed: {e}. Returning empty result.")
|
||||
return {
|
||||
"content": "",
|
||||
"id_procs": [],
|
||||
"filled_prompt": "",
|
||||
"postprocessing_succeeded": False,
|
||||
"postprocessing_failure_reason": f"Falha na chamada do LLM ({type(e).__name__})",
|
||||
"postprocessing_http_meta": {
|
||||
"url": settings.TAIS_GENAI_ENDPOINT or "N/A",
|
||||
"status_code": getattr(e, "status", "N/A"),
|
||||
"response_text": str(e)[:500],
|
||||
"latency_ms": int((time.perf_counter() - pp_start) * 1000),
|
||||
},
|
||||
}
|
||||
|
||||
def _embed_sync(self, text: str) -> tuple[list[float], int]:
|
||||
client = self._get_embed_client()
|
||||
embed_detail = oci.generative_ai_inference.models.EmbedTextDetails()
|
||||
embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(
|
||||
model_id=settings.TAIS_GENAI_EMBED_MODEL_ID
|
||||
)
|
||||
embed_detail.inputs = [text]
|
||||
embed_detail.truncate = "NONE"
|
||||
embed_detail.compartment_id = settings.TAIS_GENAI_COMPARTMENT_ID
|
||||
embed_detail.input_type = "SEARCH_QUERY"
|
||||
|
||||
endpoint = settings.TAIS_GENAI_ENDPOINT
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = client.embed_text(embed_detail)
|
||||
except oci.exceptions.ServiceError as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
raise TaisKbClientError(
|
||||
f"OCI embedding service error: {exc}",
|
||||
status_code=exc.status,
|
||||
url=endpoint,
|
||||
response_text=str(getattr(exc, "message", exc)),
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
except (oci.exceptions.RequestException, oci.exceptions.ConnectTimeout) as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
raise TaisKbClientError(
|
||||
f"OCI embedding request error: {exc}",
|
||||
url=endpoint,
|
||||
response_text=str(exc),
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
return response.data._embeddings[0], response.status
|
||||
|
||||
async def _embed(self, text: str) -> tuple[list[float], int]:
|
||||
# OCI Python SDK has no async client; isolate the blocking call.
|
||||
return await asyncio.to_thread(self._embed_sync, text)
|
||||
|
||||
@staticmethod
|
||||
def _validate_product(product: Product | None) -> None:
|
||||
"""Validate that product is a valid Product enum value."""
|
||||
if product is None:
|
||||
raise ValueError(
|
||||
f"product is required. Valid values: Product.MOVEL, Product.FIBRA"
|
||||
)
|
||||
if not isinstance(product, Product):
|
||||
valid_values = [p.name for p in Product]
|
||||
raise TypeError(
|
||||
f"product must be a Product enum value (e.g. Product.MOVEL), got {type(product).__name__}: {product!r}. "
|
||||
f"Valid values: {valid_values}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_segments(segments: list[str], product: Product) -> None:
|
||||
"""Validate segments are known and allowed for the product."""
|
||||
# Get allowed mapping for product
|
||||
if product == Product.MOVEL:
|
||||
allowed_mapping = _ALLOWED_MOVEL_SEGMENTS
|
||||
elif product == Product.FIBRA:
|
||||
allowed_mapping = _ALLOWED_FIBRA_SEGMENTS
|
||||
else:
|
||||
raise ValueError(f"Unknown product: {product}")
|
||||
|
||||
allowed_segments = set(allowed_mapping.keys())
|
||||
|
||||
for seg in segments:
|
||||
seg_lower = seg.lower()
|
||||
if seg_lower not in allowed_segments:
|
||||
raise ValueError(
|
||||
f"Unrecognized segment '{seg}' for product {product.value}. "
|
||||
f"Allowed values: {sorted(allowed_segments)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_sub_segments_for_segments(
|
||||
segments: list[str],
|
||||
sub_segments: list[str],
|
||||
product: Product
|
||||
) -> None:
|
||||
"""Validate that sub_segments are allowed for the given segments."""
|
||||
if not sub_segments:
|
||||
return # No sub_segments to validate
|
||||
|
||||
# Get allowed mapping for product
|
||||
if product == Product.MOVEL:
|
||||
allowed_mapping = _ALLOWED_MOVEL_SEGMENTS
|
||||
elif product == Product.FIBRA:
|
||||
allowed_mapping = _ALLOWED_FIBRA_SEGMENTS
|
||||
else:
|
||||
raise ValueError(f"Unknown product: {product}")
|
||||
|
||||
# Build set of allowed sub_segments for given segments
|
||||
allowed_for_segments: set[str] = set()
|
||||
for seg in segments:
|
||||
seg_lower = seg.lower()
|
||||
if seg_lower in allowed_mapping:
|
||||
allowed_for_segments.update(allowed_mapping[seg_lower])
|
||||
|
||||
# Validate each sub_segment is allowed and is case-insensitive match
|
||||
for sub_seg in sub_segments:
|
||||
sub_seg_lower = sub_seg.lower()
|
||||
|
||||
# Check if it exists in allowed set (case-insensitive)
|
||||
found = False
|
||||
for allowed_sub in allowed_for_segments:
|
||||
if allowed_sub.lower() == sub_seg_lower:
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise ValueError(
|
||||
f"Sub-segment '{sub_seg}' is not allowed for segments {segments}. "
|
||||
f"Allowed sub-segments: {sorted(allowed_for_segments)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fill_sql_with_bind_params(sql: str, bind_params: dict[str, object]) -> str:
|
||||
"""Replace bind parameters in SQL with their actual values (for debugging).
|
||||
|
||||
Note: Vectors are not filled for readability.
|
||||
"""
|
||||
filled_sql = sql
|
||||
|
||||
for key, value in bind_params.items():
|
||||
if key == "query_embedding":
|
||||
# Skip vectors for readability
|
||||
filled_sql = filled_sql.replace(f":{key}", "[VECTOR]")
|
||||
elif isinstance(value, str):
|
||||
# Escape single quotes in strings
|
||||
escaped_val = value.replace("'", "''")
|
||||
filled_sql = filled_sql.replace(f":{key}", f"'{escaped_val}'")
|
||||
elif isinstance(value, (int, float)):
|
||||
filled_sql = filled_sql.replace(f":{key}", str(value))
|
||||
elif value is None:
|
||||
filled_sql = filled_sql.replace(f":{key}", "NULL")
|
||||
else:
|
||||
filled_sql = filled_sql.replace(f":{key}", f"'{str(value)}'")
|
||||
|
||||
return filled_sql
|
||||
|
||||
@trace_tool
|
||||
async def search_documents(
|
||||
self,
|
||||
query_text: str,
|
||||
product: Product | None = None,
|
||||
segments: list[str] | None = None,
|
||||
sub_segments: list[str] | None = None,
|
||||
top_k: int = 3,
|
||||
check_expiration_date: bool = True,
|
||||
fetch_limit_multiplier: int = 100,
|
||||
preprocess: bool = True,
|
||||
postprocess: bool = True,
|
||||
deduplicate: bool = False,
|
||||
telemetry_top_n: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Search TAIS knowledge base using vector similarity with product-based filtering.
|
||||
|
||||
Args:
|
||||
query_text: Query text to embed and search
|
||||
product: Product to search within (MOVEL or FIBRA); required
|
||||
segments: List of segments to filter by (product-specific); optional
|
||||
sub_segments: List of sub-segments to filter by; optional
|
||||
top_k: Number of unique results to return (default 3)
|
||||
check_expiration_date: Whether to exclude expired documents (default True)
|
||||
fetch_limit_multiplier: Multiplier for database fetch limit (default 100)
|
||||
preprocess: Whether to preprocess the query with OCI GenAI before searching (default True)
|
||||
postprocess: Whether to postprocess results with LLM to synthesize an answer (default True)
|
||||
deduplicate: Whether to deduplicate results by title_proc (default False)
|
||||
Returns:
|
||||
dict with 'sql' (filled SQL for debugging), 'results' (unique records), 'reformulated_query', 'postprocessing', 'postprocessing_prompt'
|
||||
|
||||
Raises:
|
||||
TaisKbClientError: If DB not configured or OCI embedding fails
|
||||
ValueError: If product is None, segment is invalid, or sub-segment validation fails
|
||||
TypeError: If product is not a Product enum
|
||||
NotImplementedError: If product is FIBRA (not yet implemented)
|
||||
"""
|
||||
if top_k < 1:
|
||||
raise ValueError("top_k must be at least 1.")
|
||||
|
||||
# Validate product parameter
|
||||
self._validate_product(product)
|
||||
|
||||
if product == Product.FIBRA:
|
||||
raise NotImplementedError("Fibra product support is not yet implemented.")
|
||||
|
||||
if not all([settings.MONGODB_DB_USER, settings.MONGODB_DB_PASSWORD, settings.TAIS_DB_DSN]):
|
||||
raise TaisKbClientError(
|
||||
"TAIS DB not configured (MONGODB_DB_USER, MONGODB_DB_PASSWORD, TAIS_DB_DSN).",
|
||||
url="N/A",
|
||||
)
|
||||
|
||||
# Normalize to mutable lists
|
||||
active_segments: list[str] = list(segments) if segments else []
|
||||
active_sub_segments: list[str] = list(sub_segments) if sub_segments else []
|
||||
|
||||
# Validate inputs
|
||||
if active_segments:
|
||||
self._validate_segments(active_segments, product)
|
||||
|
||||
if active_sub_segments:
|
||||
# If we have sub_segments, we need at least one segment to validate against
|
||||
if not active_segments:
|
||||
raise ValueError(
|
||||
"sub_segments requires at least one segment to validate against"
|
||||
)
|
||||
self._validate_sub_segments_for_segments(
|
||||
active_segments, active_sub_segments, product
|
||||
)
|
||||
|
||||
# Product-based segment injection
|
||||
if product == Product.MOVEL:
|
||||
if "todosossegmentosmovel" not in [s.lower() for s in active_segments]:
|
||||
active_segments.append("todosossegmentosmovel")
|
||||
elif product == Product.FIBRA:
|
||||
if "todosossegmentosultrafibra" not in [s.lower() for s in active_segments]:
|
||||
active_segments.append("todosossegmentosultrafibra")
|
||||
|
||||
# Track original query and reformulated query
|
||||
original_query = query_text
|
||||
reformulated_query: str | None = None
|
||||
|
||||
if preprocess:
|
||||
# Preprocess the query with OCI GenAI (e.g. for better embedding quality)
|
||||
try:
|
||||
reformulated_query = await self._preprocess_query(original_query)
|
||||
query_text = reformulated_query
|
||||
except Exception as e:
|
||||
logger.warning(f"Query preprocessing failed, proceeding with original query. Error: {e}")
|
||||
reformulated_query = None
|
||||
|
||||
embed_start = time.perf_counter()
|
||||
embedding, embed_status = await self._embed(query_text)
|
||||
embedding_str = json.dumps(embedding)
|
||||
fetch_limit = max(top_k, 1) * fetch_limit_multiplier
|
||||
|
||||
bind_params: dict[str, object] = {
|
||||
"query_embedding": embedding_str,
|
||||
"fetch_limit": fetch_limit,
|
||||
}
|
||||
|
||||
conditions: list[str] = []
|
||||
|
||||
# Build segment filter: segments come as "controle + pospago + beta"
|
||||
# We need to find if ANY of our segments match ANY of the delimited values
|
||||
if active_segments:
|
||||
segment_conditions = []
|
||||
for i, seg in enumerate(active_segments):
|
||||
key = f"seg_{i}"
|
||||
# Search for " seg " or "seg " or " seg" to handle word boundaries with "+"
|
||||
# Oracle INSTR is case-insensitive by default when using LOWER()
|
||||
segment_conditions.append(
|
||||
f"INSTR(' ' || REPLACE(LOWER(segment), ' + ', ' ') || ' ', ' ' || LOWER(:{key}) || ' ') > 0"
|
||||
)
|
||||
bind_params[key] = seg.lower()
|
||||
conditions.append(f"({' OR '.join(segment_conditions)})")
|
||||
|
||||
# Build sub_segment filter: sub_segments come as "sub1, sub2, sub3"
|
||||
# We need to find if ANY of our sub_segments match ANY of the delimited values
|
||||
if active_sub_segments:
|
||||
sub_segment_conditions = []
|
||||
for i, sub_seg in enumerate(active_sub_segments):
|
||||
key = f"sub_seg_{i}"
|
||||
# Search for ",sub," or at start/end to handle word boundaries with ","
|
||||
# Match case-insensitively
|
||||
sub_segment_conditions.append(
|
||||
f"INSTR(',' || LOWER(REPLACE(sub_segments, ' ', '')) || ',', ',' || LOWER(:{key}) || ',') > 0"
|
||||
)
|
||||
bind_params[key] = sub_seg.lower().replace(" ", "")
|
||||
conditions.append(f"({' OR '.join(sub_segment_conditions)})")
|
||||
|
||||
# ── expiration filter ─────────────────────────────────────────────
|
||||
if check_expiration_date:
|
||||
# TRUNC(SYSDATE) strips the time component from the current server
|
||||
# date, ensuring a clean date-only comparison against expiration_date.
|
||||
conditions.append("(expiration_date IS NULL OR expiration_date >= TRUNC(SYSDATE))")
|
||||
|
||||
# ── distance filter ───────────────────────────────────────────────
|
||||
# Only return results with cosine distance lower than 0.5
|
||||
conditions.append("VECTOR_DISTANCE(embedding, TO_VECTOR(:query_embedding), COSINE) < 0.5")
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
id,
|
||||
doc_name,
|
||||
id_proc,
|
||||
CAST(title_proc AS VARCHAR2(4000)) AS title_proc,
|
||||
description_proc,
|
||||
updated_proc,
|
||||
segments,
|
||||
content,
|
||||
created_at,
|
||||
updated_at,
|
||||
uuid,
|
||||
subject,
|
||||
consultant_segments,
|
||||
expiration_date,
|
||||
sub_segments,
|
||||
segment,
|
||||
VECTOR_DISTANCE(embedding, TO_VECTOR(:query_embedding), COSINE) AS distance
|
||||
FROM {settings.TAIS_TABLE_CHUNKS}
|
||||
{where_clause}
|
||||
ORDER BY distance ASC
|
||||
FETCH FIRST :fetch_limit ROWS ONLY
|
||||
"""
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with oracledb.connect_async(
|
||||
user=settings.MONGODB_DB_USER,
|
||||
password=settings.MONGODB_DB_PASSWORD,
|
||||
dsn=settings.TAIS_DB_DSN,
|
||||
tcp_connect_timeout=settings.TAIS_DB_TIMEOUT,
|
||||
) as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(sql, bind_params)
|
||||
rows = await cur.fetchall()
|
||||
cols = [c[0].lower() for c in cur.description]
|
||||
except oracledb.Error as exc:
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
raise TaisKbClientError(
|
||||
f"TAIS DB error: {exc}",
|
||||
url=settings.TAIS_GENAI_ENDPOINT or "TAIS_DB",
|
||||
response_text=str(exc),
|
||||
latency_ms=latency_ms,
|
||||
) from exc
|
||||
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
records = [dict(zip(cols, row)) for row in rows]
|
||||
|
||||
# Apply deduplication if enabled
|
||||
if deduplicate:
|
||||
seen: set[str] = set()
|
||||
unique: list[dict] = []
|
||||
for r in records:
|
||||
title = r.get("title_proc")
|
||||
if not title or title in seen:
|
||||
continue
|
||||
seen.add(title)
|
||||
unique.append(r)
|
||||
if len(unique) >= top_k:
|
||||
break
|
||||
else:
|
||||
# No deduplication: just take the first top_k records
|
||||
unique = records[:top_k]
|
||||
|
||||
# Return with filled SQL for debugging (except vector value)
|
||||
filled_sql = self._fill_sql_with_bind_params(sql, bind_params)
|
||||
|
||||
# Postprocess results if enabled
|
||||
postprocessing_content = None
|
||||
postprocessing_id_procs = None
|
||||
postprocessing_id_procs_map = None # Map of id_proc -> title_proc
|
||||
postprocessing_prompt = None
|
||||
postprocessing_succeeded = None # None when postprocess skipped
|
||||
postprocessing_failure_reason = None
|
||||
postprocessing_http_meta = None
|
||||
if postprocess and unique:
|
||||
try:
|
||||
postprocessing_response = await self._postprocess_results(
|
||||
query_text,
|
||||
unique,
|
||||
reformulated_query=reformulated_query
|
||||
)
|
||||
postprocessing_content = postprocessing_response.get("content")
|
||||
postprocessing_id_procs = postprocessing_response.get("id_procs")
|
||||
postprocessing_prompt = postprocessing_response.get("filled_prompt")
|
||||
postprocessing_succeeded = postprocessing_response.get("postprocessing_succeeded")
|
||||
postprocessing_failure_reason = postprocessing_response.get("postprocessing_failure_reason")
|
||||
postprocessing_http_meta = postprocessing_response.get("postprocessing_http_meta")
|
||||
|
||||
# Build mapping of id_proc -> title_proc for the returned id_procs
|
||||
if postprocessing_id_procs:
|
||||
postprocessing_id_procs_map = {}
|
||||
for doc in unique:
|
||||
doc_id = doc.get("id_proc")
|
||||
if doc_id in postprocessing_id_procs:
|
||||
postprocessing_id_procs_map[doc_id] = doc.get("title_proc", "")
|
||||
|
||||
logger.debug(f"Results postprocessed successfully. Length: {len(postprocessing_content) if postprocessing_content else 0}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Results postprocessing failed: {e}. Continuing without postprocessing.")
|
||||
postprocessing_succeeded = False
|
||||
postprocessing_failure_reason = f"Exception inesperada no postprocess ({type(e).__name__})"
|
||||
postprocessing_http_meta = {
|
||||
"url": settings.TAIS_GENAI_ENDPOINT or "N/A",
|
||||
"status_code": getattr(e, "status", "N/A"),
|
||||
"response_text": str(e)[:500],
|
||||
"latency_ms": 0,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"TAIS KB search completed. raw=%d unique=%d top_k=%d product=%s segments=%s sub_segments=%s check_expiration=%s deduplicate=%s postprocessing=%s",
|
||||
len(records), len(unique), top_k, product.name if product else None,
|
||||
active_segments, active_sub_segments, check_expiration_date, deduplicate, bool(postprocessing_content),
|
||||
)
|
||||
|
||||
http_meta = {
|
||||
"url": settings.TAIS_GENAI_ENDPOINT or "N/A",
|
||||
"status_code": embed_status,
|
||||
"response_text": f"raw_records={len(records)} unique={len(unique)}",
|
||||
"latency_ms": int((time.perf_counter() - embed_start) * 1000),
|
||||
}
|
||||
|
||||
# Build a slim "retrieved" list for telemetry (AGAs that carry
|
||||
# ragRetrievedDocuments). Capped at `telemetry_top_n` to keep IC
|
||||
# payload size sane — `records` may have hundreds of candidates.
|
||||
# Already ordered by distance ASC from the SQL. Chunks/content are
|
||||
# dropped here because consumers of `retrieved_documents` only need
|
||||
# the doc identifier for observability.
|
||||
retrieved_documents = [
|
||||
{
|
||||
"documentId": r.get("id_proc"),
|
||||
"title": r.get("title_proc"),
|
||||
"distance": float(r["distance"]) if r.get("distance") is not None else None,
|
||||
}
|
||||
for r in records[:telemetry_top_n]
|
||||
]
|
||||
|
||||
return {
|
||||
"sql": filled_sql,
|
||||
"results": unique,
|
||||
"retrieved_documents": retrieved_documents,
|
||||
"reformulated_query": reformulated_query,
|
||||
"postprocessing_content": postprocessing_content,
|
||||
"postprocessing_id_procs": postprocessing_id_procs,
|
||||
"postprocessing_id_procs_map": postprocessing_id_procs_map,
|
||||
"postprocessing_prompt": postprocessing_prompt,
|
||||
"postprocessing_succeeded": postprocessing_succeeded,
|
||||
"postprocessing_failure_reason": postprocessing_failure_reason,
|
||||
"postprocessing_http_meta": postprocessing_http_meta,
|
||||
"http_meta": http_meta,
|
||||
}
|
||||
|
||||
@trace_tool
|
||||
async def search_documents_legacy(
|
||||
self,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
segment_filter: str | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""DEPRECATED: Use search_documents() instead with product and segments parameters.
|
||||
|
||||
Legacy API wrapper that maps old segment_filter to new product-based approach.
|
||||
This method maintains backward compatibility with existing code.
|
||||
|
||||
Returns a tuple (response_dict, http_meta_dict) following the same pattern as
|
||||
SiebelClient.open_service_request_with_retry and ImdbClient.get_imdb_access_data_with_retry.
|
||||
"""
|
||||
warnings.warn(
|
||||
"search_documents_legacy() is deprecated. Use search_documents(query_text, product=Product.MOVEL, ...) instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
# Map legacy segment_filter to new API
|
||||
# For backward compatibility, we resolve segments using a simple mapping
|
||||
segments = None
|
||||
if segment_filter:
|
||||
key = segment_filter.strip().lower()
|
||||
# Try to map old segment names to new allowed segments
|
||||
# For simplicity, if we can find a matching key in MOVEL segments, use it
|
||||
if key in _ALLOWED_MOVEL_SEGMENTS:
|
||||
segments = [key]
|
||||
|
||||
response = await self.search_documents(
|
||||
query_text=query_text,
|
||||
product=Product.MOVEL,
|
||||
segments=segments,
|
||||
top_k=top_k,
|
||||
check_expiration_date=True,
|
||||
fetch_limit_multiplier=50, # Keep old fetch limit for backward compatibility
|
||||
)
|
||||
http_meta = response.pop("http_meta", {
|
||||
"url": settings.TAIS_GENAI_ENDPOINT or "N/A",
|
||||
"status_code": "N/A",
|
||||
"response_text": "N/A",
|
||||
"latency_ms": 0,
|
||||
})
|
||||
return response, http_meta
|
||||
|
||||
@trace_tool
|
||||
async def get_content_by_id_proc(
|
||||
self,
|
||||
id_proc: str,
|
||||
return_as: str = "html"
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch document content from TAIS_BASE_CHUNKS by id_proc with format conversion.
|
||||
|
||||
Args:
|
||||
id_proc: Procedure ID to search for
|
||||
return_as: Format to return (html or markdown), case insensitive
|
||||
|
||||
Returns:
|
||||
dict with 'sql' (filled SQL for debugging), 'results' (records), 'return_as' (format)
|
||||
|
||||
Raises:
|
||||
TaisKbClientError: If DB not configured
|
||||
ValueError: If id_proc is empty or return_as is invalid
|
||||
"""
|
||||
if not all([settings.MONGODB_DB_USER, settings.MONGODB_DB_PASSWORD, settings.TAIS_DB_DSN]):
|
||||
raise TaisKbClientError(
|
||||
"TAIS DB not configured (MONGODB_DB_USER, MONGODB_DB_PASSWORD, TAIS_DB_DSN)."
|
||||
)
|
||||
|
||||
if not id_proc or not id_proc.strip():
|
||||
raise ValueError("id_proc cannot be empty.")
|
||||
|
||||
# Normalize return_as to lowercase
|
||||
return_as_lower = return_as.strip().lower()
|
||||
if return_as_lower not in ("html", "markdown"):
|
||||
raise ValueError(f"return_as must be 'html' or 'markdown', got '{return_as}'")
|
||||
|
||||
# Build SELECT clause based on return_as
|
||||
content_column = "content_html" if return_as_lower == "html" else "content_markdown"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
id,
|
||||
doc_name,
|
||||
id_proc,
|
||||
title_proc,
|
||||
description_proc,
|
||||
updated_proc,
|
||||
segments,
|
||||
{content_column} AS {content_column},
|
||||
created_at,
|
||||
updated_at,
|
||||
uuid,
|
||||
subject,
|
||||
consultant_segments,
|
||||
expiration_date,
|
||||
sub_segments,
|
||||
segment
|
||||
FROM {settings.TAIS_TABLE_FILES}
|
||||
WHERE id_proc = :id_proc
|
||||
"""
|
||||
|
||||
bind_params: dict[str, object] = {
|
||||
"id_proc": id_proc.strip(),
|
||||
}
|
||||
|
||||
try:
|
||||
async with oracledb.connect_async(
|
||||
user=settings.MONGODB_DB_USER,
|
||||
password=settings.MONGODB_DB_PASSWORD,
|
||||
dsn=settings.TAIS_DB_DSN,
|
||||
tcp_connect_timeout=settings.TAIS_DB_TIMEOUT,
|
||||
) as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(sql, bind_params)
|
||||
rows = await cur.fetchall()
|
||||
cols = [c[0].lower() for c in cur.description]
|
||||
except oracledb.Error as exc:
|
||||
raise TaisKbClientError(f"TAIS DB error: {exc}") from exc
|
||||
|
||||
records = [dict(zip(cols, row)) for row in rows]
|
||||
|
||||
# Fill the SQL with bind params for debugging (except for LOB fields)
|
||||
filled_sql = self._fill_sql_with_bind_params(sql, bind_params)
|
||||
|
||||
logger.info(
|
||||
"TAIS KB get_content_by_id_proc completed. id_proc=%s return_as=%s results=%d",
|
||||
id_proc, return_as_lower, len(records),
|
||||
)
|
||||
return {
|
||||
"sql": filled_sql,
|
||||
"results": records,
|
||||
"return_as": return_as_lower
|
||||
}
|
||||
Reference in New Issue
Block a user