673 lines
23 KiB
Python
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,
|
|
},
|
|
)
|