first commit

This commit is contained in:
2026-06-13 08:23:21 -03:00
commit 89c23fb0ed
439 changed files with 32801 additions and 0 deletions

View 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)

View 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),
}

View 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),
)

View 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

View 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
)

View 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),
)

View 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
)

View 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

View 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

View 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)

View 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)

View 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",
]

View 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()

View 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()

View 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)

View 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

View 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
}