bugfix: search engine

This commit is contained in:
T3782834
2026-08-22 09:45:02 -03:00
parent dea818b845
commit 05373deff2
5 changed files with 392 additions and 49 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import base64
import json
import os
import time
@@ -41,6 +42,48 @@ class TimApiClient:
path = self.FIXTURES / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _env_first(*names: str, default: str = "") -> str:
"""Retorna a primeira variável não vazia, preservando aliases do Contas original."""
for name in names:
value = os.getenv(name)
if value is not None and str(value).strip():
return str(value).strip()
return default
@classmethod
def _auth_value(
cls,
*auth_names: str,
user_names: tuple[str, ...] = (),
password_names: tuple[str, ...] = (),
) -> str:
user = cls._env_first(*user_names) if user_names else ""
password = cls._env_first(*password_names) if password_names else ""
if user and password:
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
return f"Basic {token}"
raw = cls._env_first(*auth_names)
if not raw:
return ""
low = raw.lower()
if low.startswith("basic ") or low.startswith("bearer "):
return raw
if ":" in raw and " " not in raw:
return "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
return raw
@staticmethod
def _url_with_msisdn(base: str, msisdn: str, *, normalize_country: bool = False) -> str:
text = str(base or "").strip()
digits = "".join(ch for ch in str(msisdn or "") if ch.isdigit())
if normalize_country and digits and not digits.startswith("55"):
digits = f"55{digits}"
value = digits or str(msisdn or "").strip()
if "{msisdn}" in text:
return text.replace("{msisdn}", quote(value))
return f"{text.rstrip('/')}/{quote(value)}" if text else value
def _headers(self, *, client_id: str | None = None, auth: str | None = None, extra: dict[str, str] | None = None) -> dict[str, str]:
h = {"Content-Type": "application/json", "Accept": "application/json"}
if client_id:
@@ -107,22 +150,33 @@ class TimApiClient:
def consultar_faturas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("complete_invoices")
url = os.getenv("TIM_COMPLETE_INVOICES_URL", "")
return self.request("POST", url, payload={"msisdn": msisdn}, headers=self._headers(client_id=None, auth=os.getenv("TIM_COMPLETE_INVOICES_AUTH", ""), extra={"ClientID": os.getenv("TIM_COMPLETE_INVOICES_CLIENT_ID", "AIAGENTCR")}))
url = self._env_first("TIM_COMPLETE_INVOICES_URL")
return self.request(
"POST", url, payload={"msisdn": msisdn},
headers=self._headers(
client_id=None, auth=self._env_first("TIM_COMPLETE_INVOICES_AUTH"),
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", default="AIAGENTCR")},
),
timeout=int(self._env_first("TIM_COMPLETE_INVOICES_TIMEOUT", default="30")),
)
def billing_analysis(self, msisdn: str, **context: Any) -> Any:
if self.mock:
return self.fixture("divergencia")
base = os.getenv("TIM_DIVERGENCIA_URL", "").rstrip("/")
base = self._env_first("TIM_DIVERGENCIA_URL").rstrip("/")
url = f"{base}/{quote(msisdn)}"
auth = os.getenv("TIM_DIVERGENCIA_AUTH", "") or os.getenv("TIM_SECURE_PDF_AUTH", "")
auth = self._auth_value(
"TIM_DIVERGENCIA_AUTH", "TIM_DIVERGENCE_AUTH",
user_names=("TIM_DIVERGENCIA_USER", "TIM_DIVERGENCIA_USERNAME", "TIM_DIVERGENCE_USER", "TIM_DIVERGENCE_USERNAME"),
password_names=("TIM_DIVERGENCIA_PASSWORD", "TIM_DIVERGENCIA_PASS", "TIM_DIVERGENCE_PASSWORD", "TIM_DIVERGENCE_PASS"),
)
headers = self._headers(client_id=None, auth=auth, extra={
"clientID": os.getenv("TIM_DIVERGENCIA_CLIENT_ID", "AIAGENTCR"),
"clientID": self._env_first("TIM_DIVERGENCIA_CLIENT_ID", default="AIAGENTCR"),
})
params = {"channel": context.get("channel") or "AIAGENTCR"}
attempts: list[dict[str, Any]] = []
try:
result = self.request("GET", url, params=params, headers=headers, timeout=int(os.getenv("TIM_DIVERGENCIA_TIMEOUT", "120")), attempt_log=attempts)
result = self.request("GET", url, params=params, headers=headers, timeout=int(self._env_first("TIM_DIVERGENCIA_TIMEOUT", "TIM_DIVERGENCE_TIMEOUT", default="120")), attempt_log=attempts)
return self._attach_transport(result, operation="base_conhecimento", attempts=attempts)
except TimApiError as exc:
if not exc.attempts:
@@ -132,9 +186,16 @@ class TimApiClient:
def consultar_vas(self, msisdn: str) -> Any:
if self.mock:
return self.fixture("query_vas")
base = os.getenv("TIM_URL_CONSULTA_VAS", "")
url = base.replace("{msisdn}", quote(msisdn))
return self.request("GET", url, headers=self._headers(client_id=None, auth=os.getenv("TIM_CONSULTA_AUTH", ""), extra={"clientId": os.getenv("TIM_CONSULTA_CLIENT_ID", "AIAAGENTCR")}))
base = self._env_first("TIM_URL_CONSULTA_VAS", "TIM_CONSULTA_URL")
url = self._url_with_msisdn(base, msisdn, normalize_country=True)
return self.request(
"GET", url,
headers=self._headers(
client_id=None, auth=self._env_first("TIM_QUERY_AUTH", "TIM_CONSULTA_AUTH"),
extra={"clientId": self._env_first("TIM_CONSULTA_CLIENT_ID", default="AIAAGENTCR")},
),
timeout=int(self._env_first("TIM_QUERY_TIMEOUT", "TIM_CONSULTA_TIMEOUT", default="30")),
)
def historico_vas(self, msisdn: str) -> Any:
if self.mock:
@@ -146,7 +207,7 @@ class TimApiClient:
client_id=None,
auth=os.getenv("TIM_VAS_HISTORY_AUTH", ""),
extra={
"clientId": os.getenv("TIM_VAS_HISTORY_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "CHAT")),
"clientId": self._env_first("TIM_VAS_HISTORY_CLIENT_ID", default="AIAGENTCR"),
"messageId": os.getenv("TIM_VAS_HISTORY_MESSAGE_ID", "") or str(uuid.uuid4()),
},
)
@@ -155,35 +216,35 @@ class TimApiClient:
def bloquear_vas(self, msisdn: str, service: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("block_vas")
url = os.getenv("TIM_URL_BLOQUEIO_VAS", "")
url = self._env_first("TIM_URL_BLOQUEIO_VAS", "TIM_BLOQUEIO_URL")
digits = "".join(ch for ch in str(msisdn) if ch.isdigit())
normalized = digits[2:] if len(digits) == 13 else digits
app_id = str(service.get("appId") or service.get("app_id") or "")
csp_id = str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740"))
op = os.getenv("TIM_BLOQUEIO_OPERATION_TYPE", "block")
op = self._env_first("TIM_BLOCK_OPERATION_TYPE", "TIM_BLOQUEIO_OPERATION_TYPE", default="block")
base = {"Customer": {"Msisdn": normalized}, "AppId": app_id, "CspId": csp_id, "TypeOperation": op}
payloads = [
{"customer": {"msisdn": normalized}, "appId": app_id, "cspId": csp_id, "typeOperation": op},
{"input": base, "Input": base},
{"vasBlock": {"msisdn": normalized, "appId": app_id, "cspId": csp_id, "type": op}},
]
mode = os.getenv("TIM_BLOQUEIO_PAYLOAD_MODE", "auto").strip().lower()
mode = self._env_first("TIM_BLOCK_PAYLOAD_MODE", "TIM_BLOQUEIO_PAYLOAD_MODE", default="auto").lower()
if mode == "vasblock": payloads = [payloads[2], payloads[0]]
elif mode == "input": payloads = [payloads[1], payloads[0]]
elif mode == "pmid": payloads = [payloads[0], payloads[1]]
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_BLOQUEIO_AUTH", ""),
auth=self._env_first("TIM_BLOCK_AUTH", "TIM_BLOQUEIO_AUTH"),
extra={
"Accept-Encoding": os.getenv("TIM_BLOQUEIO_ACCEPT_ENCODING", "gzip,deflate"),
"clientId": os.getenv("TIM_BLOQUEIO_CLIENT_ID", "AIAGENTCR"),
"Accept-Encoding": self._env_first("TIM_BLOCK_ACCEPT_ENCODING", "TIM_BLOQUEIO_ACCEPT_ENCODING", default="gzip,deflate"),
"clientId": self._env_first("TIM_BLOCK_CLIENT_ID", "TIM_BLOQUEIO_CLIENT_ID", default="AIAGENTCR"),
"messageId": str(uuid.uuid4()),
},
)
last = None
for index, payload in enumerate(payloads):
try:
return self.request("POST", url, payload=payload, headers=headers, timeout=int(os.getenv("TIM_BLOQUEIO_TIMEOUT", "30")))
return self.request("POST", url, payload=payload, headers=headers, timeout=int(self._env_first("TIM_BLOCK_TIMEOUT", "TIM_BLOQUEIO_TIMEOUT", default="30")))
except TimApiError as exc:
last = exc
if exc.status_code != 400 or index >= len(payloads) - 1:
@@ -193,7 +254,7 @@ class TimApiClient:
def cancelar_vas(self, msisdn: str, service: dict[str, Any], *, protocol: str = "") -> Any:
if self.mock:
return self.fixture("cancel_vas")
url = os.getenv("TIM_CANCELAMENTO_URL", "")
url = self._env_first("TIM_CANCELLATION_URL", "TIM_CANCELAMENTO_URL")
payload = {
"channel": "AIAGENTCR",
"msisdn": msisdn,
@@ -203,17 +264,17 @@ class TimApiClient:
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CANCELAMENTO_AUTH", ""),
auth=self._env_first("TIM_CANCELLATION_AUTH", "TIM_CANCELAMENTO_AUTH"),
extra={
"clientId": os.getenv("TIM_CANCELAMENTO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "AIAGENTCR")),
"messageId": str(uuid.uuid4()),
"AuthorizationOAM": os.getenv("TIM_CANCELAMENTO_AUTHORIZATION_OAM", ""),
"Cn_field": os.getenv("TIM_CANCELAMENTO_CN_FIELD", ""),
"Type_field": os.getenv("TIM_CANCELAMENTO_TYPE_FIELD", ""),
"AuthorizationOAM": self._env_first("TIM_CANCELLATION_AUTH_OAM", "TIM_CANCELAMENTO_AUTH_OAM", "TIM_CANCELAMENTO_AUTHORIZATION_OAM"),
"Cn_field": self._env_first("TIM_CANCELLATION_CN_FIELD", "TIM_CANCELAMENTO_CN_FIELD"),
"Type_field": self._env_first("TIM_CANCELLATION_TYPE_FIELD", "TIM_CANCELAMENTO_TYPE_FIELD"),
},
)
attempts: list[dict[str, Any]] = []
result = self.request("DELETE", url, payload=payload, headers=headers, timeout=int(os.getenv("TIM_CANCELAMENTO_TIMEOUT", "30")), attempt_log=attempts)
result = self.request("DELETE", url, payload=payload, headers=headers, timeout=int(self._env_first("TIM_CANCELLATION_TIMEOUT", "TIM_CANCELAMENTO_TIMEOUT", default="30")), attempt_log=attempts)
return self._attach_transport(result, operation="cancela_vas", attempts=attempts)
def contrato(self, msisdn: str) -> Any:
@@ -223,7 +284,7 @@ class TimApiClient:
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CONTRATO_AUTH", ""),
extra={"clientId": os.getenv("TIM_CONTRATO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "CHAT"))},
extra={"clientId": self._env_first("TIM_CONTRATO_CLIENT_ID", default="AIAGENTCR")},
)
return self.request("GET", f"{base}/{quote(msisdn)}", headers=headers, timeout=int(os.getenv("TIM_CONTRATO_TIMEOUT", "30")))
@@ -231,13 +292,13 @@ class TimApiClient:
if self.mock:
# contract fixture carries representative customer identity in local mode.
return self.fixture("contrato")
url = os.getenv("TIM_PROFILE_FULL_URL", "").replace("{msisdn}", quote(msisdn))
url = self._url_with_msisdn(self._env_first("TIM_PROFILE_FULL_URL"), msisdn)
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
)
return self.request("GET", url, headers=headers)
return self.request("GET", url, headers=headers, timeout=int(self._env_first("TIM_PROFILE_FULL_TIMEOUT", default="30")))
def abrir_protocolo(self, payload: dict[str, Any]) -> Any:
"""Registra protocolo V2 preservando o contrato externo do Contas original.
@@ -317,7 +378,7 @@ class TimApiClient:
if self.mock:
return self.fixture("contestacao_tool")
data = dict(payload)
data.setdefault("userId", "AIAGENTCR")
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", default="AIAGENTCR"))
data.setdefault("customerIdCurrent", data.get("customerId") or "")
data.setdefault("customerType", "2")
data.setdefault("customerStatus", "1")
@@ -445,17 +506,17 @@ class TimApiClient:
"""Perfil de faturamento usando o mesmo contrato CompleteInvoices do original."""
if self.mock:
return self.fixture("profile_bill")
url = os.getenv("TIM_URL_PERFIL_FATURA", "") or os.getenv("TIM_COMPLETE_INVOICES_URL", "")
url = self._env_first("TIM_COMPLETE_INVOICES_URL", "TIM_URL_PERFIL_FATURA")
return self.request(
"POST",
url,
payload={"msisdn": msisdn},
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_BILL_AUTH", "") or os.getenv("TIM_COMPLETE_INVOICES_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_BILL_CLIENT_ID", "AIAGENTCR")},
auth=self._env_first("TIM_COMPLETE_INVOICES_AUTH", "TIM_PROFILE_BILL_AUTH"),
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", "TIM_PROFILE_BILL_CLIENT_ID", default="AIAGENTCR")},
),
timeout=int(os.getenv("TIM_PROFILE_BILL_TIMEOUT", "30")),
timeout=int(self._env_first("TIM_COMPLETE_INVOICES_TIMEOUT", "TIM_PROFILE_BILL_TIMEOUT", default="30")),
)
def line_info(self, msisdn: str) -> Any:
@@ -463,7 +524,7 @@ class TimApiClient:
if self.mock:
payload = self.fixture("contrato")
else:
url = os.getenv("TIM_PROFILE_FULL_URL", "").replace("{msisdn}", quote(msisdn))
url = self._url_with_msisdn(self._env_first("TIM_PROFILE_FULL_URL"), msisdn)
payload = self.request(
"GET",
url,
@@ -472,6 +533,7 @@ class TimApiClient:
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
),
timeout=int(self._env_first("TIM_PROFILE_FULL_TIMEOUT", default="30")),
)
def extract(value: Any) -> str:
if not isinstance(value, dict):
@@ -517,9 +579,9 @@ class TimApiClient:
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_BILL_PDF_AUTH", "") or os.getenv("TIM_SECURE_PDF_AUTH", ""),
auth=self._env_first("TIM_BILL_PDF_AUTH", "TIM_SECURE_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"),
extra={
"clientId": os.getenv("TIM_BILL_PDF_CLIENT_ID", "AIAGENTCR"),
"clientId": self._env_first("TIM_BILL_PDF_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", default="AIAGENTCR"),
"Accept": "application/pdf",
},
)
@@ -528,7 +590,7 @@ class TimApiClient:
url,
payload=payload,
headers=headers,
timeout=int(os.getenv("TIM_BILL_PDF_TIMEOUT", os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30"))),
timeout=int(self._env_first("TIM_BILL_PDF_TIMEOUT", "TIM_SECURE_PDF_TIMEOUT", "TIM_INVOICE_RECOVER_TIMEOUT", default="30")),
)
raw_content = result.get("raw_content") if isinstance(result, dict) else None
parsed = result.get("parsed_content") if isinstance(result, dict) else None
@@ -558,8 +620,8 @@ class TimApiClient:
params=params,
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_SECURE_PDF_AUTH", ""),
extra={"clientId": os.getenv("TIM_INVOICE_RECOVER_CLIENT_ID", "AIAGENTCR")},
auth=self._env_first("TIM_SECURE_PDF_AUTH", "TIM_BILL_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"),
extra={"clientid": self._env_first("TIM_INVOICE_RECOVER_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", default="AIAGENTCR")},
),
timeout=int(os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30")),
)

View File

@@ -296,7 +296,7 @@ class InvoiceResolver:
for mention in mentioned_items:
if not isinstance(mention, str) or not mention.strip():
continue
resolved.extend(self._resolve_one_mention(mention, invoice_detail))
resolved.extend(self._resolve_one_mention(mention, invoice_detail, include_identity_oos=False))
return self._dedupe_exact_matches(resolved)
def resolve_each(
@@ -344,7 +344,7 @@ class InvoiceResolver:
ordered.append((idx, name))
lines[idx] = msisdn
dates[idx] = date
matches = self._resolve_one_mention(name, invoice_detail)
matches = self._resolve_one_mention(name, invoice_detail, include_identity_oos=True)
if matches:
deterministic[idx] = matches
elif (
@@ -537,6 +537,8 @@ class InvoiceResolver:
self,
mention: str,
invoice_detail: dict[str, Any],
*,
include_identity_oos: bool = False,
) -> list[ResolvedInvoiceItem]:
"""Match determinístico de UMA menção contra todas as combinações
msisdn × seção × entry, com precedência de match exato sobre substring.
@@ -564,20 +566,33 @@ class InvoiceResolver:
casava por substring o prefixo ``"VOD + Canais Abertos"`` — item errado;
a normalização a torna exata ao item ``+Fechados``.)"""
mention_normalized = self._normalize_match_text(mention)
# Primeiro procure igualdade EXATA em todo o catálogo da fatura, inclusive
# seções não acionáveis (Plano, descontos etc.). Isso é um guard de identidade:
# se o cliente nomeou precisamente um item conhecido, esse item precisa vencer
# antes de qualquer fuzzy matching. Sem isso, um plano explicitamente citado
# pode ser removido do universo tratável e o matcher acabar autorizando outro
# VAS apenas por similaridade (ex.: TIM CTRL Redes Sociais -> TIM Fashion).
if include_identity_oos:
identity_candidates = list(self._iter_identity_candidates(invoice_detail))
exact_identity = [
cand
for cand in identity_candidates
if mention_normalized
and self._normalize_match_text(cand.desc) == mention_normalized
]
if exact_identity:
return [self._build_item(cand) for cand in exact_identity]
# Fuzzy/substring continua restrito ao universo de serviços acionáveis +
# out-of-scope de seções de serviço. Seções explicitamente não acionáveis
# jamais entram no matcher aproximado; elas só podem bloquear por igualdade
# exata acima. Isso mantém o comportamento conservador do fluxo.
candidates = list(self._iter_candidates(invoice_detail))
exact_cands: list[_Candidate] = []
substring_cands: list[_Candidate] = []
for cand in candidates:
desc_normalized = self._normalize_match_text(cand.desc)
if mention_normalized and desc_normalized == mention_normalized:
exact_cands.append(cand)
elif self._was_mentioned(cand.desc, [mention]):
if self._was_mentioned(cand.desc, [mention]):
substring_cands.append(cand)
# Match exato (chave normalizada) tem precedência absoluta. ``canonical_name``
# e o payload seguem sempre o ``desc`` CRU da fatura — a normalização é só
# chave de comparação.
if exact_cands:
return [self._build_item(cand) for cand in exact_cands]
guarded = self._apply_prefix_guard(mention, substring_cands, candidates)
return [self._build_item(cand) for cand in guarded]
@@ -828,6 +843,45 @@ class InvoiceResolver:
# ----- construção de candidatos e itens --------------------------------
def _iter_identity_candidates(
self, invoice_detail: dict[str, Any]
) -> Iterable[_Candidate]:
"""Itera itens nomeáveis de TODAS as seções da fatura para match exato.
Diferente de :meth:`_iter_candidates`, este catálogo inclui também seções
não acionáveis como ``Plano``/``Planos``. Esses itens são construídos como
``out_of_scope`` e servem somente para preservar a identidade explicitamente
citada pelo cliente. Eles nunca participam do fuzzy matcher.
"""
for parent_msisdn, sections in self._iter_msisdn_buckets(invoice_detail):
if not isinstance(sections, dict):
continue
for section, entries in sections.items():
if not isinstance(entries, list):
continue
section_tool, section_type = SECTION_DEFAULTS.get(
section, (None, _OUT_OF_SCOPE_TYPE)
)
for entry in entries:
if not isinstance(entry, dict) or self._is_non_service_item(entry):
continue
desc = str(entry.get("desc") or "").strip()
if not desc:
continue
default_tool, default_type = section_tool, section_type
# Se a seção é explicitamente não acionável e a entry não foi
# carimbada como tratável pelo parser, force out_of_scope.
if section in _NON_SERVICE_SECTIONS and not self._has_treatable_flag(entry):
default_tool, default_type = None, _OUT_OF_SCOPE_TYPE
yield _Candidate(
desc=desc,
section=section,
default_tool=default_tool,
default_type=default_type,
parent_msisdn=parent_msisdn,
entry=entry,
)
def _iter_candidates(
self, invoice_detail: dict[str, Any]
) -> Iterable[_Candidate]: