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,15 @@
from agente_contas_tim.workflows.actions.discovery import ensure_actions_loaded
from agente_contas_tim.workflows.actions.registry import (
DEFAULT_ACTION_REGISTRY,
ActionRegistry,
WorkflowRuntimeContext,
workflow_action,
)
__all__ = [
"ActionRegistry",
"DEFAULT_ACTION_REGISTRY",
"WorkflowRuntimeContext",
"ensure_actions_loaded",
"workflow_action",
]

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

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',
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import importlib
import pkgutil
from threading import Lock
_loaded_packages: set[str] = set()
_lock = Lock()
def _load_actions(package_name: str) -> None:
package = importlib.import_module(package_name)
package_path = getattr(package, "__path__", None)
if package_path is None:
return
for module in pkgutil.walk_packages(package_path, package_name + "."):
importlib.import_module(module.name)
def ensure_actions_loaded(
package_name: str = "agente_contas_tim.workflows.actions",
) -> None:
with _lock:
if package_name in _loaded_packages:
return
_load_actions(package_name)
_loaded_packages.add(package_name)

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
class CancelarVasAvulsoItem(BaseModel):
"""Item de entrada do action `cancelamento_vas_avulso_batch`."""
model_config = ConfigDict(
str_strip_whitespace=True,
populate_by_name=True,
)
msisdn: str = Field(
...,
min_length=1,
description=(
"Numero completo da linha do cliente que possui o "
"servico VAS avulso a cancelar."
),
validation_alias=AliasChoices("msisdn", "Msisdn", "MSISDN"),
)
service: str = Field(
...,
min_length=1,
description=(
"Nome do servico VAS avulso a cancelar. "
"Ex: 'TIM Fashion Mensal', 'Galinha Pintadinha'."
"O nome deve ser fiel ao JSON da fatura SEM regra de vocalização ou modificação"
"'Aluguel de filmes 1', 'Aluguel de Filme 3'"
),
validation_alias=AliasChoices("service", "servico", "name"),
)
value: float = Field(
...,
description= "Valor do serviço"
)
def parse_cancel_vas_items(raw: Any) -> list[CancelarVasAvulsoItem]:
"""Valida cada item via Pydantic, descartando entradas invalidas."""
if not isinstance(raw, list):
return []
parsed: list[CancelarVasAvulsoItem] = []
for item in raw:
try:
parsed.append(CancelarVasAvulsoItem.model_validate(item))
except ValidationError:
continue
return parsed

View File

@@ -0,0 +1,548 @@
from __future__ import annotations
import logging
from datetime import (
datetime,
timezone,
)
from typing import Any
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.constants.ic_tags_enum import (
CVNTag,
MPITag,
VEBTag,
)
from agente_contas_tim.integrations.agent_event_metadata import build_billing_id_payload
from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt
from agente_contas_tim.observability import get_session_id
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,
_first_text_from_params_or_state,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
from agente_contas_tim.workflows.actions.invoice_explanation.helpers import (
_extract_invoice_explanation_text,
_extract_rag_context,
_normalizar_invoice_explanation_mensagem,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
_INVOICE_EXPLANATION_TRAILER = "Com essa explicação, sanei sua dúvida?"
def _metadata_value(metadata: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = metadata.get(key)
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
return value
return None
def _int_or_none(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return value
raw = str(value).strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
return None
def _rct_api_metadata_from_result(
result: Any,
input_state: dict[str, Any],
) -> dict[str, Any]:
result_metadata = getattr(result, "metadata", None)
if not isinstance(result_metadata, dict):
return {}
metadata: dict[str, Any] = {}
api_url = _metadata_value(result_metadata, "api_url", "apiUrl")
if api_url is not None:
metadata["apiUrl"] = str(api_url)
status_code = _int_or_none(
_metadata_value(
result_metadata,
"api_status_code",
"apiStatusCode",
"status_code",
)
)
if status_code is not None:
metadata["apiStatusCode"] = status_code
response_payload = _metadata_value(
result_metadata,
"api_response_payload",
"apiResponsePayload",
"error_body",
)
if response_payload is not None:
metadata["apiResponsePayload"] = str(response_payload)
latency_ms = _int_or_none(
_metadata_value(result_metadata, "latency_ms", "latencyMs")
)
if latency_ms is not None:
metadata["latencyMs"] = max(0, latency_ms)
agent_specific_data = build_billing_id_payload(
{
"invoice_id": str(
input_state.get("invoice_id")
or input_state.get("current_invoice_number")
or ""
).strip()
}
)
if agent_specific_data != "{}":
metadata["agentSpecificData"] = agent_specific_data
return metadata
@workflow_action("invoice_explanation")
def invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Retorna explicacao fixa da fatura."""
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return ActionResult.fail("msisdn obrigatório para invoice_explanation")
result = runtime.factory.create_invoice_explanation(
msisdn=msisdn,
customer_id=_first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
),
current_invoice_number=_first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
),
past_invoice_number=_first_text_from_params_or_state(
state,
params,
"past_invoice_number",
"pastInvoiceNumber",
),
current_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
past_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"past_invoice_due_date",
"pastInvoiceDueDate",
),
channel=_first_text_from_params_or_state(
state,
params,
"channel",
"Channel",
),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha ao montar explicacao da fatura",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("preparar_invoice_explanation")
def preparar_invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Busca a explicacao bruta da fatura via InvoiceExplanationCommand.
Reaproveita a explicacao gerada na primeira passagem ao re-pausar
(caminho OUTRO), ou a explicacao_base recebida do prefetch/cache,
evitando reexecutar o command. Nao aplica vocalizacao nem
enriquecimento — esses passos ficam em `formatar_invoice_explanation`.
"""
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
tentativa_anterior = int(params.get("tentativa_anterior") or 0)
if tentativa_anterior == 0:
_emit_ic(CVNTag.INICIO_FLUXO, input_state)
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
cached = vars_state.get("preparar", {}) if isinstance(vars_state, dict) else {}
explicacao_base = ""
if isinstance(cached, dict):
previous = cached.get("explicacao_base")
if isinstance(previous, str) and previous.strip():
explicacao_base = previous.strip()
metadata: dict[str, Any] = {}
if not explicacao_base:
explicacao_base = _first_text_from_params_or_state(
state,
params,
"explicacao_base",
"invoice_explanation_base",
"invoiceExplanationBase",
)
if not explicacao_base:
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
# Pre-validacao falhou antes de chamar o servico → loop de retry
_emit_ic(CVNTag.PRE_VALIDACAO_FAIL, input_state)
return ActionResult.ok(
{
"success": False,
"service_failed": False,
"tentativa": tentativa_anterior + 1,
}
)
# Pre-validacao ok → intencao validada antes de chamar o servico
_emit_ic(CVNTag.PRE_VALIDACAO_OK, input_state)
result = runtime.factory.create_invoice_explanation(
msisdn=msisdn,
customer_id=_first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
),
current_invoice_number=_first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
),
past_invoice_number=_first_text_from_params_or_state(
state,
params,
"past_invoice_number",
"pastInvoiceNumber",
),
current_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
past_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"past_invoice_due_date",
"pastInvoiceDueDate",
),
channel=_first_text_from_params_or_state(
state,
params,
"channel",
"Channel",
),
).execute()
if _result_failed_or_missing_data(result, state=state):
# Falha no servico → vai direto pro FIM, sem retry
_emit_ic(CVNTag.SERVICO_FAIL, input_state)
rct_metadata = _rct_api_metadata_from_result(result, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=False,
):
_emit_ic(tag, input_state, extra_metadata=rct_metadata)
return ActionResult.ok(
{
"success": False,
"service_failed": True,
}
)
explicacao_base = _extract_invoice_explanation_text(_to_dict(result.data))
if not explicacao_base:
_emit_ic(CVNTag.SERVICO_FAIL, input_state)
rct_metadata = _rct_api_metadata_from_result(result, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=False,
):
_emit_ic(tag, input_state, extra_metadata=rct_metadata)
return ActionResult.ok(
{
"success": False,
"service_failed": True,
}
)
_emit_ic(CVNTag.SERVICO_OK, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=True,
):
_emit_ic(tag, input_state)
metadata = dict(result.metadata)
else:
# Cache hit — intencao ja validada sem nova chamada ao servico
_emit_ic(CVNTag.PRE_VALIDACAO_OK, input_state)
_emit_ic(CVNTag.SERVICO_OK, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=True,
):
_emit_ic(tag, input_state)
return ActionResult.ok(
{
"success": True,
"explicacao_base": explicacao_base,
"tentativa": 0,
},
**metadata,
)
@workflow_action("checar_tentativa_cvn")
def checar_tentativa_cvn(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Verifica contador de tentativas do fluxo CVN e emite CVN.004 ou CVN.005."""
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
tentativa = int(params.get("tentativa") or 1)
if tentativa > 2:
_emit_ic(CVNTag.LIMITE_TENTATIVAS, input_state)
else:
_emit_ic(CVNTag.DENTRO_LIMITE, input_state)
return ActionResult.ok({"tentativa": tentativa})
@workflow_action("formatar_invoice_explanation")
def formatar_invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Reescreve a explicacao bruta para voz, enriquecendo com juros/multas
a partir do invoice_detail, via capability LLM dedicada.
Recebe `explicacao_base` (obrigatorio) e `invoice_detail` (opcional)
nos params. Quando `trailer_override` for informado, instrui o LLM a
usar essa frase como fechamento (em vez da pergunta padrao). Util
para o caminho de reforco (OUTRO).
Em caso de falha do LLM, faz fallback para concatenacao simples
`explicacao_base + trailer` para nao bloquear o fluxo.
"""
explicacao_base = str(
params.get("explicacao_base", "") or ""
).strip()
if not explicacao_base:
return ActionResult.fail(
"explicacao_base obrigatoria para formatar_invoice_explanation"
)
invoice_detail = str(
params.get("invoice_detail", "") or ""
).strip()
trailer_override = str(
params.get("trailer_override", "") or ""
).strip()
fallback_trailer = (
trailer_override or _INVOICE_EXPLANATION_TRAILER
)
mensagem = ""
if runtime.llm_gateway is not None:
try:
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_invoice_explanation_reescrita",
variables={
"explicacao_base": explicacao_base,
"invoice_detail": invoice_detail or "(vazio)",
"trailer_override": trailer_override,
},
user_text=explicacao_base,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
mensagem = str(
getattr(llm_result, "content", "") or ""
).strip()
mensagem = _normalizar_invoice_explanation_mensagem(
mensagem,
trailer_override=trailer_override,
)
except Exception:
logger.exception(
"formatar_invoice_explanation: falha ao invocar "
"capability fluxo_invoice_explanation_reescrita"
)
if not mensagem:
mensagem = f"{explicacao_base}\n\n{fallback_trailer}"
return ActionResult.ok(
{
"mensagem": mensagem,
"await_user_input": True,
}
)
@workflow_action("registrar_atendimento_invoice_explanation")
def registrar_atendimento_invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Stub de registro do atendimento de invoice_explanation.
Implementacao real ainda nao definida; deixa apenas um warning para
indicar que o caminho foi exercitado. A resposta ao cliente fica a
cargo do orchestrator (LLM) no proximo round.
"""
resposta = params.get("resposta_usuario")
ic = str(params.get("ic", "")).strip()
resposta_norm = str(resposta or "").strip().upper()
ic_extra = ""
cvn_ic = ""
if resposta_norm == "SIM":
ic_extra = MPITag.EXPLICACAO_SIM
cvn_ic = CVNTag.CLIENTE_CONCORDOU
elif resposta_norm == "NAO":
ic_extra = MPITag.EXPLICACAO_NAO
cvn_ic = CVNTag.CLIENTE_NAO_CONCORDOU
logger.warning(
"registrar_atendimento_invoice_explanation: registro ainda nao "
"implementado (resposta_usuario=%s)",
resposta,
)
if cvn_ic:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
_emit_ic(cvn_ic, input_state)
if ic_extra:
try:
input_state = (
state.get("input", {}) if isinstance(state, dict) else {}
)
if not isinstance(input_state, dict):
input_state = {}
rag_info = _extract_rag_context(state)
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
channel_id = str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip() or "URA"
event_date = int(datetime.now(timezone.utc).timestamp() * 1000)
customer_message = str(
input_state.get("customer_message")
or input_state.get("customerMessage")
or resposta
or ""
).strip()
metadata = {
"sessionId": get_session_id(),
"tag": ic_extra,
"eventDate": event_date,
"uraCallId": str(
input_state.get("ura_call_id", "")
).strip(),
"ani": str(input_state.get("ani", "")).strip(),
"gsm": str(input_state.get("msisdn", "")).strip(),
"agentId": "contas",
"channelId": channel_id,
"ragRetrievedDocuments": str(
rag_info.get("ragRetrievedDocuments", "")
),
"ragSelectedDocuments": str(
rag_info.get("ragSelectedDocuments", "")
),
"noMatchRag": bool(rag_info.get("noMatchRag", True)),
"llmResponse": str(input_state.get("llm_response", "") or ""),
"customerMessage": customer_message,
}
if message_id:
metadata["messageId"] = message_id
agent_framework_bridge.event(ic_extra, metadata=metadata)
if ic == VEBTag.EXPLICACAO_ACEITA:
veb_metadata = dict(metadata)
veb_metadata["tag"] = ic
agent_framework_bridge.event(ic, metadata=veb_metadata)
except Exception:
logger.debug(
"registrar_atendimento_invoice_explanation: falha ao emitir %s",
ic_extra,
exc_info=True,
)
payload: dict[str, Any] = {}
if ic:
payload["ic"] = ic
return ActionResult.ok(payload)
__all__ = [
'invoice_explanation',
'preparar_invoice_explanation',
'checar_tentativa_cvn',
'formatar_invoice_explanation',
'registrar_atendimento_invoice_explanation',
]

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import re
from typing import Any
_INVOICE_EXPLANATION_TRAILER = "Com essa explicação, sanei sua dúvida?"
_INVOICE_EXPLANATION_TRAILER_PATTERN = re.compile(
r"com essa explica[cç][aã]o,?\s+sanei sua d[uú]vida\??$",
re.IGNORECASE,
)
_INVOICE_EXPLANATION_PADRAO_SENTINEL = re.compile(
r"\s*\(use o padr[aã]o\)\s*$",
re.IGNORECASE,
)
def _normalizar_mensagem_voz(
mensagem: str,
*,
trailer_override: str,
trailer_default: str = "",
trailer_pattern: re.Pattern[str] | None = None,
) -> str:
"""Pos-processa a mensagem reescrita pelo LLM.
Remove o sentinel "(use o padrao)" caso o modelo o tenha emitido e,
quando `trailer_override` estiver vazio, garante o `trailer_default`
ao final (a menos que o texto ja termine com `trailer_pattern`).
"""
if not mensagem:
return mensagem
if trailer_override:
return mensagem
mensagem = _INVOICE_EXPLANATION_PADRAO_SENTINEL.sub("", mensagem).strip()
if not trailer_default:
return mensagem
if trailer_pattern is not None and trailer_pattern.search(mensagem):
return mensagem
return f"{mensagem} {trailer_default}".strip()
def _normalizar_invoice_explanation_mensagem(
mensagem: str,
*,
trailer_override: str,
) -> str:
return _normalizar_mensagem_voz(
mensagem,
trailer_override=trailer_override,
trailer_default=_INVOICE_EXPLANATION_TRAILER,
trailer_pattern=_INVOICE_EXPLANATION_TRAILER_PATTERN,
)
def _extract_rag_context(state: dict[str, Any]) -> dict[str, Any]:
"""Extrai campos de RAG do estado para inclusão em eventos MPI."""
vars_state = state.get("vars", {})
if not isinstance(vars_state, dict):
return {}
# Procura por qualquer nó que tenha retornado dados de RAG.
# Prioriza os nós conhecidos por realizar busca RAG.
for node_id in ("buscar_informacao", "buscar_informacao_rag", "preparar"):
node_result = vars_state.get(node_id)
if not isinstance(node_result, dict):
continue
# Se o nó já tem os campos formatados, usa eles
if "ragRetrievedDocuments" in node_result:
return {
"ragRetrievedDocuments": node_result.get("ragRetrievedDocuments", ""),
"ragSelectedDocuments": node_result.get("ragSelectedDocuments", ""),
"noMatchRag": node_result.get("noMatchRag", True),
}
# Caso contrário, tenta construir a partir da lista 'documents'
documents = node_result.get("documents")
if isinstance(documents, (list, tuple)):
retrieved_titles = []
selected_titles = []
for doc in documents:
if not isinstance(doc, dict):
continue
t = (
doc.get("title_proc")
or doc.get("title")
or doc.get("chunk_texto")
or ""
)
if t:
title = str(t)
retrieved_titles.append(title)
# Threshold de 0.6 para considerar como selecionado (distância menor é melhor)
distance = float(doc.get("distance", 1.0))
if distance <= 0.6:
selected_titles.append(title)
return {
"ragRetrievedDocuments": "|".join(retrieved_titles),
"ragSelectedDocuments": "|".join(selected_titles),
"noMatchRag": len(documents) == 0,
}
return {}
def _extract_invoice_explanation_text(data: Any) -> str:
if isinstance(data, dict):
for key in ("mensagem", "message", "explicacao", "texto"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(data, str) and data.strip():
return data.strip()
return ""
__all__ = [
'_normalizar_mensagem_voz',
'_normalizar_invoice_explanation_mensagem',
'_extract_rag_context',
'_extract_invoice_explanation_text',
]

View File

@@ -0,0 +1,726 @@
from __future__ import annotations
import logging
import re
from decimal import Decimal
from typing import Any
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
from agente_contas_tim.text_utils import vocalize_digits
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 (
_final_msisdn,
_first_text_from_params_or_state,
_first_value_from_params_or_state,
_format_amount,
_normalize_number_text,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
_utc_now_iso,
)
from agente_contas_tim.workflows.actions.invoice_explanation.helpers import _normalizar_mensagem_voz
from agente_contas_tim.workflows.actions.pro_rata.helpers import (
_build_pro_rata_ajuste_recusado_msg,
_build_pro_rata_contestacao_tool_payload,
_build_pro_rata_contestation_items,
_build_pro_rata_controle_msg,
_build_pro_rata_human_validation_text,
_build_pro_rata_oferta_ajuste_msg,
_build_pro_rata_sem_controle_msg,
_decimal_from_any,
_extract_pro_rata_contestation_context,
_fetch_invoice_payload_pro_rata,
_money,
_resolve_plano_controle,
_resolve_pro_rata_liquid_value,
_resolve_pro_rata_period_days,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("formatar_pro_rata")
def formatar_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Reescreve a mensagem crua do pro-rata aplicando vocalizacao para TTS.
Recebe `mensagem_base` (obrigatorio) e, opcionalmente, `trailer_override`
para forcar uma frase final (ex.: caminho de reperguntar). Em caso de
falha do LLM, faz fallback para a mensagem crua para nao bloquear o
fluxo.
"""
mensagem_base = str(params.get("mensagem_base", "") or "").strip()
if not mensagem_base:
return ActionResult.fail(
"mensagem_base obrigatoria para formatar_pro_rata"
)
trailer_override = str(params.get("trailer_override", "") or "").strip()
mensagem = ""
if runtime.llm_gateway is not None:
try:
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_pro_rata_reescrita",
variables={
"mensagem_base": mensagem_base,
"trailer_override": trailer_override,
},
user_text=mensagem_base,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
mensagem = str(getattr(llm_result, "content", "") or "").strip()
mensagem = _normalizar_mensagem_voz(
mensagem,
trailer_override=trailer_override,
)
except Exception:
logger.exception(
"formatar_pro_rata: falha ao invocar capability "
"fluxo_pro_rata_reescrita"
)
if not mensagem:
mensagem = (
f"{mensagem_base} {trailer_override}".strip()
if trailer_override
else mensagem_base
)
return ActionResult.ok({"mensagem": mensagem})
@workflow_action("preparar_pro_rata")
def preparar_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Monta mensagem inicial do pro-rata e decide se pausa por Plano Controle."""
raw_planos = params.get("planos")
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
has_controle = bool(params.get("has_plano_controle", False))
if len(planos) != 2 or not all(
str(p.get("desc", "")).strip() for p in planos
):
return ActionResult.fail(
"pro_rata exige exatamente dois planos com 'desc' preenchido."
)
if not has_controle:
return ActionResult.ok(
{
"await_user_input": False,
"mensagem": _build_pro_rata_sem_controle_msg(planos),
"mensagem_pos_aceite": "",
"mensagem_reperguntar_esclarecimento": "",
"mensagem_oferta_ajuste": "",
"mensagem_reperguntar_ajuste": "",
"mensagem_ajuste_recusado": "",
"has_plano_controle": False,
}
)
mensagem = _build_pro_rata_controle_msg(planos)
mensagem_oferta_ajuste = _build_pro_rata_oferta_ajuste_msg()
return ActionResult.ok(
{
"await_user_input": True,
"mensagem": mensagem,
"mensagem_pos_aceite": "",
"mensagem_reperguntar_esclarecimento": (
"Para seguirmos, preciso de uma confirmacao. " + mensagem
),
"mensagem_oferta_ajuste": mensagem_oferta_ajuste,
"mensagem_reperguntar_ajuste": (
"Para seguirmos, preciso de uma confirmacao. "
+ mensagem_oferta_ajuste
),
"mensagem_ajuste_recusado": _build_pro_rata_ajuste_recusado_msg(),
"has_plano_controle": True,
}
)
@workflow_action("calcular_itens_contestacao_pro_rata")
def calcular_itens_contestacao_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Calcula itens/valores a contestar no fluxo de pro rata."""
raw_planos = _first_value_from_params_or_state(state, params, "planos")
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
if not planos:
return ActionResult.fail(
"planos obrigatorio para calcular itens de contestacao do pro_rata"
)
items: list[dict[str, Any]] = []
total = Decimal("0")
for plano in planos:
item_name = str(
plano.get("desc")
or plano.get("name")
or plano.get("item_name")
or ""
).strip()
if not item_name:
continue
valor = _normalize_number_text(
plano.get("value")
or plano.get("valor")
or plano.get("claimed_amount")
or "0"
)
valor_decimal = Decimal(valor)
total += valor_decimal
items.append(
{
"item_name": item_name,
"item_type": "PRO_RATA",
"claimed_amount": valor,
"validated_amount": valor,
}
)
if not items:
return ActionResult.fail(
"Nao foi possivel calcular itens de contestacao a partir dos planos."
)
total_txt = _normalize_number_text(format(total, "f"))
return ActionResult.ok(
{
"items": items,
"invoice_amount_open": total_txt,
"invoice_amount": total_txt,
}
)
@workflow_action("executar_contestacao_plano_controle")
def executar_contestacao_plano_controle(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Delega para o workflow/tool contestacao_tool apos calcular os itens."""
context, context_error = _extract_pro_rata_contestation_context(state, params)
if context is None:
return ActionResult.fail(
context_error or "Dados invalidos para contestacao de pro_rata"
)
if runtime.workflow_runner is None:
return ActionResult.fail(
"workflow_runner nao configurado para delegar contestacao_tool"
)
contestacao_payload = _build_pro_rata_contestacao_tool_payload(
state,
params,
context=context,
)
contestacao_result = runtime.workflow_runner(
"contestacao_tool",
contestacao_payload,
None,
None,
)
if contestacao_result.status != "COMPLETED" or not isinstance(
contestacao_result.data, dict
):
return ActionResult.fail(
str(contestacao_result.error or "Falha ao abrir contestacao de pro_rata"),
**(
contestacao_result.metadata
if isinstance(contestacao_result.metadata, dict)
else {}
),
)
contest_payload = contestacao_result.data
contestacao_lines = (
contest_payload.get("linhas", [])
if isinstance(contest_payload.get("linhas"), list)
else []
)
protocolo_id = ""
if contestacao_lines and isinstance(contestacao_lines[0], dict):
protocolo_id = str(contestacao_lines[0].get("protocolo_id", "")).strip()
if not protocolo_id:
protocolo_id = str(
contest_payload.get("protocol_number")
or contest_payload.get("protocolo_id")
or ""
).strip()
mensagem = str(contest_payload.get("mensagem", "")).strip()
if not mensagem:
mensagem = (
"Contestacao de pro rata registrada com sucesso. "
f"Protocolo: {protocolo_id or 'n/a'}."
)
plano_names = [
str(item.get("item_name", "")).strip()
for item in context["items"]
if isinstance(item, dict) and str(item.get("item_name", "")).strip()
]
transaction_id = str(
contest_payload.get("contestation_id")
or contest_payload.get("sr")
or protocolo_id
or ""
).strip()
audit = {
"plano_cliente": ", ".join(plano_names),
"valor_calculado_credito": str(context["invoice_amount"]),
"data_hora_contestacao": _utc_now_iso(),
"transaction_id_ajuste": transaction_id,
"mensagem_confirmacao": mensagem,
}
raw_devolucao = params.get("devolucao")
devolucao = dict(raw_devolucao) if isinstance(raw_devolucao, dict) else {}
devolucao.setdefault("items", context["items"])
devolucao["invoice_amount_open"] = context["invoice_amount_open"]
devolucao["invoice_amount"] = context["invoice_amount"]
format_text = str(
contest_payload.get("format_text")
or (
"sms"
if bool(
contest_payload.get("sms_enviado")
or contest_payload.get("sms_sent")
)
else "conta_futura"
)
).strip()
devolucao["format_text"] = format_text
devolucao["resolution_type"] = str(
contest_payload.get("resolution_type")
or ("new_boleto" if format_text == "sms" else "credit_bill")
).strip()
devolucao["sms_enviado"] = bool(
contest_payload.get("sms_enviado")
or contest_payload.get("sms_sent")
or format_text == "sms"
)
devolucao["sms_sent"] = devolucao["sms_enviado"]
for key in (
"decision_reason",
"data_credito_proxima_fatura",
"barcode",
"items_response",
"sr_conta_certa_id",
):
if key in contest_payload:
devolucao[key] = contest_payload[key]
if protocolo_id:
devolucao["protocolo_id"] = protocolo_id
if transaction_id:
devolucao["transaction_id_ajuste"] = transaction_id
logger.info(
"pro_rata.contestacao.audit msisdn_final=%s plano_cliente=%s valor_credito=%s transaction_id=%s data_hora=%s mensagem_confirmacao=%s",
_final_msisdn(str(context["msisdn"])),
audit["plano_cliente"],
audit["valor_calculado_credito"],
audit["transaction_id_ajuste"],
audit["data_hora_contestacao"],
audit["mensagem_confirmacao"],
)
return ActionResult.ok(
{
"success": True,
"mensagem": mensagem,
"protocolo_id": protocolo_id,
"contestation_id": str(
contest_payload.get("contestation_id")
or ""
).strip(),
"sr": str(
contest_payload.get("sr")
or protocolo_id
).strip(),
"items": context["items"],
"invoice_amount_open": context["invoice_amount_open"],
"invoice_amount": context["invoice_amount"],
"devolucao": devolucao,
"audit": audit,
"response": contest_payload,
},
**(
contestacao_result.metadata
if isinstance(contestacao_result.metadata, dict)
else {}
),
)
@workflow_action("definir_devolucao_ajuste_pro_rata")
def definir_devolucao_ajuste_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Calcula devolucao do ajuste pro-rata para Plano Controle.
A base do pro-rata e o valor liquido do Plano Controle. A devolucao e
alocada nos itens do DANFE vinculados ao mesmo plano.
"""
raw_planos = _first_value_from_params_or_state(state, params, "planos")
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
if len(planos) != 2:
return ActionResult.fail(
"pro_rata exige exatamente dois planos para calcular devolucao."
)
resolved = _resolve_plano_controle(planos)
if resolved is None:
return ActionResult.fail(
"Nao foi possivel identificar exatamente um Plano Controle."
)
plano_controle, outro_plano = resolved
valor_liquido = _resolve_pro_rata_liquid_value(plano_controle)
if valor_liquido is None or valor_liquido <= 0:
return ActionResult.fail(
"Valor liquido do Plano Controle ausente ou invalido."
)
invoice_payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
if error:
return ActionResult.fail(error)
period_days = _resolve_pro_rata_period_days(
outro_plano=outro_plano,
invoice_payload=invoice_payload,
invoice_period=_first_text_from_params_or_state(
state,
params,
"invoice_period",
"invoicePeriod",
"periodo_fatura",
"periodoFatura",
),
invoice_emissao=_first_text_from_params_or_state(
state,
params,
"invoice_emissao",
"invoiceEmissao",
"emissao_fatura",
"emissaoFatura",
),
)
if period_days is None:
return ActionResult.fail(
"Periodo da fatura ou dos planos ausente ou invalido para calcular pro-rata."
)
dias_ciclo, dias_controle = period_days
valor_liquido = _money(valor_liquido)
valor_usado = (valor_liquido / Decimal(dias_ciclo)) * Decimal(dias_controle)
valor_devolver = _money(valor_liquido - valor_usado)
danfe = invoice_payload.get("DANFE-COM")
if not isinstance(danfe, dict) or not danfe:
return ActionResult.fail("DANFE-COM nao encontrado na fatura recuperada.")
items, restante = _build_pro_rata_contestation_items(
danfe=danfe,
plano_controle=plano_controle,
valor_devolver=valor_devolver,
)
if restante > 0:
return ActionResult.fail(
"Itens do DANFE insuficientes para cobrir devolucao de "
f"R$ {_format_amount(restante)}."
)
total_validado = sum(
(
_decimal_from_any(item.get("validatedAmount")) or Decimal("0")
)
for item in items
if isinstance(item, dict)
)
logger.info(
"definir_devolucao_ajuste_pro_rata: items=%s total_validado=%s",
items,
_format_amount(_money(total_validado)),
)
total_validado_txt = _normalize_number_text(format(_money(total_validado), "f"))
texto_validacao_humana = _build_pro_rata_human_validation_text(
plano_controle=plano_controle,
valor_liquido=valor_liquido,
dias_ciclo=dias_ciclo,
dias_controle=dias_controle,
valor_usado=valor_usado,
valor_devolver=valor_devolver,
items=items,
)
return ActionResult.ok(
{
"items": items,
"invoice_amount_open": total_validado_txt,
"invoice_amount": total_validado_txt,
"texto_validacao_humana": texto_validacao_humana,
}
)
@workflow_action("executar_devolucao_pro_rata")
def executar_devolucao_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""A implementar: dispara devolucao do ajuste pro-rata.
Por enquanto repassa o payload de definir_devolucao para o proximo no.
"""
devolucao = params.get("devolucao")
if not isinstance(devolucao, dict):
devolucao = {}
items = devolucao.get("items")
if not isinstance(items, list):
items = []
logger.warning(
"executar_devolucao_pro_rata: a implementar (mock ativo)"
)
output = dict(devolucao)
output["items"] = items
output["mocked"] = True
return ActionResult.ok(output)
@workflow_action("orientar_pagamento_pro_rata")
def orientar_pagamento_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Orienta o cliente sobre pagamento apos definir o credito."""
devolucao = params.get("devolucao")
if not isinstance(devolucao, dict):
devolucao = {}
raw_items = devolucao.get("items")
items = raw_items if isinstance(raw_items, list) else []
valor = Decimal("0")
plano_nome = ""
for item in items:
if not isinstance(item, dict):
continue
if not plano_nome:
plano_nome = str(
item.get("itemName")
or item.get("item_name")
or ""
).strip()
item_value = _decimal_from_any(
item.get("validatedAmount")
if item.get("validatedAmount") is not None
else item.get("validated_amount")
)
if item_value is not None:
valor += item_value
valor_txt = _format_amount(_money(valor))
plano_txt = f" {plano_nome}" if plano_nome else ""
protocolo_id = str(devolucao.get("protocolo_id", "")).strip()
protocolo_txt = (
f" Seu numero de protocolo e {vocalize_digits(protocolo_id)}."
if protocolo_id
else ""
)
data_credito = str(
devolucao.get("data_credito_proxima_fatura", "")
).strip()
data_credito_txt = (
f" na fatura com vencimento em {data_credito}, considerando o seu ciclo de faturamento"
if data_credito
else " em uma proxima fatura"
)
sms_codigo_txt = (
"com o codigo de barras atualizado"
if str(devolucao.get("barcode", "")).strip()
else "com as orientacoes para pagamento"
)
mensagem = (
(
"Realizei a contestacao da fatura considerando o valor "
f"proporcional do Plano Controle{plano_txt}. O valor "
f"contestado, de R$ {valor_txt}, foi retirado da sua fatura. "
f"Enviamos uma mensagem {sms_codigo_txt}, com prazo de 10 dias "
f"para pagamento.{protocolo_txt}"
)
if str(devolucao.get("format_text", "")).strip() == "sms"
else (
"Realizei a contestacao considerando o valor proporcional do "
f"Plano Controle{plano_txt}. O valor contestado, de "
f"R$ {valor_txt}, ficou registrado como credito{data_credito_txt}."
f"{protocolo_txt}"
)
).strip()
return ActionResult.ok({"mensagem": mensagem, "devolucao": devolucao})
@workflow_action("registrar_atendimento_pro_rata")
def registrar_atendimento_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Registra protocolo de fechamento do atendimento pro rata."""
mensagem_base = str(params.get("mensagem_base", "")).strip()
caminho = str(params.get("caminho", "")).strip()
devolucao = params.get("devolucao")
if not isinstance(devolucao, dict):
devolucao = {}
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return ActionResult.fail("msisdn obrigatorio para registrar_atendimento_pro_rata")
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
caminho_norm = caminho.strip().lower()
scenario = (
"pro_rata_reclamacao"
if caminho_norm == "nao_aceitou"
else "pro_rata_mensalidade"
)
close_triplet = resolve_protocol_triplet(scenario, stage="close")
reason1 = close_triplet.reason1 or "Informação"
reason2 = close_triplet.reason2 or "Conta"
reason3 = close_triplet.reason3 or "Mensalidade"
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
ic = str(params.get("ic", "")).strip()
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
register_result = runtime.factory.create_protocol_v2(
msisdn=msisdn,
interaction_protocol="",
flag_sms=True,
social_sec_no=social_sec_no,
source="CHAT",
reason1=reason1,
reason2=reason2,
reason3=reason3,
direction_contact="FROM-CLIENT",
type="CLIENTE",
request_flag=True,
request_status=request_status,
status=status,
service_request_notes="Solicitação via chat",
client_id="AIAGENTCR",
message_id=message_id,
).execute()
if _result_failed_or_missing_data(register_result, state=state):
return ActionResult.fail(
register_result.error or "Falha ao registrar protocolo do pro_rata",
**register_result.metadata,
)
payload = _to_dict(register_result.data)
response = payload.get("response", {}) if isinstance(payload, dict) else {}
protocolo_id = ""
if isinstance(response, dict):
protocolo_id = str(
response.get("interactionProtocol")
or response.get("protocolId")
or response.get("protocolo_id")
or ""
).strip()
if not protocolo_id:
return ActionResult.fail("Protocolo nao retornado para pro_rata")
if not mensagem_base and caminho_norm == "aceitou":
mensagem_base = (
f"Sua solicitação foi registrada. "
f"Seu número de protocolo é {vocalize_digits(protocolo_id)}."
)
logger.info(
"registrar_atendimento_pro_rata caminho=%s msisdn=%s protocolo=%s",
caminho_norm or "n/a",
_final_msisdn(msisdn),
protocolo_id,
)
result: dict[str, Any] = {
"success": True,
"mensagem": mensagem_base,
"protocolo": protocolo_id,
"protocolo_id": protocolo_id,
"pro_rata_protocol": protocolo_id,
"protocolos_por_linha": [
{
"msisdn": msisdn,
"nome": "pro_rata",
"protocolo_id": protocolo_id,
}
],
"tripleta": {
"reason1": reason1,
"reason2": reason2,
"reason3": reason3,
},
"protocol_closed": True,
"devolucao": devolucao,
"requires_protocol_in_response": True,
"protocols_for_response": [protocolo_id],
}
if ic:
result["ic"] = ic
return ActionResult.ok(result)
__all__ = [
'formatar_pro_rata',
'preparar_pro_rata',
'calcular_itens_contestacao_pro_rata',
'executar_contestacao_plano_controle',
'definir_devolucao_ajuste_pro_rata',
'executar_devolucao_pro_rata',
'orientar_pagamento_pro_rata',
'registrar_atendimento_pro_rata',
]

View File

@@ -0,0 +1,793 @@
from __future__ import annotations
import json
import re
import unicodedata as ud
from datetime import date
from decimal import (
Decimal,
InvalidOperation,
ROUND_HALF_UP,
)
from typing import Any
from agente_contas_tim.workflows.actions.common.helpers import (
_final_msisdn,
_first_text_from_params_or_state,
_first_value_from_params_or_state,
_format_amount,
_format_list_pt_br,
_normalize_bool,
_normalize_number_text,
_parse_amount,
_result_failed_or_missing_data,
)
from agente_contas_tim.workflows.actions.contestacao.helpers import _build_contestation_items
_CENT = Decimal("0.01")
def _format_plano_pro_rata(plano: dict[str, Any]) -> str:
desc = str(plano.get("desc", "")).strip()
valor_txt = _format_plano_pro_rata_value(plano)
final = _final_msisdn(str(plano.get("msisdn", "")))
return f"{desc} (valor R$ {valor_txt}, linha final {final})"
def _format_plano_pro_rata_value(plano: dict[str, Any]) -> str:
raw_value = (
plano.get("valor_final")
or plano.get("valor_liquido")
or plano.get("value")
or plano.get("valor_bruto")
)
if isinstance(raw_value, bool):
valor_parsed = None
elif isinstance(raw_value, (int, float, Decimal)):
valor_parsed = Decimal(str(raw_value))
else:
valor_parsed = _parse_amount(str(raw_value or ""))
return _format_amount(valor_parsed) or "0,00"
def _format_plano_pro_rata_display_name(
plano: dict[str, Any],
*,
fallback: str,
) -> str:
desc = str(plano.get("desc", "")).strip()
clean = re.sub(r"\s*\([^)]*\)\s*$", "", desc).strip()
return clean or fallback
def _format_pro_rata_period_reference(plano: dict[str, Any]) -> str:
days = _resolve_days_number(
plano.get("days") if plano.get("days") is not None else plano.get("dias")
)
if days is None:
return "referente ao período de uso na fatura"
unidade = "dia" if days == 1 else "dias"
return f"referente a {days} {unidade} de uso no período"
def _format_planos_pro_rata(planos: list[dict[str, Any]]) -> str:
formatted = [
_format_plano_pro_rata(p) for p in planos if isinstance(p, dict)
]
return _format_list_pt_br(formatted)
def _build_pro_rata_controle_msg(planos: list[dict[str, Any]]) -> str:
resolved = _resolve_plano_controle(planos)
if resolved is None:
return (
"Houve uma troca de plano e, por isso, na sua fatura "
"apareceram duas cobranças.\n\n"
"Uma é cobrança proporcional de outro plano, referente ao "
"período de uso na fatura.\n\n"
"A outra é do plano Controle.\n\n"
"No plano Controle funciona assim: sempre que a franquia é "
"renovada, o valor do plano é cobrado por completo.\n\n"
"Isso acontece porque, a partir da renovação, você já passa a "
"ter acesso a todos os benefícios do plano imediatamente, como "
"internet, ligações e outras vantagens.\n\n"
"Consegui esclarecer sua dúvida?"
)
plano_controle, outro_plano = resolved
controle_nome = _format_plano_pro_rata_display_name(
plano_controle,
fallback="Controle",
)
outro_nome = _format_plano_pro_rata_display_name(
outro_plano,
fallback="identificado",
)
controle_valor = _format_plano_pro_rata_value(plano_controle)
outro_valor = _format_plano_pro_rata_value(outro_plano)
outro_periodo = _format_pro_rata_period_reference(outro_plano)
return (
"Houve uma troca de plano e, por isso, na sua fatura apareceram "
"duas cobranças.\n\n"
f"Uma é cobrança proporcional do plano {outro_nome}, no valor de "
f"R$ {outro_valor}, {outro_periodo}.\n\n"
f"A outra é do plano {controle_nome}, no valor de R$ "
f"{controle_valor}.\n\n"
"No plano Controle funciona assim: sempre que a franquia é "
"renovada, o valor do plano é cobrado por completo.\n\n"
"Isso acontece porque, a partir da renovação, você já passa a ter "
"acesso a todos os benefícios do plano imediatamente, como "
"internet, ligações e outras vantagens.\n\n"
"Consegui esclarecer sua dúvida?"
)
def _build_pro_rata_sem_controle_msg(planos: list[dict[str, Any]]) -> str:
return (
"Identifiquei a cobranca proporcional na sua fatura "
f"envolvendo os planos {_format_planos_pro_rata(planos)}. "
"Como não envolveu troca para o Plano Controle, segue como "
"cobranca proporcional padrao dos dias usados em cada plano."
)
def _build_pro_rata_oferta_ajuste_msg() -> str:
return (
"Para buscarmos a "
"melhor solução, posso solicitar o "
"ajuste proporcional do plano Controle?"
)
def _build_pro_rata_ajuste_recusado_msg() -> str:
return (
"Entendi. Sem a sua confirmação, a solicitação de ajuste não "
"será aberta neste momento."
)
def _money(value: Decimal) -> Decimal:
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
def _decimal_from_any(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
parsed = _parse_amount(str(value or ""))
return parsed
def _first_decimal_from_mapping(
data: dict[str, Any],
*keys: str,
) -> Decimal | None:
for key in keys:
if key not in data:
continue
value = _decimal_from_any(data.get(key))
if value is not None:
return value
return None
def _normalize_match_text(value: Any) -> str:
text = re.sub(r"\s*\([^)]*\)", "", str(value or "")).strip()
text = ud.normalize("NFKD", text)
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _is_same_plan_name(left: Any, right: Any) -> bool:
left_key = _normalize_match_text(left)
right_key = _normalize_match_text(right)
if not left_key or not right_key:
return False
return left_key == right_key or left_key in right_key or right_key in left_key
def _resolve_plano_controle(
planos: list[dict[str, Any]],
) -> tuple[dict[str, Any], dict[str, Any]] | None:
controles = [
plano
for plano in planos
if isinstance(plano, dict) and bool(plano.get("is_controle"))
]
if len(controles) != 1:
return None
controle = controles[0]
outro = next((plano for plano in planos if plano is not controle), None)
if not isinstance(outro, dict):
return None
return controle, outro
def _resolve_pro_rata_liquid_value(
plano_controle: dict[str, Any],
) -> Decimal | None:
valor_liquido = _first_decimal_from_mapping(
plano_controle,
"valor_final",
"valorFinal",
"valor_liquido",
"valorLiquido",
"net_value",
"netValue",
"subtotal",
"value_final",
)
if valor_liquido is None:
valor_bruto = _first_decimal_from_mapping(
plano_controle,
"valor_bruto",
"valorBruto",
"gross_value",
"grossValue",
"valor_bruto_plano",
"valorBrutoPlano",
"preco_unit",
)
total_descontos = _first_decimal_from_mapping(
plano_controle,
"total_descontos",
"totalDescontos",
"discount_total",
)
if valor_bruto is not None and total_descontos is not None:
valor_liquido = valor_bruto + total_descontos
return valor_liquido
def _parse_emissao_year(
invoice_payload: dict[str, Any],
*,
invoice_emissao: str = "",
) -> int | None:
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", invoice_emissao)
if match:
return int(match.group("year"))
for item in invoice_payload.get("Fatura Resumo", []) or []:
if not isinstance(item, dict):
continue
emissao = str(item.get("emissao", "") or "").strip()
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", emissao)
if match:
return int(match.group("year"))
return None
def _extract_resumo_period(
invoice_payload: dict[str, Any],
*,
invoice_period: str = "",
) -> str:
if invoice_period:
return invoice_period
for item in invoice_payload.get("Fatura Resumo", []) or []:
if not isinstance(item, dict):
continue
desc = _normalize_match_text(item.get("desc", ""))
if desc == "periodo":
return str(item.get("period", "") or "").strip()
return ""
def _parse_period_range(value: Any, *, emission_year: int) -> tuple[date, date] | None:
text = str(value or "").strip()
match = re.search(
r"(?P<start_day>\d{2})/(?P<start_month>\d{2})\s+a\s+"
r"(?P<end_day>\d{2})/(?P<end_month>\d{2})",
text,
)
if not match:
return None
start_day = int(match.group("start_day"))
start_month = int(match.group("start_month"))
end_day = int(match.group("end_day"))
end_month = int(match.group("end_month"))
start_year = emission_year
end_year = emission_year
if start_month > end_month:
start_year -= 1
try:
start = date(start_year, start_month, start_day)
end = date(end_year, end_month, end_day)
except ValueError:
return None
if start > end:
return None
return start, end
def _inclusive_days(period: tuple[date, date]) -> int:
return (period[1] - period[0]).days + 1
def _resolve_days_number(value: Any) -> int | None:
try:
days = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
if days <= 0:
return None
return int(days.to_integral_value(rounding=ROUND_HALF_UP))
def _resolve_pro_rata_period_days(
*,
outro_plano: dict[str, Any],
invoice_payload: dict[str, Any],
invoice_period: str = "",
invoice_emissao: str = "",
) -> tuple[int, int] | None:
emission_year = _parse_emissao_year(
invoice_payload,
invoice_emissao=invoice_emissao,
)
resumo_period_text = _extract_resumo_period(
invoice_payload,
invoice_period=invoice_period,
)
if emission_year is None or not resumo_period_text:
return None
ciclo_period = _parse_period_range(
resumo_period_text,
emission_year=emission_year,
)
if ciclo_period is None:
return None
dias_ciclo = _inclusive_days(ciclo_period)
if dias_ciclo <= 0:
return None
dias_outro = _resolve_days_number(
outro_plano.get("days")
if outro_plano.get("days") is not None
else outro_plano.get("dias")
)
if dias_outro is None:
return None
if dias_outro >= dias_ciclo:
return dias_ciclo, 1
dias_controle = dias_ciclo - dias_outro
if dias_controle <= 0:
dias_controle = 1
return dias_ciclo, dias_controle
def _parse_invoice_detail_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, list):
return {"_items": value}
if not isinstance(value, str):
return {}
text = value.strip()
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list):
return {"_items": parsed}
return {}
def _extract_danfe_from_state_or_params(
state: dict[str, Any],
params: dict[str, Any],
) -> dict[str, Any]:
payload = _extract_invoice_payload_from_state_or_params(state, params)
danfe = payload.get("DANFE-COM") if isinstance(payload, dict) else None
return danfe if isinstance(danfe, dict) else {}
def _extract_invoice_payload_from_state_or_params(
state: dict[str, Any],
params: dict[str, Any],
) -> dict[str, Any]:
detail = _first_value_from_params_or_state(
state,
params,
"invoice_detail",
"invoiceDetail",
"danfe_detail",
"danfeDetail",
)
return _parse_invoice_detail_payload(detail)
def _fetch_invoice_payload_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> tuple[dict[str, Any], str | None]:
payload = _extract_invoice_payload_from_state_or_params(state, params)
if payload.get("DANFE-COM"):
return payload, None
invoice_id = _first_text_from_params_or_state(
state,
params,
"invoice_id",
"invoiceId",
"current_invoice_number",
"currentInvoiceNumber",
)
customer_id = _first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
)
msisdn = _first_text_from_params_or_state(state, params, "msisdn")
if not invoice_id or not customer_id or not msisdn:
return payload, (
"Pro-rata exige invoice_id, customer_id e msisdn para buscar "
"fatura com DANFE."
)
result = runtime.factory.create_bill_pdf(
invoice_id=invoice_id,
msisdn=msisdn,
customer_id=customer_id,
include_danfe=True,
).execute()
if _result_failed_or_missing_data(result, state=state):
return payload, result.error or "Falha ao buscar fatura com DANFE."
parsed = result.data.parsed_content or {}
if not isinstance(parsed, dict):
return payload, "Fatura com DANFE retornou conteudo invalido."
return parsed, None
def _fetch_danfe_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> tuple[dict[str, Any], str | None]:
payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
if error:
return {}, error
danfe = payload.get("DANFE-COM")
if not isinstance(danfe, dict) or not danfe:
return {}, "DANFE-COM nao encontrado na fatura recuperada."
return danfe, None
def _find_danfe_plan_items(
danfe: dict[str, Any],
plano_controle: dict[str, Any],
) -> list[dict[str, Any]]:
planos = danfe.get("Planos")
if not isinstance(planos, dict):
return []
controle_desc = str(plano_controle.get("desc", "")).strip()
for plan_name, raw_items in planos.items():
if not _is_same_plan_name(plan_name, controle_desc):
continue
if isinstance(raw_items, list):
return [item for item in raw_items if isinstance(item, dict)]
return []
def _build_pro_rata_contestation_items(
*,
danfe: dict[str, Any],
plano_controle: dict[str, Any],
valor_devolver: Decimal,
) -> tuple[list[dict[str, float | str]], Decimal]:
items = _find_danfe_plan_items(danfe, plano_controle)
restante = _money(valor_devolver)
contestation_items: list[dict[str, float | str]] = []
for item in items:
desc = str(item.get("desc", "")).strip()
if not desc:
continue
claimed_amount = _first_decimal_from_mapping(
item,
"valor_final",
"valorFinal",
)
if claimed_amount is None or claimed_amount <= 0:
continue
claimed_amount = _money(claimed_amount)
validated_amount = min(restante, claimed_amount)
if validated_amount <= 0:
continue
contestation_items.append(
{
"itemName": desc,
"itemType": "PRO_RATA",
"claimedAmount": float(claimed_amount),
"validatedAmount": float(validated_amount),
}
)
restante = _money(restante - validated_amount)
if restante <= 0:
break
return contestation_items, restante
def _build_pro_rata_human_validation_text(
*,
plano_controle: dict[str, Any],
valor_liquido: Decimal,
dias_ciclo: int,
dias_controle: int,
valor_usado: Decimal,
valor_devolver: Decimal,
items: list[dict[str, float | str]],
) -> str:
plano_nome = re.sub(
r"\s*\([^)]*\)",
"",
str(plano_controle.get("desc") or "").strip(),
).strip()
if not plano_nome:
plano_nome = "Plano Controle"
item_lines = []
for index, item in enumerate(items, start=1):
item_name = str(item.get("itemName") or "").strip()
claimed = _decimal_from_any(item.get("claimedAmount")) or Decimal("0")
validated = _decimal_from_any(item.get("validatedAmount")) or Decimal("0")
item_lines.append(
f"{index}. {item_name}: valor DANFE R$ {_format_amount(_money(claimed))}; "
f"valor a abater R$ {_format_amount(_money(validated))}"
)
itens_txt = "; ".join(item_lines) if item_lines else "nenhum item gerado"
return (
"Validacao humana pro-rata:"
f"Plano Controle identificado: {plano_nome}. "
f"Base liquida do plano: R$ {_format_amount(valor_liquido)}. "
f"Calculo: ciclo de {dias_ciclo} dias, uso considerado de "
f"{dias_controle} dias; valor usado R$ {_format_amount(_money(valor_usado))}; "
f"valor a devolver R$ {_format_amount(valor_devolver)}. "
f"Itens selecionados no DANFE, na ordem de abatimento: {itens_txt}. "
)
def _extract_pro_rata_contestation_context(
state: dict[str, Any], params: dict[str, Any]
) -> tuple[dict[str, Any] | None, str | None]:
devolucao = params.get("devolucao")
contestation_params = params
if isinstance(devolucao, dict):
contestation_params = {**devolucao, **params}
if "items" not in params:
contestation_params["items"] = devolucao.get("items")
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return None, "msisdn obrigatorio para contestacao de pro_rata"
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
customer_id = _first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
)
if not customer_id:
return None, "customer_id obrigatorio para contestacao de pro_rata"
invoice_number = _first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
"invoice_number",
"invoiceNumber",
)
if not invoice_number:
return None, "invoice_number obrigatorio para contestacao de pro_rata"
items = _build_contestation_items(state, contestation_params)
if not items:
return None, "items obrigatorio para contestacao de pro_rata"
fallback_amount = _normalize_number_text(
format(
sum(
(
_decimal_from_any(item.get("validated_amount"))
or Decimal("0")
)
for item in items
if isinstance(item, dict)
),
"f",
),
default="0",
)
invoice_amount_open = _normalize_number_text(
_first_text_from_params_or_state(
state,
contestation_params,
"invoice_amount_open",
"invoiceAmountOpen",
),
default=fallback_amount,
)
invoice_amount = _normalize_number_text(
_first_text_from_params_or_state(
state,
contestation_params,
"invoice_amount",
"invoiceAmount",
),
default=invoice_amount_open,
)
context = {
"msisdn": msisdn,
"social_sec_no": social_sec_no,
"customer_id": customer_id,
"invoice_number": invoice_number,
"items": items,
"invoice_amount_open": invoice_amount_open,
"invoice_amount": invoice_amount,
}
return context, None
def _build_pro_rata_contestacao_tool_payload(
state: dict[str, Any],
params: dict[str, Any],
*,
context: dict[str, Any],
) -> dict[str, Any]:
return {
"msisdn": context["msisdn"],
"social_sec_no": context["social_sec_no"],
"customer_id": context["customer_id"],
"current_invoice_number": context["invoice_number"],
"invoice_id": context["invoice_number"],
"customer_type": (
_first_text_from_params_or_state(
state, params, "customer_type", "customerType"
)
or "2"
),
"customer_status": (
_first_text_from_params_or_state(
state, params, "customer_status", "customerStatus"
)
or "1"
),
"invoice_status": (
_first_text_from_params_or_state(
state, params, "invoice_status", "invoiceStatus"
)
or "1"
),
"invoice_amount_open": context["invoice_amount_open"],
"invoice_amount": context["invoice_amount"],
"current_invoice_due_date": _first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
"contestation_type": (
_first_text_from_params_or_state(
state, params, "contestation_type", "contestationType"
)
or "0"
),
"adjust_reason": (
_first_text_from_params_or_state(
state, params, "adjust_reason", "adjustReason"
)
or "SERVICO_NAO_SOLICITADO"
),
"observation": _first_text_from_params_or_state(
state, params, "observation", "descricao"
),
"refund_option": (
_first_text_from_params_or_state(
state, params, "refund_option", "refundOption"
)
or ""
),
"manual_conta_certa_indicator": _first_value_from_params_or_state(
state,
params,
"manual_conta_certa_indicator",
"manualContaCertaIndicator",
),
"double_refund": _normalize_bool(
_first_value_from_params_or_state(
state,
params,
"double_refund",
"doubleRefund",
),
default=False,
),
"user_id": _first_text_from_params_or_state(
state, params, "user_id", "userId"
),
"message_id": _first_text_from_params_or_state(
state, params, "message_id", "messageId"
),
"client_id": _first_text_from_params_or_state(
state, params, "client_id", "clientId"
),
"tipo_atendimento": "pro_rata",
"skip_invoice_item_validation": True,
"items": context["items"],
}
__all__ = [
'_format_plano_pro_rata',
'_format_plano_pro_rata_value',
'_format_plano_pro_rata_display_name',
'_format_pro_rata_period_reference',
'_format_planos_pro_rata',
'_build_pro_rata_controle_msg',
'_build_pro_rata_sem_controle_msg',
'_build_pro_rata_oferta_ajuste_msg',
'_build_pro_rata_ajuste_recusado_msg',
'_money',
'_decimal_from_any',
'_first_decimal_from_mapping',
'_normalize_match_text',
'_is_same_plan_name',
'_resolve_plano_controle',
'_resolve_pro_rata_liquid_value',
'_parse_emissao_year',
'_extract_resumo_period',
'_parse_period_range',
'_inclusive_days',
'_resolve_days_number',
'_resolve_pro_rata_period_days',
'_parse_invoice_detail_payload',
'_extract_danfe_from_state_or_params',
'_extract_invoice_payload_from_state_or_params',
'_fetch_invoice_payload_pro_rata',
'_fetch_danfe_pro_rata',
'_find_danfe_plan_items',
'_build_pro_rata_contestation_items',
'_build_pro_rata_human_validation_text',
'_extract_pro_rata_contestation_context',
'_build_pro_rata_contestacao_tool_payload',
]

View File

@@ -0,0 +1,218 @@
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from typing import Any
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 (
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
from agente_contas_tim.workflows.actions.rag.helpers import (
_build_rag_answer_item,
_extract_rag_documents,
_is_selected_rag_document,
_normalize_rag_queries,
_rag_document_text,
_rag_document_title,
)
_RAG_FALLBACK_MESSAGE = (
"Não foi possível buscar essa informação no momento. "
"Por favor, aguarde na linha para que possamos te ajudar de outra forma."
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("buscar_informacao_rag")
def buscar_informacao_rag(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
queries = _normalize_rag_queries(params)
if not queries:
return ActionResult.fail("Informe uma pergunta para buscar na base.")
raw_top_k = params.get("top_k")
resolved_top_k: int | None = None
if raw_top_k is not None and str(raw_top_k).strip():
try:
resolved_top_k = int(raw_top_k)
except (TypeError, ValueError):
return ActionResult.fail("top_k invalido: informe um numero inteiro")
segment = str(params.get("segment", "")).strip()
results: list[dict[str, Any]] = []
documents: list[dict[str, Any]] = []
answer_parts: list[str] = []
retrieved_titles: list[str] = []
selected_titles: list[str] = []
metadata: dict[str, Any] = {}
def _execute(query: str) -> Any:
return runtime.factory.create_rag_search(
query=query,
top_k=resolved_top_k,
segment=segment,
).execute()
if len(queries) == 1:
raw_results = [_execute(queries[0])]
else:
with ThreadPoolExecutor(
max_workers=min(len(queries), 6),
thread_name_prefix="rag-search",
) as executor:
futures = [
executor.submit(copy_context().run, _execute, query)
for query in queries
]
raw_results = [future.result() for future in futures]
for query, result in zip(queries, raw_results):
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na busca RAG",
**result.metadata,
)
metadata.update(result.metadata)
payload = _to_dict(result.data)
query_documents = _extract_rag_documents(payload)
query_total = len(query_documents)
query_message = (
f"Encontrei {query_total} trecho(s) relevante(s) na base."
if query_total
else "Nao encontrei trechos relevantes para essa busca."
)
query_answer = _build_rag_answer_item(query, query_documents)
results.append(
{
"query": query,
"total": query_total,
"documents": query_documents,
"message": query_message,
"answer": query_answer,
}
)
documents.extend(query_documents)
answer_parts.append(query_answer)
for doc in query_documents:
title = _rag_document_title(doc)
if not title:
continue
retrieved_titles.append(title)
if _is_selected_rag_document(doc):
selected_titles.append(title)
total = len(documents)
message = (
f"Encontrei {total} trecho(s) relevante(s) na base."
if total
else "Nao encontrei trechos relevantes para essa busca."
)
return ActionResult.ok(
{
"success": True,
"query": queries[0],
"queries": queries,
"total": total,
"documents": documents,
"results": results,
"answer": "\n\n".join(answer_parts),
"message": message,
"ragRetrievedDocuments": "|".join(retrieved_titles),
"ragSelectedDocuments": "|".join(selected_titles),
"noMatchRag": total == 0,
},
**metadata,
)
@workflow_action("reescrever_resposta_buscar_informacao")
def reescrever_resposta_buscar_informacao(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
raw_queries = params.get("queries")
queries: list[str] = []
if isinstance(raw_queries, (list, tuple)):
for item in raw_queries:
text = str(item or "").strip()
if text:
queries.append(text)
elif isinstance(raw_queries, str) and raw_queries.strip():
queries.append(raw_queries.strip())
raw_documents = params.get("documents")
documents: list[dict[str, Any]] = []
if isinstance(raw_documents, (list, tuple)):
for item in raw_documents:
if isinstance(item, dict):
documents.append(item)
rag_context_parts: list[str] = []
for doc in documents:
text = _rag_document_text(doc)
if text:
rag_context_parts.append(text)
rag_context = "\n\n".join(rag_context_parts).strip()
if not rag_context:
rag_context = str(params.get("answer", "") or "").strip()
no_match = bool(params.get("noMatchRag", False))
def _payload(answer: str, *, success: bool = True) -> dict[str, Any]:
return {
"success": success,
"answer": answer,
"noMatchRag": no_match,
}
if runtime.llm_gateway is None:
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
queries_text = "; ".join(queries) if queries else ""
try:
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_buscar_informacao_reescrita",
variables={
"queries": queries_text,
"rag_context": rag_context,
},
user_text=queries_text,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
answer = str(getattr(llm_result, "content", "") or "").strip()
except Exception:
logger.exception(
"reescrever_resposta_buscar_informacao: falha ao invocar "
"capability fluxo_buscar_informacao_reescrita"
)
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
if not answer:
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
return ActionResult.ok(_payload(answer))
__all__ = [
'buscar_informacao_rag',
'reescrever_resposta_buscar_informacao',
]

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any
def _normalize_rag_queries(params: dict[str, Any]) -> list[str]:
raw_queries = params.get("queries")
queries: list[str] = []
if isinstance(raw_queries, (list, tuple)):
for item in raw_queries:
text = str(item or "").strip()
if text:
queries.append(text)
elif isinstance(raw_queries, str) and raw_queries.strip():
queries.append(raw_queries.strip())
if not queries:
query = str(params.get("query", "")).strip()
if query:
queries.append(query)
return queries
def _extract_rag_documents(payload: Any) -> list[dict[str, Any]]:
documents: list[dict[str, Any]] = []
if not isinstance(payload, dict):
return documents
raw_documents = payload.get("documents")
if isinstance(raw_documents, (list, tuple)):
for item in raw_documents:
if isinstance(item, dict):
documents.append(dict(item))
return documents
def _rag_document_title(doc: dict[str, Any]) -> str:
return str(
doc.get("title_proc")
or doc.get("title")
or doc.get("chunk_texto")
or ""
).strip()
def _rag_document_text(doc: dict[str, Any]) -> str:
return str(
doc.get("chunk_texto")
or doc.get("text")
or doc.get("title_proc")
or doc.get("title")
or ""
).strip()
def _is_selected_rag_document(doc: dict[str, Any]) -> bool:
try:
return float(doc.get("distance", 1.0)) <= 0.6
except (TypeError, ValueError):
return False
def _build_rag_answer_item(query: str, documents: list[dict[str, Any]]) -> str:
if not documents:
return f"{query}: Nao encontrei trechos relevantes para essa busca."
snippets = []
for doc in documents:
text = _rag_document_text(doc)
if text:
snippets.append(text)
if not snippets:
return f"{query}: Encontrei trecho(s), mas sem texto disponivel."
return f"{query}: {' '.join(snippets)}"
__all__ = [
'_normalize_rag_queries',
'_extract_rag_documents',
'_rag_document_title',
'_rag_document_text',
'_is_selected_rag_document',
'_build_rag_answer_item',
]

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass, field
from threading import Lock
from typing import TYPE_CHECKING, Any, Callable
from agente_contas_tim.factory import CommandFactory
from agente_contas_tim.workflows.runtime_types import ActionResult
if TYPE_CHECKING:
from agente_contas_tim.agent.llm_gateway import LLMCapabilityGateway
from agente_contas_tim.workflows.runtime_types import WorkflowRunResponse
RuntimeState = dict[str, Any]
@dataclass(frozen=True, slots=True)
class WorkflowRuntimeContext:
factory: CommandFactory
llm_gateway: "LLMCapabilityGateway | None" = None
workflow_runner: (
Callable[[str, dict[str, Any], str | None, int | None], "WorkflowRunResponse"]
| None
) = None
llm_callbacks: tuple[Any, ...] = ()
llm_metadata: dict[str, Any] = field(default_factory=dict)
ActionHandler = Callable[
[RuntimeState, dict[str, Any], WorkflowRuntimeContext],
ActionResult,
]
@dataclass(frozen=True, slots=True)
class ActionSpec:
name: str
handler: ActionHandler
source: str
class ActionRegistry:
def __init__(self) -> None:
self._actions: dict[str, ActionSpec] = {}
self._lock = Lock()
def register(self, name: str, handler: ActionHandler, *, source: str) -> None:
with self._lock:
existing = self._actions.get(name)
if existing is not None:
if existing.handler is handler:
return
raise ValueError(
f"Action {name!r} já registrada por {existing.source}; "
f"tentativa atual: {source}"
)
self._actions[name] = ActionSpec(
name=name,
handler=handler,
source=source,
)
def get(self, name: str) -> ActionHandler:
spec = self._actions.get(name)
if spec is None:
raise ValueError(f"Action não registrada: {name}")
return spec.handler
def list_names(self) -> list[str]:
return sorted(self._actions.keys())
DEFAULT_ACTION_REGISTRY = ActionRegistry()
def workflow_action(name: str):
def decorator(fn: ActionHandler) -> ActionHandler:
source = f"{fn.__module__}.{fn.__name__}"
DEFAULT_ACTION_REGISTRY.register(name, fn, source=source)
return fn
return decorator

View File

@@ -0,0 +1,72 @@
"""Compatibility facade for legacy workflow action imports.
The action implementations live in workflow-specific packages under
``agente_contas_tim.workflows.actions``. This module keeps the previous
``tim_actions`` import path stable for tests and any external callers.
"""
from __future__ import annotations
from types import ModuleType
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.workflows.actions.common import (
actions as _common_actions,
helpers as _common_helpers,
)
from agente_contas_tim.workflows.actions.contestacao import (
actions as _contestacao_actions,
helpers as _contestacao_helpers,
)
from agente_contas_tim.workflows.actions.invoice_explanation import (
actions as _invoice_explanation_actions,
helpers as _invoice_explanation_helpers,
)
from agente_contas_tim.workflows.actions.pro_rata import (
actions as _pro_rata_actions,
helpers as _pro_rata_helpers,
)
from agente_contas_tim.workflows.actions.rag import (
actions as _rag_actions,
helpers as _rag_helpers,
)
from agente_contas_tim.workflows.actions.vas_avulso import (
actions as _vas_avulso_actions,
helpers as _vas_avulso_helpers,
)
from agente_contas_tim.workflows.actions.vas_estrategico import (
actions as _vas_estrategico_actions,
helpers as _vas_estrategico_helpers,
)
_EXPORT_MODULES: tuple[ModuleType, ...] = (
_common_helpers,
_contestacao_helpers,
_invoice_explanation_helpers,
_pro_rata_helpers,
_rag_helpers,
_vas_avulso_helpers,
_vas_estrategico_helpers,
_common_actions,
_contestacao_actions,
_invoice_explanation_actions,
_pro_rata_actions,
_rag_actions,
_vas_avulso_actions,
_vas_estrategico_actions,
)
__all__ = ["agent_framework_bridge"]
def _export_from(module: ModuleType) -> None:
for name in getattr(module, "__all__", ()):
globals()[name] = getattr(module, name)
__all__.append(name)
for _module in _EXPORT_MODULES:
_export_from(_module)
del ModuleType, _EXPORT_MODULES, _export_from, _module

View File

@@ -0,0 +1,851 @@
from __future__ import annotations
import logging
import re
from decimal import Decimal
from typing import Any
from agente_contas_tim.constants.ic_tags_enum import VAATag
from agente_contas_tim.integrations.rct_policy import RCTOperation
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
from agente_contas_tim.workflows.actions.inputs import parse_cancel_vas_items
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 (
_build_ic_context,
_emit_vaa,
_find_matching_service,
_first_text_from_params_or_state,
_format_amount,
_idempotency_get,
_idempotency_set,
_parse_amount,
_resolve_service_csp_id,
_result_failed_or_missing_data,
_service_from_dict,
_to_dict,
)
from agente_contas_tim.workflows.actions.vas_avulso.helpers import (
_build_active_services,
_empty_cancel_result,
_service_can_be_canceled,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("consulta_vas")
def query_vas(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
app_id = str(input_state.get("app_id", ""))
service_context = input_state.get("service_context")
if isinstance(service_context, dict):
context_app_id = str(service_context.get("app_id", "")).strip()
if not app_id or context_app_id == app_id:
return ActionResult.ok(
{
"services": [service_context],
"service_found": True,
"service_status": str(service_context.get("status", "")),
"service": service_context,
"consulta_metadata": {"source": "service_context"},
}
)
result = runtime.factory.create_query_vas(
msisdn=str(params["msisdn"])
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na consulta",
**result.metadata,
)
payload = _to_dict(result.data)
services = payload.get("services", [])
target = next(
(service for service in services if str(service.get("app_id", "")) == app_id),
None,
)
return ActionResult.ok(
{
"services": services,
"service_found": target is not None,
"service_status": str((target or {}).get("status", "")),
"service": target,
"consulta_metadata": dict(result.metadata),
},
**result.metadata,
)
@workflow_action("cancelamento_vas_avulso_batch")
def cancelamento_vas_avulso_batch(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
csp_id = str(params.get("csp_id", "740")).strip() or "740"
channel = "AIAGENTCR"
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
idempotency_key = str(params.get("idempotency_key", "")).strip()
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"customer_document",
"customerDocument",
),
)
input_state = state.get("input", {}) if isinstance(state, dict) else {}
_ic_base: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"gsm": "",
"ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(),
"uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(),
"agentId": "contas",
"channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(),
}
parsed_items = parse_cancel_vas_items(params.get("items"))
if not parsed_items:
return ActionResult.ok(
_empty_cancel_result(
"", mensagem="Nenhum serviço informado para contestação."
)
)
normalized_items: list[tuple[str, str, float]] = [
(item.msisdn, item.service, item.value) for item in parsed_items
]
_emit_vaa(VAATag.INICIO_FLUXO, _ic_base, gsm=normalized_items[0][0])
msisdns_in_order: list[str] = []
seen_msisdns: set[str] = set()
for item_msisdn, _, _ in normalized_items:
if item_msisdn not in seen_msisdns:
seen_msisdns.add(item_msisdn)
msisdns_in_order.append(item_msisdn)
protocol_entries: list[dict[str, str]] = []
active_by_msisdn: dict[str, dict[str, dict[str, Any]]] = {}
query_failures: set[str] = set()
for line_msisdn in msisdns_in_order:
query_result = runtime.factory.create_query_vas(
msisdn=line_msisdn
).execute()
if _result_failed_or_missing_data(query_result, state=state):
query_failures.add(line_msisdn)
active_by_msisdn[line_msisdn] = {}
continue
active_by_msisdn[line_msisdn] = _build_active_services(
getattr(query_result.data, "services", [])
)
canceled_items: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
not_found_services: list[dict[str, str]] = []
contestation_candidates: list[dict[str, Any]] = []
technical_error_in_iu = False
def _to_display_amount(value: Any) -> str:
candidates: list[Any] = [value]
def _collect_from_any(raw: Any) -> list[Any]:
collected: list[Any] = []
if raw is None:
return collected
if isinstance(raw, (str, int, float, Decimal)):
collected.append(raw)
return collected
if isinstance(raw, dict):
for key in (
"valor",
"price",
"amount",
"cost",
"value",
"valor_numeric",
"valor_float",
"price_numeric",
"amount_float",
):
if key in raw:
candidate = raw.get(key)
if candidate not in (None, ""):
collected.append(candidate)
details = raw.get("details")
if isinstance(details, dict):
collected.extend(_collect_from_any(details))
extra = raw.get("extra")
if isinstance(extra, dict):
collected.extend(_collect_from_any(extra))
if isinstance(raw, (list, tuple, set)):
for item in raw:
if item not in (None, ""):
collected.extend(_collect_from_any(item))
return collected
for candidate in _collect_from_any(value):
parsed = _parse_amount(candidate)
if parsed is None:
continue
try:
return _format_amount(parsed)
except Exception:
continue
return ""
def _resolve_item_amount(
requested: Any,
matched_service: dict[str, Any],
) -> str:
service_context = matched_service.get("service_context")
context = service_context if isinstance(service_context, dict) else {}
return (
_to_display_amount(requested)
or _to_display_amount(
{
"valor": matched_service.get("valor"),
"price": matched_service.get("price"),
"amount": matched_service.get("amount"),
"cost": matched_service.get("cost"),
"value": matched_service.get("value"),
"amount_numeric": matched_service.get("amount_numeric"),
"valor_numeric": matched_service.get("valor_numeric"),
"valor_float": matched_service.get("valor_float"),
"price_numeric": matched_service.get("price_numeric"),
"service_context": context,
}
)
)
def _parse_amount_from_item(item: dict[str, Any]) -> Decimal | None:
for key in ("valor", "value", "amount", "price", "servico_valor"):
raw_value = item.get(key)
if raw_value is None:
continue
parsed = _parse_amount(raw_value)
if parsed is not None:
return parsed
return None
def _build_contestation_candidate(
*,
msisdn: str,
service_name: str,
amount: Any,
app_id: str = "",
codigo_boleto: str = "",
iu_reason: str = "",
) -> dict[str, Any]:
resolved_amount = _to_display_amount(amount) or "0"
return {
"msisdn": msisdn,
"servico": str(service_name or "").strip(),
"service": str(service_name or "").strip(),
"app_id": str(app_id or "").strip(),
"valor": resolved_amount,
"value": resolved_amount,
"amount": resolved_amount,
"price": resolved_amount,
"codigo_boleto": str(codigo_boleto or "").strip(),
"iu_reason": str(iu_reason or "").strip(),
}
for item_msisdn, requested_service, requested_value in normalized_items:
if item_msisdn in query_failures:
technical_error_in_iu = True
errors.append(
{
"msisdn": item_msisdn,
"servico": requested_service,
"erro": (
"Falha ao consultar a linha de final "
f"{item_msisdn[-2:]}."
),
}
)
continue
active_services = active_by_msisdn.get(item_msisdn, {})
matched_service = _find_matching_service(
requested_service, active_services,
)
if not matched_service:
logger.info(
"cancelamento_vas_avulso_batch.skip reason=service_not_found msisdn=%s requested_service=%s available_services=%s",
item_msisdn,
requested_service,
list(active_services.keys()),
)
not_found_services.append(
{"msisdn": item_msisdn, "servico": requested_service}
)
contestation_candidates.append(
_build_contestation_candidate(
msisdn=item_msisdn,
service_name=requested_service,
amount=requested_value,
iu_reason="service_not_found",
)
)
continue
if not _service_can_be_canceled(matched_service):
logger.info(
"cancelamento_vas_avulso_batch.skip reason=can_cancel_false msisdn=%s requested_service=%s matched_service=%s",
item_msisdn,
requested_service,
str(matched_service.get("service_name", "")).strip(),
)
errors.append(
{
"msisdn": item_msisdn,
"servico": requested_service,
"erro": (
"Serviço já se encontra cancelado, não sendo possível "
"realizar recancelamento."
),
}
)
contestation_candidates.append(
_build_contestation_candidate(
msisdn=item_msisdn,
service_name=str(
matched_service.get("service_name", "")
).strip()
or requested_service,
amount=requested_value,
app_id=str(matched_service.get("app_id", "")).strip(),
codigo_boleto=str(
matched_service.get("codigo_boleto", "")
).strip(),
iu_reason="already_inactive_or_blocked",
)
)
continue
service_name = str(matched_service.get("service_name", ""))
app_id = str(matched_service.get("app_id", ""))
valor = _resolve_item_amount(requested_value, matched_service)
codigo_boleto = str(matched_service.get("codigo_boleto", "")).strip()
cache_key = ""
if idempotency_key and service_name:
cache_key = (
f"{idempotency_key}:{item_msisdn}:"
f"{service_name.lower()}:{app_id}"
)
cached_item = _idempotency_get(cache_key)
if cached_item is not None:
merged_item = dict(cached_item)
if not str(
merged_item.get("valor", "")
or merged_item.get("value", "")
or merged_item.get("amount", "")
).strip():
merged_item.update(
{
"valor": valor,
"value": valor,
"amount": valor,
"price": valor,
}
)
if cache_key:
_idempotency_set(cache_key, merged_item)
canceled_items.append(merged_item)
continue
resolved_csp_id = _resolve_service_csp_id(
matched_service,
fallback_csp_id=csp_id,
)
_emit_vaa(VAATag.BLOQUEIO_INICIO, _ic_base, gsm=item_msisdn)
block_result = runtime.factory.create_block_vas(
msisdn=item_msisdn,
app_id=app_id,
csp_id=resolved_csp_id,
service=_service_from_dict(
matched_service.get("service_context")
),
ic_context={**_ic_base, "gsm": item_msisdn},
).execute()
if not block_result.success:
technical_error_in_iu = True
_emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn)
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": block_result.error or "Falha no bloqueio",
}
)
continue
cancel_result = runtime.factory.create_cancellation_vas(
msisdn=item_msisdn,
app_id=app_id,
csp_id=resolved_csp_id,
channel=channel,
interaction_protocol="",
service=_service_from_dict(
matched_service.get("service_context")
),
).execute()
if not cancel_result.success:
technical_error_in_iu = True
_emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn)
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": cancel_result.error or "Falha no cancelamento",
}
)
continue
_emit_vaa(VAATag.ITEM_CANCELADO_OK, _ic_base, gsm=item_msisdn)
cancel_payload = _to_dict(cancel_result.data)
cancelamento = (
cancel_payload.get("cancelamento", {})
if isinstance(cancel_payload, dict)
else {}
)
cancellation_protocol = ""
if isinstance(cancelamento, dict):
cancellation_protocol = str(
cancelamento.get("interactionProtocol")
or cancelamento.get("protocolId")
or cancelamento.get("protocolo_id")
or ""
).strip()
if not cancellation_protocol:
body_payload = cancelamento.get("body", {})
if isinstance(body_payload, dict):
cancellation_protocol = str(
body_payload.get("interactionProtocol")
or body_payload.get("protocolId")
or body_payload.get("protocolo_id")
or ""
).strip()
canceled_item: dict[str, Any] = {
"msisdn": item_msisdn,
"servico": service_name,
"app_id": app_id,
"valor": valor,
"value": valor,
"amount": valor,
"price": valor,
"codigo_boleto": codigo_boleto,
}
canceled_items.append(canceled_item)
contestation_candidates.append(dict(canceled_item))
if cache_key:
_idempotency_set(cache_key, canceled_item)
_emit_vaa(VAATag.PROTOCOLO_INICIO, _ic_base, gsm=item_msisdn)
close_triplet = resolve_protocol_triplet(
"cancelamento_vas_avulso",
stage="close",
context={"servico": service_name},
)
protocol_result = runtime.factory.create_protocol_v2(
msisdn=item_msisdn,
interaction_protocol=cancellation_protocol,
flag_sms=True,
social_sec_no=social_sec_no,
source="CHAT",
reason1=close_triplet.reason1 or "Informação",
reason2=close_triplet.reason2 or "Conta",
reason3=close_triplet.reason3 or "Valor",
direction_contact="FROM-CLIENT",
type="CLIENTE",
request_flag=True,
request_status=request_status,
status=status,
service_request_notes=f"Serviço cancelado: {service_name}",
client_id="AIAGENTCR",
ic_context={**_ic_base, "gsm": str(item_msisdn)},
rct_operation=RCTOperation.REG_ATEND_VAS_AVULSO,
message_id=message_id,
).execute()
if _result_failed_or_missing_data(protocol_result, state=state):
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": (
protocol_result.error
or "Falha ao registrar protocolo após cancelamento"
),
}
)
continue
_emit_vaa(VAATag.PROTOCOLO_OK, _ic_base, gsm=item_msisdn)
protocol_payload = _to_dict(protocol_result.data)
protocol_response = (
protocol_payload.get("response", {})
if isinstance(protocol_payload, dict)
else {}
)
if not isinstance(protocol_response, dict):
protocol_response = {}
protocolo_id = str(
cancellation_protocol
or protocol_response.get("interactionProtocol")
or protocol_response.get("protocolId")
or protocol_response.get("protocolo_id")
or ""
).strip()
if not protocolo_id:
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": "Protocolo não retornado no registro de atendimento",
}
)
continue
protocol_entries.append(
{"msisdn": item_msisdn, "protocolo_id": protocolo_id}
)
summary_parts: list[str] = []
if canceled_items:
by_line: dict[str, list[str]] = {}
for item in canceled_items:
by_line.setdefault(
str(item.get("msisdn", "")), []
).append(str(item.get("servico", "")))
for line, names in by_line.items():
summary_parts.append(
"Cancelados com sucesso na linha de final "
f"{line[-2:]}: {', '.join(names)}."
)
for error in errors:
summary_parts.append(
f"Erro ao cancelar {error['servico']} "
f"(linha {error['msisdn'][-2:]}): {error['erro']}."
)
if not_found_services:
joined = ", ".join(
f"{nf['servico']} (linha {nf['msisdn'][-2:]})"
for nf in not_found_services
)
summary_parts.append(f"Não encontrados: {joined}.")
total_valor = Decimal("0")
total_tem_valor = False
cancelados_descritivo: list[str] = []
for item in canceled_items:
servico = str(item.get("servico", "")).strip()
valor = str(item.get("valor", "")).strip()
parsed = _parse_amount_from_item(item)
if parsed is not None:
total_valor += parsed
total_tem_valor = True
if servico:
valor_txt = f" (valor: R$ {valor})" if valor else ""
cancelados_descritivo.append(f"{servico}{valor_txt}")
total_valor_txt = (
f"R$ {_format_amount(total_valor)}"
if total_tem_valor
else "R$ 0,00"
)
if technical_error_in_iu:
contestation_candidates = []
primary_msisdn = msisdns_in_order[0] if msisdns_in_order else ""
resumo_agente = (
"Cancelamento VAS avulso concluido. "
f"Telefone: {primary_msisdn}. "
"Servicos cancelados: "
f"{', '.join(cancelados_descritivo) if cancelados_descritivo else 'nenhum'}. "
f"Total dos servicos cancelados: {total_valor_txt}."
)
return ActionResult.ok(
{
"success": len(canceled_items) > 0,
"mensagem": " ".join(summary_parts),
"resumo_agente": resumo_agente,
"msisdn": primary_msisdn,
"cancelados": canceled_items,
"erros": errors,
"nao_encontrados": not_found_services,
"itens_para_contestacao": contestation_candidates,
"protocolos_por_linha": protocol_entries,
"protocol_closed": bool(protocol_entries),
"prompt_signals": {
"servicos_cancelados": [
item.get("servico") for item in canceled_items
],
},
}
)
@workflow_action("avaliar_proxima_acao")
def evaluate_next_action(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Action exemplo: decide se deve aguardar confirmação do usuário."""
services = []
previous_query = state.get("vars", {}).get("node1")
if isinstance(previous_query, dict):
previous_services = previous_query.get("services", [])
if isinstance(previous_services, list):
services = previous_services
metadata: dict[str, Any] = {}
if not services:
result = runtime.factory.create_query_vas(
msisdn=str(params["msisdn"])
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na consulta para avaliação",
**result.metadata,
)
services = _to_dict(result.data).get("services", [])
metadata = dict(result.metadata)
tim_music = next(
(
s
for s in services
if str(s.get("service_name", "")).strip().lower() == "tim music"
and str(s.get("status", "")).upper() == "ACTIVE"
),
None,
)
if tim_music:
client_payload = {
"type": "question",
"text": (
"O motivo do aumento da sua fatura é o serviço TIM Music, que está ativo em sua linha. "
"Deseja cancelar o TIM Music? Se sim, responda SIM para que eu possa prosseguir com o bloqueio e cancelamento."
),
"options": ["SIM", "NAO"],
"service": {
"app_id": tim_music.get("app_id", ""),
"service_name": tim_music.get("service_name", ""),
"status": tim_music.get("status", ""),
},
}
return ActionResult.ok(
{
"await_user_input": True,
"client_payload": client_payload,
"service_context": tim_music,
},
**metadata,
)
# Caso não haja TIM Music ativo, segue fluxo normal
app_id = str(params.get("app_id", state.get("input", {}).get("app_id", "")))
target = next(
(service for service in services if str(service.get("app_id", "")) == app_id),
None,
)
if target is None:
return ActionResult.ok(
{
"await_user_input": False,
"client_payload": {
"type": "info",
"text": f"Serviço appId={app_id} não localizado para confirmação.",
},
}
)
status = str(target.get("status", "")).upper()
await_user_input = status == "ACTIVE"
if await_user_input:
client_payload = {
"type": "question",
"text": "Deseja continuar com a operação deste serviço?",
"options": ["SIM", "NAO"],
"service": {
"app_id": target.get("app_id", ""),
"service_name": target.get("service_name", ""),
"status": target.get("status", ""),
},
}
else:
client_payload = {
"type": "info",
"text": "Serviço não requer confirmação adicional.",
"service": {
"app_id": target.get("app_id", ""),
"service_name": target.get("service_name", ""),
"status": target.get("status", ""),
},
}
return ActionResult.ok(
{
"await_user_input": await_user_input,
"client_payload": client_payload,
"service_context": target,
},
**metadata,
)
@workflow_action("bloquear_vas")
def bloquear_vas(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
service_context = state.get("vars", {}).get("node2", {}).get("service_context")
msisdn = str(params["msisdn"])
input_state = state.get("input", {}) if isinstance(state, dict) else {}
result = runtime.factory.create_block_vas(
msisdn=msisdn,
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
service=_service_from_dict(service_context),
ic_context=_build_ic_context(input_state, gsm=msisdn),
query_metadata=state.get("vars", {})
.get("node1", {})
.get("consulta_metadata", {}),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no bloqueio",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("cancelar_vas")
def cancelar_vas(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
service_context = state.get("vars", {}).get("node2", {}).get("service_context")
result = runtime.factory.create_cancellation_vas(
msisdn=str(params["msisdn"]),
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
interaction_protocol=str(params.get("interaction_protocol", "")),
channel="AIAGENTCR",
service=_service_from_dict(service_context),
query_metadata=state.get("vars", {})
.get("node1", {})
.get("consulta_metadata", {}),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no cancelamento",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("bloquear_vas_single")
def bloquear_vas_single(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Bloqueia um serviço VAS usando msisdn + app_id dos params."""
service_context = (
state.get("vars", {})
.get("consulta_vas", {})
.get("service")
)
msisdn = str(params["msisdn"])
input_state = state.get("input", {}) if isinstance(state, dict) else {}
result = runtime.factory.create_block_vas(
msisdn=msisdn,
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
service=_service_from_dict(service_context),
ic_context=_build_ic_context(input_state, gsm=msisdn),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no bloqueio",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("cancelar_vas_single")
def cancelar_vas_single(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Cancela um serviço VAS usando msisdn + app_id dos params."""
service_context = (
state.get("vars", {})
.get("consulta_vas", {})
.get("service")
)
result = runtime.factory.create_cancellation_vas(
msisdn=str(params["msisdn"]),
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
channel="AIAGENTCR",
interaction_protocol=str(params.get("interaction_protocol", "")),
service=_service_from_dict(service_context),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no cancelamento",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
__all__ = [
'query_vas',
'cancelamento_vas_avulso_batch',
'evaluate_next_action',
'bloquear_vas',
'cancelar_vas',
'bloquear_vas_single',
'cancelar_vas_single',
]

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from agente_contas_tim.workflows.actions.common.helpers import (
_extract_amount,
_extract_boleto_code,
_to_dict,
)
def _empty_cancel_result(
msisdn: str,
*,
mensagem: str,
nao_encontrados: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
return {
"success": False,
"mensagem": mensagem,
"resumo_agente": (
"Contestacao VAS avulso concluida. "
f"Telefone: {msisdn}. "
"SMS enviado: nao. "
"Servicos cancelados: nenhum. "
"Total dos servicos cancelados: R$ 0,00."
),
"msisdn": msisdn,
"cancelados": [],
"erros": [],
"nao_encontrados": nao_encontrados or [],
"prompt_signals": {
"sms_enviado": False,
"servicos_com_sms": [],
"servicos_com_credito_fatura": [],
"tipos_fatura_por_servico": {},
"boletos_por_servico": {},
"data_credito_por_servico": {},
},
}
def _build_active_services(raw_services: Any) -> dict[str, dict[str, Any]]:
active: dict[str, dict[str, Any]] = {}
if not isinstance(raw_services, (list, tuple)):
return active
for raw_service in raw_services:
service_dict = _to_dict(raw_service)
service_name = str(service_dict.get("service_name", "") or "")
app_id = str(service_dict.get("app_id", "") or "")
extra = service_dict.get("extra", {}) or {}
if service_name and app_id:
active[service_name.lower()] = {
"service_name": service_name,
"app_id": app_id,
"valor": _extract_amount(extra),
"codigo_boleto": _extract_boleto_code(extra),
"service_context": service_dict,
}
return active
def _service_can_be_canceled(matched_service: dict[str, Any]) -> bool:
context = matched_service.get("service_context", {})
if not isinstance(context, dict):
return False
extra = context.get("extra", {})
if not isinstance(extra, dict):
return False
can = extra.get("can", {})
if not isinstance(can, dict):
return False
return can.get("cancel") is True
__all__ = [
'_empty_cancel_result',
'_build_active_services',
'_service_can_be_canceled',
]

View File

@@ -0,0 +1,408 @@
from __future__ import annotations
import logging
import re
from typing import Any
from agente_contas_tim.integrations.rct_policy import RCTOperation
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
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 (
_final_msisdn,
_first_text_from_params_or_state,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
from agente_contas_tim.workflows.actions.vas_estrategico.helpers import (
_build_vas_accept_message,
_build_vas_bundle_close_message,
_build_vas_initial_message,
_build_vas_lines_from_items,
_emit_veb_005_cancelamento_info,
_format_protocolo_suffix_vas,
_is_vas_cancelamento_info_path,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("montar_resposta_texto")
def montar_resposta_text(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Monta resposta de texto para o cliente (sem SMS)."""
dados = params.get("dados", {})
texto = str(dados.get("texto_usuario", ""))
return ActionResult.ok({
"success": True,
"mensagem": texto,
})
@workflow_action("preparar_vas_estrategico")
def preparar_vas_estrategico(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
lines = params.get("linhas", [])
if not isinstance(lines, list) or not lines:
items = params.get("items", [])
if not isinstance(items, list):
items = []
lines = _build_vas_lines_from_items(
[item for item in items if isinstance(item, dict)]
)
normalized_lines = []
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
if not msisdn:
continue
raw_items = line.get("items", [])
line_items = (
[item for item in raw_items if isinstance(item, dict)]
if isinstance(raw_items, list)
else []
)
if not line_items:
line_items = []
for bundle_name in line.get("bundle_names", []):
name = str(bundle_name).strip()
if name:
line_items.append(
{"type": "bundle", "msisdn": msisdn, "name": name}
)
for estrategico_name in line.get("estrategico_names", []):
name = str(estrategico_name).strip()
if name:
line_items.append(
{"type": "estrategico", "msisdn": msisdn, "name": name}
)
rebuilt = _build_vas_lines_from_items(line_items)
if rebuilt:
normalized_lines.extend(rebuilt)
if not normalized_lines:
return ActionResult.fail(
"Nenhum item valido para o fluxo de VAS estrategico."
)
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
source_node = str(params.get("source_node", "resolve")).strip() or "resolve"
payload = vars_state.get(source_node, {})
if not isinstance(payload, dict):
payload = {}
instrucoes = str(payload.get("content", "")).strip()
has_estrategico_items = any(
bool(
isinstance(line, dict)
and isinstance(line.get("estrategico_names", []), list)
and any(str(name).strip() for name in line.get("estrategico_names", []))
)
for line in normalized_lines
)
has_bundle_items = any(
bool(
isinstance(line, dict)
and isinstance(line.get("bundle_names", []), list)
and any(str(name).strip() for name in line.get("bundle_names", []))
)
for line in normalized_lines
)
return ActionResult.ok(
{
"linhas": normalized_lines,
"instrucoes": instrucoes,
"mensagem": _build_vas_initial_message(normalized_lines),
"mensagem_pos_aceite": _build_vas_accept_message(normalized_lines),
"mensagem_bundle_fechamento": _build_vas_bundle_close_message(
normalized_lines
),
# Bundle também pausa em "sanei sua dúvida?": o agente não cancela,
# só explica que é incluso. A finalização ocorre DEPOIS da resposta
# do cliente, não no turno da explicação. (incidente CY0010)
"await_user_input": has_estrategico_items or has_bundle_items,
"has_estrategico_items": has_estrategico_items,
"has_bundle_items": has_bundle_items,
}
)
@workflow_action("montar_explicacao_cancelamento_vas_estrategico")
def montar_explicacao_cancelamento_vas_estrategico(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
lines = params.get("linhas", [])
orientacoes = params.get("orientacoes_cancelamento", [])
orientacoes_map: dict[tuple[str, str], str] = {}
if isinstance(orientacoes, list):
for item in orientacoes:
if not isinstance(item, dict):
continue
msisdn = str(item.get("msisdn", "")).strip()
name = str(item.get("name", "")).strip().lower()
orientacao = str(item.get("orientacao", "")).strip()
if msisdn and name and orientacao:
orientacoes_map[(msisdn, name)] = orientacao
context_lines: list[str] = []
fallback_sections: list[str] = []
if isinstance(lines, list):
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
if not msisdn:
continue
final_line = _final_msisdn(msisdn)
estrategico_names = [
str(name).strip()
for name in line.get("estrategico_names", [])
if str(name).strip()
]
for service_name in estrategico_names:
orientacao = orientacoes_map.get(
(msisdn, service_name.lower()),
(
"O cancelamento deve ser feito no app ou site oficial "
"do parceiro que fornece o serviço."
),
)
raw = str(orientacao).replace("\\n", "\n")
parts = [
re.sub(r"\s+", " ", p).strip()
for p in raw.split("\n---\n")
]
cleaned_orientation = " || ".join(p for p in parts if p)
context_lines.append(
f"Serviço: {service_name} | Linha final: {final_line} | "
f"Orientação bruta: {cleaned_orientation}"
)
fallback_sections.append(
f"Para cancelar {service_name} na linha final {final_line}: "
"acesse o app ou site oficial do parceiro e solicite "
"o cancelamento da assinatura."
)
if not context_lines:
fallback_sections.append(
"Não há serviços estratégicos com procedimento adicional de "
"cancelamento para este atendimento."
)
input_state = state.get("input", {}) if isinstance(state, dict) else {}
next_subject = str(
(input_state.get("next_subject") if isinstance(input_state, dict) else "")
or params.get("next_subject", "")
or ""
).strip()
if runtime.llm_gateway is not None and context_lines:
try:
cancelamento_context = "\n".join(context_lines)
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_vas_estrategico_cancelamento_resumo",
variables={
"cancelamento_context": cancelamento_context,
"next_subject": next_subject,
},
user_text=cancelamento_context,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
llm_message = str(llm_result.content or "").strip()
if llm_message:
return ActionResult.ok({"mensagem": llm_message})
except Exception:
pass
fallback_msg = "\n".join(fallback_sections[:4])
if next_subject:
transition = f" Podemos seguir agora com o tratamento de {next_subject}?"
if transition.strip().lower() not in fallback_msg.lower():
fallback_msg = f"{fallback_msg.rstrip()}{transition}"
return ActionResult.ok({"mensagem": fallback_msg})
@workflow_action("registrar_atendimento_vas_estrategico")
def registrar_atendimento_vas_estrategico(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Registra protocolo por linha para atendimento de VAS estratégico."""
lines = params.get("linhas", [])
if not isinstance(lines, list):
lines = []
mensagem_base = str(params.get("mensagem_base", "")).strip()
input_state = state.get("input", {}) if isinstance(state, dict) else {}
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
open_triplet = resolve_protocol_triplet("vas_estrategico", stage="open")
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
should_emit_veb_005 = _is_vas_cancelamento_info_path(
state,
mensagem_base=mensagem_base,
)
protocolos_por_linha: list[dict[str, Any]] = []
erros: list[dict[str, Any]] = []
_ic_emitted = False
_veb_005_emitted = False
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
if not msisdn:
continue
bundle_names = line.get("bundle_names", [])
estrategico_names = line.get("estrategico_names", [])
if not isinstance(bundle_names, list):
bundle_names = []
if not isinstance(estrategico_names, list):
estrategico_names = []
reason3 = open_triplet.reason3 or "Valor"
_ic_base: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"gsm": str(msisdn),
"ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(),
"uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(),
"agentId": "contas",
"channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(),
}
if should_emit_veb_005 and not _veb_005_emitted:
_emit_veb_005_cancelamento_info(
input_state=input_state if isinstance(input_state, dict) else {},
msisdn=msisdn,
mensagem_base=mensagem_base,
)
_veb_005_emitted = True
register_result = runtime.factory.create_protocol_v2(
msisdn=msisdn,
interaction_protocol="",
social_sec_no=social_sec_no,
source="CHAT",
reason1=open_triplet.reason1 or "Informação",
reason2=open_triplet.reason2 or "Conta",
reason3=reason3,
direction_contact="FROM-CLIENT",
type="CLIENTE",
request_flag=True,
request_status=request_status,
status=status,
service_request_notes="Solicitação via chat",
client_id="AIAGENTCR",
ic_context=_ic_base,
rct_operation=RCTOperation.REG_ATEND_VAS_ESTRAT,
message_id=message_id,
).execute()
if register_result.success:
_ic_emitted = True
if _result_failed_or_missing_data(register_result, state=state):
erros.append(
{
"msisdn": msisdn,
"stage": "register_protocol",
"erro": register_result.error or "Falha ao registrar protocolo",
}
)
continue
payload = _to_dict(register_result.data)
response = payload.get("response", {}) if isinstance(payload, dict) else {}
protocolo_id = ""
if isinstance(response, dict):
protocolo_id = str(
response.get("interactionProtocol")
or response.get("protocolId")
or response.get("protocolo_id")
or ""
).strip()
if not protocolo_id:
erros.append(
{
"msisdn": msisdn,
"stage": "register_protocol",
"erro": "Protocolo não retornado pelo serviço",
}
)
continue
protocolos_por_linha.append(
{
"msisdn": msisdn,
"protocolo_id": protocolo_id,
}
)
suffix = _format_protocolo_suffix_vas(protocolos_por_linha)
if not mensagem_base:
mensagem_final = "Manteremos o serviço."
if suffix:
mensagem_final = f"{mensagem_final} {suffix}"
else:
mensagem_final = (
f"{mensagem_base} {suffix}".strip() if suffix else mensagem_base
)
protocolos_raw = [
str(p.get("protocolo_id", "")).strip()
for p in protocolos_por_linha
if isinstance(p, dict) and str(p.get("protocolo_id", "")).strip()
]
output: dict[str, Any] = {
"success": len(protocolos_por_linha) > 0,
"mensagem": mensagem_final,
"protocolos_por_linha": protocolos_por_linha,
"erros": erros,
"protocol_closed": len(protocolos_por_linha) > 0,
"mocked": False,
}
if protocolos_raw:
output["requires_protocol_in_response"] = True
output["protocols_for_response"] = protocolos_raw
return ActionResult.ok(output)
__all__ = [
'montar_resposta_text',
'preparar_vas_estrategico',
'montar_explicacao_cancelamento_vas_estrategico',
'registrar_atendimento_vas_estrategico',
]

View File

@@ -0,0 +1,243 @@
from __future__ import annotations
import logging
from datetime import (
datetime,
timezone,
)
from typing import Any
from agente_contas_tim.constants.ic_tags_enum import VEBTag
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.text_utils import vocalize_digits
from agente_contas_tim.workflows.actions.common.helpers import (
_final_msisdn,
_format_list_pt_br,
)
logger = logging.getLogger(__name__)
def _build_vas_lines_from_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
grouped: dict[str, dict[str, Any]] = {}
for item in items:
if not isinstance(item, dict):
continue
msisdn = str(item.get("msisdn", "")).strip()
item_type = str(item.get("type", "")).strip().lower()
name = str(item.get("name", "")).strip()
if not msisdn or not name or item_type not in {"bundle", "estrategico"}:
continue
current = grouped.setdefault(
msisdn,
{
"msisdn": msisdn,
"items": [],
"bundle_names": [],
"estrategico_names": [],
},
)
current["items"].append(
{"type": item_type, "msisdn": msisdn, "name": name}
)
if item_type == "bundle":
current["bundle_names"].append(name)
else:
current["estrategico_names"].append(name)
return list(grouped.values())
def _build_vas_initial_message(
lines: list[dict[str, Any]],
) -> str:
sections: list[str] = []
estrategicos_mencionados: list[str] = []
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
final_line = _final_msisdn(msisdn)
bundle_names = [
str(name).strip()
for name in line.get("bundle_names", [])
if str(name).strip()
]
estrategico_names = [
str(name).strip()
for name in line.get("estrategico_names", [])
if str(name).strip()
]
if bundle_names:
services_txt = _format_list_pt_br(bundle_names)
if len(bundle_names) == 1:
bundle_txt = (
f"Na linha final {final_line}, o serviço {services_txt} "
"está incluído no plano e não gera cobrança adicional, além disso, não pode ser cancelado."
)
else:
bundle_txt = (
f"Na linha final {final_line}, os serviços {services_txt} "
"estão incluídos no plano e não geram cobrança adicional, além disso, não podem ser cancelados."
)
# Linha só com bundle (sem estratégico): a explicação precisa convidar
# a resposta do cliente, igual ao estratégico, para o workflow pausar
# em "sanei sua dúvida?" e NÃO finalizar/registrar protocolo no mesmo
# turno. Em linha mista, a pergunta já vem na seção estratégica.
if not estrategico_names:
bundle_txt += " Com essa explicação, sanei sua dúvida?"
sections.append(bundle_txt)
if estrategico_names:
services_txt = _format_list_pt_br(estrategico_names)
estrategicos_mencionados.extend(estrategico_names)
if len(estrategico_names) == 1:
sections.append(
f"O {services_txt} só é ativado depois que você confirma a contratação. "
"No momento da contratação, é enviado um código de verificação para a sua linha, e a "
"ativação acontece somente depois dessa confirmação. Como essa "
"validação foi feita com sucesso, a cobrança é considerada válida e, por isso, "
"não conseguimos retirar ou ressarcir esse valor da fatura. Com essa explicação, sanei sua dúvida?"
)
else:
sections.append(
f"Os {services_txt} só são ativados depois que você confirma a contratação. "
"No momento da contratação, é enviado um código de verificação para a sua linha, e a "
"ativação acontece somente depois dessa confirmação. Como essa "
"validação foi feita com sucesso, a cobrança é considerada válida e, por isso, "
"não conseguimos retirar ou ressarcir esse valor da fatura. Com essa explicação, sanei sua dúvida?"
)
if not sections:
return (
"Não encontrei itens válidos para a tratativa de VAS estratégico. "
"Poderia revisar os dados informados?"
)
return " ".join(sections)
def _build_vas_accept_message(_lines: list[dict[str, Any]]) -> str:
# Após o cliente aceitar manter o serviço, o fluxo apenas registra
# o atendimento e devolve o controle ao agente roteador sem
# mensagem adicional ao cliente.
return ""
def _build_vas_bundle_close_message(lines: list[dict[str, Any]]) -> str:
"""Mensagem de fechamento do fluxo de bundle, usada DEPOIS que o cliente
responde ao "sanei sua dúvida?". Reafirma de forma breve que o serviço é
incluso no plano e não cancelável, sem repetir a explicação completa nem a
pergunta (que já foram ditas na pausa). O sufixo de protocolo e o
encerramento são anexados por ``registrar_atendimento_vas_estrategico``."""
names: list[str] = []
for line in lines:
if not isinstance(line, dict):
continue
names.extend(
str(name).strip()
for name in line.get("bundle_names", [])
if str(name).strip()
)
if not names:
return ""
services_txt = _format_list_pt_br(names)
if len(names) == 1:
return (
f"Como expliquei, o serviço {services_txt} é incluso no seu plano e "
"não há cobrança a remover."
)
return (
f"Como expliquei, os serviços {services_txt} são inclusos no seu plano e "
"não há cobrança a remover."
)
def _format_protocolo_suffix_vas(
protocolos_por_linha: list[dict[str, Any]],
) -> str:
"""Formata o sufixo 'Seu número de protocolo é X.' para anexar à mensagem.
O número é vocalizado (dígitos como palavra, letras isoladas) para que
o TTS leia cada caractere individualmente.
"""
protocolos = [
str(p.get("protocolo_id", "")).strip()
for p in protocolos_por_linha
if isinstance(p, dict) and str(p.get("protocolo_id", "")).strip()
]
if not protocolos:
return ""
spoken = [vocalize_digits(p) for p in protocolos]
if len(spoken) == 1:
return f"Seu número de protocolo é {spoken[0]}."
return f"Seus números de protocolo são {_format_list_pt_br(spoken)}."
def _is_vas_cancelamento_info_path(
state: dict[str, Any],
*,
mensagem_base: str,
) -> bool:
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
if not isinstance(vars_state, dict):
return False
explicar_payload = vars_state.get("explicar_cancelamento", {})
if not isinstance(explicar_payload, dict):
return False
return (
bool(mensagem_base)
and str(explicar_payload.get("mensagem", "") or "").strip()
== mensagem_base
)
def _emit_veb_005_cancelamento_info(
*,
input_state: dict[str, Any],
msisdn: str,
mensagem_base: str,
) -> None:
try:
metadata: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"tag": VEBTag.INFO_CANCELAMENTO,
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
"gsm": str(msisdn or "").strip(),
"agentId": "contas",
"channelId": str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip()
or "URA",
"llmResponse": mensagem_base,
}
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
customer_message = str(
input_state.get("customer_message")
or input_state.get("customerMessage")
or ""
).strip()
if customer_message:
metadata["customerMessage"] = customer_message
agent_framework_bridge.event(VEBTag.INFO_CANCELAMENTO, metadata=metadata)
except Exception:
logger.debug("registrar_atendimento_vas_estrategico: falha ao emitir VEB.005", exc_info=True)
__all__ = [
'_build_vas_lines_from_items',
'_build_vas_initial_message',
'_build_vas_accept_message',
'_format_protocolo_suffix_vas',
'_is_vas_cancelamento_info_path',
'_emit_veb_005_cancelamento_info',
]