""" 对话流节点 用于对话流模式的智能体,支持与用户的交互式对话 """ 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, db_session=None) -> 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(db_session) 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, context.db_session ) 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, }, )