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