mirror of
https://github.com/hoshikawa2/first_contas.git
synced 2026-07-10 02:34:20 +00:00
first commit
This commit is contained in:
304
legacy_reference/workflows/actions/common/actions.py
Normal file
304
legacy_reference/workflows/actions/common/actions.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from typing import Any
|
||||
from agente_contas_tim.constants.ic_tags_enum import SADTag, TMDTag
|
||||
from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_emit_ic,
|
||||
_normalize_bool,
|
||||
_result_failed_or_missing_data,
|
||||
_runtime_llm_callbacks,
|
||||
_runtime_llm_metadata,
|
||||
_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("finalizar_atendimento_action")
|
||||
def finalizar_atendimento_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
_emit_ic(SADTag.FINALIZACAO, input_state)
|
||||
status = str(params.get("status", "")).strip()
|
||||
summary = str(params.get("summary", "")).strip()
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": True,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("formatar_capability_resposta")
|
||||
def formatar_capability_resposta(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
source_node = str(params.get("source_node", "resolve"))
|
||||
payload = vars_state.get(source_node, {})
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
msisdn = str(params.get("msisdn", input_state.get("msisdn", "")))
|
||||
servico = str(params.get("servico", input_state.get("servico", "")))
|
||||
nome_plano = str(params.get("nome_plano", input_state.get("nome_plano", "")))
|
||||
tipo = str(params.get("tipo", "")).strip().lower()
|
||||
|
||||
instrucoes = str(payload.get("content", "")).strip()
|
||||
mensagem = ""
|
||||
|
||||
if tipo == "vas_estrategico":
|
||||
mensagem = (
|
||||
f"O serviço {servico} já está incluso no seu plano "
|
||||
f"na linha de final {msisdn[-2:]} e não gera "
|
||||
"cobrança adicional na fatura."
|
||||
)
|
||||
elif tipo == "valor_divergente":
|
||||
mensagem = (
|
||||
f"Identificamos uma alteração no valor do plano "
|
||||
f"na linha de final {msisdn[-2:]}. Vou verificar "
|
||||
"os detalhes para você."
|
||||
)
|
||||
elif tipo == "pro_rata":
|
||||
mensagem = (
|
||||
f"A fatura da linha de final {msisdn[-2:]} possui uma "
|
||||
"cobrança proporcional (pro rata) porque houve mudança "
|
||||
"de plano durante o ciclo de faturamento. Na próxima "
|
||||
"fatura o valor volta ao normal do novo plano."
|
||||
)
|
||||
elif tipo == "termino_desconto":
|
||||
_emit_ic(TMDTag.CHEGADA_FLUXO, input_state)
|
||||
plan_info = f" do plano {nome_plano}" if nome_plano else ""
|
||||
mensagem = (
|
||||
f"O desconto de fidelidade{plan_info} vinculado à "
|
||||
f"linha de final {msisdn[-2:]} chegou ao fim. "
|
||||
"O período promocional contratado expirou e o valor "
|
||||
"do plano volta ao preço original."
|
||||
)
|
||||
_emit_ic(TMDTag.FIM_FLUXO, input_state)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": True,
|
||||
"instrucoes": instrucoes,
|
||||
"mensagem": mensagem,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
if servico:
|
||||
response["servico"] = servico
|
||||
if nome_plano:
|
||||
response["nome_plano"] = nome_plano
|
||||
return ActionResult.ok(response)
|
||||
|
||||
|
||||
@workflow_action("combinar_divergencia")
|
||||
def combinar_divergencia(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
divergence = vars_state.get("consultar_divergencia", {})
|
||||
explanation = vars_state.get("invoice_explanation", {})
|
||||
|
||||
if not isinstance(divergence, dict):
|
||||
divergence = {}
|
||||
if not isinstance(explanation, dict):
|
||||
explanation = {}
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
msisdn = str(input_state.get("msisdn", ""))
|
||||
|
||||
resumo = str(
|
||||
divergence.get("resumo")
|
||||
or divergence.get("mensagem")
|
||||
or ""
|
||||
).strip()
|
||||
detalhes = divergence.get("detalhes", {})
|
||||
explicacao = str(explanation.get("mensagem", "")).strip()
|
||||
|
||||
success = bool(resumo or explicacao)
|
||||
mensagem = resumo or explicacao
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": success,
|
||||
"resumo": resumo,
|
||||
"detalhes": detalhes if isinstance(detalhes, dict) else {},
|
||||
"mensagem": mensagem,
|
||||
"explicacao": explicacao,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("consultar_divergencia")
|
||||
def query_divergence(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Consulta resumo de divergência da fatura."""
|
||||
result = runtime.factory.create_divergence(
|
||||
msisdn=str(params["msisdn"]),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha na consulta de divergencia",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
@workflow_action("resolve_capability")
|
||||
def resolve_capability_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Resolve uma capability via LLM Gateway (sem invocar LLM)."""
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.ok({"content": "", "source": "unavailable"})
|
||||
|
||||
capability_id = str(params.get("capability_id", "")).strip()
|
||||
if not capability_id:
|
||||
return ActionResult.fail(
|
||||
"capability_id obrigatorio para resolve_capability",
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = runtime.llm_gateway.resolve_capability(
|
||||
capability_id=capability_id,
|
||||
)
|
||||
return ActionResult.ok({
|
||||
"capability_id": resolved.capability_id,
|
||||
"prompt_id": resolved.prompt_id,
|
||||
"content": resolved.content,
|
||||
"source": resolved.source,
|
||||
"version": resolved.version,
|
||||
})
|
||||
except Exception as exc:
|
||||
return ActionResult.ok(
|
||||
{"content": "", "source": "fallback", "error": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("no_op")
|
||||
def no_op(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
return ActionResult.ok({})
|
||||
|
||||
|
||||
@workflow_action("llm_capability")
|
||||
def llm_capability(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.fail("LLM gateway nao configurado")
|
||||
|
||||
capability_id = str(params.get("capability_id", "")).strip()
|
||||
if not capability_id:
|
||||
return ActionResult.fail("capability_id obrigatorio para llm_capability")
|
||||
|
||||
variables = params.get("variables", {})
|
||||
if not isinstance(variables, dict):
|
||||
return ActionResult.fail("variables deve ser um objeto")
|
||||
|
||||
user_text_raw = params.get("user_text")
|
||||
user_text = None if user_text_raw is None else str(user_text_raw)
|
||||
|
||||
result = runtime.llm_gateway.execute(
|
||||
capability_id=capability_id,
|
||||
variables=variables,
|
||||
user_text=user_text,
|
||||
callbacks=_runtime_llm_callbacks(runtime),
|
||||
tags=["workflow_action"],
|
||||
metadata=_runtime_llm_metadata(runtime),
|
||||
)
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"capability_id": result.capability_id,
|
||||
"prompt_id": result.prompt_id,
|
||||
"content": result.content,
|
||||
"source": result.source,
|
||||
"version": result.version,
|
||||
"metadata": dict(result.metadata),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("buscar_fatura")
|
||||
def buscar_fatura(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Busca fatura (PDF) do cliente ou dados interpretados."""
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
tentativa = int(params.get("tentativa_anterior") or 0) + 1
|
||||
|
||||
result = runtime.factory.create_bill_pdf(
|
||||
invoice_id=str(params["invoice_id"]),
|
||||
msisdn=str(params["msisdn"]),
|
||||
customer_id=str(params["customer_id"]),
|
||||
output=str(params.get("output", "")),
|
||||
include_danfe=_normalize_bool(params.get("include_danfe", False)),
|
||||
).execute()
|
||||
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=False):
|
||||
_emit_ic(tag, input_state)
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha ao buscar fatura",
|
||||
**result.metadata,
|
||||
)
|
||||
|
||||
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=True):
|
||||
_emit_ic(tag, input_state)
|
||||
|
||||
payload = _to_dict(result.data)
|
||||
if isinstance(payload, dict):
|
||||
file_content = payload.get("file_content")
|
||||
if isinstance(file_content, (bytes, bytearray)):
|
||||
payload["file_content_b64"] = base64.b64encode(
|
||||
bytes(file_content)
|
||||
).decode("ascii")
|
||||
payload["file_content"] = None
|
||||
|
||||
return ActionResult.ok(payload, **result.metadata)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'finalizar_atendimento_action',
|
||||
'formatar_capability_resposta',
|
||||
'combinar_divergencia',
|
||||
'query_divergence',
|
||||
'resolve_capability_action',
|
||||
'no_op',
|
||||
'llm_capability',
|
||||
'buscar_fatura',
|
||||
]
|
||||
574
legacy_reference/workflows/actions/common/helpers.py
Normal file
574
legacy_reference/workflows/actions/common/helpers.py
Normal 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.001–VAA.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',
|
||||
]
|
||||
Reference in New Issue
Block a user