first commit

This commit is contained in:
2026-06-16 20:54:49 -03:00
commit 60c2f03eb3
153 changed files with 19704 additions and 0 deletions

View File

@@ -0,0 +1,574 @@
from __future__ import annotations
import logging
import os
import re
import time
import unicodedata as ud
from dataclasses import (
asdict,
is_dataclass,
)
from datetime import (
datetime,
timezone,
)
from decimal import (
Decimal,
InvalidOperation,
)
from typing import Any
from pydantic import BaseModel
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.integrations.noc_events import emit_api_content_mismatch
from agente_contas_tim.models.service_info import ServiceInfo
from agente_contas_tim.observability import get_session_id
_IDEMPOTENCY_TTL_SECONDS = int(os.getenv("TIM_IDEMPOTENCY_TTL_SECONDS", "3600"))
_IDEMPOTENCY_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
logger = logging.getLogger(__name__)
def _emit_vaa(
tag: str,
ic_base: dict[str, Any],
*,
gsm: str = "",
extra_metadata: dict[str, Any] | None = None,
) -> None:
"""Emite um IC do fluxo VAS Avulso (VAA.001VAA.017)."""
try:
metadata = {
**ic_base,
"tag": tag,
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
}
if isinstance(extra_metadata, dict):
metadata.update(extra_metadata)
if gsm:
metadata["gsm"] = gsm
agent_framework_bridge.event(tag, metadata=metadata)
except Exception:
logger.warning("_emit_vaa tag=%s falhou silenciosamente", tag)
def _build_ic_context(
input_state: dict[str, Any] | None,
*,
gsm: str = "",
) -> dict[str, Any]:
if not isinstance(input_state, dict):
input_state = {}
return {
"sessionId": str(get_session_id() or ""),
"gsm": str(gsm or "").strip(),
"ani": str(input_state.get("ani", "") or "").strip(),
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
"agentId": "contas",
"channelId": str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip()
or "URA",
}
def _emit_ic(
ic_code: str,
input_state: dict[str, Any],
*,
extra_metadata: dict[str, Any] | None = None,
) -> None:
"""Emite um IC via agent_framework_bridge com metadados padrao."""
try:
metadata: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"tag": ic_code,
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
"gsm": str(input_state.get("msisdn", "") or "").strip(),
"agentId": "contas",
"channelId": str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip()
or "URA",
}
ani = str(input_state.get("ani", "") or "").strip()
if ani:
metadata["ani"] = ani
message_id = str(
input_state.get("message_id") or input_state.get("messageId") or ""
).strip()
if message_id:
metadata["messageId"] = message_id
if isinstance(extra_metadata, dict):
metadata.update(extra_metadata)
agent_framework_bridge.event(ic_code, metadata=metadata)
except Exception:
logger.debug("_emit_ic: falha ao emitir %s", ic_code, exc_info=True)
def _idempotency_get(key: str) -> dict[str, Any] | None:
if not key:
return None
now = time.monotonic()
cached = _IDEMPOTENCY_CACHE.get(key)
if cached is None:
return None
created_at, value = cached
if now - created_at > _IDEMPOTENCY_TTL_SECONDS:
_IDEMPOTENCY_CACHE.pop(key, None)
return None
return value
def _idempotency_set(key: str, value: dict[str, Any]) -> None:
if not key:
return
_IDEMPOTENCY_CACHE[key] = (time.monotonic(), value)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _to_epoch_millis(value: Any) -> int:
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return int(value.timestamp() * 1000)
raw = str(value or "").strip()
if not raw:
return int(datetime.now(timezone.utc).timestamp() * 1000)
if re.fullmatch(r"\d+(?:\.\d+)?", raw):
number = float(raw)
if number > 100_000_000_000:
return int(number)
return int(number * 1000)
iso_raw = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
try:
parsed = datetime.fromisoformat(iso_raw)
except ValueError:
return int(datetime.now(timezone.utc).timestamp() * 1000)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp() * 1000)
def _to_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
return text in {"1", "true", "t", "sim", "yes", "y"}
def _to_dict(data: Any) -> Any:
if isinstance(data, BaseModel):
return data.model_dump(by_alias=True)
if is_dataclass(data):
return _to_dict(asdict(data))
if isinstance(data, tuple):
return [_to_dict(item) for item in data]
if isinstance(data, list):
return [_to_dict(item) for item in data]
if isinstance(data, dict):
return {key: _to_dict(value) for key, value in data.items()}
return data
def _result_failed_or_missing_data(result: Any, *, state: dict[str, Any]) -> bool:
if not getattr(result, "success", False):
return True
if getattr(result, "data", None) is not None:
return False
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
emit_api_content_mismatch(
values={
"msisdn": str(input_state.get("msisdn", "") or "").strip(),
"invoice_id": str(
input_state.get("invoice_id")
or input_state.get("current_invoice_number")
or ""
).strip(),
"channel_id": str(input_state.get("channel_id", "") or "").strip(),
"ura_call_id": str(input_state.get("ura_call_id", "") or "").strip(),
},
reason="workflow_action_command_success_without_data",
command=str(getattr(result, "metadata", {}).get("command", "") or ""),
api_response_payload=getattr(result, "metadata", {}),
)
return True
def _runtime_llm_callbacks(runtime: WorkflowRuntimeContext) -> list[Any] | None:
callbacks = list(runtime.llm_callbacks)
return callbacks or None
def _runtime_llm_metadata(runtime: WorkflowRuntimeContext) -> dict[str, Any]:
return dict(runtime.llm_metadata)
def _service_from_dict(service: dict[str, Any] | None) -> ServiceInfo | None:
if service is None:
return None
return ServiceInfo(
msisdn=str(service.get("msisdn", "")),
app_id=str(service.get("app_id", "")),
csp_id=str(service.get("csp_id", "")),
ippid=str(service.get("ippid", "")),
service_name=str(service.get("service_name", "")),
status=str(service.get("status", "")),
extra=dict(service.get("extra", {})),
)
def _extract_amount(extra: dict[str, Any] | None) -> str:
if not isinstance(extra, dict):
return ""
details = extra.get("details", {})
if not isinstance(details, dict):
details = {}
return str(
extra.get("valor")
or extra.get("price")
or details.get("valor")
or details.get("price")
or ""
)
def _extract_boleto_code(extra: dict[str, Any] | None) -> str:
if not isinstance(extra, dict):
return ""
return str(
extra.get("codigo_boleto")
or extra.get("boleto_code")
or extra.get("linha_digitavel")
or ""
).strip()
def _find_matching_service(
requested_name: str,
active_services: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
lowered_name = requested_name.strip().lower()
exact = active_services.get(lowered_name)
if exact:
return exact
normalized_requested = _normalize_service_name(lowered_name)
for key, value in active_services.items():
if _normalize_service_name(key) == normalized_requested:
return value
return next(
(
value
for key, value in active_services.items()
if (
lowered_name in key
or key in lowered_name
or normalized_requested in _normalize_service_name(key)
or _normalize_service_name(key) in normalized_requested
)
),
None,
)
def _normalize_service_name(value: str) -> str:
text = ud.normalize("NFKD", str(value or ""))
text = "".join(ch for ch in text if not ud.combining(ch))
text = re.sub(r"[^a-zA-Z0-9]+", " ", text).strip().lower()
return re.sub(r"\s+", " ", text)
def _resolve_service_csp_id(
matched_service: dict[str, Any],
*,
fallback_csp_id: str,
) -> str:
if not isinstance(matched_service, dict):
return fallback_csp_id
context = matched_service.get("service_context")
if isinstance(context, dict):
csp_id = str(
context.get("csp_id")
or context.get("cspId")
or (
context.get("csp", {}).get("id")
if isinstance(context.get("csp"), dict)
else ""
)
or ""
).strip()
if csp_id:
return csp_id
return fallback_csp_id
def _normalize_amount(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
text = (
text.replace("R$", "")
.replace("$", "")
.replace("\u00a0", "")
.strip()
)
text = text.replace("", "-").replace("", "-")
has_neg = text.startswith("-")
if has_neg:
text = text[1:]
else:
text = text.lstrip("+")
text = re.sub(r"\s+", "", text)
text = re.sub(r"[^0-9.,-]", "", text)
if not text:
return ""
has_any = ("." in text) or ("," in text)
if not has_any:
return f"{'-' if has_neg else ''}{text}"
if "." in text and "," in text:
last_dot = text.rfind(".")
last_comma = text.rfind(",")
if last_comma > last_dot:
int_part = text[:last_comma].replace(".", "")
frac_part = text[last_comma + 1 :]
else:
int_part = text[:last_dot].replace(",", "")
frac_part = text[last_dot + 1 :]
normalized = f"{'-' if has_neg else ''}{int_part}.{frac_part}"
return normalized
sep = "." if "." in text else ","
parts = text.split(sep)
if len(parts) > 2:
frac = parts[-1]
if len(frac) <= 2:
normalized = f"{'-' if has_neg else ''}{''.join(parts[:-1])}.{frac}"
else:
normalized = f"{'-' if has_neg else ''}{''.join(parts)}"
return normalized
int_part, frac_part = parts[0], parts[1]
if len(frac_part) <= 2:
normalized = (
f"{'-' if has_neg else ''}{int_part.replace(',', '').replace('.', '')}."
f"{frac_part}"
)
else:
normalized = f"{'-' if has_neg else ''}{int_part}{frac_part}"
return normalized
def _parse_amount(value: Any) -> Decimal | None:
normalized = _normalize_amount(value)
if not normalized:
return None
try:
return Decimal(normalized)
except (InvalidOperation, ValueError):
return None
def _format_amount(value: Decimal | None) -> str:
if value is None:
return ""
return f"{value:.2f}".replace(".", ",")
def _build_sr_notes(
*,
sms_enviado: bool,
codigo_boleto: str,
data_credito_proxima_fatura: str,
) -> str:
if sms_enviado:
return (
f"SMS com código do boleto enviado. Código: {codigo_boleto}."
if codigo_boleto
else "SMS com código do boleto enviado."
)
if data_credito_proxima_fatura:
return (
"Crédito do valor do serviço cancelado registrado "
f"na próxima fatura com vencimento em {data_credito_proxima_fatura}."
)
return "Crédito do valor do serviço cancelado registrado na próxima fatura."
def _format_list_pt_br(values: list[str]) -> str:
cleaned = [str(item).strip() for item in values if str(item).strip()]
if not cleaned:
return ""
if len(cleaned) == 1:
return cleaned[0]
if len(cleaned) == 2:
return f"{cleaned[0]} e {cleaned[1]}"
return f"{', '.join(cleaned[:-1])} e {cleaned[-1]}"
def _final_msisdn(msisdn: str) -> str:
digits = re.sub(r"\D", "", msisdn)
if len(digits) >= 4:
return digits[-4:]
return msisdn[-4:] if len(msisdn) >= 4 else msisdn
def _first_text_from_params_or_state(
state: dict[str, Any],
params: dict[str, Any],
*keys: str,
) -> str:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
sources: list[dict[str, Any]] = []
if isinstance(params, dict):
sources.append(params)
if isinstance(input_state, dict):
sources.append(input_state)
for source in sources:
for key in keys:
value = source.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _first_value_from_params_or_state(
state: dict[str, Any],
params: dict[str, Any],
*keys: str,
) -> Any:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
sources: list[dict[str, Any]] = []
if isinstance(params, dict):
sources.append(params)
if isinstance(input_state, dict):
sources.append(input_state)
for source in sources:
for key in keys:
if key not in source:
continue
value = source.get(key)
if value is None:
continue
if isinstance(value, str):
text = value.strip()
if text:
return text
continue
return value
return None
def _contains_any_keyword(value: str, keywords: tuple[str, ...]) -> bool:
normalized = str(value or "").strip().lower()
if not normalized:
return False
return any(keyword in normalized for keyword in keywords)
def _normalize_number_text(value: Any, *, default: str = "0") -> str:
text = str(value).strip()
if not text:
return default
cleaned = text.replace("R$", "").replace(" ", "")
if "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
try:
normalized = format(Decimal(cleaned), "f")
except (InvalidOperation, ValueError):
return default
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or default
def _normalize_bool(value: Any, *, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if not text:
return default
if text in {"true", "1", "sim", "yes", "y"}:
return True
if text in {"false", "0", "nao", "não", "no", "n"}:
return False
return default
def _map_customer_status_code(value: Any) -> str:
text = str(value).strip().lower()
if not text:
return ""
if text in {"0", "1", "2", "3"}:
return text
if "inativ" in text:
return "0"
if "ativ" in text:
return "1"
if "suspens" in text:
return "2"
if "bloque" in text:
return "3"
return ""
def _text_from_keys(source: Any, keys: tuple[str, ...]) -> str:
if not isinstance(source, dict):
return ""
return " ".join(str(source.get(key) or "") for key in keys).strip()
__all__ = [
'_emit_vaa',
'_build_ic_context',
'_emit_ic',
'_idempotency_get',
'_idempotency_set',
'_utc_now_iso',
'_to_epoch_millis',
'_to_bool',
'_to_dict',
'_result_failed_or_missing_data',
'_runtime_llm_callbacks',
'_runtime_llm_metadata',
'_service_from_dict',
'_extract_amount',
'_extract_boleto_code',
'_find_matching_service',
'_normalize_service_name',
'_resolve_service_csp_id',
'_parse_amount',
'_format_amount',
'_build_sr_notes',
'_format_list_pt_br',
'_final_msisdn',
'_first_text_from_params_or_state',
'_first_value_from_params_or_state',
'_contains_any_keyword',
'_normalize_number_text',
'_normalize_bool',
'_map_customer_status_code',
'_text_from_keys',
]