Files
replypier/backend/app/services/workflow_execution.py
T
WangShen007 83a16ed07d feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
2026-10-17 22:05:00 +08:00

1439 lines
63 KiB
Python

"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import traceback
import asyncio
from typing import Optional, Dict, Any, List, Tuple
from uuid import UUID
from sqlalchemy.orm import Session
from dataclasses import dataclass
import json
import re
from datetime import datetime, timedelta
from app.models.workflow import Workflow, WorkflowStatus
from app.models.workflow_node import WorkflowNode, NodeType, ExitCondition
from app.models.workflow_connection import WorkflowConnection
from app.models.session_to_agent import SessionToAgent
from app.repositories.workflow import WorkflowRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.repositories.chat import ChatRepository
from app.agents.chat_agent import ChatAgent, ChatResponse
from app.core.logger import get_logger
from app.services.workflow_conditions import (
MISSING,
evaluate_condition_config,
evaluate_expression,
resolve_variable,
)
logger = get_logger(__name__)
def _branch_label(value: Any) -> Optional[str]:
return value.strip().lower() if isinstance(value, str) else None
@dataclass
class WorkflowExecutionResult:
"""Result of workflow execution"""
success: bool
message: str
next_node_id: Optional[UUID] = None
workflow_state: Optional[Dict[str, Any]] = None
should_continue: bool = True
transfer_to_human: bool = False
transfer_group_id: Optional[str] = None
transfer_reason: Optional[str] = None
transfer_description: Optional[str] = None
end_chat: bool = False
request_rating: bool = False
error: Optional[str] = None
form_data: Optional[Dict[str, Any]] = None
landing_page_data: Optional[Dict[str, Any]] = None
intermediate_messages: Optional[List[str]] = None # Collect messages from MESSAGE nodes during execution
class WorkflowExecutionService:
"""Service for executing workflows in chat sessions"""
def __init__(self, db: Session):
self.db = db
self.workflow_repo = WorkflowRepository(db)
self.session_repo = SessionToAgentRepository(db)
async def execute_workflow(
self,
session_id: str,
user_message: Optional[str],
workflow_id: UUID,
current_node_id: Optional[UUID] = None,
workflow_state: Optional[Dict[str, Any]] = None,
api_key: str = None,
model_name: str = None,
model_type: str = None,
base_url: str = None,
org_id: str = None,
agent_id: str = None,
customer_id: str = None,
is_initial_execution: bool = False,
source: str = None
) -> WorkflowExecutionResult:
"""
Execute workflow for a chat session
Args:
session_id: Session ID
user_message: User's message
workflow_id: Workflow to execute
current_node_id: Current node (None to start from beginning)
workflow_state: Current workflow state
api_key: API key for AI model
model_name: AI model name
model_type: AI model type
org_id: Organization ID
agent_id: Agent ID
customer_id: Customer ID
Returns:
WorkflowExecutionResult with execution details
"""
try:
logger.info(f"Executing workflow {workflow_id} for session {session_id}")
logger.debug(f"Current node ID: {current_node_id}")
logger.debug(f"Workflow state: {workflow_state}")
logger.debug(f"User message: {user_message}")
logger.debug(f"Is initial execution: {is_initial_execution}")
# Get workflow with nodes and connections
workflow = self.workflow_repo.get_workflow_with_nodes_and_connections(workflow_id)
logger.debug(f"Workflow ID: {workflow.id}, Name: {workflow.name}, Status: {workflow.status}")
logger.debug(f"Nodes count: {len(workflow.nodes)}")
if not workflow:
return WorkflowExecutionResult(
success=False,
message="Workflow not found",
error="Workflow not found"
)
# Check if workflow is published
if workflow.status != WorkflowStatus.PUBLISHED:
return WorkflowExecutionResult(
success=False,
message="Workflow is not published",
error="Workflow is not published"
)
# Initialize workflow state if not provided - keep it minimal
if workflow_state is None:
workflow_state = {}
# Store user message in workflow state if provided
# This makes it available to all nodes via {{user_message}} variable
if user_message:
if "variables" not in workflow_state:
workflow_state["variables"] = {}
workflow_state["variables"]["user_message"] = user_message
logger.debug(f"Stored user message in workflow state: {user_message}")
# Determine starting node
if current_node_id is None:
current_node = self._find_start_node(workflow)
logger.debug(f"Found starting node: {current_node.id}")
if not current_node:
return WorkflowExecutionResult(
success=False,
message="No start node found in workflow",
error="No start node found"
)
else:
current_node = self._find_node_by_id(workflow, current_node_id)
logger.debug(f"Current node: {current_node}")
if not current_node:
return WorkflowExecutionResult(
success=False,
message="Current node not found",
error="Current node not found"
)
# Automatic execution loop - continue executing nodes until we reach one that requires user interaction
final_result = None
intermediate_messages = [] # Collect messages from MESSAGE nodes during execution
final_message_collected = False
while current_node:
logger.debug(f"Executing node: {current_node.id} ({current_node.node_type})")
# Execute current node
result = await self._execute_node(
current_node,
workflow,
workflow_state,
user_message,
api_key,
model_name,
model_type,
base_url,
org_id,
agent_id,
customer_id,
session_id,
source
)
collected_before = len(intermediate_messages)
# Collect messages from MESSAGE nodes during automatic execution
if current_node.node_type == NodeType.MESSAGE and result.message:
intermediate_messages.append(result.message)
logger.debug(f"Collected intermediate message from MESSAGE node: {result.message}")
# Also collect messages from LLM nodes that are not the final stopping node
# This handles the case where LLM node with single execution produces a response
# but then moves to the next node (like USER_INPUT)
if (current_node.node_type == NodeType.LLM and result.message and
result.should_continue and result.next_node_id is not None):
intermediate_messages.append(result.message)
logger.debug(f"Collected intermediate message from LLM node: {result.message}")
# Also collect confirmation messages from USER_INPUT nodes that continue to next node
# This ensures confirmation messages are displayed to the user
if (current_node.node_type == NodeType.USER_INPUT and result.message and
result.should_continue and result.next_node_id is not None):
intermediate_messages.append(result.message)
logger.debug(f"Collected confirmation message from USER_INPUT node: {result.message}")
final_result = result
final_message_collected = len(intermediate_messages) > collected_before
# Check if we should continue to next node automatically
if not result.success:
logger.error(f"Node execution failed: {result.error}")
break
# Check stopping conditions
if (result.form_data or
result.landing_page_data or
result.transfer_to_human or
result.end_chat or
not result.should_continue or
result.next_node_id is None or
(current_node.node_type == NodeType.USER_INPUT and not result.should_continue)):
logger.info(f"Stopping execution at node {current_node.id}: form_data={bool(result.form_data)}, landing_page={bool(result.landing_page_data)}, transfer={result.transfer_to_human}, end_chat={result.end_chat}, should_continue={result.should_continue}, next_node={result.next_node_id}, user_input_waiting={current_node.node_type == NodeType.USER_INPUT and not result.should_continue}")
break
# For LLM nodes with continuous execution, don't auto-advance unless specific conditions are met
if (current_node.node_type == NodeType.LLM and
current_node.config and
current_node.config.get("exit_condition") == "continuous_execution"):
logger.info(f"LLM node {current_node.id} with continuous execution - stopping automatic advancement")
break
# Move to next node
current_node = self._find_node_by_id(workflow, result.next_node_id)
if not current_node:
logger.info(f"Next node {result.next_node_id} not found, reached end of workflow")
break
# Clear user message after first iteration (subsequent nodes shouldn't use the original user message)
user_message = None
if not final_result:
return WorkflowExecutionResult(
success=False,
message="No execution result",
error="No execution result"
)
# The node we stopped on is returned as the final message, which every
# caller sends and stores on top of the intermediate ones. Leaving it
# in both lists sent the visitor the same text twice.
if final_message_collected:
intermediate_messages.pop()
# Update session workflow state based on final result
if (final_result.landing_page_data or
final_result.form_data or
(current_node and current_node.node_type == NodeType.USER_INPUT and not final_result.should_continue) or
(current_node and current_node.node_type == NodeType.LLM and not final_result.should_continue and not final_result.transfer_to_human and not final_result.end_chat and final_result.next_node_id is not None)):
# We're displaying a landing page, form, waiting for user input, or staying on LLM node - stay on current node
# Note: LLM nodes only stay on current node if there's a next node (continuous execution)
self._update_session_workflow_state(session_id, current_node.id if current_node else None, workflow_state)
elif final_result.transfer_to_human:
# Transfer to human requested - stay on current node and let the chat handler manage the transfer
self._update_session_workflow_state(session_id, current_node.id if current_node else None, workflow_state)
logger.info(f"Transfer to human requested from node {current_node.id if current_node else 'unknown'}, staying on current node")
elif current_node and current_node.node_type == NodeType.LLM and final_result.next_node_id is None and not final_result.end_chat:
# Check if this is single execution - if so, reset workflow to restart from beginning
config = current_node.config or {}
exit_condition = config.get("exit_condition", ExitCondition.SINGLE_EXECUTION)
if isinstance(exit_condition, str):
try:
exit_condition = ExitCondition(exit_condition)
except ValueError:
exit_condition = ExitCondition.SINGLE_EXECUTION
if exit_condition == ExitCondition.SINGLE_EXECUTION:
# Single execution LLM node that is the last node - reset workflow to restart from beginning
logger.info(f"LLM node {current_node.id} is last node with single execution - resetting workflow to restart")
self._update_session_workflow_state(session_id, None, {})
else:
# Continuous execution - stay on current node
logger.info(f"LLM node {current_node.id} is last node with continuous execution - staying on current node")
self._update_session_workflow_state(session_id, current_node.id, workflow_state)
else:
# Normal flow - move to next node (or end workflow if end_chat)
self._update_session_workflow_state(session_id, final_result.next_node_id, workflow_state)
return WorkflowExecutionResult(
success=final_result.success,
message=final_result.message,
next_node_id=final_result.next_node_id,
workflow_state=workflow_state,
should_continue=final_result.should_continue,
transfer_to_human=final_result.transfer_to_human,
transfer_group_id=final_result.transfer_group_id,
transfer_reason=final_result.transfer_reason,
transfer_description=final_result.transfer_description,
end_chat=final_result.end_chat,
request_rating=final_result.request_rating,
error=final_result.error,
form_data=final_result.form_data, # Pass through form_data
landing_page_data=final_result.landing_page_data, # Pass through landing_page_data
intermediate_messages=intermediate_messages # Include collected messages
)
except Exception as e:
traceback.print_exc()
logger.error(f"Error executing workflow: {str(e)}")
return WorkflowExecutionResult(
success=False,
message="An error occurred while executing the workflow",
error=str(e)
)
async def submit_form(
self,
session_id: str,
form_data: Dict[str, Any],
workflow_id: UUID,
org_id: str = None,
agent_id: str = None,
customer_id: str = None,
api_key: str = None,
model_name: str = None,
model_type: str = None,
base_url: str = None,
source: str = None
) -> WorkflowExecutionResult:
"""
Handle form submission and continue workflow
Args:
session_id: Session ID
form_data: Submitted form data
workflow_id: Workflow ID
org_id: Organization ID
agent_id: Agent ID
customer_id: Customer ID
api_key: API key for AI model
model_name: AI model name
model_type: AI model type
Returns:
WorkflowExecutionResult with next step
"""
try:
logger.info(f"Submitting form for session {session_id}")
logger.debug(f"Form data: {form_data}")
logger.debug(f"Workflow ID: {workflow_id}")
logger.debug(f"Org ID: {org_id}")
logger.debug(f"Agent ID: {agent_id}")
logger.debug(f"Customer ID: {customer_id}")
logger.debug(f"API key: {api_key}")
# Get current session state
session = self.session_repo.get_session(session_id)
if not session:
return WorkflowExecutionResult(
success=False,
message="Session not found",
error="Session not found"
)
workflow_state = session.workflow_state or {}
current_node_id = session.current_node_id
# Validate that we're in a form waiting state
if workflow_state.get("form_state") != "waiting":
return WorkflowExecutionResult(
success=False,
message="No form submission expected",
error="No form submission expected"
)
# Store form submission in workflow state
workflow_state["form_data"] = form_data
workflow_state["form_state"] = "submitted"
logger.debug(f"Form submission received for session {session_id}")
logger.debug(f"Form data: {form_data}")
logger.debug(f"Updated workflow state: {workflow_state}")
# Continue workflow execution with form submission
return await self.execute_workflow(
session_id=session_id,
user_message="", # No user message for form submission
workflow_id=workflow_id,
current_node_id=current_node_id,
workflow_state=workflow_state,
api_key=api_key,
model_name=model_name,
model_type=model_type,
base_url=base_url,
org_id=org_id,
agent_id=agent_id,
customer_id=customer_id,
source=source
)
except Exception as e:
logger.error(f"Error submitting form: {str(e)}")
return WorkflowExecutionResult(
success=False,
message="An error occurred while submitting the form",
error=str(e)
)
def _find_start_node(self, workflow: Workflow) -> Optional[WorkflowNode]:
"""Find the starting node of a workflow"""
# Look for a node with no incoming connections
for node in workflow.nodes:
if not node.incoming_connections:
return node
# If no node without incoming connections, return the first node
return workflow.nodes[0] if workflow.nodes else None
def _find_node_by_id(self, workflow: Workflow, node_id: UUID) -> Optional[WorkflowNode]:
"""Find a node by ID in the workflow"""
for node in workflow.nodes:
if node.id == node_id:
return node
return None
async def _execute_node(
self,
node: WorkflowNode,
workflow: Workflow,
workflow_state: Dict[str, Any],
user_message: str,
api_key: str,
model_name: str,
model_type: str,
base_url: str,
org_id: str,
agent_id: str,
customer_id: str,
session_id: str,
source: str = None
) -> WorkflowExecutionResult:
"""Execute a specific node based on its type"""
logger.info(f"Executing node {node.id} of type {node.node_type}")
try:
if node.node_type == NodeType.MESSAGE:
return self._execute_message_node(node, workflow_state)
elif node.node_type == NodeType.LLM:
return await self._execute_llm_node(
node, workflow, workflow_state, user_message, api_key, model_name,
model_type, base_url, org_id, agent_id, customer_id, session_id, source
)
elif node.node_type == NodeType.CONDITION:
return self._execute_condition_node(node, workflow, workflow_state)
elif node.node_type == NodeType.FORM:
return self._execute_form_node(node, workflow_state, user_message, session_id)
elif node.node_type == NodeType.LANDING_PAGE:
return self._execute_landing_page_node(node, workflow_state, user_message)
elif node.node_type == NodeType.ACTION:
return self._execute_action_node(node, workflow_state)
elif node.node_type == NodeType.HUMAN_TRANSFER:
return self._execute_human_transfer_node(node, workflow_state)
elif node.node_type == NodeType.WAIT:
return self._execute_wait_node(node, workflow_state)
elif node.node_type == NodeType.END:
return self._execute_end_node(node, workflow_state)
elif node.node_type == NodeType.USER_INPUT:
return self._execute_user_input_node(node, workflow_state, user_message, session_id)
elif node.node_type == NodeType.GUARDRAILS:
return self._execute_guardrails_node(node, workflow, workflow_state, user_message)
else:
return WorkflowExecutionResult(
success=False,
message=f"Unknown node type: {node.node_type}",
error=f"Unknown node type: {node.node_type}"
)
except Exception as e:
logger.error(f"Error executing node {node.id}: {str(e)}")
return WorkflowExecutionResult(
success=False,
message=f"Error executing node: {str(e)}",
error=str(e)
)
def _execute_message_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult:
"""Execute a message node"""
config = node.config or {}
message = config.get("message_text", "No message configured")
# Process variables in message
message = self._process_variables(message, workflow_state)
# Find next node
next_node_id = self._find_next_node(node)
return WorkflowExecutionResult(
success=True,
message=message,
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
async def _execute_llm_node(
self,
node: WorkflowNode,
workflow: Workflow,
workflow_state: Dict[str, Any],
user_message: str,
api_key: str,
model_name: str,
model_type: str,
base_url: str,
org_id: str,
agent_id: str,
customer_id: str,
session_id: str,
source: str = None
) -> WorkflowExecutionResult:
"""Execute an LLM node"""
try:
# Check if both user message and workflow history are empty
if not user_message or user_message.strip() == "":
# Check if there's any meaningful chat history or workflow history
chat_repo = ChatRepository(self.db)
chat_history = await chat_repo.get_session_history(session_id)
workflow_history = self.session_repo.get_workflow_history(session_id)
# If both message and history are empty, skip LLM execution
if (not chat_history or len(chat_history) == 0) and (not workflow_history or len(workflow_history) == 0):
logger.info(f"Skipping LLM node {node.id} execution - no user message and no history available")
# Get exit condition to determine how to proceed
config = node.config or {}
exit_condition = config.get("exit_condition", ExitCondition.SINGLE_EXECUTION)
if isinstance(exit_condition, str):
try:
exit_condition = ExitCondition(exit_condition)
except ValueError:
exit_condition = ExitCondition.SINGLE_EXECUTION
if exit_condition == ExitCondition.SINGLE_EXECUTION:
# Single execution: move to next node
next_node_id = self._find_next_node(node)
return WorkflowExecutionResult(
success=True,
message="", # Empty message since no execution occurred
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
else:
# Continuous execution: wait for user input
return WorkflowExecutionResult(
success=True,
message="", # Empty message since no execution occurred
next_node_id=None,
should_continue=False
)
# Get system prompt from config and process variables
config = node.config or {}
system_prompt = config.get("system_prompt", "You are a helpful assistant.")
system_prompt = self._process_variables(system_prompt, workflow_state)
# Get exit condition configuration
exit_condition = config.get("exit_condition", ExitCondition.SINGLE_EXECUTION)
# Ensure it's an ExitCondition enum value
if isinstance(exit_condition, str):
try:
exit_condition = ExitCondition(exit_condition)
except ValueError:
exit_condition = ExitCondition.SINGLE_EXECUTION
# Transfer to human setting only applies to continuous execution
auto_transfer = False
transfer_group_id = None
ask_for_rating_config = False
if exit_condition == ExitCondition.CONTINUOUS_EXECUTION:
auto_transfer = config.get("auto_transfer_enabled", False)
transfer_group_id = config.get("transfer_group_id")
ask_for_rating_config = config.get("ask_for_rating", True) # Default to True
# Handle empty or null user message by creating structured context
processed_user_message = user_message
if not user_message or user_message.strip() == "":
processed_user_message = await self._build_context_message(session_id, workflow_state)
# Create chat agent with custom system prompt
chat_agent = await ChatAgent.create_async(
api_key=api_key,
model_name=model_name,
model_type=model_type,
base_url=base_url,
org_id=org_id,
agent_id=agent_id,
customer_id=customer_id,
session_id=session_id,
custom_system_prompt=system_prompt,
transfer_to_human=auto_transfer,
source=source
)
try:
# Get response from LLM using the agent's internal method to avoid double message storage
response = await chat_agent._get_llm_response_only(
message=processed_user_message,
session_id=session_id,
org_id=org_id,
agent_id=agent_id,
customer_id=customer_id
)
finally:
await chat_agent.safe_cleanup_mcp_tools()
logger.debug(f"Response: {response}")
# Handle exit conditions
next_node_id = None
should_continue = False
transfer_to_human = response.transfer_to_human
end_chat = response.end_chat
if exit_condition == ExitCondition.SINGLE_EXECUTION:
# Single execution: always move to next node after one response
next_node_id = self._find_next_node(node)
should_continue = next_node_id is not None
logger.info(f"LLM node {node.id} single execution - moving to next node {next_node_id}")
elif exit_condition == ExitCondition.CONTINUOUS_EXECUTION:
# Continuous execution: stay on current node, only exit on explicit conditions
if response.transfer_to_human and auto_transfer:
# Auto transfer is enabled and LLM requested transfer
transfer_to_human = True
should_continue = False
logger.info(f"LLM node {node.id} auto transfer triggered")
elif response.end_chat:
# LLM requested end chat - move to next node or end workflow
next_node_id = self._find_next_node(node)
should_continue = next_node_id is not None
logger.info(f"LLM node {node.id} end chat requested - moving to next node {next_node_id}")
else:
# Stay on current node for continued conversation
should_continue = False
next_node_id = None
logger.info(f"LLM node {node.id} continuous execution - staying on current node")
# Determine rating request based on config for workflow end chat handling
request_rating = response.request_rating
if end_chat and exit_condition == ExitCondition.CONTINUOUS_EXECUTION:
# For continuous execution, check the ask_for_rating config
if 'ask_for_rating' in config:
request_rating = config['ask_for_rating']
else:
request_rating = True # Default to True if not specified
# Update the response with the determined rating value
response.request_rating = request_rating
return WorkflowExecutionResult(
success=True,
message=response.message,
next_node_id=next_node_id,
should_continue=should_continue,
transfer_to_human=transfer_to_human,
transfer_group_id=transfer_group_id if transfer_to_human else None,
transfer_reason=response.transfer_reason.value if response.transfer_reason else None,
transfer_description=response.transfer_description,
end_chat=end_chat,
request_rating=request_rating
)
except Exception as e:
logger.error(f"Error executing LLM node: {str(e)}")
return WorkflowExecutionResult(
success=False,
message="Sorry, I encountered an error processing your request.",
error=str(e)
)
def _execute_condition_node(
self,
node: WorkflowNode,
workflow: Workflow,
workflow_state: Dict[str, Any]
) -> WorkflowExecutionResult:
"""Execute a condition node"""
try:
logger.info(f"Executing condition node: {node.id}")
condition_result = evaluate_condition_config(node.config or {}, workflow_state)
if condition_result is None:
logger.error(f"No condition expression configured for condition node: {node.id}")
return WorkflowExecutionResult(
success=False,
message="No condition expression configured",
error="No condition expression configured"
)
logger.info(f"Condition result: {condition_result}")
# Find next node based on condition result
next_node_id = self._find_conditional_next_node(node, condition_result)
return WorkflowExecutionResult(
success=True,
message="", # Condition nodes don't produce user-facing messages
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
except Exception as e:
logger.error(f"Error executing condition node: {str(e)}")
return WorkflowExecutionResult(
success=False,
message="Error evaluating condition",
error=str(e)
)
def _execute_form_node(
self,
node: WorkflowNode,
workflow_state: Dict[str, Any],
user_message: str,
session_id: str = None
) -> WorkflowExecutionResult:
"""Execute a form node"""
logger.info(f"Executing form node: {node.id}")
config = node.config or {}
# Get form fields from config (frontend stores them there)
form_fields = config.get("form_fields", [])
if not form_fields:
return WorkflowExecutionResult(
success=False,
message="No form fields configured",
error="No form fields"
)
# Check if we're waiting for form submission
current_state = workflow_state.get("form_state", "display")
if current_state == "display" or current_state == "waiting":
# First time hitting form node OR user refreshed while waiting - display the form
form_data = {
"title": config.get("form_title", ""),
"description": config.get("form_description", ""),
"submit_button_text": config.get("submit_button_text", "Submit"),
"fields": form_fields,
"form_full_screen": config.get("form_full_screen", False)
}
logger.debug(f"Form data: {form_data}")
# Mark that we're waiting for form submission
workflow_state["form_state"] = "waiting"
result = WorkflowExecutionResult(
success=True,
message="", # No text message for form display
next_node_id=None, # Don't proceed yet
should_continue=False, # Wait for form submission
form_data=form_data # Include form data for display
)
logger.debug(f"Form result: {result}")
return result
elif current_state == "submitted":
# Form has been submitted, process and continue
form_submission = workflow_state.get("form_data", {})
logger.debug(f"Form submission: {form_submission}")
# Store form variables for use in other nodes with node-based naming
node_based_variables = {}
node_name = node.name.lower().replace(' ', '_').replace('-', '_') if node.name else 'form'
# Remove non-alphanumeric characters except underscores
import re
node_name = re.sub(r'[^a-z0-9_]', '', node_name)
for field_name, field_value in form_submission.items():
variable_name = f"{node_name}_{field_name}"
node_based_variables[variable_name] = field_value
self._store_form_variables(workflow_state, node.id, node_based_variables)
# Clear form state after processing
workflow_state.pop("form_state", None)
workflow_state.pop("form_data", None)
# Store form submission in workflow history
self.session_repo.add_workflow_history_entry(session_id, node.id, "form_submission", form_submission)
# Find next node
next_node_id = self._find_next_node(node)
logger.debug(f"Next node ID: {next_node_id}")
return WorkflowExecutionResult(
success=True,
message="",
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
else:
# Invalid state - but still try to display the form rather than error
logger.warning(f"Unknown form state '{current_state}', defaulting to display form")
form_data = {
"title": config.get("form_title", ""),
"description": config.get("form_description", ""),
"submit_button_text": config.get("submit_button_text", "Submit"),
"fields": form_fields,
"form_full_screen": config.get("form_full_screen", False)
}
# Reset to waiting state
workflow_state["form_state"] = "waiting"
return WorkflowExecutionResult(
success=True,
message="",
next_node_id=None,
should_continue=False,
form_data=form_data
)
def _execute_landing_page_node(self, node: WorkflowNode, workflow_state: Dict[str, Any], user_message: Optional[str] = None) -> WorkflowExecutionResult:
"""Execute a landing page node"""
logger.info(f"Executing landing page node: {node.id}, user_message present: {bool(user_message)}")
config = node.config or {}
heading = config.get("landing_page_heading", "Welcome")
content = config.get("landing_page_content", "Thank you for visiting!")
# Process variables in heading and content
heading = self._process_variables(heading, workflow_state)
content = self._process_variables(content, workflow_state)
# Create landing page data
landing_page_data = {
"heading": heading,
"content": content
}
# If user message is present, proceed to next node
if user_message:
logger.info(f"User message received on landing page node, proceeding to next node")
next_node_id = self._find_next_node(node)
return WorkflowExecutionResult(
success=True,
message="", # No text message needed
next_node_id=next_node_id,
should_continue=True, # Continue execution
landing_page_data=None # Don't show landing page again
)
# Don't find next node immediately - wait for user to proceed
result = WorkflowExecutionResult(
success=True,
message="", # No text message for landing page display
next_node_id=None, # Don't proceed yet
should_continue=False, # Wait for user to proceed
landing_page_data=landing_page_data # Include landing page data for display
)
logger.debug(f"Landing page result: {result}")
return result
def _execute_action_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult:
"""Execute an action node"""
# This is a simplified implementation
# In a full implementation, you'd handle webhooks, database operations, etc.
config = node.config or {}
action_type = config.get("action_type")
action_config = config.get("action_config", {})
logger.info(f"Executing action: {action_type}")
# Don't store action execution in workflow state to keep it minimal
# Find next node
next_node_id = self._find_next_node(node)
return WorkflowExecutionResult(
success=True,
message="Action completed successfully.",
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
def _execute_human_transfer_node(
self,
node: WorkflowNode,
workflow_state: Dict[str, Any]
) -> WorkflowExecutionResult:
"""Execute a human transfer node"""
config = node.config or {}
transfer_rules = config.get("transfer_rules", {})
message = transfer_rules.get("message", "Transferring you to a human agent. Please wait...")
transfer_group_id = config.get("transfer_group_id")
return WorkflowExecutionResult(
success=True,
message=message,
transfer_to_human=True,
transfer_group_id=transfer_group_id,
should_continue=False
)
def _execute_wait_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult:
"""Execute a wait node"""
# This is a simplified implementation
# In a full implementation, you'd handle timed waits and conditions
config = node.config or {}
wait_duration = config.get("wait_duration", 0)
wait_unit = config.get("wait_unit", "seconds")
message = f"Please wait {wait_duration} {wait_unit}..."
# Find next node
next_node_id = self._find_next_node(node)
return WorkflowExecutionResult(
success=True,
message=message,
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
def _execute_end_node(self, node: WorkflowNode, workflow_state: Dict[str, Any]) -> WorkflowExecutionResult:
"""Execute an end node"""
config = node.config or {}
message = config.get("message_text", "Thank you for using our service!")
# Check if we should request rating
request_rating = config.get("request_rating", False)
return WorkflowExecutionResult(
success=True,
message=message,
end_chat=True,
request_rating=request_rating,
should_continue=False
)
def _execute_guardrails_node(
self,
node: WorkflowNode,
workflow: Workflow,
workflow_state: Dict[str, Any],
user_message: str
) -> WorkflowExecutionResult:
"""
Execute a guardrails node to check content against configured guardrails
Args:
node: Guardrails workflow node
workflow: Current workflow
workflow_state: Current workflow state
user_message: User's message to check
Returns:
WorkflowExecutionResult with pass/fail status
"""
from app.utils.guardrails import check_guardrails
config = node.config or {}
# Get guardrail configuration
enabled_guardrails = config.get("enabled_guardrails", ["pii", "jailbreak"])
pii_action = config.get("pii_action", "block")
jailbreak_sensitivity = config.get("jailbreak_sensitivity", 0.7)
# Get text to check - either from user message or from a variable
text_source = config.get("text_source", "user_message")
if text_source == "user_message":
# Try to get from parameter first, then from workflow state
if user_message:
text_to_check = user_message
elif "variables" in workflow_state and "user_message" in workflow_state["variables"]:
text_to_check = workflow_state["variables"]["user_message"]
logger.debug(f"Using user_message from workflow state: {text_to_check}")
else:
text_to_check = ""
else:
# Check if it's a variable reference
# If the text_source doesn't contain {{}} syntax, wrap it
if not (text_source.startswith("{{") and text_source.endswith("}}")):
text_source = f"{{{{{text_source}}}}}"
text_to_check = self._process_variables(text_source, workflow_state)
if not text_to_check:
logger.warning(f"No text to check in guardrails node {node.id}")
# No text to check - pass through
next_node_id = self._find_next_node(node)
return WorkflowExecutionResult(
success=True,
message="",
next_node_id=next_node_id,
should_continue=True
)
# Run guardrail checks
passed, results, block_message = check_guardrails(
text=text_to_check,
guardrail_types=enabled_guardrails,
pii_action=pii_action,
jailbreak_sensitivity=jailbreak_sensitivity
)
# Store results in workflow state
if "guardrails" not in workflow_state:
workflow_state["guardrails"] = {}
workflow_state["guardrails"][str(node.id)] = {
"passed": passed,
"results": results,
"checked_text": text_to_check[:100] + "..." if len(text_to_check) > 100 else text_to_check
}
logger.info(f"Guardrails node {node.id} - Passed: {passed}, Results: {results}")
# Find connections - guardrails nodes can have two outputs: "pass" and "fail"
pass_node_id = None
fail_node_id = None
for connection in node.outgoing_connections:
condition_label = connection.label
if condition_label and condition_label.lower() == "fail":
fail_node_id = connection.target_node_id
else:
# Default or "pass" label goes to pass path
pass_node_id = connection.target_node_id
if passed:
# Guardrails passed - continue to pass node or default next
next_node_id = pass_node_id or self._find_next_node(node)
# Check if we should redact and update the message
redacted_text = None
for result in results:
if result.get("redacted_text"):
redacted_text = result["redacted_text"]
break
if redacted_text and pii_action == "redact":
# Store redacted text in workflow state for use by subsequent nodes
workflow_state["last_redacted_message"] = redacted_text
# Determine message and continuation based on whether there's a next node
message = ""
should_continue = True
if not next_node_id:
# Guardrails passed but this is a terminal node - provide feedback
logger.info(f"Guardrails node {node.id} passed - this is a terminal node (no next node configured)")
should_continue = False
# Optionally provide a success message if this is the end of workflow
message = "Content check passed successfully."
return WorkflowExecutionResult(
success=True,
message=message,
next_node_id=next_node_id,
should_continue=should_continue
)
else:
# Guardrails failed - go to fail node or block
if fail_node_id:
# Continue to fail handling node
next_node_id = fail_node_id
return WorkflowExecutionResult(
success=True,
message="",
next_node_id=next_node_id,
should_continue=True
)
else:
# No fail node - block with message
block_message_config = config.get("block_message", "")
final_message = block_message_config if block_message_config else block_message
return WorkflowExecutionResult(
success=True,
message=final_message,
next_node_id=None,
should_continue=False
)
def _execute_user_input_node(
self,
node: WorkflowNode,
workflow_state: Dict[str, Any],
user_message: str,
session_id: str = None
) -> WorkflowExecutionResult:
"""Execute a user input node"""
logger.info(f"Executing user input node: {node.id}")
config = node.config or {}
# Check if we're waiting for user input
current_state = workflow_state.get("user_input_state", "waiting")
if current_state == "waiting" and (not user_message or user_message.strip() == ""):
# Still waiting for user input
prompt_message = config.get("prompt_message", "")
# Only process and display prompt if it exists and is not empty
if prompt_message and prompt_message.strip():
prompt_message = self._process_variables(prompt_message, workflow_state)
message_to_display = prompt_message
else:
# No prompt message configured - return empty message
message_to_display = ""
# Mark that we're waiting for user input
workflow_state["user_input_state"] = "waiting"
workflow_state["user_input_node_id"] = str(node.id)
return WorkflowExecutionResult(
success=True,
message=message_to_display,
next_node_id=None, # Don't proceed yet
should_continue=False # Wait for user input
)
elif current_state == "waiting" and user_message and user_message.strip():
# User has provided input - process and continue
user_input = user_message.strip()
logger.debug(f"User input received: {user_input}")
# Store user input as a variable for use in other nodes with node-based naming
node_name = node.name.lower().replace(' ', '_').replace('-', '_') if node.name else 'user_input'
# Remove non-alphanumeric characters except underscores
node_name = re.sub(r'[^a-z0-9_]', '', node_name)
variable_name = f"{node_name}_input"
user_input_data = {variable_name: user_input}
self._store_form_variables(workflow_state, node.id, user_input_data)
# Store user input in workflow state temporarily
workflow_state["user_input_data"] = user_input
workflow_state["user_input_state"] = "received"
# Store user input in workflow history
if session_id:
self.session_repo.add_workflow_history_entry(
session_id,
node.id,
"user_input",
{"input": user_input, "node_name": node.name}
)
# Clear the user input state after processing
workflow_state.pop("user_input_state", None)
workflow_state.pop("user_input_node_id", None)
workflow_state.pop("user_input_data", None)
# Find next node
next_node_id = self._find_next_node(node)
logger.debug(f"Next node ID: {next_node_id}")
# Get confirmation message or use default
confirmation_message = config.get("confirmation_message", "")
if confirmation_message:
confirmation_message = self._process_variables(confirmation_message, workflow_state)
return WorkflowExecutionResult(
success=True,
message=confirmation_message,
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
else:
# No confirmation message - proceed silently to next node
return WorkflowExecutionResult(
success=True,
message="",
next_node_id=next_node_id,
should_continue=next_node_id is not None
)
else:
# Invalid state - reset to waiting
logger.warning(f"Unknown user input state '{current_state}', resetting to waiting")
prompt_message = config.get("prompt_message", "")
# Only process and display prompt if it exists and is not empty
if prompt_message and prompt_message.strip():
prompt_message = self._process_variables(prompt_message, workflow_state)
message_to_display = prompt_message
else:
# No prompt message configured - return empty message
message_to_display = ""
workflow_state["user_input_state"] = "waiting"
workflow_state["user_input_node_id"] = str(node.id)
return WorkflowExecutionResult(
success=True,
message=message_to_display,
next_node_id=None,
should_continue=False
)
def _find_next_node(self, node: WorkflowNode) -> Optional[UUID]:
"""Find the next node in the workflow"""
if not node.outgoing_connections:
return None
# Return the first outgoing connection's target
# In a more complex implementation, you'd handle multiple connections
return node.outgoing_connections[0].target_node_id
def _find_conditional_next_node(self, node: WorkflowNode, condition_result: bool) -> Optional[UUID]:
"""Find the connection labelled for the condition result.
With no such connection the workflow stops here rather than taking
another branch: a false result must never run the "true" path.
"""
wanted = "true" if condition_result else "false"
for connection in node.outgoing_connections or []:
if wanted in (_branch_label(connection.label), _branch_label(connection.condition)):
return connection.target_node_id
logger.warning(
f"Condition node '{node.name}' ({node.id}) has no '{wanted}' connection; "
f"stopping the workflow here. Connect its '{wanted}' branch to fix this."
)
return None
def _process_variables(self, text: str, workflow_state: Dict[str, Any]) -> str:
"""Process variables in text using {{variable}} syntax"""
return self._interpolate_variables(text, workflow_state)
def _evaluate_condition(self, condition: str, workflow_state: Dict[str, Any]) -> bool:
"""Evaluate a condition expression string"""
return evaluate_expression(condition, workflow_state)
def _update_session_workflow_state(
self,
session_id: str,
next_node_id: Optional[UUID],
workflow_state: Dict[str, Any]
) -> None:
"""Update session with new workflow state"""
try:
logger.info(f"Updating session {session_id} with next_node_id: {next_node_id}")
logger.info(f"Updating session {session_id} with workflow_state: {workflow_state}")
success = self.session_repo.update_workflow_state(session_id, next_node_id, workflow_state)
if success:
logger.info(f"Successfully updated session {session_id} workflow state")
else:
logger.error(f"Failed to update session {session_id} workflow state")
except Exception as e:
logger.error(f"Error updating session workflow state: {str(e)}")
async def _build_context_message(self, session_id: str, workflow_state: Dict[str, Any]) -> str:
"""
Build a structured context message when user_message is empty or null.
Includes chat history and workflow history formatted as instructions for LLM.
"""
try:
# Initialize repositories
chat_repo = ChatRepository(self.db)
# Get chat history for the session
chat_history = await chat_repo.get_session_history(session_id)
# Get workflow history for the session
workflow_history = self.session_repo.get_workflow_history(session_id)
# Build structured context message
context_parts = []
context_parts.append("CONTEXT on previous workflow messages, you can use this along with instructions for understanding context")
# Add instruction header
# Add chat history section
if chat_history:
context_parts.append("CONVERSATION HISTORY:")
context_parts.append("-" * 25)
for i, message in enumerate(chat_history, 1):
timestamp = message.created_at.strftime("%Y-%m-%d %H:%M:%S") if message.created_at else "Unknown"
message_type = message.message_type.upper()
# Format message with context
context_parts.append(f"{i}. [{timestamp}] {message_type}: {message.message}")
# Add attributes if they contain useful context
if message.attributes:
relevant_attrs = {}
for key, value in message.attributes.items():
if key in ['workflow_execution', 'transfer_to_human', 'end_chat', 'form_submission', 'user_input']:
relevant_attrs[key] = value
if relevant_attrs:
context_parts.append(f" Context: {relevant_attrs}")
context_parts.append("")
else:
context_parts.append("CONVERSATION HISTORY: No previous messages")
context_parts.append("")
# Add workflow history section
if workflow_history:
context_parts.append("WORKFLOW INTERACTION HISTORY:")
context_parts.append("-" * 35)
for i, entry in enumerate(workflow_history, 1):
timestamp = entry.get('timestamp', 'Unknown')
entry_type = entry.get('type', 'Unknown')
node_id = entry.get('node_id', 'Unknown')
data = entry.get('data', {})
context_parts.append(f"{i}. [{timestamp}] {entry_type.upper()} (Node: {node_id})")
# Format specific data types
if entry_type == 'form_submission' and data:
context_parts.append(f" Form Data: {json.dumps(data, indent=6)}")
elif entry_type == 'user_input' and data:
context_parts.append(f" User Input: {data}")
elif data:
context_parts.append(f" Data: {json.dumps(data, indent=6)}")
context_parts.append("")
else:
context_parts.append("WORKFLOW INTERACTION HISTORY: No previous workflow interactions")
context_parts.append("")
# Add current workflow state
if workflow_state:
context_parts.append("CURRENT WORKFLOW STATE:")
context_parts.append("-" * 27)
context_parts.append(json.dumps(workflow_state, indent=2))
context_parts.append("")
# Add analysis instructions
return "\n".join(context_parts)
except Exception as e:
logger.error(f"Error building context message: {str(e)}")
# Fallback message
return ("Please analyze the current conversation context and provide an appropriate response. "
"The user has not provided a new message, so please review the conversation history "
"and workflow progress to determine the next appropriate action.")
def _store_form_variables(self, workflow_state: Dict[str, Any], node_id: UUID, form_data: Dict[str, Any]) -> None:
"""
Store form variables in workflow state for use in other nodes
Args:
workflow_state: Current workflow state
node_id: ID of the form node
form_data: Submitted form data
"""
try:
# Initialize variables structure if it doesn't exist
if "variables" not in workflow_state:
workflow_state["variables"] = {}
# Store variables namespaced by node ID
workflow_state["variables"][str(node_id)] = form_data.copy()
logger.info(f"Stored form variables from node {node_id}: {list(form_data.keys())}")
logger.debug(f"Variable values: {form_data}")
except Exception as e:
logger.error(f"Error storing form variables: {str(e)}")
def _interpolate_variables(self, text: str, workflow_state: Dict[str, Any]) -> str:
"""
Replace variable placeholders in text with actual values
Supports syntax:
- {{user_message}} - A top-level variable such as the latest user message
- {{field_name}} - Search all form nodes for field (latest value wins)
- {{node_id.field_name}} - Specific node's field value
Args:
text: Text containing variable placeholders
workflow_state: Current workflow state with variables
Returns:
Text with variables interpolated
"""
if not text or not isinstance(text, str):
return text
try:
variables = workflow_state.get("variables", {})
if not variables:
return text
# Pattern to match {{variable}} or {{node_id.field_name}}
pattern = r'\{\{([^}]+)\}\}'
def replace_variable(match):
var_expression = match.group(1).strip()
value = resolve_variable(var_expression, workflow_state)
if value is MISSING:
logger.warning(f"Variable not found: {var_expression}")
return match.group(0) # Return original {{variable}}
return str(value)
# Replace all variable placeholders
result = re.sub(pattern, replace_variable, text)
if result != text:
logger.debug(f"Variable interpolation: '{text}' -> '{result}'")
return result
except Exception as e:
logger.error(f"Error interpolating variables: {str(e)}")
return text # Return original text on error