Files
ai-agent-admin/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py
T
2026-06-08 18:14:59 +08:00

673 lines
23 KiB
Python

"""
对话流节点
用于对话流模式的智能体,支持与用户的交互式对话
"""
import logging
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class QuestionNode(BaseNode):
"""
问答节点
向用户提出问题,等待用户输入回答
支持输入验证和默认值
"""
node_type = 'question'
node_name = '问答节点'
node_category = 'dialog'
inputs = {
'question': {
'type': 'string',
'description': '要向用户提出的问题',
'required': True,
},
'variable_name': {
'type': 'string',
'description': '存储用户回答的变量名',
'required': True,
},
'input_type': {
'type': 'string',
'description': '输入类型:text/number/email/phone/date',
'default': 'text',
},
'placeholder': {
'type': 'string',
'description': '输入框占位文本',
'default': '',
},
'default_value': {
'type': 'string',
'description': '默认值',
'default': '',
},
'required': {
'type': 'boolean',
'description': '是否必填',
'default': True,
},
'validation_regex': {
'type': 'string',
'description': '验证正则表达式',
'default': '',
},
'validation_message': {
'type': 'string',
'description': '验证失败提示',
'default': '输入格式不正确',
},
'render_input': {
'type': 'boolean',
'description': '是否渲染输入框,默认不渲染(用户直接在聊天框输入)',
'default': False,
},
}
outputs = {
'answer': {
'type': 'string',
'description': '用户的回答',
},
'is_valid': {
'type': 'boolean',
'description': '回答是否有效',
},
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行问答节点
这个节点会暂停工作流执行,等待用户输入
"""
question = self.config.get('question', '')
variable_name = self.config.get('variable_name', 'user_input')
input_type = self.config.get('input_type', 'text')
placeholder = self.config.get('placeholder', '')
default_value = self.config.get('default_value', '')
required = self.config.get('required', True)
validation_regex = self.config.get('validation_regex', '')
validation_message = self.config.get('validation_message', '输入格式不正确')
render_input = self.config.get('render_input', False)
# 解析模板变量
question = context.resolve_template(question)
placeholder = context.resolve_template(placeholder)
default_value = context.resolve_template(default_value)
# 检查是否已有用户输入(续流时)
user_input = context.variables.get('__user_input__')
if user_input is not None:
# 用户已输入,验证并继续
import re
# 将非字符串输入转换为字符串
if not isinstance(user_input, str):
user_input = str(user_input)
is_valid = True
# 必填验证
if required and not user_input.strip():
is_valid = False
# 正则验证
if is_valid and validation_regex:
if not re.match(validation_regex, user_input):
is_valid = False
# 类型验证
if is_valid and input_type == 'number':
try:
user_input = float(user_input)
except ValueError:
is_valid = False
elif is_valid and input_type == 'email':
email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_regex, user_input):
is_valid = False
elif is_valid and input_type == 'phone':
phone_regex = r'^1[3-9]\d{9}$'
if not re.match(phone_regex, user_input):
is_valid = False
if is_valid:
return NodeResult(
success=True,
output={
'answer': user_input,
'is_valid': True,
},
output_variables={
variable_name: user_input,
},
)
else:
# 验证失败,重新等待输入
return NodeResult(
success=True,
output={
'answer': '',
'is_valid': False,
},
waiting_for_input=True,
waiting_config={
'type': 'question',
'question': question,
'input_type': input_type,
'placeholder': placeholder,
'default_value': default_value,
'required': required,
'error_message': validation_message,
'render_input': render_input,
},
)
else:
# 首次执行,等待用户输入
return NodeResult(
success=True,
output={
'answer': '',
'is_valid': False,
},
waiting_for_input=True,
waiting_config={
'type': 'question',
'question': question,
'input_type': input_type,
'placeholder': placeholder,
'default_value': default_value,
'required': required,
'render_input': render_input,
},
)
@NodeRegistry.register
class ChoiceNode(BaseNode):
"""
选项节点
向用户展示多个选项,用户选择后继续执行
支持单选和多选
"""
node_type = 'choice'
node_name = '选项节点'
node_category = 'dialog'
inputs = {
'question': {
'type': 'string',
'description': '问题或提示文本',
'required': True,
},
'variable_name': {
'type': 'string',
'description': '存储用户选择的变量名',
'required': True,
},
'options': {
'type': 'array',
'description': '选项列表',
'required': True,
'items': {
'type': 'object',
'properties': {
'value': {'type': 'string', 'description': '选项值'},
'label': {'type': 'string', 'description': '显示文本'},
'description': {'type': 'string', 'description': '选项描述'},
},
},
},
'multiple': {
'type': 'boolean',
'description': '是否多选',
'default': False,
},
'min_select': {
'type': 'integer',
'description': '最少选择数量',
'default': 1,
},
'max_select': {
'type': 'integer',
'description': '最多选择数量',
'default': 1,
},
}
outputs = {
'selected': {
'type': 'any',
'description': '用户选择的值(单选为字符串,多选为数组)',
},
'selected_labels': {
'type': 'any',
'description': '用户选择的显示文本',
},
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行选项节点
"""
question = self.config.get('question', '')
variable_name = self.config.get('variable_name', 'user_choice')
options = self.config.get('options', [])
multiple = self.config.get('multiple', False)
min_select = self.config.get('min_select', 1)
max_select = self.config.get('max_select', 1)
# 解析模板变量
question = context.resolve_template(question)
# 检查是否已有用户选择
user_selection = context.variables.get('__user_input__')
if user_selection is not None:
# 用户已选择
if multiple:
# 多选:user_selection 应该是数组
if isinstance(user_selection, str):
user_selection = [user_selection]
elif not isinstance(user_selection, list):
# 如果不是字符串也不是列表(比如布尔值),转换为字符串后放入列表
user_selection = [str(user_selection)]
# 验证所有选项值是否有效
valid_values = [opt.get('value') for opt in options]
invalid_selections = [val for val in user_selection if val not in valid_values]
if invalid_selections:
# 有无效选项,重新显示选择界面
return NodeResult(
success=True,
output={'selected': [], 'selected_labels': []},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
'error_message': '请从选项中选择',
},
)
# 验证选择数量
if len(user_selection) < min_select or len(user_selection) > max_select:
return NodeResult(
success=True,
output={'selected': [], 'selected_labels': []},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
'error_message': f'请选择 {min_select}-{max_select} 个选项',
},
)
# 获取选中的标签
selected_labels = []
for opt in options:
if opt.get('value') in user_selection:
selected_labels.append(opt.get('label', opt.get('value')))
return NodeResult(
success=True,
output={
'selected': user_selection,
'selected_labels': selected_labels,
},
output_variables={
variable_name: user_selection,
f'{variable_name}_labels': selected_labels,
},
)
else:
# 单选:验证选项值是否有效
valid_values = [opt.get('value') for opt in options]
if user_selection not in valid_values:
# 无效选项,重新显示选择界面
return NodeResult(
success=True,
output={'selected': None, 'selected_labels': None},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
'error_message': '请从选项中选择',
},
)
selected_label = ''
for opt in options:
if opt.get('value') == user_selection:
selected_label = opt.get('label', opt.get('value'))
break
return NodeResult(
success=True,
output={
'selected': user_selection,
'selected_labels': selected_label,
},
output_variables={
variable_name: user_selection,
f'{variable_name}_label': selected_label,
},
)
else:
# 首次执行,等待用户选择
return NodeResult(
success=True,
output={
'selected': None,
'selected_labels': None,
},
waiting_for_input=True,
waiting_config={
'type': 'choice',
'question': question,
'options': options,
'multiple': multiple,
'min_select': min_select,
'max_select': max_select,
},
)
@NodeRegistry.register
class MessageNode(BaseNode):
"""
消息节点
向用户发送消息,不等待回复
"""
node_type = 'message'
node_name = '消息节点'
node_category = 'dialog'
inputs = {
'content': {
'type': 'string',
'description': '消息内容',
'required': True,
},
'message_type': {
'type': 'string',
'description': '消息类型:text/markdown/html',
'default': 'text',
},
}
outputs = {
'sent': {
'type': 'boolean',
'description': '是否发送成功',
},
}
def execute(self, context: NodeContext) -> NodeResult:
"""
执行消息节点
"""
import logging
logger = logging.getLogger(__name__)
content = self.config.get('content', '')
message_type = self.config.get('message_type', 'text')
logger.info(f'[MessageNode] Original content: {content}')
logger.info(f'[MessageNode] Context variables: item={context.get_variable("item")}, index={context.get_variable("index")}')
# 解析模板变量
content = context.resolve_template(content)
logger.info(f'[MessageNode] Resolved content: {content}')
return NodeResult(
success=True,
output={
'sent': True,
'content': content, # 也在 output 中返回消息内容
},
# 发送消息事件
events=[{
'type': 'message',
'content': content,
'message_type': message_type,
}],
)
@NodeRegistry.register
class ConfirmNode(BaseNode):
"""
确认节点
向用户展示确认对话框,等待用户确认或取消
"""
node_type = 'confirm'
node_name = '确认节点'
node_category = 'dialog'
# 扩展的确认关键词列表(覆盖常见表达)
CONFIRM_KEYWORDS = {
# 英文
'true', 'yes', 'ok', 'okay', 'sure', 'confirm', 'confirmed', 'agree', 'accept', 'y',
# 中文
'', '是的', '', '对的', '', '好的', '', '行的', '可以', '没问题', '没有问题',
'同意', '确认', '确定', '', '嗯嗯', '好吧', '', '', '', '', '', '要的',
'继续', '执行', '进行', '开始', '去吧', '做吧', '干吧',
# 数字
'1',
}
# 扩展的取消关键词列表
CANCEL_KEYWORDS = {
# 英文
'false', 'no', 'cancel', 'reject', 'decline', 'deny', 'n', 'nope',
# 中文
'', '', '不是', '不行', '不可以', '不要', '不用', '取消', '拒绝', '算了',
'停止', '终止', '放弃', '', '别了', '不了', '不用了', '不需要',
# 数字
'0',
}
inputs = {
'title': {
'type': 'string',
'description': '确认框标题',
'default': '确认',
},
'content': {
'type': 'string',
'description': '确认内容',
'required': True,
},
'confirm_text': {
'type': 'string',
'description': '确认按钮文本',
'default': '确认',
},
'cancel_text': {
'type': 'string',
'description': '取消按钮文本',
'default': '取消',
},
'variable_name': {
'type': 'string',
'description': '存储结果的变量名',
'default': 'confirmed',
},
'use_llm_intent': {
'type': 'boolean',
'description': '使用 LLM 进行意图识别(当关键词匹配失败时)',
'default': False,
},
'llm_model_id': {
'type': 'string',
'description': '用于意图识别的 LLM 模型 ID',
'default': '',
},
}
outputs = {
'confirmed': {
'type': 'boolean',
'description': '用户是否确认',
},
}
def _match_keywords(self, user_input) -> tuple[bool, bool]:
"""
使用关键词匹配判断用户意图
Returns:
(matched, confirmed): matched 表示是否匹配到关键词,confirmed 表示是否确认
"""
# 布尔值特殊处理(必须在字符串处理之前)
if user_input is True or user_input == True:
return True, True
if user_input is False or user_input == False:
return True, False
# 确保是字符串
if not isinstance(user_input, str):
user_input = str(user_input)
# 标准化输入:去除空格、转小写
normalized = user_input.strip().lower()
# 精确匹配
if normalized in self.CONFIRM_KEYWORDS:
return True, True
if normalized in self.CANCEL_KEYWORDS:
return True, False
return False, False
def _llm_intent_recognition(self, user_input: str, context_content: str, model_id: str) -> bool:
"""
使用 LLM 进行意图识别
Returns:
confirmed: 用户是否确认
"""
try:
from ai_platform.services.llm_service import LLMService
system_prompt = """你是一个意图识别助手。用户正在回应一个确认请求,你需要判断用户的回复是"确认"还是"取消"
规则:
1. 如果用户表达同意、肯定、愿意继续的意思,返回 "confirm"
2. 如果用户表达拒绝、否定、不愿意继续的意思,返回 "cancel"
3. 如果无法判断,默认返回 "cancel"
只返回 "confirm""cancel",不要返回其他内容。"""
user_prompt = f"""确认请求内容:{context_content}
用户回复:{user_input}
请判断用户意图:"""
llm_service = LLMService()
response = llm_service.chat(
model_id=model_id,
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
temperature=0,
max_tokens=10,
)
result = response.content.strip().lower()
return result == 'confirm'
except Exception as e:
logger.warning(f'LLM 意图识别失败,回退到默认行为: {e}')
return False
def execute(self, context: NodeContext) -> NodeResult:
"""
执行确认节点
"""
title = self.config.get('title', '确认')
content = self.config.get('content', '')
confirm_text = self.config.get('confirm_text', '确认')
cancel_text = self.config.get('cancel_text', '取消')
variable_name = self.config.get('variable_name', 'confirmed')
use_llm_intent = self.config.get('use_llm_intent', False)
llm_model_id = self.config.get('llm_model_id', '')
# 解析模板变量
title = context.resolve_template(title)
content = context.resolve_template(content)
# 检查是否已有用户选择
user_input = context.variables.get('__user_input__')
if user_input is not None:
# 首先尝试关键词匹配
matched, confirmed = self._match_keywords(user_input)
if not matched and use_llm_intent and llm_model_id:
# 关键词未匹配,使用 LLM 意图识别
logger.info(f'关键词未匹配,使用 LLM 意图识别: {user_input}')
confirmed = self._llm_intent_recognition(str(user_input), content, llm_model_id)
elif not matched:
# 关键词未匹配且未启用 LLM,默认为取消
logger.info(f'关键词未匹配,默认取消: {user_input}')
confirmed = False
return NodeResult(
success=True,
output={
'confirmed': confirmed,
},
output_variables={
variable_name: confirmed,
},
)
else:
# 等待用户确认
return NodeResult(
success=True,
output={
'confirmed': False,
},
waiting_for_input=True,
waiting_config={
'type': 'confirm',
'title': title,
'content': content,
'confirm_text': confirm_text,
'cancel_text': cancel_text,
},
)