Files
2026-06-16 20:54:49 -03:00

881 lines
29 KiB
Python

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}"