mirror of
https://github.com/hoshikawa2/first_contas.git
synced 2026-07-09 18:24:20 +00:00
first commit
This commit is contained in:
BIN
legacy_reference/.DS_Store
vendored
Normal file
BIN
legacy_reference/.DS_Store
vendored
Normal file
Binary file not shown.
1581
legacy_reference/docs/conversation_spec.md
Normal file
1581
legacy_reference/docs/conversation_spec.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
legacy_reference/workflows/.DS_Store
vendored
Normal file
BIN
legacy_reference/workflows/.DS_Store
vendored
Normal file
Binary file not shown.
3
legacy_reference/workflows/__init__.py
Normal file
3
legacy_reference/workflows/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from agente_contas_tim.workflows.service import WorkflowService
|
||||
|
||||
__all__ = ["WorkflowService"]
|
||||
15
legacy_reference/workflows/actions/__init__.py
Normal file
15
legacy_reference/workflows/actions/__init__.py
Normal 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",
|
||||
]
|
||||
304
legacy_reference/workflows/actions/common/actions.py
Normal file
304
legacy_reference/workflows/actions/common/actions.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from typing import Any
|
||||
from agente_contas_tim.constants.ic_tags_enum import SADTag, TMDTag
|
||||
from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_emit_ic,
|
||||
_normalize_bool,
|
||||
_result_failed_or_missing_data,
|
||||
_runtime_llm_callbacks,
|
||||
_runtime_llm_metadata,
|
||||
_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("finalizar_atendimento_action")
|
||||
def finalizar_atendimento_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
_emit_ic(SADTag.FINALIZACAO, input_state)
|
||||
status = str(params.get("status", "")).strip()
|
||||
summary = str(params.get("summary", "")).strip()
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": True,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("formatar_capability_resposta")
|
||||
def formatar_capability_resposta(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
source_node = str(params.get("source_node", "resolve"))
|
||||
payload = vars_state.get(source_node, {})
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
msisdn = str(params.get("msisdn", input_state.get("msisdn", "")))
|
||||
servico = str(params.get("servico", input_state.get("servico", "")))
|
||||
nome_plano = str(params.get("nome_plano", input_state.get("nome_plano", "")))
|
||||
tipo = str(params.get("tipo", "")).strip().lower()
|
||||
|
||||
instrucoes = str(payload.get("content", "")).strip()
|
||||
mensagem = ""
|
||||
|
||||
if tipo == "vas_estrategico":
|
||||
mensagem = (
|
||||
f"O serviço {servico} já está incluso no seu plano "
|
||||
f"na linha de final {msisdn[-2:]} e não gera "
|
||||
"cobrança adicional na fatura."
|
||||
)
|
||||
elif tipo == "valor_divergente":
|
||||
mensagem = (
|
||||
f"Identificamos uma alteração no valor do plano "
|
||||
f"na linha de final {msisdn[-2:]}. Vou verificar "
|
||||
"os detalhes para você."
|
||||
)
|
||||
elif tipo == "pro_rata":
|
||||
mensagem = (
|
||||
f"A fatura da linha de final {msisdn[-2:]} possui uma "
|
||||
"cobrança proporcional (pro rata) porque houve mudança "
|
||||
"de plano durante o ciclo de faturamento. Na próxima "
|
||||
"fatura o valor volta ao normal do novo plano."
|
||||
)
|
||||
elif tipo == "termino_desconto":
|
||||
_emit_ic(TMDTag.CHEGADA_FLUXO, input_state)
|
||||
plan_info = f" do plano {nome_plano}" if nome_plano else ""
|
||||
mensagem = (
|
||||
f"O desconto de fidelidade{plan_info} vinculado à "
|
||||
f"linha de final {msisdn[-2:]} chegou ao fim. "
|
||||
"O período promocional contratado expirou e o valor "
|
||||
"do plano volta ao preço original."
|
||||
)
|
||||
_emit_ic(TMDTag.FIM_FLUXO, input_state)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": True,
|
||||
"instrucoes": instrucoes,
|
||||
"mensagem": mensagem,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
if servico:
|
||||
response["servico"] = servico
|
||||
if nome_plano:
|
||||
response["nome_plano"] = nome_plano
|
||||
return ActionResult.ok(response)
|
||||
|
||||
|
||||
@workflow_action("combinar_divergencia")
|
||||
def combinar_divergencia(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
divergence = vars_state.get("consultar_divergencia", {})
|
||||
explanation = vars_state.get("invoice_explanation", {})
|
||||
|
||||
if not isinstance(divergence, dict):
|
||||
divergence = {}
|
||||
if not isinstance(explanation, dict):
|
||||
explanation = {}
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
msisdn = str(input_state.get("msisdn", ""))
|
||||
|
||||
resumo = str(
|
||||
divergence.get("resumo")
|
||||
or divergence.get("mensagem")
|
||||
or ""
|
||||
).strip()
|
||||
detalhes = divergence.get("detalhes", {})
|
||||
explicacao = str(explanation.get("mensagem", "")).strip()
|
||||
|
||||
success = bool(resumo or explicacao)
|
||||
mensagem = resumo or explicacao
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": success,
|
||||
"resumo": resumo,
|
||||
"detalhes": detalhes if isinstance(detalhes, dict) else {},
|
||||
"mensagem": mensagem,
|
||||
"explicacao": explicacao,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("consultar_divergencia")
|
||||
def query_divergence(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Consulta resumo de divergência da fatura."""
|
||||
result = runtime.factory.create_divergence(
|
||||
msisdn=str(params["msisdn"]),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha na consulta de divergencia",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
@workflow_action("resolve_capability")
|
||||
def resolve_capability_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Resolve uma capability via LLM Gateway (sem invocar LLM)."""
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.ok({"content": "", "source": "unavailable"})
|
||||
|
||||
capability_id = str(params.get("capability_id", "")).strip()
|
||||
if not capability_id:
|
||||
return ActionResult.fail(
|
||||
"capability_id obrigatorio para resolve_capability",
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = runtime.llm_gateway.resolve_capability(
|
||||
capability_id=capability_id,
|
||||
)
|
||||
return ActionResult.ok({
|
||||
"capability_id": resolved.capability_id,
|
||||
"prompt_id": resolved.prompt_id,
|
||||
"content": resolved.content,
|
||||
"source": resolved.source,
|
||||
"version": resolved.version,
|
||||
})
|
||||
except Exception as exc:
|
||||
return ActionResult.ok(
|
||||
{"content": "", "source": "fallback", "error": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("no_op")
|
||||
def no_op(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
return ActionResult.ok({})
|
||||
|
||||
|
||||
@workflow_action("llm_capability")
|
||||
def llm_capability(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.fail("LLM gateway nao configurado")
|
||||
|
||||
capability_id = str(params.get("capability_id", "")).strip()
|
||||
if not capability_id:
|
||||
return ActionResult.fail("capability_id obrigatorio para llm_capability")
|
||||
|
||||
variables = params.get("variables", {})
|
||||
if not isinstance(variables, dict):
|
||||
return ActionResult.fail("variables deve ser um objeto")
|
||||
|
||||
user_text_raw = params.get("user_text")
|
||||
user_text = None if user_text_raw is None else str(user_text_raw)
|
||||
|
||||
result = runtime.llm_gateway.execute(
|
||||
capability_id=capability_id,
|
||||
variables=variables,
|
||||
user_text=user_text,
|
||||
callbacks=_runtime_llm_callbacks(runtime),
|
||||
tags=["workflow_action"],
|
||||
metadata=_runtime_llm_metadata(runtime),
|
||||
)
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"capability_id": result.capability_id,
|
||||
"prompt_id": result.prompt_id,
|
||||
"content": result.content,
|
||||
"source": result.source,
|
||||
"version": result.version,
|
||||
"metadata": dict(result.metadata),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("buscar_fatura")
|
||||
def buscar_fatura(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Busca fatura (PDF) do cliente ou dados interpretados."""
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
tentativa = int(params.get("tentativa_anterior") or 0) + 1
|
||||
|
||||
result = runtime.factory.create_bill_pdf(
|
||||
invoice_id=str(params["invoice_id"]),
|
||||
msisdn=str(params["msisdn"]),
|
||||
customer_id=str(params["customer_id"]),
|
||||
output=str(params.get("output", "")),
|
||||
include_danfe=_normalize_bool(params.get("include_danfe", False)),
|
||||
).execute()
|
||||
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=False):
|
||||
_emit_ic(tag, input_state)
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha ao buscar fatura",
|
||||
**result.metadata,
|
||||
)
|
||||
|
||||
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=True):
|
||||
_emit_ic(tag, input_state)
|
||||
|
||||
payload = _to_dict(result.data)
|
||||
if isinstance(payload, dict):
|
||||
file_content = payload.get("file_content")
|
||||
if isinstance(file_content, (bytes, bytearray)):
|
||||
payload["file_content_b64"] = base64.b64encode(
|
||||
bytes(file_content)
|
||||
).decode("ascii")
|
||||
payload["file_content"] = None
|
||||
|
||||
return ActionResult.ok(payload, **result.metadata)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'finalizar_atendimento_action',
|
||||
'formatar_capability_resposta',
|
||||
'combinar_divergencia',
|
||||
'query_divergence',
|
||||
'resolve_capability_action',
|
||||
'no_op',
|
||||
'llm_capability',
|
||||
'buscar_fatura',
|
||||
]
|
||||
574
legacy_reference/workflows/actions/common/helpers.py
Normal file
574
legacy_reference/workflows/actions/common/helpers.py
Normal file
@@ -0,0 +1,574 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import unicodedata as ud
|
||||
|
||||
from dataclasses import (
|
||||
asdict,
|
||||
is_dataclass,
|
||||
)
|
||||
from datetime import (
|
||||
datetime,
|
||||
timezone,
|
||||
)
|
||||
from decimal import (
|
||||
Decimal,
|
||||
InvalidOperation,
|
||||
)
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
from agente_contas_tim.integrations import agent_framework_bridge
|
||||
from agente_contas_tim.integrations.noc_events import emit_api_content_mismatch
|
||||
from agente_contas_tim.models.service_info import ServiceInfo
|
||||
from agente_contas_tim.observability import get_session_id
|
||||
|
||||
_IDEMPOTENCY_TTL_SECONDS = int(os.getenv("TIM_IDEMPOTENCY_TTL_SECONDS", "3600"))
|
||||
_IDEMPOTENCY_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _emit_vaa(
|
||||
tag: str,
|
||||
ic_base: dict[str, Any],
|
||||
*,
|
||||
gsm: str = "",
|
||||
extra_metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emite um IC do fluxo VAS Avulso (VAA.001–VAA.017)."""
|
||||
try:
|
||||
metadata = {
|
||||
**ic_base,
|
||||
"tag": tag,
|
||||
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||||
}
|
||||
if isinstance(extra_metadata, dict):
|
||||
metadata.update(extra_metadata)
|
||||
if gsm:
|
||||
metadata["gsm"] = gsm
|
||||
agent_framework_bridge.event(tag, metadata=metadata)
|
||||
except Exception:
|
||||
logger.warning("_emit_vaa tag=%s falhou silenciosamente", tag)
|
||||
|
||||
|
||||
def _build_ic_context(
|
||||
input_state: dict[str, Any] | None,
|
||||
*,
|
||||
gsm: str = "",
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
return {
|
||||
"sessionId": str(get_session_id() or ""),
|
||||
"gsm": str(gsm or "").strip(),
|
||||
"ani": str(input_state.get("ani", "") or "").strip(),
|
||||
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
|
||||
"agentId": "contas",
|
||||
"channelId": str(
|
||||
input_state.get("channel_id")
|
||||
or input_state.get("channelId")
|
||||
or "URA"
|
||||
).strip()
|
||||
or "URA",
|
||||
}
|
||||
|
||||
|
||||
def _emit_ic(
|
||||
ic_code: str,
|
||||
input_state: dict[str, Any],
|
||||
*,
|
||||
extra_metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emite um IC via agent_framework_bridge com metadados padrao."""
|
||||
try:
|
||||
metadata: dict[str, Any] = {
|
||||
"sessionId": str(get_session_id() or ""),
|
||||
"tag": ic_code,
|
||||
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||||
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
|
||||
"gsm": str(input_state.get("msisdn", "") or "").strip(),
|
||||
"agentId": "contas",
|
||||
"channelId": str(
|
||||
input_state.get("channel_id")
|
||||
or input_state.get("channelId")
|
||||
or "URA"
|
||||
).strip()
|
||||
or "URA",
|
||||
}
|
||||
ani = str(input_state.get("ani", "") or "").strip()
|
||||
if ani:
|
||||
metadata["ani"] = ani
|
||||
message_id = str(
|
||||
input_state.get("message_id") or input_state.get("messageId") or ""
|
||||
).strip()
|
||||
if message_id:
|
||||
metadata["messageId"] = message_id
|
||||
if isinstance(extra_metadata, dict):
|
||||
metadata.update(extra_metadata)
|
||||
agent_framework_bridge.event(ic_code, metadata=metadata)
|
||||
except Exception:
|
||||
logger.debug("_emit_ic: falha ao emitir %s", ic_code, exc_info=True)
|
||||
|
||||
|
||||
def _idempotency_get(key: str) -> dict[str, Any] | None:
|
||||
if not key:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
cached = _IDEMPOTENCY_CACHE.get(key)
|
||||
if cached is None:
|
||||
return None
|
||||
created_at, value = cached
|
||||
if now - created_at > _IDEMPOTENCY_TTL_SECONDS:
|
||||
_IDEMPOTENCY_CACHE.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _idempotency_set(key: str, value: dict[str, Any]) -> None:
|
||||
if not key:
|
||||
return
|
||||
_IDEMPOTENCY_CACHE[key] = (time.monotonic(), value)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _to_epoch_millis(value: Any) -> int:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return int(value.timestamp() * 1000)
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
if re.fullmatch(r"\d+(?:\.\d+)?", raw):
|
||||
number = float(raw)
|
||||
if number > 100_000_000_000:
|
||||
return int(number)
|
||||
return int(number * 1000)
|
||||
iso_raw = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
||||
try:
|
||||
parsed = datetime.fromisoformat(iso_raw)
|
||||
except ValueError:
|
||||
return int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return int(parsed.timestamp() * 1000)
|
||||
|
||||
|
||||
def _to_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value or "").strip().lower()
|
||||
return text in {"1", "true", "t", "sim", "yes", "y"}
|
||||
|
||||
|
||||
def _to_dict(data: Any) -> Any:
|
||||
if isinstance(data, BaseModel):
|
||||
return data.model_dump(by_alias=True)
|
||||
if is_dataclass(data):
|
||||
return _to_dict(asdict(data))
|
||||
if isinstance(data, tuple):
|
||||
return [_to_dict(item) for item in data]
|
||||
if isinstance(data, list):
|
||||
return [_to_dict(item) for item in data]
|
||||
if isinstance(data, dict):
|
||||
return {key: _to_dict(value) for key, value in data.items()}
|
||||
return data
|
||||
|
||||
|
||||
def _result_failed_or_missing_data(result: Any, *, state: dict[str, Any]) -> bool:
|
||||
if not getattr(result, "success", False):
|
||||
return True
|
||||
if getattr(result, "data", None) is not None:
|
||||
return False
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
emit_api_content_mismatch(
|
||||
values={
|
||||
"msisdn": str(input_state.get("msisdn", "") or "").strip(),
|
||||
"invoice_id": str(
|
||||
input_state.get("invoice_id")
|
||||
or input_state.get("current_invoice_number")
|
||||
or ""
|
||||
).strip(),
|
||||
"channel_id": str(input_state.get("channel_id", "") or "").strip(),
|
||||
"ura_call_id": str(input_state.get("ura_call_id", "") or "").strip(),
|
||||
},
|
||||
reason="workflow_action_command_success_without_data",
|
||||
command=str(getattr(result, "metadata", {}).get("command", "") or ""),
|
||||
api_response_payload=getattr(result, "metadata", {}),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _runtime_llm_callbacks(runtime: WorkflowRuntimeContext) -> list[Any] | None:
|
||||
callbacks = list(runtime.llm_callbacks)
|
||||
return callbacks or None
|
||||
|
||||
|
||||
def _runtime_llm_metadata(runtime: WorkflowRuntimeContext) -> dict[str, Any]:
|
||||
return dict(runtime.llm_metadata)
|
||||
|
||||
|
||||
def _service_from_dict(service: dict[str, Any] | None) -> ServiceInfo | None:
|
||||
if service is None:
|
||||
return None
|
||||
return ServiceInfo(
|
||||
msisdn=str(service.get("msisdn", "")),
|
||||
app_id=str(service.get("app_id", "")),
|
||||
csp_id=str(service.get("csp_id", "")),
|
||||
ippid=str(service.get("ippid", "")),
|
||||
service_name=str(service.get("service_name", "")),
|
||||
status=str(service.get("status", "")),
|
||||
extra=dict(service.get("extra", {})),
|
||||
)
|
||||
|
||||
|
||||
def _extract_amount(extra: dict[str, Any] | None) -> str:
|
||||
if not isinstance(extra, dict):
|
||||
return ""
|
||||
details = extra.get("details", {})
|
||||
if not isinstance(details, dict):
|
||||
details = {}
|
||||
return str(
|
||||
extra.get("valor")
|
||||
or extra.get("price")
|
||||
or details.get("valor")
|
||||
or details.get("price")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _extract_boleto_code(extra: dict[str, Any] | None) -> str:
|
||||
if not isinstance(extra, dict):
|
||||
return ""
|
||||
return str(
|
||||
extra.get("codigo_boleto")
|
||||
or extra.get("boleto_code")
|
||||
or extra.get("linha_digitavel")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _find_matching_service(
|
||||
requested_name: str,
|
||||
active_services: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
lowered_name = requested_name.strip().lower()
|
||||
exact = active_services.get(lowered_name)
|
||||
if exact:
|
||||
return exact
|
||||
normalized_requested = _normalize_service_name(lowered_name)
|
||||
for key, value in active_services.items():
|
||||
if _normalize_service_name(key) == normalized_requested:
|
||||
return value
|
||||
return next(
|
||||
(
|
||||
value
|
||||
for key, value in active_services.items()
|
||||
if (
|
||||
lowered_name in key
|
||||
or key in lowered_name
|
||||
or normalized_requested in _normalize_service_name(key)
|
||||
or _normalize_service_name(key) in normalized_requested
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_service_name(value: str) -> str:
|
||||
text = ud.normalize("NFKD", str(value or ""))
|
||||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||||
text = re.sub(r"[^a-zA-Z0-9]+", " ", text).strip().lower()
|
||||
return re.sub(r"\s+", " ", text)
|
||||
|
||||
|
||||
def _resolve_service_csp_id(
|
||||
matched_service: dict[str, Any],
|
||||
*,
|
||||
fallback_csp_id: str,
|
||||
) -> str:
|
||||
if not isinstance(matched_service, dict):
|
||||
return fallback_csp_id
|
||||
context = matched_service.get("service_context")
|
||||
if isinstance(context, dict):
|
||||
csp_id = str(
|
||||
context.get("csp_id")
|
||||
or context.get("cspId")
|
||||
or (
|
||||
context.get("csp", {}).get("id")
|
||||
if isinstance(context.get("csp"), dict)
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if csp_id:
|
||||
return csp_id
|
||||
return fallback_csp_id
|
||||
|
||||
|
||||
def _normalize_amount(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = (
|
||||
text.replace("R$", "")
|
||||
.replace("$", "")
|
||||
.replace("\u00a0", "")
|
||||
.strip()
|
||||
)
|
||||
text = text.replace("−", "-").replace("–", "-")
|
||||
|
||||
has_neg = text.startswith("-")
|
||||
if has_neg:
|
||||
text = text[1:]
|
||||
else:
|
||||
text = text.lstrip("+")
|
||||
|
||||
text = re.sub(r"\s+", "", text)
|
||||
text = re.sub(r"[^0-9.,-]", "", text)
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
has_any = ("." in text) or ("," in text)
|
||||
if not has_any:
|
||||
return f"{'-' if has_neg else ''}{text}"
|
||||
|
||||
if "." in text and "," in text:
|
||||
last_dot = text.rfind(".")
|
||||
last_comma = text.rfind(",")
|
||||
if last_comma > last_dot:
|
||||
int_part = text[:last_comma].replace(".", "")
|
||||
frac_part = text[last_comma + 1 :]
|
||||
else:
|
||||
int_part = text[:last_dot].replace(",", "")
|
||||
frac_part = text[last_dot + 1 :]
|
||||
normalized = f"{'-' if has_neg else ''}{int_part}.{frac_part}"
|
||||
return normalized
|
||||
|
||||
sep = "." if "." in text else ","
|
||||
parts = text.split(sep)
|
||||
if len(parts) > 2:
|
||||
frac = parts[-1]
|
||||
if len(frac) <= 2:
|
||||
normalized = f"{'-' if has_neg else ''}{''.join(parts[:-1])}.{frac}"
|
||||
else:
|
||||
normalized = f"{'-' if has_neg else ''}{''.join(parts)}"
|
||||
return normalized
|
||||
|
||||
int_part, frac_part = parts[0], parts[1]
|
||||
if len(frac_part) <= 2:
|
||||
normalized = (
|
||||
f"{'-' if has_neg else ''}{int_part.replace(',', '').replace('.', '')}."
|
||||
f"{frac_part}"
|
||||
)
|
||||
else:
|
||||
normalized = f"{'-' if has_neg else ''}{int_part}{frac_part}"
|
||||
return normalized
|
||||
|
||||
|
||||
def _parse_amount(value: Any) -> Decimal | None:
|
||||
normalized = _normalize_amount(value)
|
||||
if not normalized:
|
||||
return None
|
||||
try:
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_amount(value: Decimal | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return f"{value:.2f}".replace(".", ",")
|
||||
|
||||
|
||||
def _build_sr_notes(
|
||||
*,
|
||||
sms_enviado: bool,
|
||||
codigo_boleto: str,
|
||||
data_credito_proxima_fatura: str,
|
||||
) -> str:
|
||||
if sms_enviado:
|
||||
return (
|
||||
f"SMS com código do boleto enviado. Código: {codigo_boleto}."
|
||||
if codigo_boleto
|
||||
else "SMS com código do boleto enviado."
|
||||
)
|
||||
if data_credito_proxima_fatura:
|
||||
return (
|
||||
"Crédito do valor do serviço cancelado registrado "
|
||||
f"na próxima fatura com vencimento em {data_credito_proxima_fatura}."
|
||||
)
|
||||
return "Crédito do valor do serviço cancelado registrado na próxima fatura."
|
||||
|
||||
|
||||
def _format_list_pt_br(values: list[str]) -> str:
|
||||
cleaned = [str(item).strip() for item in values if str(item).strip()]
|
||||
if not cleaned:
|
||||
return ""
|
||||
if len(cleaned) == 1:
|
||||
return cleaned[0]
|
||||
if len(cleaned) == 2:
|
||||
return f"{cleaned[0]} e {cleaned[1]}"
|
||||
return f"{', '.join(cleaned[:-1])} e {cleaned[-1]}"
|
||||
|
||||
|
||||
def _final_msisdn(msisdn: str) -> str:
|
||||
digits = re.sub(r"\D", "", msisdn)
|
||||
if len(digits) >= 4:
|
||||
return digits[-4:]
|
||||
return msisdn[-4:] if len(msisdn) >= 4 else msisdn
|
||||
|
||||
|
||||
def _first_text_from_params_or_state(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
*keys: str,
|
||||
) -> str:
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
sources: list[dict[str, Any]] = []
|
||||
if isinstance(params, dict):
|
||||
sources.append(params)
|
||||
if isinstance(input_state, dict):
|
||||
sources.append(input_state)
|
||||
|
||||
for source in sources:
|
||||
for key in keys:
|
||||
value = source.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _first_value_from_params_or_state(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
*keys: str,
|
||||
) -> Any:
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
sources: list[dict[str, Any]] = []
|
||||
if isinstance(params, dict):
|
||||
sources.append(params)
|
||||
if isinstance(input_state, dict):
|
||||
sources.append(input_state)
|
||||
|
||||
for source in sources:
|
||||
for key in keys:
|
||||
if key not in source:
|
||||
continue
|
||||
value = source.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if text:
|
||||
return text
|
||||
continue
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _contains_any_keyword(value: str, keywords: tuple[str, ...]) -> bool:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(keyword in normalized for keyword in keywords)
|
||||
|
||||
|
||||
def _normalize_number_text(value: Any, *, default: str = "0") -> str:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return default
|
||||
cleaned = text.replace("R$", "").replace(" ", "")
|
||||
if "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
normalized = format(Decimal(cleaned), "f")
|
||||
except (InvalidOperation, ValueError):
|
||||
return default
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
return normalized or default
|
||||
|
||||
|
||||
def _normalize_bool(value: Any, *, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return default
|
||||
if text in {"true", "1", "sim", "yes", "y"}:
|
||||
return True
|
||||
if text in {"false", "0", "nao", "não", "no", "n"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _map_customer_status_code(value: Any) -> str:
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
if text in {"0", "1", "2", "3"}:
|
||||
return text
|
||||
if "inativ" in text:
|
||||
return "0"
|
||||
if "ativ" in text:
|
||||
return "1"
|
||||
if "suspens" in text:
|
||||
return "2"
|
||||
if "bloque" in text:
|
||||
return "3"
|
||||
return ""
|
||||
|
||||
|
||||
def _text_from_keys(source: Any, keys: tuple[str, ...]) -> str:
|
||||
if not isinstance(source, dict):
|
||||
return ""
|
||||
return " ".join(str(source.get(key) or "") for key in keys).strip()
|
||||
|
||||
|
||||
__all__ = [
|
||||
'_emit_vaa',
|
||||
'_build_ic_context',
|
||||
'_emit_ic',
|
||||
'_idempotency_get',
|
||||
'_idempotency_set',
|
||||
'_utc_now_iso',
|
||||
'_to_epoch_millis',
|
||||
'_to_bool',
|
||||
'_to_dict',
|
||||
'_result_failed_or_missing_data',
|
||||
'_runtime_llm_callbacks',
|
||||
'_runtime_llm_metadata',
|
||||
'_service_from_dict',
|
||||
'_extract_amount',
|
||||
'_extract_boleto_code',
|
||||
'_find_matching_service',
|
||||
'_normalize_service_name',
|
||||
'_resolve_service_csp_id',
|
||||
'_parse_amount',
|
||||
'_format_amount',
|
||||
'_build_sr_notes',
|
||||
'_format_list_pt_br',
|
||||
'_final_msisdn',
|
||||
'_first_text_from_params_or_state',
|
||||
'_first_value_from_params_or_state',
|
||||
'_contains_any_keyword',
|
||||
'_normalize_number_text',
|
||||
'_normalize_bool',
|
||||
'_map_customer_status_code',
|
||||
'_text_from_keys',
|
||||
]
|
||||
2016
legacy_reference/workflows/actions/contestacao/actions.py
Normal file
2016
legacy_reference/workflows/actions/contestacao/actions.py
Normal file
File diff suppressed because it is too large
Load Diff
1207
legacy_reference/workflows/actions/contestacao/helpers.py
Normal file
1207
legacy_reference/workflows/actions/contestacao/helpers.py
Normal file
File diff suppressed because it is too large
Load Diff
28
legacy_reference/workflows/actions/discovery.py
Normal file
28
legacy_reference/workflows/actions/discovery.py
Normal 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)
|
||||
53
legacy_reference/workflows/actions/inputs.py
Normal file
53
legacy_reference/workflows/actions/inputs.py
Normal 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
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
726
legacy_reference/workflows/actions/pro_rata/actions.py
Normal file
726
legacy_reference/workflows/actions/pro_rata/actions.py
Normal 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',
|
||||
]
|
||||
793
legacy_reference/workflows/actions/pro_rata/helpers.py
Normal file
793
legacy_reference/workflows/actions/pro_rata/helpers.py
Normal 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',
|
||||
]
|
||||
0
legacy_reference/workflows/actions/rag/__init__.py
Normal file
0
legacy_reference/workflows/actions/rag/__init__.py
Normal file
218
legacy_reference/workflows/actions/rag/actions.py
Normal file
218
legacy_reference/workflows/actions/rag/actions.py
Normal 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',
|
||||
]
|
||||
81
legacy_reference/workflows/actions/rag/helpers.py
Normal file
81
legacy_reference/workflows/actions/rag/helpers.py
Normal 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',
|
||||
]
|
||||
83
legacy_reference/workflows/actions/registry.py
Normal file
83
legacy_reference/workflows/actions/registry.py
Normal 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
|
||||
72
legacy_reference/workflows/actions/tim_actions.py
Normal file
72
legacy_reference/workflows/actions/tim_actions.py
Normal 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
|
||||
851
legacy_reference/workflows/actions/vas_avulso/actions.py
Normal file
851
legacy_reference/workflows/actions/vas_avulso/actions.py
Normal 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',
|
||||
]
|
||||
78
legacy_reference/workflows/actions/vas_avulso/helpers.py
Normal file
78
legacy_reference/workflows/actions/vas_avulso/helpers.py
Normal 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',
|
||||
]
|
||||
408
legacy_reference/workflows/actions/vas_estrategico/actions.py
Normal file
408
legacy_reference/workflows/actions/vas_estrategico/actions.py
Normal 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',
|
||||
]
|
||||
243
legacy_reference/workflows/actions/vas_estrategico/helpers.py
Normal file
243
legacy_reference/workflows/actions/vas_estrategico/helpers.py
Normal 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',
|
||||
]
|
||||
880
legacy_reference/workflows/compiler.py
Normal file
880
legacy_reference/workflows/compiler.py
Normal file
@@ -0,0 +1,880 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from agente_contas_tim.observability import (
|
||||
emit_workflow_step,
|
||||
langfuse_context_has_active_span,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
ActionRegistry,
|
||||
WorkflowRuntimeContext,
|
||||
)
|
||||
from agente_contas_tim.workflows.conditions import (
|
||||
evaluate_condition,
|
||||
resolve_value,
|
||||
)
|
||||
from agente_contas_tim.workflows.contracts import (
|
||||
EdgeDef,
|
||||
NodeDef,
|
||||
PauseDef,
|
||||
WorkflowDef,
|
||||
)
|
||||
from agente_contas_tim.workflows.exceptions import (
|
||||
WorkflowConfigurationError,
|
||||
WorkflowInputError,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.templating import render_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FINISH_NODE = "__workflow_finish__"
|
||||
|
||||
_ACTION_LABELS: dict[str, str] = {
|
||||
"consulta_vas": "Consulta VAS na linha",
|
||||
"cancelar_vas_single": "Cancelamento do serviço",
|
||||
"consultar_perfil_fatura": "Consulta tipo de fatura",
|
||||
"check_invoice_status": "Check invoice status",
|
||||
"enviar_sms": "Envio de SMS com boleto",
|
||||
"atualizar_status_sr": "Atualização status SR",
|
||||
"consultar_divergencia": "Consulta divergência",
|
||||
"bloquear_vas": "Bloqueio de VAS",
|
||||
"bloquear_vas_single": "Bloqueio de VAS",
|
||||
"cancelar_vas": "Cancelamento de VAS",
|
||||
"avaliar_proxima_acao": "Avaliação próxima ação",
|
||||
"resolve_capability": "Resolução de capability",
|
||||
"preparar_vas_estrategico": "Preparacao VAS estrategico",
|
||||
"montar_explicacao_cancelamento_vas_estrategico": (
|
||||
"Explicacao cancelamento VAS estrategico"
|
||||
),
|
||||
"registrar_atendimento_vas_estrategico": (
|
||||
"Registro de atendimento VAS estrategico"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _import_langfuse_get_client() -> Any:
|
||||
from langfuse import get_client
|
||||
return get_client
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _import_langfuse_callback_handler() -> Any:
|
||||
from langfuse.langchain import CallbackHandler
|
||||
return CallbackHandler
|
||||
|
||||
|
||||
def _has_langfuse_credentials() -> bool:
|
||||
return bool(
|
||||
os.getenv("LANGFUSE_PUBLIC_KEY", "").strip()
|
||||
and os.getenv("LANGFUSE_SECRET_KEY", "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _start_workflow_observation(
|
||||
*,
|
||||
name: str,
|
||||
input: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
parent_span_id: str | None = None,
|
||||
) -> Any:
|
||||
if not _has_langfuse_credentials():
|
||||
return nullcontext(None)
|
||||
try:
|
||||
return _import_langfuse_get_client()().start_as_current_observation(
|
||||
name=name,
|
||||
as_type="span",
|
||||
input=input,
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"langfuse.workflow_start_observation_failed name=%s",
|
||||
name,
|
||||
exc_info=True,
|
||||
)
|
||||
return nullcontext(None)
|
||||
|
||||
|
||||
def _update_workflow_observation(
|
||||
observation: Any | None,
|
||||
*,
|
||||
output: dict[str, Any] | None = None,
|
||||
level: str | None = None,
|
||||
status_message: str | None = None,
|
||||
) -> None:
|
||||
if observation is None:
|
||||
return
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if output is not None:
|
||||
kwargs["output"] = output
|
||||
if level is not None:
|
||||
kwargs["level"] = level
|
||||
if status_message is not None:
|
||||
kwargs["status_message"] = status_message
|
||||
observation.update(**kwargs)
|
||||
except Exception:
|
||||
logger.debug("langfuse.workflow_update_failed", exc_info=True)
|
||||
|
||||
|
||||
def _build_workflow_llm_callbacks(
|
||||
*,
|
||||
parent_span_id: str | None = None,
|
||||
) -> tuple[Any, ...]:
|
||||
if not _has_langfuse_credentials():
|
||||
return ()
|
||||
if not langfuse_context_has_active_span():
|
||||
return ()
|
||||
|
||||
try:
|
||||
client = _import_langfuse_get_client()()
|
||||
trace_id = str(client.get_current_trace_id() or "").strip()
|
||||
if not trace_id:
|
||||
return ()
|
||||
trace_context: dict[str, str] = {"trace_id": trace_id}
|
||||
if parent_span_id:
|
||||
trace_context["parent_span_id"] = parent_span_id
|
||||
return (_import_langfuse_callback_handler()(trace_context=trace_context),)
|
||||
except Exception:
|
||||
logger.debug("langfuse.workflow_callback_handler_failed", exc_info=True)
|
||||
return ()
|
||||
|
||||
|
||||
def _summarize_mapping_keys(value: Any) -> list[str]:
|
||||
if not isinstance(value, dict):
|
||||
return []
|
||||
return sorted(str(key) for key in value.keys())
|
||||
|
||||
|
||||
def _action_observation_input(
|
||||
*,
|
||||
params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"input_keys": _summarize_mapping_keys(params),
|
||||
}
|
||||
|
||||
|
||||
def _action_observation_output(
|
||||
*,
|
||||
result: ActionResult,
|
||||
next_target: str | None = None,
|
||||
paused: bool = False,
|
||||
pause_def: PauseDef | None = None,
|
||||
) -> dict[str, Any]:
|
||||
output: dict[str, Any] = {
|
||||
"success": result.success,
|
||||
"output_keys": _summarize_mapping_keys(result.output),
|
||||
"metadata_keys": _summarize_mapping_keys(result.metadata),
|
||||
}
|
||||
if isinstance(result.output, dict):
|
||||
human_validation_text = result.output.get("texto_validacao_humana")
|
||||
if isinstance(human_validation_text, str) and human_validation_text.strip():
|
||||
output["texto_validacao_humana"] = human_validation_text.strip()
|
||||
if result.error:
|
||||
output["error"] = result.error
|
||||
if next_target is not None:
|
||||
output["next_target"] = next_target
|
||||
if paused and pause_def is not None:
|
||||
output["status"] = "WAITING_INPUT"
|
||||
output["resume_from"] = pause_def.resume_from
|
||||
output["expected_input_key"] = pause_def.expected_input.key
|
||||
return output
|
||||
|
||||
|
||||
def _workflow_metadata(
|
||||
*,
|
||||
workflow_name: str,
|
||||
workflow_version: int,
|
||||
node_id: str | None = None,
|
||||
action_name: str | None = None,
|
||||
label: str | None = None,
|
||||
stage: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {
|
||||
"workflow_name": workflow_name,
|
||||
"workflow_version": str(workflow_version),
|
||||
}
|
||||
if node_id:
|
||||
metadata["node_id"] = node_id
|
||||
if action_name:
|
||||
metadata["action"] = action_name
|
||||
if label:
|
||||
metadata["label"] = label
|
||||
if stage:
|
||||
metadata["stage"] = stage
|
||||
return metadata
|
||||
|
||||
|
||||
class WorkflowState(TypedDict, total=False):
|
||||
input: dict[str, Any]
|
||||
vars: dict[str, Any]
|
||||
trace: list[dict[str, Any]]
|
||||
status: str
|
||||
current_node: str | None
|
||||
last_node: str | None
|
||||
pending_interrupt: dict[str, Any] | None
|
||||
final_data: Any
|
||||
final_error: str | None
|
||||
final_metadata: dict[str, Any]
|
||||
|
||||
|
||||
def compile_workflow(
|
||||
definition: WorkflowDef,
|
||||
*,
|
||||
action_registry: ActionRegistry,
|
||||
runtime: WorkflowRuntimeContext,
|
||||
checkpointer: Any,
|
||||
) -> Any:
|
||||
try:
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.types import Command
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente
|
||||
raise ModuleNotFoundError(
|
||||
"langgraph nao esta instalado. Adicione a dependencia para usar workflows."
|
||||
) from exc
|
||||
|
||||
nodes_by_id = {node.id: node for node in definition.nodes}
|
||||
edges_by_source: dict[str, list[EdgeDef]] = {}
|
||||
for edge in sorted(definition.edges, key=lambda item: item.priority):
|
||||
edges_by_source.setdefault(edge.source, []).append(edge)
|
||||
|
||||
builder = StateGraph(WorkflowState)
|
||||
|
||||
def finish_node(state: WorkflowState) -> dict[str, Any]:
|
||||
# Preserve the terminal state set by the last workflow step.
|
||||
# Returning an empty payload here can drop status/final_data in some
|
||||
# graph runtimes/checkpointer combinations, making the service read
|
||||
# a FAILED default at the end.
|
||||
return dict(state)
|
||||
|
||||
builder.add_node(FINISH_NODE, finish_node)
|
||||
builder.add_edge(FINISH_NODE, END)
|
||||
|
||||
for node in definition.nodes:
|
||||
builder.add_node(
|
||||
node.id,
|
||||
_make_action_node(
|
||||
definition=definition,
|
||||
node=node,
|
||||
outgoing_edges=edges_by_source.get(node.id, []),
|
||||
nodes_by_id=nodes_by_id,
|
||||
action_registry=action_registry,
|
||||
runtime=runtime,
|
||||
command_type=Command,
|
||||
),
|
||||
)
|
||||
if node.pause is not None and node.pause.enabled:
|
||||
builder.add_node(
|
||||
_pause_node_name(node.id),
|
||||
_make_pause_node(
|
||||
node=node,
|
||||
command_type=Command,
|
||||
),
|
||||
)
|
||||
|
||||
builder.add_edge(START, definition.start)
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
|
||||
|
||||
def build_initial_state(
|
||||
definition: WorkflowDef, input_payload: dict[str, Any]
|
||||
) -> WorkflowState:
|
||||
return WorkflowState(
|
||||
input=deepcopy(input_payload),
|
||||
vars={},
|
||||
trace=[],
|
||||
status="RUNNING",
|
||||
current_node=definition.start,
|
||||
last_node=None,
|
||||
pending_interrupt=None,
|
||||
final_data=None,
|
||||
final_error=None,
|
||||
final_metadata={
|
||||
"workflow": definition.name,
|
||||
"version": definition.version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_action_node(
|
||||
*,
|
||||
definition: WorkflowDef,
|
||||
node: NodeDef,
|
||||
outgoing_edges: list[EdgeDef],
|
||||
nodes_by_id: dict[str, NodeDef],
|
||||
action_registry: ActionRegistry,
|
||||
runtime: WorkflowRuntimeContext,
|
||||
command_type: Any,
|
||||
):
|
||||
def action_node(state: WorkflowState) -> Any:
|
||||
context = _build_context(state)
|
||||
params = render_template(node.input, context)
|
||||
|
||||
try:
|
||||
handler = action_registry.get(node.action)
|
||||
except ValueError as exc:
|
||||
raise WorkflowConfigurationError(str(exc)) from exc
|
||||
|
||||
action_label = _ACTION_LABELS.get(
|
||||
node.action, node.action,
|
||||
)
|
||||
logger.info(
|
||||
"workflow.iniciou | %s",
|
||||
action_label,
|
||||
)
|
||||
step_metadata = _workflow_metadata(
|
||||
workflow_name=definition.name,
|
||||
workflow_version=definition.version,
|
||||
node_id=node.id,
|
||||
action_name=node.action,
|
||||
label=action_label,
|
||||
stage="step",
|
||||
)
|
||||
with _start_workflow_observation(
|
||||
name=f"workflow.step.{node.id}",
|
||||
input=_action_observation_input(params=params),
|
||||
metadata=step_metadata,
|
||||
) as step_observation:
|
||||
emit_workflow_step({
|
||||
"stage": "step_started",
|
||||
"step": node.id,
|
||||
"label": action_label,
|
||||
})
|
||||
step_parent_span_id = (
|
||||
str(getattr(step_observation, "id", "")).strip() or None
|
||||
)
|
||||
step_runtime = replace(
|
||||
runtime,
|
||||
llm_callbacks=_build_workflow_llm_callbacks(
|
||||
parent_span_id=step_parent_span_id,
|
||||
),
|
||||
llm_metadata=step_metadata,
|
||||
)
|
||||
try:
|
||||
action_result = handler(
|
||||
state, params, step_runtime,
|
||||
)
|
||||
except Exception as exc:
|
||||
_update_workflow_observation(
|
||||
step_observation,
|
||||
output={"error": str(exc)},
|
||||
level="ERROR",
|
||||
status_message=str(exc),
|
||||
)
|
||||
raise
|
||||
updated_state = _apply_action_result(
|
||||
state, node.id, node.action,
|
||||
action_result,
|
||||
)
|
||||
logger.info(
|
||||
"workflow.finalizou | %s | success=%s",
|
||||
action_label, action_result.success,
|
||||
)
|
||||
emit_workflow_step({
|
||||
"stage": "step_completed",
|
||||
"step": node.id,
|
||||
"label": action_label,
|
||||
"success": action_result.success,
|
||||
})
|
||||
|
||||
if not action_result.success:
|
||||
logger.error(
|
||||
"workflow.step.failed workflow=%s version=%s node=%s "
|
||||
"action=%s error=%s metadata=%s",
|
||||
definition.name,
|
||||
definition.version,
|
||||
node.id,
|
||||
node.action,
|
||||
action_result.error,
|
||||
action_result.metadata,
|
||||
)
|
||||
updated_state["status"] = "FAILED"
|
||||
updated_state["current_node"] = None
|
||||
updated_state["final_data"] = None
|
||||
updated_state["final_error"] = action_result.error
|
||||
updated_state["pending_interrupt"] = None
|
||||
updated_state["final_metadata"] = {
|
||||
"workflow": definition.name,
|
||||
"version": definition.version,
|
||||
"failed_node": node.id,
|
||||
**action_result.metadata,
|
||||
}
|
||||
_update_workflow_observation(
|
||||
step_observation,
|
||||
output=_action_observation_output(
|
||||
result=action_result,
|
||||
next_target=FINISH_NODE,
|
||||
),
|
||||
level="ERROR",
|
||||
status_message=action_result.error or "workflow step failed",
|
||||
)
|
||||
return command_type(update=updated_state, goto=FINISH_NODE)
|
||||
|
||||
if node.pause is not None and node.pause.enabled:
|
||||
pause_state = _build_pause_state(
|
||||
definition=definition,
|
||||
state=updated_state,
|
||||
node=node,
|
||||
result=action_result,
|
||||
parent_span_id=step_parent_span_id,
|
||||
)
|
||||
if pause_state is not None:
|
||||
_update_workflow_observation(
|
||||
step_observation,
|
||||
output=_action_observation_output(
|
||||
result=action_result,
|
||||
next_target=_pause_node_name(node.id),
|
||||
paused=True,
|
||||
pause_def=node.pause,
|
||||
),
|
||||
)
|
||||
return command_type(
|
||||
update=pause_state,
|
||||
goto=_pause_node_name(node.id),
|
||||
)
|
||||
|
||||
target = _resolve_next_target(
|
||||
outgoing_edges,
|
||||
updated_state,
|
||||
workflow_name=definition.name,
|
||||
workflow_version=definition.version,
|
||||
parent_span_id=step_parent_span_id,
|
||||
)
|
||||
if target is None or target == "END":
|
||||
logger.info("workflow.end node=%s", node.id)
|
||||
updated_state["status"] = "COMPLETED"
|
||||
updated_state["current_node"] = None
|
||||
updated_state["final_data"] = action_result.output
|
||||
updated_state["final_error"] = None
|
||||
updated_state["pending_interrupt"] = None
|
||||
updated_state["final_metadata"] = {
|
||||
"workflow": definition.name,
|
||||
"version": definition.version,
|
||||
"last_node": node.id,
|
||||
**action_result.metadata,
|
||||
}
|
||||
_update_workflow_observation(
|
||||
step_observation,
|
||||
output=_action_observation_output(
|
||||
result=action_result,
|
||||
next_target="END",
|
||||
),
|
||||
)
|
||||
return command_type(update=updated_state, goto=FINISH_NODE)
|
||||
|
||||
if target not in nodes_by_id:
|
||||
raise WorkflowConfigurationError(
|
||||
f"Destino {target!r} nao encontrado para o node {node.id!r}"
|
||||
)
|
||||
|
||||
updated_state["status"] = "RUNNING"
|
||||
updated_state["current_node"] = target
|
||||
updated_state["pending_interrupt"] = None
|
||||
_update_workflow_observation(
|
||||
step_observation,
|
||||
output=_action_observation_output(
|
||||
result=action_result,
|
||||
next_target=target,
|
||||
),
|
||||
)
|
||||
return command_type(update=updated_state, goto=target)
|
||||
|
||||
return action_node
|
||||
|
||||
|
||||
def _make_pause_node(*, node: NodeDef, command_type: Any):
|
||||
pause_def = node.pause
|
||||
assert pause_def is not None
|
||||
|
||||
def pause_node(state: WorkflowState) -> Any:
|
||||
try:
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.types import interrupt
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente
|
||||
raise ModuleNotFoundError(
|
||||
"langgraph nao esta instalado. "
|
||||
"Adicione a dependencia para usar workflows."
|
||||
) from exc
|
||||
|
||||
pending = state.get("pending_interrupt")
|
||||
if not isinstance(pending, dict):
|
||||
raise WorkflowConfigurationError(
|
||||
f"Node de pausa {node.id!r} sem pending_interrupt no estado"
|
||||
)
|
||||
|
||||
graph_interrupt: GraphInterrupt | None = None
|
||||
with _start_workflow_observation(
|
||||
name="workflow.resume",
|
||||
input={
|
||||
"node_id": node.id,
|
||||
"expected_input_key": pause_def.expected_input.key,
|
||||
"resume_from": pause_def.resume_from,
|
||||
},
|
||||
metadata={
|
||||
"node_id": node.id,
|
||||
"resume_from": pause_def.resume_from,
|
||||
"stage": "resume",
|
||||
},
|
||||
) as resume_observation:
|
||||
input_date: dict[str, Any] | None = None
|
||||
try:
|
||||
resume_payload = interrupt(pending["payload"])
|
||||
input_date = dict(state.get("input", {}))
|
||||
resumed_input = _resume_input_dict(
|
||||
resume_payload,
|
||||
pause_def.expected_input.key,
|
||||
)
|
||||
input_date.update(resumed_input)
|
||||
_normalize_and_validate_input(input_date, pause_def)
|
||||
except GraphInterrupt as exc:
|
||||
graph_interrupt = exc
|
||||
_update_workflow_observation(
|
||||
resume_observation,
|
||||
output={
|
||||
"status": "WAITING_INPUT",
|
||||
"resume_from": pause_def.resume_from,
|
||||
"expected_input_key": pause_def.expected_input.key,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
_update_workflow_observation(
|
||||
resume_observation,
|
||||
output={"error": str(exc)},
|
||||
level="ERROR",
|
||||
status_message=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
if graph_interrupt is None:
|
||||
if input_date is None:
|
||||
raise WorkflowConfigurationError(
|
||||
f"Node de pausa {node.id!r} nao recebeu input de retomada"
|
||||
)
|
||||
updated_state = _clone_state(state)
|
||||
updated_state["input"] = input_date
|
||||
updated_state["status"] = "RUNNING"
|
||||
updated_state["pending_interrupt"] = None
|
||||
updated_state["current_node"] = pause_def.resume_from
|
||||
updated_state["trace"].append(
|
||||
{
|
||||
"node_id": node.id,
|
||||
"action": "resume_input",
|
||||
"success": True,
|
||||
"error": None,
|
||||
}
|
||||
)
|
||||
|
||||
if pause_def.resume_from == "END":
|
||||
updated_state["status"] = "COMPLETED"
|
||||
updated_state["current_node"] = None
|
||||
updated_state["final_data"] = updated_state.get("vars", {}).get(node.id)
|
||||
updated_state["final_error"] = None
|
||||
_update_workflow_observation(
|
||||
resume_observation,
|
||||
output={
|
||||
"resume_from": "END",
|
||||
"normalized_input_keys": _summarize_mapping_keys(input_date),
|
||||
},
|
||||
)
|
||||
return command_type(update=updated_state, goto=FINISH_NODE)
|
||||
|
||||
_update_workflow_observation(
|
||||
resume_observation,
|
||||
output={
|
||||
"resume_from": pause_def.resume_from,
|
||||
"normalized_input_keys": _summarize_mapping_keys(input_date),
|
||||
},
|
||||
)
|
||||
return command_type(update=updated_state, goto=pause_def.resume_from)
|
||||
|
||||
if graph_interrupt is not None:
|
||||
raise graph_interrupt
|
||||
raise WorkflowConfigurationError(
|
||||
f"Node de pausa {node.id!r} finalizou sem estado valido de retomada"
|
||||
)
|
||||
|
||||
return pause_node
|
||||
|
||||
|
||||
def _apply_action_result(
|
||||
state: WorkflowState,
|
||||
node_id: str,
|
||||
action_name: str,
|
||||
result: ActionResult,
|
||||
) -> WorkflowState:
|
||||
updated_state = _clone_state(state)
|
||||
vars_state = dict(updated_state.get("vars", {}))
|
||||
vars_state[node_id] = deepcopy(result.output)
|
||||
updated_state["vars"] = vars_state
|
||||
|
||||
trace = list(updated_state.get("trace", []))
|
||||
trace.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"action": action_name,
|
||||
"success": result.success,
|
||||
"error": result.error,
|
||||
}
|
||||
)
|
||||
updated_state["trace"] = trace
|
||||
updated_state["last_node"] = node_id
|
||||
updated_state["current_node"] = node_id
|
||||
return updated_state
|
||||
|
||||
|
||||
def _build_pause_state(
|
||||
*,
|
||||
definition: WorkflowDef,
|
||||
state: WorkflowState,
|
||||
node: NodeDef,
|
||||
result: ActionResult,
|
||||
parent_span_id: str | None = None,
|
||||
) -> WorkflowState | None:
|
||||
pause_def = node.pause
|
||||
if pause_def is None:
|
||||
return None
|
||||
|
||||
pause_context = _build_context(state, output=result.output)
|
||||
if not evaluate_condition(pause_def.when, pause_context):
|
||||
return None
|
||||
|
||||
payload = resolve_value(pause_def.return_from, pause_context)
|
||||
if payload is None:
|
||||
raise WorkflowConfigurationError(
|
||||
"pause.return_from="
|
||||
f"{pause_def.return_from!r} nao resolveu valor "
|
||||
f"no node {node.id!r}"
|
||||
)
|
||||
|
||||
updated_state = _clone_state(state)
|
||||
updated_state["status"] = "WAITING_INPUT"
|
||||
updated_state["current_node"] = node.id
|
||||
updated_state["pending_interrupt"] = {
|
||||
"node_id": node.id,
|
||||
"payload": payload,
|
||||
"resume_from": pause_def.resume_from,
|
||||
"expected_input_key": pause_def.expected_input.key,
|
||||
"allowed_values": list(pause_def.expected_input.allowed_values),
|
||||
"normalize": pause_def.expected_input.normalize,
|
||||
}
|
||||
updated_state["final_metadata"] = {
|
||||
"workflow": definition.name,
|
||||
"version": definition.version,
|
||||
"paused_at": node.id,
|
||||
"resume_from": pause_def.resume_from,
|
||||
"expected_input_key": pause_def.expected_input.key,
|
||||
"allowed_values": list(pause_def.expected_input.allowed_values),
|
||||
"normalize": pause_def.expected_input.normalize,
|
||||
**result.metadata,
|
||||
}
|
||||
with _start_workflow_observation(
|
||||
name="workflow.pause",
|
||||
input={
|
||||
"node_id": node.id,
|
||||
"resume_from": pause_def.resume_from,
|
||||
"expected_input_key": pause_def.expected_input.key,
|
||||
},
|
||||
metadata=_workflow_metadata(
|
||||
workflow_name=definition.name,
|
||||
workflow_version=definition.version,
|
||||
node_id=node.id,
|
||||
action_name=node.action,
|
||||
stage="pause",
|
||||
),
|
||||
parent_span_id=parent_span_id,
|
||||
) as pause_observation:
|
||||
_update_workflow_observation(
|
||||
pause_observation,
|
||||
output={
|
||||
"resume_from": pause_def.resume_from,
|
||||
"expected_input_key": pause_def.expected_input.key,
|
||||
"allowed_values": list(pause_def.expected_input.allowed_values),
|
||||
},
|
||||
)
|
||||
return updated_state
|
||||
|
||||
|
||||
def _resolve_next_target(
|
||||
outgoing_edges: list[EdgeDef],
|
||||
state: WorkflowState,
|
||||
*,
|
||||
workflow_name: str = "",
|
||||
workflow_version: int = 0,
|
||||
parent_span_id: str | None = None,
|
||||
) -> str | None:
|
||||
context = _build_context(state)
|
||||
current = state.get("current_node", "?")
|
||||
|
||||
# Extrair info do perfil de fatura para logs descritivos
|
||||
vars_state = context.get("vars", {})
|
||||
perfil = vars_state.get("consultar_perfil_fatura", {})
|
||||
forma_pagamento = ""
|
||||
if isinstance(perfil, dict):
|
||||
forma_pagamento = str(
|
||||
perfil.get("forma_pagamento", "")
|
||||
).strip()
|
||||
|
||||
for edge in outgoing_edges:
|
||||
matched = evaluate_condition(edge.when, context)
|
||||
if matched:
|
||||
decision = ""
|
||||
# Decisões de fatura
|
||||
if (
|
||||
current == "consultar_perfil_fatura"
|
||||
and forma_pagamento
|
||||
):
|
||||
if edge.target == "registrar_protocolo":
|
||||
decision = (
|
||||
f"Fatura tipo {forma_pagamento}"
|
||||
" → crédito na próxima fatura"
|
||||
)
|
||||
else:
|
||||
decision = (
|
||||
f"Fatura tipo {forma_pagamento}"
|
||||
" → verificando se está aberta"
|
||||
)
|
||||
elif current == "check_invoice_status":
|
||||
if edge.target == "enviar_sms":
|
||||
decision = (
|
||||
"Fatura aberta"
|
||||
" → enviando SMS com boleto"
|
||||
)
|
||||
else:
|
||||
decision = (
|
||||
"Fatura fechada"
|
||||
" → crédito na próxima fatura"
|
||||
)
|
||||
|
||||
if decision:
|
||||
logger.info(
|
||||
"workflow.decisao | %s", decision,
|
||||
)
|
||||
emit_workflow_step({
|
||||
"stage": "decision",
|
||||
"from": current,
|
||||
"to": edge.target,
|
||||
"label": decision,
|
||||
})
|
||||
else:
|
||||
logger.info(
|
||||
"workflow.route | %s → %s",
|
||||
current, edge.target,
|
||||
)
|
||||
with _start_workflow_observation(
|
||||
name="workflow.decision",
|
||||
input={
|
||||
"from": current,
|
||||
"to": edge.target,
|
||||
},
|
||||
metadata=_workflow_metadata(
|
||||
workflow_name=workflow_name or "workflow",
|
||||
workflow_version=workflow_version or 0,
|
||||
node_id=str(current),
|
||||
stage="decision",
|
||||
label=decision or f"{current} -> {edge.target}",
|
||||
),
|
||||
parent_span_id=parent_span_id,
|
||||
) as decision_observation:
|
||||
_update_workflow_observation(
|
||||
decision_observation,
|
||||
output={
|
||||
"matched": True,
|
||||
"from": current,
|
||||
"to": edge.target,
|
||||
"label": decision or "",
|
||||
},
|
||||
)
|
||||
return edge.target
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_and_validate_input(
|
||||
input_date: dict[str, Any], pause_def: PauseDef
|
||||
) -> None:
|
||||
expected = pause_def.expected_input
|
||||
if expected.key not in input_date:
|
||||
raise WorkflowInputError(
|
||||
f"Input obrigatorio ausente para continuar fluxo: {expected.key}"
|
||||
)
|
||||
|
||||
value = input_date[expected.key]
|
||||
normalized = _normalize_input_value(value, expected.normalize)
|
||||
input_date[expected.key] = normalized
|
||||
|
||||
if expected.allowed_values:
|
||||
allowed = {
|
||||
str(_normalize_input_value(item, expected.normalize))
|
||||
for item in expected.allowed_values
|
||||
}
|
||||
if str(normalized) not in allowed:
|
||||
raise WorkflowInputError(
|
||||
f"Valor invalido para {expected.key}: {normalized!r}. "
|
||||
f"Permitidos: {sorted(allowed)}"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_input_value(value: Any, normalize_mode: str | None) -> Any:
|
||||
if not isinstance(value, str) or normalize_mode is None:
|
||||
return value
|
||||
if normalize_mode == "strip":
|
||||
return value.strip()
|
||||
if normalize_mode == "upper":
|
||||
return value.upper()
|
||||
if normalize_mode == "lower":
|
||||
return value.lower()
|
||||
if normalize_mode == "upper_strip":
|
||||
return value.strip().upper()
|
||||
if normalize_mode == "lower_strip":
|
||||
return value.strip().lower()
|
||||
return value
|
||||
|
||||
|
||||
def _resume_input_dict(resume_payload: Any, expected_input_key: str) -> dict[str, Any]:
|
||||
if isinstance(resume_payload, dict):
|
||||
return dict(resume_payload)
|
||||
return {expected_input_key: resume_payload}
|
||||
|
||||
|
||||
def _build_context(
|
||||
state: WorkflowState,
|
||||
*,
|
||||
output: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"input": deepcopy(state.get("input", {})),
|
||||
"vars": deepcopy(state.get("vars", {})),
|
||||
"trace": deepcopy(state.get("trace", [])),
|
||||
"status": state.get("status"),
|
||||
"current_node": state.get("current_node"),
|
||||
"last_node": state.get("last_node"),
|
||||
"output": deepcopy(output or {}),
|
||||
}
|
||||
|
||||
|
||||
def _clone_state(state: WorkflowState) -> WorkflowState:
|
||||
return WorkflowState(
|
||||
input=deepcopy(state.get("input", {})),
|
||||
vars=deepcopy(state.get("vars", {})),
|
||||
trace=deepcopy(state.get("trace", [])),
|
||||
status=str(state.get("status", "RUNNING")),
|
||||
current_node=state.get("current_node"),
|
||||
last_node=state.get("last_node"),
|
||||
pending_interrupt=deepcopy(state.get("pending_interrupt")),
|
||||
final_data=deepcopy(state.get("final_data")),
|
||||
final_error=state.get("final_error"),
|
||||
final_metadata=deepcopy(state.get("final_metadata", {})),
|
||||
)
|
||||
|
||||
|
||||
def _pause_node_name(node_id: str) -> str:
|
||||
return f"__pause__{node_id}"
|
||||
59
legacy_reference/workflows/conditions.py
Normal file
59
legacy_reference/workflows/conditions.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agente_contas_tim.workflows.exceptions import WorkflowConfigurationError
|
||||
|
||||
|
||||
def get_path_value(data: dict[str, Any], path: str) -> Any:
|
||||
"""Resolve paths simples no formato $.a.b.c."""
|
||||
if not path.startswith("$."):
|
||||
return path
|
||||
|
||||
current: Any = data
|
||||
for part in path[2:].split("."):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(part)
|
||||
continue
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def resolve_value(value: Any, context: dict[str, Any]) -> Any:
|
||||
if isinstance(value, str) and value.startswith("$."):
|
||||
return get_path_value(context, value)
|
||||
return value
|
||||
|
||||
|
||||
def evaluate_condition(
|
||||
condition: dict[str, Any] | None, context: dict[str, Any]
|
||||
) -> bool:
|
||||
if condition is None:
|
||||
return True
|
||||
|
||||
if "eq" in condition:
|
||||
left, right = condition["eq"]
|
||||
return resolve_value(left, context) == resolve_value(right, context)
|
||||
|
||||
if "neq" in condition:
|
||||
left, right = condition["neq"]
|
||||
return resolve_value(left, context) != resolve_value(right, context)
|
||||
|
||||
if "all" in condition:
|
||||
return all(evaluate_condition(item, context) for item in condition["all"])
|
||||
|
||||
if "any" in condition:
|
||||
return any(evaluate_condition(item, context) for item in condition["any"])
|
||||
|
||||
if "exists" in condition:
|
||||
return resolve_value(condition["exists"], context) is not None
|
||||
|
||||
if "in" in condition:
|
||||
left, right = condition["in"]
|
||||
return resolve_value(left, context) in resolve_value(right, context)
|
||||
|
||||
if "not_in" in condition:
|
||||
left, right = condition["not_in"]
|
||||
return resolve_value(left, context) not in resolve_value(right, context)
|
||||
|
||||
raise WorkflowConfigurationError(f"Operador de condição não suportado: {condition}")
|
||||
84
legacy_reference/workflows/contracts.py
Normal file
84
legacy_reference/workflows/contracts.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from agente_contas_tim.workflows.exceptions import WorkflowConfigurationError
|
||||
|
||||
NormalizeMode = Literal[
|
||||
"upper",
|
||||
"lower",
|
||||
"strip",
|
||||
"upper_strip",
|
||||
"lower_strip",
|
||||
]
|
||||
|
||||
|
||||
class PauseExpectedInputDef(BaseModel):
|
||||
key: str = Field(..., min_length=1)
|
||||
allowed_values: list[str] = Field(default_factory=list)
|
||||
normalize: NormalizeMode | None = None
|
||||
|
||||
|
||||
class PauseDef(BaseModel):
|
||||
enabled: bool = True
|
||||
when: dict[str, Any] | None = None
|
||||
return_from: str = Field(..., min_length=1)
|
||||
expected_input: PauseExpectedInputDef
|
||||
resume_from: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class NodeDef(BaseModel):
|
||||
id: str = Field(..., min_length=1)
|
||||
action: str = Field(..., min_length=1)
|
||||
input: dict[str, Any] = Field(default_factory=dict)
|
||||
pause: PauseDef | None = None
|
||||
|
||||
|
||||
class EdgeDef(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
source: str = Field(..., alias="from", min_length=1)
|
||||
target: str = Field(..., alias="to", min_length=1)
|
||||
when: dict[str, Any] | None = None
|
||||
priority: int = 100
|
||||
|
||||
|
||||
class WorkflowDef(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
version: int = Field(..., ge=1)
|
||||
start: str = Field(..., min_length=1)
|
||||
nodes: list[NodeDef]
|
||||
edges: list[EdgeDef]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_graph(self) -> WorkflowDef:
|
||||
node_ids = {node.id for node in self.nodes}
|
||||
if self.start not in node_ids:
|
||||
raise WorkflowConfigurationError(
|
||||
f"start={self.start!r} não existe em nodes"
|
||||
)
|
||||
|
||||
for edge in self.edges:
|
||||
if edge.source not in node_ids:
|
||||
raise WorkflowConfigurationError(
|
||||
f"edge.from={edge.source!r} não existe em nodes"
|
||||
)
|
||||
if edge.target != "END" and edge.target not in node_ids:
|
||||
raise WorkflowConfigurationError(
|
||||
f"edge.to={edge.target!r} não existe em nodes"
|
||||
)
|
||||
|
||||
for node in self.nodes:
|
||||
if node.pause is None:
|
||||
continue
|
||||
if (
|
||||
node.pause.resume_from != "END"
|
||||
and node.pause.resume_from not in node_ids
|
||||
):
|
||||
raise WorkflowConfigurationError(
|
||||
f"pause.resume_from={node.pause.resume_from!r} não existe em nodes"
|
||||
)
|
||||
|
||||
return self
|
||||
@@ -0,0 +1 @@
|
||||
version: 1
|
||||
@@ -0,0 +1,170 @@
|
||||
name: contestacao_tool
|
||||
version: 1
|
||||
start: registrar_protocolo
|
||||
|
||||
nodes:
|
||||
# 1. Registrar protocolo — primeiro passo ao entrar em contestação
|
||||
- id: registrar_protocolo
|
||||
action: registrar_protocolo
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
scenario: "contestacao"
|
||||
request_status: "Aberto"
|
||||
status: "OPENED"
|
||||
|
||||
# 2. Consultar perfil de fatura (forma de pagamento)
|
||||
- id: consultar_perfil_fatura
|
||||
action: consultar_perfil_fatura
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
|
||||
# 3. Enviar SMS com código do boleto
|
||||
- id: enviar_sms
|
||||
action: enviar_sms
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
message: $.input.mensagem_sms
|
||||
|
||||
# 3.1 Consultar status da fatura (CompleteInvoices)
|
||||
- id: check_invoice_status
|
||||
action: check_invoice_status
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
invoice_id: $.input.invoice_id
|
||||
current_invoice_number: $.input.current_invoice_number
|
||||
|
||||
# 4. Abrir contestação no serviço dedicado
|
||||
- id: abrir_contestacao_cliente
|
||||
action: abrir_contestacao_cliente
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
customer_id: $.input.customer_id
|
||||
current_invoice_number: $.input.current_invoice_number
|
||||
invoice_id: $.input.invoice_id
|
||||
current_invoice_due_date: $.input.current_invoice_due_date
|
||||
servico: $.input.servico
|
||||
valor: $.input.valor
|
||||
descricao: $.input.descricao
|
||||
items: $.input.items
|
||||
protocolo_id: $.vars.registrar_protocolo.protocolo_id
|
||||
customer_type: $.input.customer_type
|
||||
customer_status: $.input.customer_status
|
||||
invoice_status: $.vars.check_invoice_status.invoice_status
|
||||
invoice_amount_open: $.input.invoice_amount_open
|
||||
invoice_amount: $.input.invoice_amount
|
||||
contestation_type: $.input.contestation_type
|
||||
adjust_reason: $.input.adjust_reason
|
||||
refund_option: $.input.refund_option
|
||||
manual_conta_certa_indicator: $.input.manual_conta_certa_indicator
|
||||
double_refund: $.input.double_refund
|
||||
user_id: $.input.user_id
|
||||
message_id: $.input.message_id
|
||||
client_id: $.input.client_id
|
||||
tipo_atendimento: $.input.tipo_atendimento
|
||||
skip_invoice_item_validation: $.input.skip_invoice_item_validation
|
||||
|
||||
# 5. Consultar contrato para verificar data de corte
|
||||
- id: consultar_contrato_corte
|
||||
action: consultar_contrato_corte
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
current_invoice_due_date: $.input.current_invoice_due_date
|
||||
|
||||
# 6. Abrir SR de Conta Certa Manual (quando após data de corte)
|
||||
- id: abrir_sr_conta_certa_manual
|
||||
action: abrir_sr_conta_certa_manual
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
dia_vencimento: $.vars.consultar_contrato_corte.dia_vencimento
|
||||
request_status: "Encaminhado"
|
||||
status: "Encaminhado"
|
||||
|
||||
# 7. Atualizar status do protocolo principal — último passo
|
||||
- id: atualizar_status_sr
|
||||
action: atualizar_status_sr
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
protocolo_id: $.vars.registrar_protocolo.protocolo_id
|
||||
dia_vencimento: $.vars.abrir_sr_conta_certa_manual.dia_vencimento
|
||||
scenario: "contestacao"
|
||||
channel: "AIAGENTCR"
|
||||
status: "Fechado"
|
||||
client_id: "AIAGENTCR"
|
||||
|
||||
# 8. Atualiza status do protocolo principal após Conta Certa Manual
|
||||
- id: atualizar_status_sr_registro
|
||||
action: atualizar_status_sr
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
protocolo_id: $.vars.abrir_sr_conta_certa_manual.sr_conta_certa_id
|
||||
scenario: "conta_certa_manual"
|
||||
status: "Encaminhado"
|
||||
client_id: "AIAGENTCR"
|
||||
|
||||
edges:
|
||||
# Protocolo registrado → consultar perfil de fatura
|
||||
- from: registrar_protocolo
|
||||
to: consultar_perfil_fatura
|
||||
|
||||
# Consultou perfil → consulta status da fatura (CompleteInvoices)
|
||||
- from: consultar_perfil_fatura
|
||||
to: check_invoice_status
|
||||
priority: 10
|
||||
|
||||
# Todas as formas → abrir contestação
|
||||
- from: check_invoice_status
|
||||
to: abrir_contestacao_cliente
|
||||
priority: 10
|
||||
|
||||
# Após contestação: quando necessário, enviar SMS
|
||||
- from: abrir_contestacao_cliente
|
||||
to: enviar_sms
|
||||
priority: 10
|
||||
when:
|
||||
all:
|
||||
- exists: $.vars.abrir_contestacao_cliente.barcode
|
||||
- neq: [$.vars.abrir_contestacao_cliente.barcode, ""]
|
||||
|
||||
# Após contestação (sem SMS) → verificar data de corte
|
||||
- from: abrir_contestacao_cliente
|
||||
to: consultar_contrato_corte
|
||||
priority: 99
|
||||
|
||||
# Após SMS → verificar data de corte
|
||||
- from: enviar_sms
|
||||
to: consultar_contrato_corte
|
||||
|
||||
# Após data de corte → abrir SR Conta Certa Manual
|
||||
- from: consultar_contrato_corte
|
||||
to: abrir_sr_conta_certa_manual
|
||||
priority: 10
|
||||
when:
|
||||
all:
|
||||
- eq: [$.vars.consultar_contrato_corte.apos_data_corte, true]
|
||||
- eq: [$.vars.consultar_contrato_corte.contestation_success, true]
|
||||
- any:
|
||||
- eq: [$.input.manual_conta_certa_indicator, true]
|
||||
- eq: [$.vars.abrir_contestacao_cliente.manual_conta_certa_indicator, true]
|
||||
|
||||
# Antes da data de corte → ir direto para fechar protocolo
|
||||
- from: consultar_contrato_corte
|
||||
to: atualizar_status_sr
|
||||
priority: 99
|
||||
|
||||
# Após SR manual → fechar protocolo
|
||||
- from: abrir_sr_conta_certa_manual
|
||||
to: atualizar_status_sr_registro
|
||||
priority: 10
|
||||
|
||||
# Protocolo principal de contestação atualizado após Conta Certa Manual → END
|
||||
- from: atualizar_status_sr_registro
|
||||
to: END
|
||||
|
||||
# Protocolo atualizado → END
|
||||
- from: atualizar_status_sr
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 2
|
||||
@@ -0,0 +1,85 @@
|
||||
name: invoice_explanation
|
||||
version: 2
|
||||
start: preparar
|
||||
|
||||
nodes:
|
||||
- id: preparar
|
||||
action: preparar_invoice_explanation
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
customer_id: $.input.customer_id
|
||||
current_invoice_number: $.input.current_invoice_number
|
||||
past_invoice_number: $.input.past_invoice_number
|
||||
current_invoice_due_date: $.input.current_invoice_due_date
|
||||
past_invoice_due_date: $.input.past_invoice_due_date
|
||||
channel: $.input.channel
|
||||
tentativa_anterior: $.vars.preparar.tentativa
|
||||
invoice_explanation_base: $.input.invoice_explanation_base
|
||||
|
||||
- id: formatar
|
||||
action: formatar_invoice_explanation
|
||||
input:
|
||||
explicacao_base: $.vars.preparar.explicacao_base
|
||||
invoice_detail: $.input.invoice_detail
|
||||
pause:
|
||||
enabled: true
|
||||
when:
|
||||
eq: [$.output.await_user_input, true]
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao
|
||||
|
||||
- id: decisao
|
||||
action: no_op
|
||||
input: {}
|
||||
|
||||
- id: registrar_sim
|
||||
action: registrar_atendimento_invoice_explanation
|
||||
input:
|
||||
resposta_usuario: SIM
|
||||
ic: VEB.003
|
||||
|
||||
- id: registrar_protocolo_aceite
|
||||
action: registrar_protocolo_inicio
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
scenario: invoice_explanation_aceite_fechado
|
||||
request_status: Fechado
|
||||
status: CLOSED
|
||||
|
||||
- id: registrar_nao
|
||||
action: registrar_atendimento_invoice_explanation
|
||||
input:
|
||||
resposta_usuario: NAO
|
||||
ic: VEB.004
|
||||
|
||||
edges:
|
||||
- from: preparar
|
||||
to: formatar
|
||||
|
||||
- from: formatar
|
||||
to: decisao
|
||||
|
||||
- from: decisao
|
||||
to: registrar_sim
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, SIM]
|
||||
|
||||
- from: decisao
|
||||
to: registrar_nao
|
||||
priority: 20
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, NAO]
|
||||
|
||||
- from: registrar_sim
|
||||
to: registrar_protocolo_aceite
|
||||
|
||||
- from: registrar_protocolo_aceite
|
||||
to: END
|
||||
|
||||
- from: registrar_nao
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
210
legacy_reference/workflows/conversational/pro_rata.v2.yaml
Normal file
210
legacy_reference/workflows/conversational/pro_rata.v2.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
name: pro_rata
|
||||
version: 2
|
||||
start: preparar
|
||||
|
||||
nodes:
|
||||
- id: preparar
|
||||
action: preparar_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
planos: $.input.planos
|
||||
has_plano_controle: $.input.has_plano_controle
|
||||
|
||||
- id: formatar
|
||||
action: formatar_pro_rata
|
||||
input:
|
||||
mensagem_base: $.vars.preparar.mensagem
|
||||
pause:
|
||||
enabled: true
|
||||
when:
|
||||
eq: [$.vars.preparar.await_user_input, true]
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao_esclarecimento
|
||||
|
||||
- id: decisao_esclarecimento
|
||||
action: no_op
|
||||
input: {}
|
||||
|
||||
- id: ofertar_ajuste
|
||||
action: formatar_pro_rata
|
||||
input:
|
||||
mensagem_base: $.vars.preparar.mensagem_oferta_ajuste
|
||||
pause:
|
||||
enabled: true
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao_ajuste
|
||||
|
||||
- id: decisao_ajuste
|
||||
action: no_op
|
||||
input: {}
|
||||
|
||||
- id: reperguntar_esclarecimento
|
||||
action: formatar_pro_rata
|
||||
input:
|
||||
mensagem_base: $.vars.preparar.mensagem_reperguntar_esclarecimento
|
||||
pause:
|
||||
enabled: true
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao_esclarecimento
|
||||
|
||||
- id: reperguntar_ajuste
|
||||
action: formatar_pro_rata
|
||||
input:
|
||||
mensagem_base: $.vars.preparar.mensagem_reperguntar_ajuste
|
||||
pause:
|
||||
enabled: true
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao_ajuste
|
||||
|
||||
- id: registrar_nao_controle
|
||||
action: registrar_atendimento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
mensagem_base: $.vars.formatar.mensagem
|
||||
caminho: nao_controle
|
||||
|
||||
- id: registrar_aceitou
|
||||
action: registrar_atendimento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
mensagem_base: $.vars.preparar.mensagem_pos_aceite
|
||||
caminho: aceitou
|
||||
ic: VEB.003
|
||||
|
||||
- id: registrar_recusou_ajuste
|
||||
action: registrar_atendimento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
mensagem_base: $.vars.preparar.mensagem_ajuste_recusado
|
||||
caminho: recusou_ajuste
|
||||
ic: VEB.003
|
||||
|
||||
- id: executar_contestacao
|
||||
action: executar_contestacao_plano_controle
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
customer_id: $.input.customer_id
|
||||
current_invoice_number: $.input.current_invoice_number
|
||||
current_invoice_due_date: $.input.current_invoice_due_date
|
||||
customer_type: $.input.customer_type
|
||||
customer_status: $.input.customer_status
|
||||
invoice_status: $.input.invoice_status
|
||||
adjust_reason: $.input.adjust_reason
|
||||
refund_option: $.input.refund_option
|
||||
manual_conta_certa_indicator: $.input.manual_conta_certa_indicator
|
||||
items: $.vars.definir_devolucao.items
|
||||
invoice_amount_open: $.vars.definir_devolucao.invoice_amount_open
|
||||
invoice_amount: $.vars.definir_devolucao.invoice_amount
|
||||
devolucao: $.vars.definir_devolucao
|
||||
|
||||
- id: definir_devolucao
|
||||
action: definir_devolucao_ajuste_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
planos: $.input.planos
|
||||
invoice_id: $.input.invoice_id
|
||||
customer_id: $.input.customer_id
|
||||
invoice_detail: $.input.invoice_detail
|
||||
invoice_period: $.input.invoice_period
|
||||
invoice_emissao: $.input.invoice_emissao
|
||||
|
||||
- id: orientar_pagamento
|
||||
action: orientar_pagamento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
devolucao: $.vars.executar_contestacao.devolucao
|
||||
|
||||
- id: registrar_atendimento
|
||||
action: registrar_atendimento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
social_sec_no: $.input.social_sec_no
|
||||
mensagem_base: $.vars.orientar_pagamento.mensagem
|
||||
devolucao: $.vars.orientar_pagamento.devolucao
|
||||
caminho: nao_aceitou
|
||||
ic: VEB.004
|
||||
|
||||
edges:
|
||||
- from: preparar
|
||||
to: formatar
|
||||
|
||||
- from: formatar
|
||||
to: decisao_esclarecimento
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.vars.preparar.await_user_input, true]
|
||||
|
||||
- from: formatar
|
||||
to: registrar_nao_controle
|
||||
priority: 20
|
||||
|
||||
- from: decisao_esclarecimento
|
||||
to: registrar_aceitou
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, SIM]
|
||||
|
||||
- from: decisao_esclarecimento
|
||||
to: ofertar_ajuste
|
||||
priority: 20
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, NAO]
|
||||
|
||||
- from: decisao_esclarecimento
|
||||
to: reperguntar_esclarecimento
|
||||
priority: 30
|
||||
|
||||
- from: decisao_ajuste
|
||||
to: definir_devolucao
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, SIM]
|
||||
|
||||
- from: decisao_ajuste
|
||||
to: registrar_recusou_ajuste
|
||||
priority: 20
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, NAO]
|
||||
|
||||
- from: decisao_ajuste
|
||||
to: reperguntar_ajuste
|
||||
priority: 30
|
||||
|
||||
- from: definir_devolucao
|
||||
to: executar_contestacao
|
||||
|
||||
- from: executar_contestacao
|
||||
to: orientar_pagamento
|
||||
|
||||
- from: orientar_pagamento
|
||||
to: registrar_atendimento
|
||||
|
||||
- from: registrar_nao_controle
|
||||
to: END
|
||||
|
||||
- from: registrar_aceitou
|
||||
to: END
|
||||
|
||||
- from: registrar_recusou_ajuste
|
||||
to: END
|
||||
|
||||
- from: registrar_atendimento
|
||||
to: END
|
||||
104
legacy_reference/workflows/conversational/pro_rata.v3.yaml
Normal file
104
legacy_reference/workflows/conversational/pro_rata.v3.yaml
Normal file
@@ -0,0 +1,104 @@
|
||||
name: pro_rata
|
||||
version: 3
|
||||
start: preparar
|
||||
|
||||
nodes:
|
||||
- id: preparar
|
||||
action: preparar_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
planos: $.input.planos
|
||||
has_plano_controle: $.input.has_plano_controle
|
||||
|
||||
- id: formatar
|
||||
action: formatar_pro_rata
|
||||
input:
|
||||
mensagem_base: $.vars.preparar.mensagem
|
||||
pause:
|
||||
enabled: true
|
||||
when:
|
||||
eq: [$.vars.preparar.await_user_input, true]
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao_esclarecimento
|
||||
|
||||
- id: decisao_esclarecimento
|
||||
action: no_op
|
||||
input: {}
|
||||
|
||||
- id: devolver_orquestrador
|
||||
action: no_op
|
||||
input: {}
|
||||
|
||||
- id: reperguntar_esclarecimento
|
||||
action: formatar_pro_rata
|
||||
input:
|
||||
mensagem_base: $.vars.preparar.mensagem_reperguntar_esclarecimento
|
||||
pause:
|
||||
enabled: true
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao_esclarecimento
|
||||
|
||||
- id: registrar_nao_controle
|
||||
action: registrar_atendimento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
mensagem_base: $.vars.formatar.mensagem
|
||||
caminho: nao_controle
|
||||
|
||||
- id: registrar_aceitou
|
||||
action: registrar_atendimento_pro_rata
|
||||
input:
|
||||
msisdn: $.input.msisdn
|
||||
mensagem_base: $.vars.preparar.mensagem_pos_aceite
|
||||
caminho: aceitou
|
||||
ic: VEB.003
|
||||
|
||||
edges:
|
||||
- from: preparar
|
||||
to: formatar
|
||||
|
||||
- from: formatar
|
||||
to: decisao_esclarecimento
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.vars.preparar.await_user_input, true]
|
||||
|
||||
- from: formatar
|
||||
to: registrar_nao_controle
|
||||
priority: 20
|
||||
|
||||
- from: decisao_esclarecimento
|
||||
to: registrar_aceitou
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, SIM]
|
||||
|
||||
- from: decisao_esclarecimento
|
||||
to: devolver_orquestrador
|
||||
priority: 20
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, NAO]
|
||||
|
||||
- from: decisao_esclarecimento
|
||||
to: reperguntar_esclarecimento
|
||||
priority: 30
|
||||
|
||||
- from: devolver_orquestrador
|
||||
to: END
|
||||
|
||||
- from: reperguntar_esclarecimento
|
||||
to: decisao_esclarecimento
|
||||
|
||||
- from: registrar_nao_controle
|
||||
to: END
|
||||
|
||||
- from: registrar_aceitou
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1,132 @@
|
||||
name: vas_estrategico
|
||||
version: 3
|
||||
start: preparar
|
||||
|
||||
nodes:
|
||||
- id: preparar
|
||||
action: preparar_vas_estrategico
|
||||
input:
|
||||
items: $.input.items
|
||||
linhas: $.input.linhas
|
||||
pause:
|
||||
enabled: true
|
||||
when:
|
||||
eq: [$.output.await_user_input, true]
|
||||
return_from: $.output.mensagem
|
||||
expected_input:
|
||||
key: resposta_usuario
|
||||
allowed_values: ["SIM", "NAO", "OUTRO"]
|
||||
normalize: upper_strip
|
||||
resume_from: decisao
|
||||
|
||||
- id: resposta_bundle
|
||||
action: montar_resposta_texto
|
||||
input:
|
||||
dados:
|
||||
texto_usuario: $.vars.preparar.mensagem_bundle_fechamento
|
||||
|
||||
- id: decisao
|
||||
action: no_op
|
||||
input: {}
|
||||
|
||||
- id: resposta_sim
|
||||
action: montar_resposta_texto
|
||||
input:
|
||||
dados:
|
||||
texto_usuario: $.vars.preparar.mensagem_pos_aceite
|
||||
|
||||
- id: explicar_cancelamento
|
||||
action: montar_explicacao_cancelamento_vas_estrategico
|
||||
input:
|
||||
linhas: $.vars.preparar.linhas
|
||||
orientacoes_cancelamento: $.input.orientacoes_cancelamento
|
||||
|
||||
- id: registrar_sim
|
||||
action: registrar_atendimento_vas_estrategico
|
||||
input:
|
||||
linhas: $.vars.preparar.linhas
|
||||
mensagem_base: $.vars.resposta_sim.mensagem
|
||||
request_status: "Fechado"
|
||||
status: "CLOSED"
|
||||
|
||||
- id: registrar_nao
|
||||
action: registrar_atendimento_vas_estrategico
|
||||
input:
|
||||
linhas: $.vars.preparar.linhas
|
||||
mensagem_base: $.vars.explicar_cancelamento.mensagem
|
||||
request_status: "Fechado"
|
||||
status: "CLOSED"
|
||||
|
||||
- id: registrar_bundle
|
||||
action: registrar_atendimento_vas_estrategico
|
||||
input:
|
||||
linhas: $.vars.preparar.linhas
|
||||
mensagem_base: $.vars.resposta_bundle.mensagem
|
||||
request_status: "Fechado"
|
||||
status: "CLOSED"
|
||||
|
||||
- id: registrar_outro
|
||||
action: registrar_atendimento_vas_estrategico
|
||||
input:
|
||||
linhas: $.vars.preparar.linhas
|
||||
mensagem_base: "Atendimento encerrado sem confirmação de manutenção do serviço."
|
||||
request_status: "Fechado"
|
||||
status: "CLOSED"
|
||||
|
||||
edges:
|
||||
- from: preparar
|
||||
to: decisao
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.vars.preparar.await_user_input, true]
|
||||
|
||||
- from: preparar
|
||||
to: resposta_bundle
|
||||
priority: 20
|
||||
|
||||
# Bundle puro (sem item estratégico): após a pausa em "sanei sua dúvida?", o
|
||||
# cliente respondeu. O agente não cancela bundle, então fecha com a reafirmação
|
||||
# breve (resposta_bundle) e registra/finaliza, independente de SIM/NAO. Tem
|
||||
# precedência (priority menor) sobre os ramos SIM/NAO do estratégico.
|
||||
- from: decisao
|
||||
to: resposta_bundle
|
||||
priority: 5
|
||||
when:
|
||||
eq: [$.vars.preparar.has_estrategico_items, false]
|
||||
|
||||
- from: decisao
|
||||
to: resposta_sim
|
||||
priority: 10
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, SIM]
|
||||
|
||||
- from: decisao
|
||||
to: explicar_cancelamento
|
||||
priority: 20
|
||||
when:
|
||||
eq: [$.input.resposta_usuario, NAO]
|
||||
|
||||
- from: decisao
|
||||
to: registrar_outro
|
||||
priority: 99
|
||||
|
||||
- from: resposta_sim
|
||||
to: registrar_sim
|
||||
|
||||
- from: explicar_cancelamento
|
||||
to: registrar_nao
|
||||
|
||||
- from: registrar_sim
|
||||
to: END
|
||||
|
||||
- from: registrar_nao
|
||||
to: END
|
||||
|
||||
- from: resposta_bundle
|
||||
to: registrar_bundle
|
||||
|
||||
- from: registrar_bundle
|
||||
to: END
|
||||
|
||||
- from: registrar_outro
|
||||
to: END
|
||||
25
legacy_reference/workflows/exceptions.py
Normal file
25
legacy_reference/workflows/exceptions.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class WorkflowError(Exception):
|
||||
"""Erro base dos workflows configuráveis."""
|
||||
|
||||
|
||||
class WorkflowConfigurationError(WorkflowError):
|
||||
"""Erro de configuração do workflow (arquivo/schema inválido)."""
|
||||
|
||||
|
||||
class WorkflowNotFoundError(WorkflowError):
|
||||
"""Workflow não encontrado no repositório."""
|
||||
|
||||
|
||||
class WorkflowExecutionNotFoundError(WorkflowError):
|
||||
"""Execução de workflow inexistente para o execution_id informado."""
|
||||
|
||||
|
||||
class WorkflowExecutionStateError(WorkflowError):
|
||||
"""Estado da execução não permite a operação solicitada."""
|
||||
|
||||
|
||||
class WorkflowInputError(WorkflowError):
|
||||
"""Input inválido durante a execução/retomada do workflow."""
|
||||
477
legacy_reference/workflows/execution_store.py
Normal file
477
legacy_reference/workflows/execution_store.py
Normal file
@@ -0,0 +1,477 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
from typing import Any, Iterator, Protocol, runtime_checkable
|
||||
|
||||
from agente_contas_tim.workflows.exceptions import (
|
||||
WorkflowExecutionNotFoundError,
|
||||
WorkflowExecutionStateError,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import (
|
||||
ExecutionStatus,
|
||||
WorkflowExecutionRecord,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ExecutionStore(Protocol):
|
||||
def create(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
started_by_message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord: ...
|
||||
|
||||
def get(self, execution_id: str) -> WorkflowExecutionRecord: ...
|
||||
|
||||
def mark_status(
|
||||
self,
|
||||
execution_id: str,
|
||||
*,
|
||||
status: ExecutionStatus,
|
||||
current_node: str | None,
|
||||
resume_from: str | None,
|
||||
expected_input_key: str | None,
|
||||
) -> WorkflowExecutionRecord: ...
|
||||
|
||||
def claim_resume(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int | None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class InMemoryExecutionStore:
|
||||
"""Execution store em memória (sem persistência, para dev/teste)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: dict[str, WorkflowExecutionRecord] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def create(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
started_by_message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
now = datetime.now()
|
||||
record = WorkflowExecutionRecord(
|
||||
execution_id=execution_id,
|
||||
workflow_name=workflow_name,
|
||||
workflow_version=workflow_version,
|
||||
status="RUNNING",
|
||||
current_node=None,
|
||||
resume_from=None,
|
||||
expected_input_key=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
with self._lock:
|
||||
self._records[execution_id] = record
|
||||
return record
|
||||
|
||||
def get(self, execution_id: str) -> WorkflowExecutionRecord:
|
||||
with self._lock:
|
||||
record = self._records.get(execution_id)
|
||||
if record is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
return record
|
||||
|
||||
def mark_status(
|
||||
self,
|
||||
execution_id: str,
|
||||
*,
|
||||
status: ExecutionStatus,
|
||||
current_node: str | None,
|
||||
resume_from: str | None,
|
||||
expected_input_key: str | None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with self._lock:
|
||||
record = self._records.get(execution_id)
|
||||
if record is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
updated = WorkflowExecutionRecord(
|
||||
execution_id=record.execution_id,
|
||||
workflow_name=record.workflow_name,
|
||||
workflow_version=record.workflow_version,
|
||||
status=status,
|
||||
current_node=current_node,
|
||||
resume_from=resume_from,
|
||||
expected_input_key=expected_input_key,
|
||||
created_at=record.created_at,
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
self._records[execution_id] = updated
|
||||
return updated
|
||||
|
||||
def claim_resume(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int | None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with self._lock:
|
||||
record = self._records.get(execution_id)
|
||||
if record is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
if record.workflow_name != workflow_name:
|
||||
raise WorkflowExecutionStateError(
|
||||
"workflow_name informado nao corresponde ao execution_id"
|
||||
)
|
||||
if (
|
||||
workflow_version is not None
|
||||
and record.workflow_version != workflow_version
|
||||
):
|
||||
raise WorkflowExecutionStateError(
|
||||
"version informada nao corresponde a execucao existente"
|
||||
)
|
||||
if record.status != "WAITING_INPUT":
|
||||
raise WorkflowExecutionStateError(
|
||||
f"Execucao {execution_id} nao esta aguardando input"
|
||||
)
|
||||
updated = WorkflowExecutionRecord(
|
||||
execution_id=record.execution_id,
|
||||
workflow_name=record.workflow_name,
|
||||
workflow_version=record.workflow_version,
|
||||
status="RUNNING",
|
||||
current_node=record.current_node,
|
||||
resume_from=record.resume_from,
|
||||
expected_input_key=record.expected_input_key,
|
||||
created_at=record.created_at,
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
self._records[execution_id] = updated
|
||||
return record
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class PostgresExecutionStore:
|
||||
"""Persistencia duravel e lock atomico para execucoes de workflow em Postgres."""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self._dsn = dsn
|
||||
self._setup_lock = Lock()
|
||||
self._setup()
|
||||
|
||||
def create(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
started_by_message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with (
|
||||
self._connect() as conn,
|
||||
conn.cursor(row_factory=self._dict_row_factory()) as cur,
|
||||
):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO workflow_execution (
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
RETURNING
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
""",
|
||||
(
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
"RUNNING",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_record(row)
|
||||
|
||||
def get(self, execution_id: str) -> WorkflowExecutionRecord:
|
||||
with (
|
||||
self._connect() as conn,
|
||||
conn.cursor(row_factory=self._dict_row_factory()) as cur,
|
||||
):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM workflow_execution
|
||||
WHERE execution_id = %s
|
||||
""",
|
||||
(execution_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
return self._row_to_record(row)
|
||||
|
||||
def mark_status(
|
||||
self,
|
||||
execution_id: str,
|
||||
*,
|
||||
status: ExecutionStatus,
|
||||
current_node: str | None,
|
||||
resume_from: str | None,
|
||||
expected_input_key: str | None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with (
|
||||
self._connect() as conn,
|
||||
conn.cursor(row_factory=self._dict_row_factory()) as cur,
|
||||
):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE workflow_execution
|
||||
SET status = %s,
|
||||
current_node = %s,
|
||||
resume_from = %s,
|
||||
expected_input_key = %s,
|
||||
updated_at = NOW()
|
||||
WHERE execution_id = %s
|
||||
RETURNING
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
""",
|
||||
(
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
execution_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
return self._row_to_record(row)
|
||||
|
||||
def claim_resume(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int | None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with (
|
||||
self._connect() as conn,
|
||||
conn.cursor(row_factory=self._dict_row_factory()) as cur,
|
||||
):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM workflow_execution
|
||||
WHERE execution_id = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(execution_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
|
||||
record = self._row_to_record(row)
|
||||
if record.workflow_name != workflow_name:
|
||||
raise WorkflowExecutionStateError(
|
||||
"workflow_name informado nao corresponde ao execution_id"
|
||||
)
|
||||
if (
|
||||
workflow_version is not None
|
||||
and record.workflow_version != workflow_version
|
||||
):
|
||||
raise WorkflowExecutionStateError(
|
||||
"version informada nao corresponde a execucao existente"
|
||||
)
|
||||
if record.status != "WAITING_INPUT":
|
||||
raise WorkflowExecutionStateError(
|
||||
f"Execucao {execution_id} nao esta aguardando input"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE workflow_execution
|
||||
SET status = %s,
|
||||
updated_at = NOW()
|
||||
WHERE execution_id = %s
|
||||
AND status = %s
|
||||
RETURNING
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
""",
|
||||
("RUNNING", execution_id, "WAITING_INPUT"),
|
||||
)
|
||||
updated = cur.fetchone()
|
||||
if updated is None:
|
||||
raise WorkflowExecutionStateError(
|
||||
f"Execucao {execution_id} foi retomada por outra requisicao"
|
||||
)
|
||||
|
||||
return self._row_to_record(updated)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def _setup(self) -> None:
|
||||
with self._setup_lock:
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workflow_execution (
|
||||
execution_id TEXT PRIMARY KEY,
|
||||
workflow_name TEXT NOT NULL,
|
||||
workflow_version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
current_node TEXT,
|
||||
resume_from TEXT,
|
||||
expected_input_key TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_execution_status
|
||||
ON workflow_execution (status)
|
||||
"""
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _connect(self) -> Iterator[Any]:
|
||||
psycopg = self._import_psycopg()
|
||||
conn = psycopg.connect(self._dsn, autocommit=False)
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _import_psycopg() -> Any:
|
||||
try:
|
||||
import psycopg
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente
|
||||
raise ModuleNotFoundError(
|
||||
"psycopg nao esta instalado. "
|
||||
"Adicione a dependencia para usar workflows em PostgreSQL."
|
||||
) from exc
|
||||
return psycopg
|
||||
|
||||
@staticmethod
|
||||
def _dict_row_factory() -> Any:
|
||||
try:
|
||||
from psycopg.rows import dict_row
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente
|
||||
raise ModuleNotFoundError(
|
||||
"psycopg nao esta instalado. "
|
||||
"Adicione a dependencia para usar workflows em PostgreSQL."
|
||||
) from exc
|
||||
return dict_row
|
||||
|
||||
@staticmethod
|
||||
def _row_to_record(row: Any) -> WorkflowExecutionRecord:
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError("Registro de workflow nao encontrado")
|
||||
|
||||
return WorkflowExecutionRecord(
|
||||
execution_id=str(row["execution_id"]),
|
||||
workflow_name=str(row["workflow_name"]),
|
||||
workflow_version=int(row["workflow_version"]),
|
||||
status=str(row["status"]), # type: ignore[arg-type]
|
||||
current_node=row["current_node"],
|
||||
resume_from=row["resume_from"],
|
||||
expected_input_key=row["expected_input_key"],
|
||||
created_at=PostgresExecutionStore._as_datetime(row["created_at"]),
|
||||
updated_at=PostgresExecutionStore._as_datetime(row["updated_at"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_datetime(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value))
|
||||
763
legacy_reference/workflows/oracle_checkpoint.py
Normal file
763
legacy_reference/workflows/oracle_checkpoint.py
Normal file
@@ -0,0 +1,763 @@
|
||||
"""LangGraph checkpoint saver backed by Oracle ADB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from random import random
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
WRITES_IDX_MAP,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from agente_contas_tim.repositories.oracle_state_connection import (
|
||||
OracleStateConnectionFactory,
|
||||
is_unique_constraint_error,
|
||||
normalize_json,
|
||||
set_blob_inputsizes,
|
||||
read_json_value,
|
||||
set_json_inputsizes,
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_NS = "__default__"
|
||||
_PRIMITIVE_TYPES = (str, int, float, bool)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OracleCheckpointSaver(BaseCheckpointSaver):
|
||||
"""Synchronous LangGraph checkpointer persisted in Oracle native JSON/BLOB."""
|
||||
|
||||
_REQUIRED_TABLES = {
|
||||
"TB_CONT_WORKFLOW_CHECKPOINT",
|
||||
"TB_CONT_WORKFLOW_CHECKPOINT_WRITE",
|
||||
"TB_CONT_WORKFLOW_CHECKPOINT_BLOB",
|
||||
}
|
||||
|
||||
def __init__(self, connection_factory: OracleStateConnectionFactory) -> None:
|
||||
super().__init__()
|
||||
self._connection_factory = connection_factory
|
||||
self._connection_factory.validate_required_tables(self._REQUIRED_TABLES)
|
||||
|
||||
def setup(self) -> None:
|
||||
self._connection_factory.validate_required_tables(self._REQUIRED_TABLES)
|
||||
|
||||
def get_tuple(self, config: Mapping[str, Any]) -> CheckpointTuple | None:
|
||||
configurable = dict(config.get("configurable", {}) or {})
|
||||
thread_id = str(configurable["thread_id"])
|
||||
checkpoint_ns = _to_db_ns(str(configurable.get("checkpoint_ns", "")))
|
||||
checkpoint_id = get_checkpoint_id(config) # type: ignore[arg-type]
|
||||
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
if checkpoint_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
parent_checkpoint_id,
|
||||
checkpoint_json,
|
||||
metadata_json
|
||||
FROM tb_cont_workflow_checkpoint
|
||||
WHERE thread_id = :thread_id
|
||||
AND (
|
||||
checkpoint_ns = :checkpoint_ns
|
||||
OR (
|
||||
:checkpoint_ns = :default_checkpoint_ns
|
||||
AND checkpoint_ns IS NULL
|
||||
)
|
||||
)
|
||||
AND checkpoint_id = :checkpoint_id
|
||||
""",
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"default_checkpoint_ns": _DEFAULT_NS,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
parent_checkpoint_id,
|
||||
checkpoint_json,
|
||||
metadata_json
|
||||
FROM tb_cont_workflow_checkpoint
|
||||
WHERE thread_id = :thread_id
|
||||
AND (
|
||||
checkpoint_ns = :checkpoint_ns
|
||||
OR (
|
||||
:checkpoint_ns = :default_checkpoint_ns
|
||||
AND checkpoint_ns IS NULL
|
||||
)
|
||||
)
|
||||
ORDER BY checkpoint_id DESC
|
||||
FETCH FIRST 1 ROWS ONLY
|
||||
""",
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"default_checkpoint_ns": _DEFAULT_NS,
|
||||
},
|
||||
)
|
||||
row = _fetchone_dict(cur)
|
||||
if row is None:
|
||||
return None
|
||||
return self._load_checkpoint_tuple(cur, row)
|
||||
|
||||
def list(
|
||||
self,
|
||||
config: Mapping[str, Any] | None,
|
||||
*,
|
||||
filter: dict[str, Any] | None = None,
|
||||
before: Mapping[str, Any] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
clauses: list[str] = []
|
||||
params: dict[str, Any] = {}
|
||||
configurable = dict((config or {}).get("configurable", {}) or {})
|
||||
if configurable.get("thread_id"):
|
||||
clauses.append("thread_id = :thread_id")
|
||||
params["thread_id"] = str(configurable["thread_id"])
|
||||
if "checkpoint_ns" in configurable:
|
||||
clauses.append(
|
||||
"(checkpoint_ns = :checkpoint_ns OR "
|
||||
"(:checkpoint_ns = :default_checkpoint_ns "
|
||||
"AND checkpoint_ns IS NULL))"
|
||||
)
|
||||
params["checkpoint_ns"] = _to_db_ns(
|
||||
str(configurable.get("checkpoint_ns", ""))
|
||||
)
|
||||
params["default_checkpoint_ns"] = _DEFAULT_NS
|
||||
if before:
|
||||
before_id = get_checkpoint_id(before) # type: ignore[arg-type]
|
||||
if before_id:
|
||||
clauses.append("checkpoint_id < :before_checkpoint_id")
|
||||
params["before_checkpoint_id"] = before_id
|
||||
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
query = f"""
|
||||
SELECT
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
parent_checkpoint_id,
|
||||
checkpoint_json,
|
||||
metadata_json
|
||||
FROM tb_cont_workflow_checkpoint
|
||||
{where}
|
||||
ORDER BY checkpoint_id DESC
|
||||
"""
|
||||
|
||||
emitted = 0
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
rows = [_row_to_dict(cur, row) for row in cur.fetchall()]
|
||||
for row in rows:
|
||||
metadata = read_json_value(row.get("metadata_json")) or {}
|
||||
if filter and not _metadata_matches(metadata, filter):
|
||||
continue
|
||||
yield self._load_checkpoint_tuple(cur, row)
|
||||
emitted += 1
|
||||
if limit is not None and emitted >= limit:
|
||||
return
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: Mapping[str, Any],
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> dict[str, Any]:
|
||||
configurable = dict(config["configurable"])
|
||||
thread_id = str(configurable.pop("thread_id"))
|
||||
checkpoint_ns = str(configurable.pop("checkpoint_ns", ""))
|
||||
parent_checkpoint_id = configurable.pop("checkpoint_id", None)
|
||||
checkpoint_id = str(checkpoint["id"])
|
||||
db_checkpoint_ns = _to_db_ns(checkpoint_ns)
|
||||
|
||||
checkpoint_copy = deepcopy(checkpoint)
|
||||
channel_values = dict(checkpoint_copy.get("channel_values") or {})
|
||||
checkpoint_copy["channel_values"] = channel_values
|
||||
blob_values: dict[str, Any] = {}
|
||||
for channel, value in list(channel_values.items()):
|
||||
if isinstance(value, _DeltaSnapshot):
|
||||
blob_values[channel] = value
|
||||
channel_values[channel] = True
|
||||
continue
|
||||
if value is None or isinstance(value, _PRIMITIVE_TYPES):
|
||||
continue
|
||||
blob_values[channel] = channel_values.pop(channel)
|
||||
|
||||
try:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
blob_versions = {
|
||||
channel: version
|
||||
for channel, version in dict(new_versions).items()
|
||||
if channel in blob_values
|
||||
}
|
||||
for channel, version in blob_versions.items():
|
||||
self._upsert_blob(
|
||||
cur,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=db_checkpoint_ns,
|
||||
channel=str(channel),
|
||||
version=str(version),
|
||||
value=blob_values[channel],
|
||||
)
|
||||
|
||||
set_json_inputsizes(cur, "checkpoint_json", "metadata_json")
|
||||
cur.execute(
|
||||
"""
|
||||
MERGE INTO tb_cont_workflow_checkpoint dst
|
||||
USING (
|
||||
SELECT
|
||||
:thread_id thread_id,
|
||||
:checkpoint_ns checkpoint_ns,
|
||||
:checkpoint_id checkpoint_id
|
||||
FROM dual
|
||||
) src
|
||||
ON (
|
||||
dst.thread_id = src.thread_id
|
||||
AND dst.checkpoint_ns = src.checkpoint_ns
|
||||
AND dst.checkpoint_id = src.checkpoint_id
|
||||
)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
parent_checkpoint_id = :parent_checkpoint_id,
|
||||
checkpoint_json = :checkpoint_json,
|
||||
metadata_json = :metadata_json
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
parent_checkpoint_id,
|
||||
checkpoint_json,
|
||||
metadata_json
|
||||
) VALUES (
|
||||
:thread_id,
|
||||
:checkpoint_ns,
|
||||
:checkpoint_id,
|
||||
:parent_checkpoint_id,
|
||||
:checkpoint_json,
|
||||
:metadata_json
|
||||
)
|
||||
""",
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": db_checkpoint_ns,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"parent_checkpoint_id": parent_checkpoint_id,
|
||||
"checkpoint_json": normalize_json(checkpoint_copy),
|
||||
"metadata_json": normalize_json(
|
||||
get_serializable_checkpoint_metadata(
|
||||
config, # type: ignore[arg-type]
|
||||
metadata,
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"oracle_checkpoint.put.failed thread_id=%s checkpoint_ns=%s "
|
||||
"checkpoint_id=%s parent_checkpoint_id=%s blob_channels=%s "
|
||||
"channel_keys=%s",
|
||||
thread_id,
|
||||
db_checkpoint_ns,
|
||||
checkpoint_id,
|
||||
parent_checkpoint_id,
|
||||
sorted(str(channel) for channel in blob_values.keys()),
|
||||
sorted(str(channel) for channel in channel_values.keys()),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
}
|
||||
}
|
||||
|
||||
def put_writes(
|
||||
self,
|
||||
config: Mapping[str, Any],
|
||||
writes: Sequence[tuple[str, Any]],
|
||||
task_id: str,
|
||||
task_path: str = "",
|
||||
) -> None:
|
||||
configurable = dict(config["configurable"])
|
||||
thread_id = str(configurable["thread_id"])
|
||||
checkpoint_ns = _to_db_ns(str(configurable.get("checkpoint_ns", "")))
|
||||
checkpoint_id = str(configurable["checkpoint_id"])
|
||||
use_upsert = all(channel in WRITES_IDX_MAP for channel, _ in writes)
|
||||
|
||||
try:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
set_blob_inputsizes(cur, "blob_payload")
|
||||
for idx, (channel, value) in enumerate(writes):
|
||||
write_idx = WRITES_IDX_MAP.get(channel, idx)
|
||||
type_name, payload = self.serde.dumps_typed(value)
|
||||
params = {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"idx": write_idx,
|
||||
"channel": channel,
|
||||
"type_name": type_name,
|
||||
"blob_payload": _blob_payload_for_storage(
|
||||
type_name,
|
||||
payload,
|
||||
),
|
||||
}
|
||||
if use_upsert:
|
||||
cur.execute(
|
||||
"""
|
||||
MERGE INTO tb_cont_workflow_checkpoint_write dst
|
||||
USING (
|
||||
SELECT
|
||||
:thread_id thread_id,
|
||||
:checkpoint_ns checkpoint_ns,
|
||||
:checkpoint_id checkpoint_id,
|
||||
:task_id task_id,
|
||||
:idx idx
|
||||
FROM dual
|
||||
) src
|
||||
ON (
|
||||
dst.thread_id = src.thread_id
|
||||
AND dst.checkpoint_ns = src.checkpoint_ns
|
||||
AND dst.checkpoint_id = src.checkpoint_id
|
||||
AND dst.task_id = src.task_id
|
||||
AND dst.idx = src.idx
|
||||
)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
task_path = :task_path,
|
||||
channel = :channel,
|
||||
type_name = :type_name,
|
||||
blob_payload = :blob_payload,
|
||||
value_json = NULL
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
task_id,
|
||||
task_path,
|
||||
idx,
|
||||
channel,
|
||||
type_name,
|
||||
blob_payload,
|
||||
value_json
|
||||
) VALUES (
|
||||
:thread_id,
|
||||
:checkpoint_ns,
|
||||
:checkpoint_id,
|
||||
:task_id,
|
||||
:task_path,
|
||||
:idx,
|
||||
:channel,
|
||||
:type_name,
|
||||
:blob_payload,
|
||||
NULL
|
||||
)
|
||||
""",
|
||||
params,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tb_cont_workflow_checkpoint_write (
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
task_id,
|
||||
task_path,
|
||||
idx,
|
||||
channel,
|
||||
type_name,
|
||||
blob_payload,
|
||||
value_json
|
||||
) VALUES (
|
||||
:thread_id,
|
||||
:checkpoint_ns,
|
||||
:checkpoint_id,
|
||||
:task_id,
|
||||
:task_path,
|
||||
:idx,
|
||||
:channel,
|
||||
:type_name,
|
||||
:blob_payload,
|
||||
NULL
|
||||
)
|
||||
""",
|
||||
params,
|
||||
)
|
||||
except Exception as exc:
|
||||
if is_unique_constraint_error(exc):
|
||||
continue
|
||||
raise
|
||||
except Exception:
|
||||
logger.error(
|
||||
"oracle_checkpoint.put_writes.failed thread_id=%s "
|
||||
"checkpoint_ns=%s checkpoint_id=%s task_id=%s "
|
||||
"task_path=%s channels=%s use_upsert=%s",
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
task_id,
|
||||
task_path,
|
||||
[str(channel) for channel, _ in writes],
|
||||
use_upsert,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
def delete_thread(self, thread_id: str) -> None:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM tb_cont_workflow_checkpoint_write WHERE thread_id = :thread_id",
|
||||
{"thread_id": thread_id},
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM tb_cont_workflow_checkpoint_blob WHERE thread_id = :thread_id",
|
||||
{"thread_id": thread_id},
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM tb_cont_workflow_checkpoint WHERE thread_id = :thread_id",
|
||||
{"thread_id": thread_id},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def _load_checkpoint_tuple(
|
||||
self,
|
||||
cur: Any,
|
||||
row: dict[str, Any],
|
||||
) -> CheckpointTuple:
|
||||
checkpoint = read_json_value(row.get("checkpoint_json")) or {}
|
||||
metadata = read_json_value(row.get("metadata_json")) or {}
|
||||
thread_id = str(row["thread_id"])
|
||||
checkpoint_ns = _db_ns_from_row(row.get("checkpoint_ns"))
|
||||
checkpoint_id = str(row["checkpoint_id"])
|
||||
parent_checkpoint_id = row.get("parent_checkpoint_id")
|
||||
channel_values = dict(checkpoint.get("channel_values") or {})
|
||||
checkpoint["channel_values"] = {
|
||||
**channel_values,
|
||||
**self._load_blobs(
|
||||
cur,
|
||||
checkpoint=checkpoint,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=checkpoint_ns,
|
||||
),
|
||||
}
|
||||
pending_writes = self._load_writes(
|
||||
cur,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=checkpoint_ns,
|
||||
checkpoint_id=checkpoint_id,
|
||||
)
|
||||
return CheckpointTuple(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": _from_db_ns(checkpoint_ns),
|
||||
"checkpoint_id": checkpoint_id,
|
||||
}
|
||||
},
|
||||
checkpoint,
|
||||
metadata,
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": _from_db_ns(checkpoint_ns),
|
||||
"checkpoint_id": parent_checkpoint_id,
|
||||
}
|
||||
}
|
||||
if parent_checkpoint_id
|
||||
else None
|
||||
),
|
||||
pending_writes,
|
||||
)
|
||||
|
||||
def _load_blobs(
|
||||
self,
|
||||
cur: Any,
|
||||
*,
|
||||
checkpoint: dict[str, Any],
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
) -> dict[str, Any]:
|
||||
channel_versions = checkpoint.get("channel_versions") or {}
|
||||
if not isinstance(channel_versions, dict):
|
||||
return {}
|
||||
values: dict[str, Any] = {}
|
||||
for channel, version in channel_versions.items():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT channel, type_name, blob_payload
|
||||
FROM tb_cont_workflow_checkpoint_blob
|
||||
WHERE thread_id = :thread_id
|
||||
AND (
|
||||
checkpoint_ns = :checkpoint_ns
|
||||
OR (
|
||||
:checkpoint_ns = :default_checkpoint_ns
|
||||
AND checkpoint_ns IS NULL
|
||||
)
|
||||
)
|
||||
AND channel = :channel
|
||||
AND version = :version
|
||||
""",
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"default_checkpoint_ns": _DEFAULT_NS,
|
||||
"channel": str(channel),
|
||||
"version": str(version),
|
||||
},
|
||||
)
|
||||
blob_row = _fetchone_dict(cur)
|
||||
if blob_row is None:
|
||||
continue
|
||||
type_name = str(blob_row.get("type_name") or "")
|
||||
if type_name == "empty":
|
||||
continue
|
||||
payload = _read_lob_bytes(blob_row.get("blob_payload"))
|
||||
try:
|
||||
values[str(blob_row["channel"])] = self.serde.loads_typed(
|
||||
(type_name, payload)
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"oracle_checkpoint.load_blob.failed thread_id=%s "
|
||||
"checkpoint_ns=%s channel=%s version=%s type_name=%s "
|
||||
"payload_len=%s",
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
version,
|
||||
type_name,
|
||||
len(payload),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
return values
|
||||
|
||||
def _load_writes(
|
||||
self,
|
||||
cur: Any,
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
checkpoint_id: str,
|
||||
) -> list[tuple[str, str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT task_id, channel, type_name, blob_payload
|
||||
FROM tb_cont_workflow_checkpoint_write
|
||||
WHERE thread_id = :thread_id
|
||||
AND (
|
||||
checkpoint_ns = :checkpoint_ns
|
||||
OR (
|
||||
:checkpoint_ns = :default_checkpoint_ns
|
||||
AND checkpoint_ns IS NULL
|
||||
)
|
||||
)
|
||||
AND checkpoint_id = :checkpoint_id
|
||||
ORDER BY task_id, idx
|
||||
""",
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"default_checkpoint_ns": _DEFAULT_NS,
|
||||
"checkpoint_id": checkpoint_id,
|
||||
},
|
||||
)
|
||||
rows = [_row_to_dict(cur, row) for row in cur.fetchall()]
|
||||
writes: list[tuple[str, str, Any]] = []
|
||||
for row in rows:
|
||||
type_name = str(row["type_name"])
|
||||
payload = _read_lob_bytes(row.get("blob_payload"))
|
||||
try:
|
||||
value = self.serde.loads_typed((type_name, payload))
|
||||
except Exception:
|
||||
logger.error(
|
||||
"oracle_checkpoint.load_write.failed thread_id=%s "
|
||||
"checkpoint_ns=%s checkpoint_id=%s task_id=%s "
|
||||
"channel=%s type_name=%s payload_len=%s",
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id,
|
||||
row.get("task_id"),
|
||||
row.get("channel"),
|
||||
type_name,
|
||||
len(payload),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
writes.append(
|
||||
(
|
||||
str(row["task_id"]),
|
||||
str(row["channel"]),
|
||||
value,
|
||||
)
|
||||
)
|
||||
return writes
|
||||
|
||||
def _upsert_blob(
|
||||
self,
|
||||
cur: Any,
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
channel: str,
|
||||
version: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
type_name, payload = self.serde.dumps_typed(value)
|
||||
set_blob_inputsizes(cur, "blob_payload")
|
||||
cur.execute(
|
||||
"""
|
||||
MERGE INTO tb_cont_workflow_checkpoint_blob dst
|
||||
USING (
|
||||
SELECT
|
||||
:thread_id thread_id,
|
||||
:checkpoint_ns checkpoint_ns,
|
||||
:channel channel,
|
||||
:version version
|
||||
FROM dual
|
||||
) src
|
||||
ON (
|
||||
dst.thread_id = src.thread_id
|
||||
AND dst.checkpoint_ns = src.checkpoint_ns
|
||||
AND dst.channel = src.channel
|
||||
AND dst.version = src.version
|
||||
)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
type_name = :type_name,
|
||||
blob_payload = :blob_payload,
|
||||
json_payload = NULL
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
version,
|
||||
type_name,
|
||||
blob_payload,
|
||||
json_payload
|
||||
) VALUES (
|
||||
:thread_id,
|
||||
:checkpoint_ns,
|
||||
:channel,
|
||||
:version,
|
||||
:type_name,
|
||||
:blob_payload,
|
||||
NULL
|
||||
)
|
||||
""",
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"channel": channel,
|
||||
"version": version,
|
||||
"type_name": type_name,
|
||||
"blob_payload": _blob_payload_for_storage(type_name, payload),
|
||||
},
|
||||
)
|
||||
|
||||
def get_next_version(self, current: Any, channel: None) -> str:
|
||||
if current is None:
|
||||
current_v = 0
|
||||
elif isinstance(current, int):
|
||||
current_v = current
|
||||
else:
|
||||
current_v = int(str(current).split(".")[0])
|
||||
next_v = current_v + 1
|
||||
next_h = random()
|
||||
return f"{next_v:032}.{next_h:016}"
|
||||
|
||||
|
||||
def _to_db_ns(checkpoint_ns: str) -> str:
|
||||
return checkpoint_ns or _DEFAULT_NS
|
||||
|
||||
|
||||
def _from_db_ns(checkpoint_ns: str | None) -> str:
|
||||
return (
|
||||
""
|
||||
if not checkpoint_ns or checkpoint_ns == _DEFAULT_NS
|
||||
else checkpoint_ns
|
||||
)
|
||||
|
||||
|
||||
def _db_ns_from_row(checkpoint_ns: Any) -> str:
|
||||
if checkpoint_ns is None:
|
||||
return _DEFAULT_NS
|
||||
return str(checkpoint_ns) or _DEFAULT_NS
|
||||
|
||||
|
||||
def _fetchone_dict(cur: Any) -> dict[str, Any] | None:
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(cur, row)
|
||||
|
||||
|
||||
def _row_to_dict(cur: Any, row: Any) -> dict[str, Any]:
|
||||
names = [str(col[0]).lower() for col in cur.description]
|
||||
return dict(zip(names, row, strict=False))
|
||||
|
||||
|
||||
def _metadata_matches(metadata: Any, expected: dict[str, Any]) -> bool:
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
for key, value in expected.items():
|
||||
if metadata.get(key) != value:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read_lob_bytes(value: Any) -> bytes:
|
||||
if value is None:
|
||||
return b""
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if isinstance(value, bytearray):
|
||||
return bytes(value)
|
||||
if isinstance(value, memoryview):
|
||||
return value.tobytes()
|
||||
if isinstance(value, str):
|
||||
return value.encode("utf-8")
|
||||
read = getattr(value, "read", None)
|
||||
if callable(read):
|
||||
return _read_lob_bytes(read())
|
||||
return bytes(value)
|
||||
|
||||
|
||||
def _blob_payload_for_storage(type_name: str, payload: bytes | None) -> bytes:
|
||||
if payload:
|
||||
return payload
|
||||
if type_name in {"empty", "null"}:
|
||||
# Oracle can normalize zero-length BLOB binds to NULL. LangGraph's
|
||||
# serde ignores the payload for these tags, so a one-byte sentinel keeps
|
||||
# legacy payload checks and Oracle's empty-BLOB behavior from breaking
|
||||
# resume writes that legitimately carry None.
|
||||
return b"\x00"
|
||||
return payload if payload is not None else b""
|
||||
342
legacy_reference/workflows/oracle_execution_store.py
Normal file
342
legacy_reference/workflows/oracle_execution_store.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""Oracle implementation of workflow execution state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agente_contas_tim.repositories.oracle_state_connection import (
|
||||
OracleStateConnectionFactory,
|
||||
is_unique_constraint_error,
|
||||
normalize_json,
|
||||
set_json_inputsizes,
|
||||
)
|
||||
from agente_contas_tim.workflows.exceptions import (
|
||||
WorkflowExecutionNotFoundError,
|
||||
WorkflowExecutionStateError,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import (
|
||||
ExecutionStatus,
|
||||
WorkflowExecutionRecord,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OracleExecutionStore:
|
||||
"""Persistencia duravel e lock atomico para workflows em Oracle ADB."""
|
||||
|
||||
_REQUIRED_TABLES = {
|
||||
"TB_CONT_AGENT_SESSION",
|
||||
"TB_CONT_AGENT_MESSAGE",
|
||||
"TB_CONT_WORKFLOW_EXECUTION",
|
||||
"TB_CONT_WORKFLOW_MESSAGE_LINK",
|
||||
}
|
||||
|
||||
def __init__(self, connection_factory: OracleStateConnectionFactory) -> None:
|
||||
self._connection_factory = connection_factory
|
||||
self._connection_factory.validate_required_tables(self._REQUIRED_TABLES)
|
||||
|
||||
def create(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
started_by_message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tb_cont_workflow_execution (
|
||||
execution_id,
|
||||
session_id,
|
||||
started_by_message_id,
|
||||
last_message_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key
|
||||
) VALUES (
|
||||
:execution_id,
|
||||
:session_id,
|
||||
:started_by_message_id,
|
||||
:last_message_id,
|
||||
:workflow_name,
|
||||
:workflow_version,
|
||||
'RUNNING',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
)
|
||||
""",
|
||||
{
|
||||
"execution_id": execution_id,
|
||||
"session_id": session_id,
|
||||
"started_by_message_id": started_by_message_id,
|
||||
"last_message_id": started_by_message_id,
|
||||
"workflow_name": workflow_name,
|
||||
"workflow_version": workflow_version,
|
||||
},
|
||||
)
|
||||
if session_id and started_by_message_id:
|
||||
self._insert_message_link(
|
||||
cur,
|
||||
execution_id=execution_id,
|
||||
session_id=session_id,
|
||||
message_id=started_by_message_id,
|
||||
purpose="STARTED",
|
||||
metadata={"workflow": workflow_name, "version": workflow_version},
|
||||
)
|
||||
row = self._select_execution(cur, execution_id)
|
||||
return self._row_to_record(row)
|
||||
|
||||
def get(self, execution_id: str) -> WorkflowExecutionRecord:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
row = self._select_execution(cur, execution_id)
|
||||
return self._row_to_record(row)
|
||||
|
||||
def mark_status(
|
||||
self,
|
||||
execution_id: str,
|
||||
*,
|
||||
status: ExecutionStatus,
|
||||
current_node: str | None,
|
||||
resume_from: str | None,
|
||||
expected_input_key: str | None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE tb_cont_workflow_execution
|
||||
SET status = :status,
|
||||
current_node = :current_node,
|
||||
resume_from = :resume_from,
|
||||
expected_input_key = :expected_input_key
|
||||
WHERE execution_id = :execution_id
|
||||
""",
|
||||
{
|
||||
"execution_id": execution_id,
|
||||
"status": status,
|
||||
"current_node": current_node,
|
||||
"resume_from": resume_from,
|
||||
"expected_input_key": expected_input_key,
|
||||
},
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
row = self._select_execution(cur, execution_id)
|
||||
if status in {"COMPLETED", "FAILED"}:
|
||||
purpose = "COMPLETED" if status == "COMPLETED" else "FAILED"
|
||||
self._link_last_message(cur, row=row, purpose=purpose)
|
||||
return self._row_to_record(row)
|
||||
|
||||
def claim_resume(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int | None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> WorkflowExecutionRecord:
|
||||
with self._connection_factory.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
execution_id,
|
||||
session_id,
|
||||
started_by_message_id,
|
||||
last_message_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM tb_cont_workflow_execution
|
||||
WHERE execution_id = :execution_id
|
||||
FOR UPDATE
|
||||
""",
|
||||
{"execution_id": execution_id},
|
||||
)
|
||||
row = _fetchone_dict(cur)
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
|
||||
record = self._row_to_record(row)
|
||||
if record.workflow_name != workflow_name:
|
||||
raise WorkflowExecutionStateError(
|
||||
"workflow_name informado nao corresponde ao execution_id"
|
||||
)
|
||||
if (
|
||||
workflow_version is not None
|
||||
and record.workflow_version != workflow_version
|
||||
):
|
||||
raise WorkflowExecutionStateError(
|
||||
"version informada nao corresponde a execucao existente"
|
||||
)
|
||||
if record.status != "WAITING_INPUT":
|
||||
raise WorkflowExecutionStateError(
|
||||
f"Execucao {execution_id} nao esta aguardando input"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE tb_cont_workflow_execution
|
||||
SET status = 'RUNNING',
|
||||
last_message_id = COALESCE(:message_id, last_message_id)
|
||||
WHERE execution_id = :execution_id
|
||||
AND status = 'WAITING_INPUT'
|
||||
""",
|
||||
{"execution_id": execution_id, "message_id": message_id},
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise WorkflowExecutionStateError(
|
||||
f"Execucao {execution_id} foi retomada por outra requisicao"
|
||||
)
|
||||
row = self._select_execution(cur, execution_id)
|
||||
effective_session_id = session_id or row.get("session_id")
|
||||
if effective_session_id and message_id:
|
||||
self._insert_message_link(
|
||||
cur,
|
||||
execution_id=execution_id,
|
||||
session_id=str(effective_session_id),
|
||||
message_id=message_id,
|
||||
purpose="RESUMED",
|
||||
metadata={"workflow": workflow_name, "version": workflow_version},
|
||||
)
|
||||
return self._row_to_record(row)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _select_execution(cur: Any, execution_id: str) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
execution_id,
|
||||
session_id,
|
||||
started_by_message_id,
|
||||
last_message_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
status,
|
||||
current_node,
|
||||
resume_from,
|
||||
expected_input_key,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM tb_cont_workflow_execution
|
||||
WHERE execution_id = :execution_id
|
||||
""",
|
||||
{"execution_id": execution_id},
|
||||
)
|
||||
row = _fetchone_dict(cur)
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError(
|
||||
f"execution_id={execution_id!r} nao encontrado"
|
||||
)
|
||||
return row
|
||||
|
||||
def _link_last_message(
|
||||
self,
|
||||
cur: Any,
|
||||
*,
|
||||
row: dict[str, Any],
|
||||
purpose: str,
|
||||
) -> None:
|
||||
session_id = row.get("session_id")
|
||||
message_id = row.get("last_message_id")
|
||||
if not session_id or not message_id:
|
||||
return
|
||||
self._insert_message_link(
|
||||
cur,
|
||||
execution_id=str(row["execution_id"]),
|
||||
session_id=str(session_id),
|
||||
message_id=str(message_id),
|
||||
purpose=purpose,
|
||||
metadata={"workflow": row.get("workflow_name")},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_message_link(
|
||||
cur: Any,
|
||||
*,
|
||||
execution_id: str,
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
purpose: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
set_json_inputsizes(cur, "metadata_json")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tb_cont_workflow_message_link (
|
||||
execution_id,
|
||||
session_id,
|
||||
message_id,
|
||||
purpose,
|
||||
metadata_json
|
||||
) VALUES (
|
||||
:execution_id,
|
||||
:session_id,
|
||||
:message_id,
|
||||
:purpose,
|
||||
:metadata_json
|
||||
)
|
||||
""",
|
||||
{
|
||||
"execution_id": execution_id,
|
||||
"session_id": session_id,
|
||||
"message_id": message_id,
|
||||
"purpose": purpose,
|
||||
"metadata_json": normalize_json(metadata),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
if is_unique_constraint_error(exc):
|
||||
return
|
||||
logger.debug("workflow.message_link.insert_failed", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_record(row: Any) -> WorkflowExecutionRecord:
|
||||
if row is None:
|
||||
raise WorkflowExecutionNotFoundError("Registro de workflow nao encontrado")
|
||||
return WorkflowExecutionRecord(
|
||||
execution_id=str(row["execution_id"]),
|
||||
workflow_name=str(row["workflow_name"]),
|
||||
workflow_version=int(row["workflow_version"]),
|
||||
status=str(row["status"]), # type: ignore[arg-type]
|
||||
current_node=row["current_node"],
|
||||
resume_from=row["resume_from"],
|
||||
expected_input_key=row["expected_input_key"],
|
||||
created_at=_as_datetime(row["created_at"]),
|
||||
updated_at=_as_datetime(row["updated_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _fetchone_dict(cur: Any) -> dict[str, Any] | None:
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
names = [str(col[0]).lower() for col in cur.description]
|
||||
return dict(zip(names, row, strict=False))
|
||||
|
||||
|
||||
def _as_datetime(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value))
|
||||
9
legacy_reference/workflows/repositories/__init__.py
Normal file
9
legacy_reference/workflows/repositories/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from agente_contas_tim.workflows.repositories.db_repo import DbWorkflowRepository
|
||||
from agente_contas_tim.workflows.repositories.base import WorkflowRepository
|
||||
from agente_contas_tim.workflows.repositories.file_repo import FileWorkflowRepository
|
||||
|
||||
__all__ = [
|
||||
"WorkflowRepository",
|
||||
"FileWorkflowRepository",
|
||||
"DbWorkflowRepository",
|
||||
]
|
||||
11
legacy_reference/workflows/repositories/base.py
Normal file
11
legacy_reference/workflows/repositories/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from agente_contas_tim.workflows.contracts import WorkflowDef
|
||||
|
||||
|
||||
class WorkflowRepository(Protocol):
|
||||
def get_active(self, name: str) -> WorkflowDef: ...
|
||||
|
||||
def get_version(self, name: str, version: int) -> WorkflowDef: ...
|
||||
51
legacy_reference/workflows/repositories/db_repo.py
Normal file
51
legacy_reference/workflows/repositories/db_repo.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Protocol
|
||||
|
||||
from agente_contas_tim.workflows.contracts import WorkflowDef
|
||||
from agente_contas_tim.workflows.exceptions import WorkflowNotFoundError
|
||||
|
||||
|
||||
class DbConnection(Protocol):
|
||||
def execute(self, query: str, params: tuple[Any, ...]): ...
|
||||
|
||||
|
||||
class DbWorkflowRepository:
|
||||
"""Repositório de workflow em banco.
|
||||
|
||||
Espera schema lógico:
|
||||
workflow_definitions(name, version, status, definition_json)
|
||||
workflow_active(name, version)
|
||||
"""
|
||||
|
||||
def __init__(self, connection: DbConnection) -> None:
|
||||
self._connection = connection
|
||||
|
||||
def get_active(self, name: str) -> WorkflowDef:
|
||||
row = self._connection.execute(
|
||||
(
|
||||
"SELECT d.definition_json "
|
||||
"FROM workflow_active a "
|
||||
"JOIN workflow_definitions d "
|
||||
" ON d.name = a.name AND d.version = a.version "
|
||||
"WHERE a.name = %s"
|
||||
),
|
||||
(name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise WorkflowNotFoundError(f"Workflow {name!r} ativo não encontrado")
|
||||
return WorkflowDef.model_validate(json.loads(row[0]))
|
||||
|
||||
def get_version(self, name: str, version: int) -> WorkflowDef:
|
||||
row = self._connection.execute(
|
||||
(
|
||||
"SELECT definition_json "
|
||||
"FROM workflow_definitions "
|
||||
"WHERE name = %s AND version = %s"
|
||||
),
|
||||
(name, version),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise WorkflowNotFoundError(f"Workflow {name!r} v{version} não encontrado")
|
||||
return WorkflowDef.model_validate(json.loads(row[0]))
|
||||
161
legacy_reference/workflows/repositories/file_repo.py
Normal file
161
legacy_reference/workflows/repositories/file_repo.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agente_contas_tim.workflows.contracts import WorkflowDef
|
||||
from agente_contas_tim.workflows.exceptions import (
|
||||
WorkflowConfigurationError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
class FileWorkflowRepository:
|
||||
"""Repositório de workflow baseado em arquivos no disco.
|
||||
|
||||
Convenções suportadas:
|
||||
- Versão explícita: ``<name>.v<version>.json|yaml|yml``
|
||||
- Ativo por ponteiro: ``<name>.active.json|yaml|yml``
|
||||
- Pode conter um inteiro: ``{"version": 3}``
|
||||
- Ou conter o workflow completo.
|
||||
- Sem ponteiro ativo: usa maior versão disponível.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str | Path) -> None:
|
||||
self._base_dir = Path(base_dir)
|
||||
|
||||
def get_active(self, name: str) -> WorkflowDef:
|
||||
self._ensure_base_dir()
|
||||
active = self._find_active_file(name)
|
||||
if active is not None:
|
||||
loaded = self._load_dict(active)
|
||||
if self._is_workflow_dict(loaded):
|
||||
return WorkflowDef.model_validate(loaded)
|
||||
version = loaded.get("version")
|
||||
if not isinstance(version, int):
|
||||
raise WorkflowConfigurationError(
|
||||
f"Arquivo ativo inválido: {active}. "
|
||||
"Esperado {'version': <int>} ou workflow completo."
|
||||
)
|
||||
return self.get_version(name, version)
|
||||
|
||||
versions = self._list_versions(name)
|
||||
if not versions:
|
||||
raise WorkflowNotFoundError(f"Workflow {name!r} não encontrado")
|
||||
return self.get_version(name, max(versions))
|
||||
|
||||
def get_version(self, name: str, version: int) -> WorkflowDef:
|
||||
self._ensure_base_dir()
|
||||
candidate = self._find_version_file(name, version)
|
||||
if candidate is not None:
|
||||
return WorkflowDef.model_validate(self._load_dict(candidate))
|
||||
|
||||
raise WorkflowNotFoundError(
|
||||
f"Workflow {name!r} v{version} não encontrado em {self._base_dir}"
|
||||
)
|
||||
|
||||
def _ensure_base_dir(self) -> None:
|
||||
if not self._base_dir.exists():
|
||||
raise WorkflowConfigurationError(
|
||||
f"Diretório de workflows não existe: {self._base_dir}"
|
||||
)
|
||||
|
||||
def _find_active_file(self, name: str) -> Path | None:
|
||||
return self._find_unique_file(
|
||||
[f"{name}.active.json", f"{name}.active.yaml", f"{name}.active.yml"],
|
||||
not_found_message=None,
|
||||
ambiguous_message=(
|
||||
"Múltiplos ponteiros ativos encontrados para "
|
||||
f"{name!r} em {self._base_dir}. "
|
||||
"Mantenha apenas um arquivo <name>.active.<ext>."
|
||||
),
|
||||
)
|
||||
|
||||
def _list_versions(self, name: str) -> list[int]:
|
||||
versions: list[int] = []
|
||||
for file in self._base_dir.rglob(f"{name}.v*.*"):
|
||||
suffixes = file.suffixes
|
||||
if not suffixes:
|
||||
continue
|
||||
ext = suffixes[-1]
|
||||
if ext not in {".json", ".yaml", ".yml"}:
|
||||
continue
|
||||
stem = file.stem
|
||||
# stem ex.: vas_decision.v1
|
||||
if ".v" not in stem:
|
||||
continue
|
||||
version_str = stem.rsplit(".v", maxsplit=1)[-1]
|
||||
if version_str.isdigit():
|
||||
versions.append(int(version_str))
|
||||
return versions
|
||||
|
||||
def _find_version_file(self, name: str, version: int) -> Path | None:
|
||||
return self._find_unique_file(
|
||||
[
|
||||
f"{name}.v{version}.json",
|
||||
f"{name}.v{version}.yaml",
|
||||
f"{name}.v{version}.yml",
|
||||
],
|
||||
not_found_message=None,
|
||||
ambiguous_message=(
|
||||
f"Múltiplos workflows encontrados para {name!r} v{version} "
|
||||
f"em {self._base_dir}. Mantenha apenas um arquivo por versão."
|
||||
),
|
||||
)
|
||||
|
||||
def _find_unique_file(
|
||||
self,
|
||||
names: list[str],
|
||||
*,
|
||||
not_found_message: str | None,
|
||||
ambiguous_message: str,
|
||||
) -> Path | None:
|
||||
matches: list[Path] = []
|
||||
for file_name in names:
|
||||
matches.extend(self._base_dir.rglob(file_name))
|
||||
|
||||
if not matches:
|
||||
if not_found_message is None:
|
||||
return None
|
||||
raise WorkflowConfigurationError(not_found_message)
|
||||
|
||||
unique_matches = sorted({path.resolve() for path in matches})
|
||||
if len(unique_matches) > 1:
|
||||
raise WorkflowConfigurationError(ambiguous_message)
|
||||
return unique_matches[0]
|
||||
|
||||
def _load_dict(self, path: Path) -> dict[str, Any]:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
loaded = json.loads(raw)
|
||||
if not isinstance(loaded, dict):
|
||||
raise WorkflowConfigurationError(
|
||||
f"Workflow em {path} deve ser um objeto/dict"
|
||||
)
|
||||
return loaded
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if path.suffix not in {".yaml", ".yml"}:
|
||||
raise WorkflowConfigurationError(f"Arquivo inválido: {path}")
|
||||
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise WorkflowConfigurationError(
|
||||
f"Arquivo YAML detectado em {path}, mas PyYAML não está instalado. "
|
||||
"Instale a dependência 'pyyaml' ou use JSON."
|
||||
) from exc
|
||||
|
||||
loaded_yaml = yaml.safe_load(raw)
|
||||
if not isinstance(loaded_yaml, dict):
|
||||
raise WorkflowConfigurationError(
|
||||
f"Workflow YAML em {path} deve ser um objeto/dict"
|
||||
)
|
||||
return loaded_yaml
|
||||
|
||||
@staticmethod
|
||||
def _is_workflow_dict(data: dict[str, Any]) -> bool:
|
||||
return {"name", "version", "start", "nodes", "edges"} <= set(data.keys())
|
||||
53
legacy_reference/workflows/runtime_types.py
Normal file
53
legacy_reference/workflows/runtime_types.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
ExecutionStatus = Literal["RUNNING", "WAITING_INPUT", "COMPLETED", "FAILED"]
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionResult:
|
||||
success: bool
|
||||
output: dict[str, Any] = field(default_factory=dict)
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def ok(
|
||||
cls,
|
||||
output: dict[str, Any] | None = None,
|
||||
**metadata: Any,
|
||||
) -> ActionResult:
|
||||
return cls(success=True, output=output or {}, metadata=metadata)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, error: str, **metadata: Any) -> ActionResult:
|
||||
return cls(success=False, error=error, metadata=metadata)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowExecutionRecord:
|
||||
execution_id: str
|
||||
workflow_name: str
|
||||
workflow_version: int
|
||||
status: ExecutionStatus
|
||||
current_node: str | None
|
||||
resume_from: str | None
|
||||
expected_input_key: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowRunResponse:
|
||||
execution_id: str
|
||||
status: ExecutionStatus
|
||||
data: Any = None
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
528
legacy_reference/workflows/service.py
Normal file
528
legacy_reference/workflows/service.py
Normal file
@@ -0,0 +1,528 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from agente_contas_tim.agent.llm_gateway import LLMCapabilityGateway
|
||||
from agente_contas_tim.factory import CommandFactory
|
||||
from agente_contas_tim.observability import get_session_id
|
||||
from agente_contas_tim.workflows.actions.discovery import ensure_actions_loaded
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
DEFAULT_ACTION_REGISTRY,
|
||||
ActionRegistry,
|
||||
WorkflowRuntimeContext,
|
||||
)
|
||||
from agente_contas_tim.workflows.compiler import build_initial_state, compile_workflow
|
||||
from agente_contas_tim.workflows.execution_store import (
|
||||
ExecutionStore,
|
||||
PostgresExecutionStore,
|
||||
)
|
||||
from agente_contas_tim.workflows.exceptions import (
|
||||
WorkflowConfigurationError,
|
||||
WorkflowExecutionStateError,
|
||||
WorkflowInputError,
|
||||
)
|
||||
from agente_contas_tim.workflows.repositories.base import WorkflowRepository
|
||||
from agente_contas_tim.workflows.runtime_types import WorkflowRunResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowService:
|
||||
def __init__(
|
||||
self,
|
||||
repository: WorkflowRepository,
|
||||
factory: CommandFactory,
|
||||
*,
|
||||
llm_gateway: LLMCapabilityGateway | None = None,
|
||||
postgres_dsn: str | None = None,
|
||||
action_registry: ActionRegistry | None = None,
|
||||
execution_store: ExecutionStore | None = None,
|
||||
checkpointer: Any | None = None,
|
||||
checkpointer_manager: Any | None = None,
|
||||
) -> None:
|
||||
self._repository = repository
|
||||
self._factory = factory
|
||||
self._runtime = WorkflowRuntimeContext(
|
||||
factory=factory,
|
||||
llm_gateway=llm_gateway,
|
||||
workflow_runner=lambda workflow_name, input_payload, execution_id=None, version=None: self.run(
|
||||
workflow_name,
|
||||
input_payload,
|
||||
execution_id=execution_id,
|
||||
version=version,
|
||||
),
|
||||
)
|
||||
self._registry = action_registry or DEFAULT_ACTION_REGISTRY
|
||||
self._compiled_cache: dict[tuple[str, int], Any] = {}
|
||||
self._checkpointer_manager = checkpointer_manager
|
||||
|
||||
if execution_store is not None:
|
||||
self._execution_store = execution_store
|
||||
else:
|
||||
if not postgres_dsn:
|
||||
raise ValueError(
|
||||
"postgres_dsn e obrigatorio quando execution_store nao e informado"
|
||||
)
|
||||
self._execution_store = PostgresExecutionStore(postgres_dsn)
|
||||
|
||||
if checkpointer is not None:
|
||||
self._checkpointer = checkpointer
|
||||
else:
|
||||
if not postgres_dsn:
|
||||
raise ValueError(
|
||||
"postgres_dsn e obrigatorio quando checkpointer nao e informado"
|
||||
)
|
||||
(
|
||||
self._checkpointer,
|
||||
self._checkpointer_manager,
|
||||
) = self._create_checkpointer(postgres_dsn)
|
||||
|
||||
ensure_actions_loaded("agente_contas_tim.workflows.actions")
|
||||
|
||||
def run(
|
||||
self,
|
||||
workflow_name: str,
|
||||
input_payload: Mapping[str, Any],
|
||||
*,
|
||||
execution_id: str | None = None,
|
||||
version: int | None = None,
|
||||
) -> WorkflowRunResponse:
|
||||
started_at = time.monotonic()
|
||||
input_keys = list(input_payload.keys()) if isinstance(input_payload, Mapping) else []
|
||||
logger.info(
|
||||
"workflow.run.start name=%s version=%s execution_id=%s input_keys=%s",
|
||||
workflow_name,
|
||||
version,
|
||||
execution_id,
|
||||
input_keys,
|
||||
)
|
||||
if execution_id is None:
|
||||
result = self._start_execution(
|
||||
workflow_name=workflow_name,
|
||||
input_payload=dict(input_payload),
|
||||
version=version,
|
||||
)
|
||||
else:
|
||||
result = self._resume_execution(
|
||||
execution_id=execution_id,
|
||||
workflow_name=workflow_name,
|
||||
input_payload=dict(input_payload),
|
||||
version=version,
|
||||
)
|
||||
elapsed_ms = round((time.monotonic() - started_at) * 1000, 2)
|
||||
logger.info(
|
||||
"workflow.run.end name=%s execution_id=%s status=%s elapsed_ms=%s",
|
||||
workflow_name,
|
||||
result.execution_id,
|
||||
result.status,
|
||||
elapsed_ms,
|
||||
)
|
||||
return result
|
||||
|
||||
def _start_execution(
|
||||
self,
|
||||
*,
|
||||
workflow_name: str,
|
||||
input_payload: dict[str, Any],
|
||||
version: int | None,
|
||||
) -> WorkflowRunResponse:
|
||||
definition = (
|
||||
self._repository.get_version(workflow_name, version)
|
||||
if version is not None
|
||||
else self._repository.get_active(workflow_name)
|
||||
)
|
||||
graph = self._get_graph(definition)
|
||||
execution_id = str(uuid4())
|
||||
session_id = self._current_session_id()
|
||||
message_id = self._extract_message_id(input_payload)
|
||||
self._execution_store.create(
|
||||
execution_id=execution_id,
|
||||
workflow_name=definition.name,
|
||||
workflow_version=definition.version,
|
||||
session_id=session_id,
|
||||
started_by_message_id=message_id,
|
||||
)
|
||||
|
||||
initial_state = build_initial_state(definition, input_payload)
|
||||
config = self._config(execution_id)
|
||||
|
||||
try:
|
||||
graph.invoke(initial_state, config=config)
|
||||
except Exception as exc:
|
||||
self._execution_store.mark_status(
|
||||
execution_id,
|
||||
status="FAILED",
|
||||
current_node=None,
|
||||
resume_from=None,
|
||||
expected_input_key=None,
|
||||
)
|
||||
logger.exception("Falha inesperada ao iniciar workflow: %s", exc)
|
||||
return WorkflowRunResponse(
|
||||
execution_id=execution_id,
|
||||
status="FAILED",
|
||||
error="Erro inesperado ao executar workflow",
|
||||
metadata={
|
||||
"workflow": definition.name,
|
||||
"version": definition.version,
|
||||
"error_code": "WORKFLOW_INTERNAL_ERROR",
|
||||
},
|
||||
)
|
||||
|
||||
return self._build_response(
|
||||
execution_id, definition.name, definition.version, graph
|
||||
)
|
||||
|
||||
def _resume_execution(
|
||||
self,
|
||||
*,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
input_payload: dict[str, Any],
|
||||
version: int | None,
|
||||
) -> WorkflowRunResponse:
|
||||
record = self._execution_store.claim_resume(
|
||||
execution_id=execution_id,
|
||||
workflow_name=workflow_name,
|
||||
workflow_version=version,
|
||||
session_id=self._current_session_id(),
|
||||
message_id=self._extract_message_id(input_payload),
|
||||
)
|
||||
definition = self._repository.get_version(
|
||||
record.workflow_name,
|
||||
record.workflow_version,
|
||||
)
|
||||
graph = self._get_graph(definition)
|
||||
config = self._config(execution_id)
|
||||
checkpoint_summary = self._checkpoint_debug_summary(config)
|
||||
logger.info(
|
||||
"workflow.resume.checkpoint execution_id=%s workflow=%s "
|
||||
"version=%s checkpoint=%s record_status=%s current_node=%s "
|
||||
"resume_from=%s expected_input_key=%s",
|
||||
execution_id,
|
||||
definition.name,
|
||||
definition.version,
|
||||
checkpoint_summary,
|
||||
record.status,
|
||||
record.current_node,
|
||||
record.resume_from,
|
||||
record.expected_input_key,
|
||||
)
|
||||
|
||||
try:
|
||||
from langgraph.types import Command
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
"langgraph nao esta instalado. "
|
||||
"Adicione a dependencia para usar workflows."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
graph.invoke(Command(resume=input_payload), config=config)
|
||||
except WorkflowInputError:
|
||||
self._execution_store.mark_status(
|
||||
execution_id,
|
||||
status="WAITING_INPUT",
|
||||
current_node=record.current_node,
|
||||
resume_from=record.resume_from,
|
||||
expected_input_key=record.expected_input_key,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._execution_store.mark_status(
|
||||
execution_id,
|
||||
status="FAILED",
|
||||
current_node=None,
|
||||
resume_from=None,
|
||||
expected_input_key=None,
|
||||
)
|
||||
logger.info(
|
||||
"workflow.resume.failed.summary execution_id=%s workflow=%s "
|
||||
"version=%s error_type=%s error=%s checkpoint=%s "
|
||||
"record_status=%s current_node=%s resume_from=%s "
|
||||
"expected_input_key=%s",
|
||||
execution_id,
|
||||
definition.name,
|
||||
definition.version,
|
||||
type(exc).__name__,
|
||||
str(exc),
|
||||
checkpoint_summary,
|
||||
record.status,
|
||||
record.current_node,
|
||||
record.resume_from,
|
||||
record.expected_input_key,
|
||||
)
|
||||
logger.error(
|
||||
"workflow.resume.failed execution_id=%s workflow=%s version=%s "
|
||||
"error_type=%s error=%s checkpoint=%s record_status=%s "
|
||||
"current_node=%s resume_from=%s expected_input_key=%s",
|
||||
execution_id,
|
||||
definition.name,
|
||||
definition.version,
|
||||
type(exc).__name__,
|
||||
str(exc),
|
||||
checkpoint_summary,
|
||||
record.status,
|
||||
record.current_node,
|
||||
record.resume_from,
|
||||
record.expected_input_key,
|
||||
exc_info=True,
|
||||
)
|
||||
return WorkflowRunResponse(
|
||||
execution_id=execution_id,
|
||||
status="FAILED",
|
||||
error="Erro inesperado ao executar workflow",
|
||||
metadata={
|
||||
"workflow": definition.name,
|
||||
"version": definition.version,
|
||||
"error_code": "WORKFLOW_INTERNAL_ERROR",
|
||||
},
|
||||
)
|
||||
|
||||
return self._build_response(
|
||||
execution_id, definition.name, definition.version, graph
|
||||
)
|
||||
|
||||
def _checkpoint_debug_summary(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
get_tuple = getattr(self._checkpointer, "get_tuple", None)
|
||||
if not callable(get_tuple):
|
||||
return {"available": False, "reason": "checkpointer_without_get_tuple"}
|
||||
try:
|
||||
checkpoint_tuple = get_tuple(config)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"workflow.resume.checkpoint_read_failed thread_id=%s "
|
||||
"error_type=%s error=%s",
|
||||
config.get("configurable", {}).get("thread_id"),
|
||||
type(exc).__name__,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
return {
|
||||
"available": False,
|
||||
"read_error_type": type(exc).__name__,
|
||||
"read_error": str(exc),
|
||||
}
|
||||
if checkpoint_tuple is None:
|
||||
return {"available": False, "reason": "checkpoint_not_found"}
|
||||
|
||||
checkpoint = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
|
||||
channel_values = dict(checkpoint.get("channel_values") or {})
|
||||
pending_writes = list(getattr(checkpoint_tuple, "pending_writes", []) or [])
|
||||
config_values = dict(getattr(checkpoint_tuple, "config", {}) or {})
|
||||
checkpoint_config = dict(config_values.get("configurable", {}) or {})
|
||||
return {
|
||||
"available": True,
|
||||
"checkpoint_id": checkpoint_config.get("checkpoint_id"),
|
||||
"channel_keys": sorted(str(key) for key in channel_values.keys()),
|
||||
"pending_writes_count": len(pending_writes),
|
||||
"pending_write_channels": sorted(
|
||||
{str(write[1]) for write in pending_writes if len(write) >= 2}
|
||||
),
|
||||
}
|
||||
|
||||
def _build_response(
|
||||
self,
|
||||
execution_id: str,
|
||||
workflow_name: str,
|
||||
workflow_version: int,
|
||||
graph: Any,
|
||||
) -> WorkflowRunResponse:
|
||||
snapshot = graph.get_state(self._config(execution_id))
|
||||
values = dict(getattr(snapshot, "values", {}) or {})
|
||||
status = str(values.get("status") or "")
|
||||
if not status:
|
||||
logger.warning(
|
||||
"workflow.response.status_missing execution_id=%s workflow=%s "
|
||||
"version=%s snapshot=%s",
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
self._snapshot_debug_summary(values),
|
||||
)
|
||||
# Salvaguarda: alguns checkpointers podem nao propagar a channel
|
||||
# `status` ate o snapshot final apos FINISH_NODE. Inferir pelo
|
||||
# par final_data/final_error setado no action_node antes da
|
||||
# transicao para o no terminal.
|
||||
if values.get("final_error"):
|
||||
status = "FAILED"
|
||||
elif values.get("final_data") is not None:
|
||||
status = "COMPLETED"
|
||||
else:
|
||||
status = "FAILED"
|
||||
|
||||
if status == "WAITING_INPUT":
|
||||
pending = values.get("pending_interrupt")
|
||||
if not isinstance(pending, dict):
|
||||
raise WorkflowConfigurationError(
|
||||
f"Workflow {workflow_name!r} pausou sem pending_interrupt"
|
||||
)
|
||||
self._execution_store.mark_status(
|
||||
execution_id,
|
||||
status="WAITING_INPUT",
|
||||
current_node=str(pending.get("node_id", "")) or None,
|
||||
resume_from=str(pending.get("resume_from", "")) or None,
|
||||
expected_input_key=str(pending.get("expected_input_key", "")) or None,
|
||||
)
|
||||
return WorkflowRunResponse(
|
||||
execution_id=execution_id,
|
||||
status="WAITING_INPUT",
|
||||
data=pending.get("payload"),
|
||||
metadata={
|
||||
"workflow": workflow_name,
|
||||
"version": workflow_version,
|
||||
"paused_at": pending.get("node_id"),
|
||||
"resume_from": pending.get("resume_from"),
|
||||
"expected_input_key": pending.get("expected_input_key"),
|
||||
"allowed_values": pending.get("allowed_values", []),
|
||||
"normalize": pending.get("normalize"),
|
||||
},
|
||||
)
|
||||
|
||||
if status == "COMPLETED":
|
||||
self._execution_store.mark_status(
|
||||
execution_id,
|
||||
status="COMPLETED",
|
||||
current_node=None,
|
||||
resume_from=None,
|
||||
expected_input_key=None,
|
||||
)
|
||||
metadata = dict(values.get("final_metadata", {}) or {})
|
||||
metadata.setdefault("workflow", workflow_name)
|
||||
metadata.setdefault("version", workflow_version)
|
||||
return WorkflowRunResponse(
|
||||
execution_id=execution_id,
|
||||
status="COMPLETED",
|
||||
data=values.get("final_data"),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if status == "FAILED":
|
||||
logger.error(
|
||||
"workflow.response.failed execution_id=%s workflow=%s "
|
||||
"version=%s snapshot=%s",
|
||||
execution_id,
|
||||
workflow_name,
|
||||
workflow_version,
|
||||
self._snapshot_debug_summary(values),
|
||||
)
|
||||
self._execution_store.mark_status(
|
||||
execution_id,
|
||||
status="FAILED",
|
||||
current_node=None,
|
||||
resume_from=None,
|
||||
expected_input_key=None,
|
||||
)
|
||||
metadata = dict(values.get("final_metadata", {}) or {})
|
||||
metadata.setdefault("workflow", workflow_name)
|
||||
metadata.setdefault("version", workflow_version)
|
||||
return WorkflowRunResponse(
|
||||
execution_id=execution_id,
|
||||
status="FAILED",
|
||||
data=None,
|
||||
error=str(values.get("final_error") or "Erro na execucao do workflow"),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
raise WorkflowExecutionStateError(
|
||||
f"Estado final invalido para workflow: {status!r}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_debug_summary(values: dict[str, Any]) -> dict[str, Any]:
|
||||
trace = values.get("trace")
|
||||
trace_tail = trace[-3:] if isinstance(trace, list) else []
|
||||
final_metadata = values.get("final_metadata")
|
||||
return {
|
||||
"status": values.get("status"),
|
||||
"current_node": values.get("current_node"),
|
||||
"last_node": values.get("last_node"),
|
||||
"final_error": values.get("final_error"),
|
||||
"final_metadata": (
|
||||
final_metadata if isinstance(final_metadata, dict) else {}
|
||||
),
|
||||
"final_data_type": type(values.get("final_data")).__name__,
|
||||
"pending_interrupt_present": isinstance(
|
||||
values.get("pending_interrupt"),
|
||||
dict,
|
||||
),
|
||||
"trace_tail": trace_tail,
|
||||
"channel_keys": sorted(str(key) for key in values.keys()),
|
||||
}
|
||||
|
||||
def _get_graph(self, definition: Any) -> Any:
|
||||
key = (definition.name, definition.version)
|
||||
graph = self._compiled_cache.get(key)
|
||||
if graph is not None:
|
||||
return graph
|
||||
|
||||
graph = compile_workflow(
|
||||
definition,
|
||||
action_registry=self._registry,
|
||||
runtime=self._runtime,
|
||||
checkpointer=self._checkpointer,
|
||||
)
|
||||
self._compiled_cache[key] = graph
|
||||
logger.info(
|
||||
"Workflow compilado com LangGraph: name=%s version=%s",
|
||||
definition.name,
|
||||
definition.version,
|
||||
)
|
||||
return graph
|
||||
|
||||
@staticmethod
|
||||
def _config(execution_id: str) -> dict[str, Any]:
|
||||
return {"configurable": {"thread_id": execution_id}}
|
||||
|
||||
@staticmethod
|
||||
def _current_session_id() -> str | None:
|
||||
session_id = str(get_session_id() or "").strip()
|
||||
return session_id if session_id and session_id != "-" else None
|
||||
|
||||
@staticmethod
|
||||
def _extract_message_id(input_payload: Mapping[str, Any]) -> str | None:
|
||||
for key in ("message_id", "messageId"):
|
||||
value = str(input_payload.get(key, "") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _create_checkpointer(postgres_dsn: str) -> tuple[Any, Any | None]:
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
"langgraph.checkpoint.postgres nao esta instalado. "
|
||||
"Adicione as dependencias para usar workflows com PostgreSQL."
|
||||
) from exc
|
||||
|
||||
manager = PostgresSaver.from_conn_string(postgres_dsn)
|
||||
if hasattr(manager, "__enter__") and hasattr(manager, "__exit__"):
|
||||
saver = manager.__enter__()
|
||||
setup = getattr(saver, "setup", None)
|
||||
if callable(setup):
|
||||
setup()
|
||||
return saver, manager
|
||||
|
||||
setup = getattr(manager, "setup", None)
|
||||
if callable(setup):
|
||||
setup()
|
||||
return manager, None
|
||||
|
||||
def close(self) -> None:
|
||||
close_store = getattr(self._execution_store, "close", None)
|
||||
if callable(close_store):
|
||||
close_store()
|
||||
|
||||
manager = getattr(self, "_checkpointer_manager", None)
|
||||
if manager is not None and hasattr(manager, "__exit__"):
|
||||
manager.__exit__(None, None, None)
|
||||
return
|
||||
|
||||
close_checkpointer = getattr(self._checkpointer, "close", None)
|
||||
if callable(close_checkpointer):
|
||||
close_checkpointer()
|
||||
14
legacy_reference/workflows/templating.py
Normal file
14
legacy_reference/workflows/templating.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agente_contas_tim.workflows.conditions import resolve_value
|
||||
|
||||
|
||||
def render_template(template: Any, context: dict[str, Any]) -> Any:
|
||||
"""Resolve templates recursivos com paths $.x.y."""
|
||||
if isinstance(template, dict):
|
||||
return {key: render_template(value, context) for key, value in template.items()}
|
||||
if isinstance(template, list):
|
||||
return [render_template(item, context) for item in template]
|
||||
return resolve_value(template, context)
|
||||
@@ -0,0 +1 @@
|
||||
version: 1
|
||||
@@ -0,0 +1,16 @@
|
||||
name: buscar_fatura
|
||||
version: 1
|
||||
start: buscar_fatura
|
||||
|
||||
nodes:
|
||||
- id: buscar_fatura
|
||||
action: buscar_fatura
|
||||
input:
|
||||
invoice_id: $.input.invoice_id
|
||||
msisdn: $.input.msisdn
|
||||
customer_id: $.input.customer_id
|
||||
output: $.input.output
|
||||
|
||||
edges:
|
||||
- from: buscar_fatura
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 2
|
||||
@@ -0,0 +1,16 @@
|
||||
name: buscar_informacao
|
||||
version: 1
|
||||
start: buscar_informacao
|
||||
|
||||
nodes:
|
||||
- id: buscar_informacao
|
||||
action: buscar_informacao_rag
|
||||
input:
|
||||
query: $.input.query
|
||||
queries: $.input.queries
|
||||
top_k: $.input.top_k
|
||||
segment: $.input.segment
|
||||
|
||||
edges:
|
||||
- from: buscar_informacao
|
||||
to: END
|
||||
@@ -0,0 +1,28 @@
|
||||
name: buscar_informacao
|
||||
version: 2
|
||||
start: buscar_informacao
|
||||
|
||||
nodes:
|
||||
- id: buscar_informacao
|
||||
action: buscar_informacao_rag
|
||||
input:
|
||||
query: $.input.query
|
||||
queries: $.input.queries
|
||||
top_k: $.input.top_k
|
||||
segment: $.input.segment
|
||||
|
||||
- id: reescrever_resposta
|
||||
action: reescrever_resposta_buscar_informacao
|
||||
input:
|
||||
queries: $.vars.buscar_informacao.queries
|
||||
documents: $.vars.buscar_informacao.documents
|
||||
answer: $.vars.buscar_informacao.answer
|
||||
noMatchRag: $.vars.buscar_informacao.noMatchRag
|
||||
ragRetrievedDocuments: $.vars.buscar_informacao.ragRetrievedDocuments
|
||||
ragSelectedDocuments: $.vars.buscar_informacao.ragSelectedDocuments
|
||||
|
||||
edges:
|
||||
- from: buscar_informacao
|
||||
to: reescrever_resposta
|
||||
- from: reescrever_resposta
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 1
|
||||
@@ -0,0 +1,20 @@
|
||||
name: cancelamento_vas_avulso
|
||||
version: 1
|
||||
start: cancelar_vas_avulso
|
||||
|
||||
nodes:
|
||||
- id: cancelar_vas_avulso
|
||||
action: cancelamento_vas_avulso_batch
|
||||
input:
|
||||
items: $.input.items
|
||||
csp_id: $.input.csp_id
|
||||
channel: $.input.channel
|
||||
social_sec_no: $.input.social_sec_no
|
||||
request_status: "Fechado"
|
||||
status: "CLOSED"
|
||||
data_credito_proxima_fatura: $.input.data_credito_proxima_fatura
|
||||
idempotency_key: $.input.idempotency_key
|
||||
|
||||
edges:
|
||||
- from: cancelar_vas_avulso
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
name: finalizar_atendimento
|
||||
version: 1
|
||||
start: finalizar
|
||||
|
||||
nodes:
|
||||
- id: finalizar
|
||||
action: finalizar_atendimento_action
|
||||
input:
|
||||
status: $.input.status
|
||||
summary: $.input.summary
|
||||
|
||||
edges:
|
||||
- from: finalizar
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 1
|
||||
@@ -0,0 +1,15 @@
|
||||
name: termino_desconto
|
||||
version: 1
|
||||
start: formatar
|
||||
|
||||
nodes:
|
||||
- id: formatar
|
||||
action: formatar_capability_resposta
|
||||
input:
|
||||
tipo: termino_desconto
|
||||
msisdn: $.input.msisdn
|
||||
nome_plano: $.input.nome_plano
|
||||
|
||||
edges:
|
||||
- from: formatar
|
||||
to: END
|
||||
@@ -0,0 +1 @@
|
||||
version: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
name: valor_divergente
|
||||
version: 1
|
||||
start: formatar
|
||||
|
||||
nodes:
|
||||
- id: formatar
|
||||
action: formatar_capability_resposta
|
||||
input:
|
||||
tipo: valor_divergente
|
||||
msisdn: $.input.msisdn
|
||||
|
||||
edges:
|
||||
- from: formatar
|
||||
to: END
|
||||
Reference in New Issue
Block a user