Files
first_contas/legacy_reference/workflows/actions/common/helpers.py
2026-06-16 20:54:49 -03:00

575 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
]