Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
AI 工作流节点系统
|
||||
|
||||
提供可扩展的节点架构,支持:
|
||||
- 内置节点(LLM、条件、代码等)
|
||||
- 自定义节点扩展
|
||||
- 知识库节点(预留)
|
||||
- 工具节点(预留)
|
||||
"""
|
||||
from .base import BaseNode, NodeContext, NodeResult
|
||||
from .registry import NodeRegistry
|
||||
|
||||
__all__ = [
|
||||
'BaseNode',
|
||||
'NodeContext',
|
||||
'NodeResult',
|
||||
'NodeRegistry',
|
||||
]
|
||||
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
节点基类
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeContext:
|
||||
"""
|
||||
节点执行上下文
|
||||
|
||||
包含节点执行所需的所有信息
|
||||
"""
|
||||
# 工作流运行实例
|
||||
workflow_run_id: str = ''
|
||||
|
||||
# 变量存储(所有节点共享)
|
||||
variables: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 用户输入
|
||||
user_input: str = ''
|
||||
|
||||
# 当前用户
|
||||
user_id: str = ''
|
||||
|
||||
# 对话历史(用于 LLM 节点)
|
||||
conversation_history: List[Dict[str, str]] = field(default_factory=list)
|
||||
|
||||
# 节点配置
|
||||
node_config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 上一个节点的输出
|
||||
previous_output: Any = None
|
||||
|
||||
# 元数据
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 数据库会话(用于需要数据库访问的节点)
|
||||
db_session: Any = None
|
||||
|
||||
def get_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""获取变量"""
|
||||
return self.variables.get(name, default)
|
||||
|
||||
def set_variable(self, name: str, value: Any) -> None:
|
||||
"""设置变量"""
|
||||
self.variables[name] = value
|
||||
|
||||
def resolve_template(self, template: str) -> str:
|
||||
"""
|
||||
解析模板中的变量引用
|
||||
|
||||
支持格式:
|
||||
- {{variable_name}} - 直接变量引用
|
||||
- {{variable_name[0]}} - 数组索引访问
|
||||
- {{variable_name[-1]}} - 负数索引(最后一个)
|
||||
- {{NodeID.key}} - 节点输出引用(如果存在)
|
||||
- {{NodeID.key}}.property - 访问解析结果的嵌套属性
|
||||
- {{NodeID.key[0].property}} - 数组索引 + 属性访问
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
result = template
|
||||
|
||||
# 匹配 {{...}} 格式的变量引用,以及可选的后续属性访问 .property1.property2...
|
||||
pattern = r'\{\{([^}]+)\}\}((?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)'
|
||||
|
||||
def get_nested_value(obj, path: str):
|
||||
"""从对象中获取嵌套属性值,支持数组索引"""
|
||||
if not path:
|
||||
return obj
|
||||
|
||||
# 移除开头的点
|
||||
if path.startswith('.'):
|
||||
path = path[1:]
|
||||
|
||||
current = obj
|
||||
# 使用正则分割路径,支持 .property 和 [index] 格式
|
||||
# 例如: "items[0].name" -> ["items", "[0]", "name"]
|
||||
parts = re.split(r'(?=\[)|\.', path)
|
||||
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# 如果是字符串,尝试解析为 JSON
|
||||
if isinstance(current, str):
|
||||
try:
|
||||
current = json.loads(current)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# 检查是否是数组索引 [n]
|
||||
index_match = re.match(r'\[(-?\d+)\]', part)
|
||||
if index_match:
|
||||
index = int(index_match.group(1))
|
||||
if isinstance(current, (list, tuple)):
|
||||
try:
|
||||
current = current[index]
|
||||
except IndexError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
# 从字典中获取属性
|
||||
elif isinstance(current, dict):
|
||||
if part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
def replace_var(match):
|
||||
var_ref = match.group(1).strip()
|
||||
extra_path = match.group(2) or '' # 额外的属性路径,如 .customer_name
|
||||
|
||||
value = None
|
||||
index_path = '' # 变量名后的索引/属性路径
|
||||
|
||||
# 检查是否有数组索引 [n],如果有则分割
|
||||
# 例如: "loop_results[0]" -> var_name="loop_results", index_path="[0]"
|
||||
# 例如: "llm-123.llm_response" -> var_name="llm-123.llm_response", index_path=""
|
||||
bracket_pos = var_ref.find('[')
|
||||
if bracket_pos > 0:
|
||||
var_name = var_ref[:bracket_pos]
|
||||
index_path = var_ref[bracket_pos:]
|
||||
else:
|
||||
var_name = var_ref
|
||||
|
||||
# 尝试解析 NodeID.key.subkey... 格式(支持多层属性访问)
|
||||
if '.' in var_name:
|
||||
parts = var_name.split('.')
|
||||
node_id = parts[0]
|
||||
remaining_path = '.'.join(parts[1:]) # 剩余路径,如 "item.module_name"
|
||||
|
||||
# 先尝试从节点输出命名空间获取
|
||||
node_outputs = self.variables.get(f'_node_{node_id}')
|
||||
if isinstance(node_outputs, dict):
|
||||
# 尝试获取第一层 key
|
||||
first_key = parts[1] if len(parts) > 1 else None
|
||||
if first_key and first_key in node_outputs:
|
||||
value = node_outputs[first_key]
|
||||
# 如果还有更多层级,继续递归获取
|
||||
if len(parts) > 2:
|
||||
nested_path = '.'.join(parts[2:])
|
||||
nested_value = get_nested_value(value, nested_path)
|
||||
if nested_value is not None:
|
||||
value = nested_value
|
||||
|
||||
# 回退:尝试直接从变量获取(node_id 作为变量名)
|
||||
if value is None and node_id in self.variables:
|
||||
node_data = self.variables[node_id]
|
||||
if isinstance(node_data, dict):
|
||||
first_key = parts[1] if len(parts) > 1 else None
|
||||
if first_key and first_key in node_data:
|
||||
value = node_data[first_key]
|
||||
if len(parts) > 2:
|
||||
nested_path = '.'.join(parts[2:])
|
||||
nested_value = get_nested_value(value, nested_path)
|
||||
if nested_value is not None:
|
||||
value = nested_value
|
||||
|
||||
# 再回退:直接从顶层变量获取完整路径
|
||||
if value is None and remaining_path in self.variables:
|
||||
value = self.variables[remaining_path]
|
||||
else:
|
||||
# 直接变量引用
|
||||
if var_name in self.variables:
|
||||
value = self.variables[var_name]
|
||||
|
||||
# 如果找到了值,处理索引路径和额外的属性路径
|
||||
if value is not None:
|
||||
# 合并索引路径和额外路径
|
||||
full_path = index_path + extra_path
|
||||
if full_path:
|
||||
nested_value = get_nested_value(value, full_path)
|
||||
if nested_value is not None:
|
||||
return str(nested_value)
|
||||
# 嵌套属性未找到,返回原始值
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
# 未找到变量,保持原样
|
||||
return match.group(0)
|
||||
|
||||
result = re.sub(pattern, replace_var, result)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeResult:
|
||||
"""
|
||||
节点执行结果
|
||||
"""
|
||||
# 是否成功
|
||||
success: bool = True
|
||||
|
||||
# 输出数据
|
||||
output: Any = None
|
||||
|
||||
# 输出变量(会合并到上下文的 variables 中)
|
||||
output_variables: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 错误信息
|
||||
error: str = ''
|
||||
|
||||
# 下一个节点 ID(用于条件分支)
|
||||
next_node_id: str = ''
|
||||
|
||||
# Token 使用(LLM 节点)
|
||||
tokens_used: int = 0
|
||||
|
||||
# 耗时(毫秒)
|
||||
elapsed_time: int = 0
|
||||
|
||||
# 元数据
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# ========== 对话流相关 ==========
|
||||
|
||||
# 是否等待用户输入(对话流模式)
|
||||
waiting_for_input: bool = False
|
||||
|
||||
# 等待配置(描述需要什么类型的输入)
|
||||
waiting_config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 事件列表(如发送消息)
|
||||
events: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# ========== 设计预览相关 ==========
|
||||
|
||||
# 设计预览数据(用于工作流中显示设计结果并允许编辑)
|
||||
preview: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BaseNode(ABC):
|
||||
"""
|
||||
节点基类
|
||||
|
||||
所有节点必须继承此类并实现 execute 方法
|
||||
"""
|
||||
|
||||
# 节点类型标识(必须唯一)
|
||||
node_type: str = ''
|
||||
|
||||
# 节点显示名称
|
||||
node_name: str = ''
|
||||
|
||||
# 节点分类
|
||||
node_category: str = 'basic' # basic, llm, logic, data, tool, knowledge
|
||||
|
||||
# 节点图标
|
||||
node_icon: str = ''
|
||||
|
||||
# 节点描述
|
||||
node_description: str = ''
|
||||
|
||||
# 输入参数定义
|
||||
inputs: List[Dict[str, Any]] = []
|
||||
|
||||
# 输出参数定义
|
||||
outputs: List[Dict[str, Any]] = []
|
||||
|
||||
# 是否支持多个输出分支
|
||||
supports_branches: bool = False
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
"""
|
||||
初始化节点
|
||||
|
||||
Args:
|
||||
config: 节点配置
|
||||
"""
|
||||
self.config = config or {}
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""
|
||||
执行节点
|
||||
|
||||
Args:
|
||||
context: 执行上下文
|
||||
|
||||
Returns:
|
||||
NodeResult
|
||||
"""
|
||||
pass
|
||||
|
||||
async def execute_async(self, context: NodeContext) -> NodeResult:
|
||||
"""
|
||||
异步执行节点(默认调用同步方法)
|
||||
|
||||
Args:
|
||||
context: 执行上下文
|
||||
|
||||
Returns:
|
||||
NodeResult
|
||||
"""
|
||||
return self.execute(context)
|
||||
|
||||
def validate_config(self) -> tuple:
|
||||
"""
|
||||
验证节点配置
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
return True, ''
|
||||
|
||||
@classmethod
|
||||
def get_schema(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
获取节点 Schema(供前端渲染)
|
||||
|
||||
Returns:
|
||||
节点 Schema
|
||||
"""
|
||||
return {
|
||||
'type': cls.node_type,
|
||||
'name': cls.node_name,
|
||||
'category': cls.node_category,
|
||||
'icon': cls.node_icon,
|
||||
'description': cls.node_description,
|
||||
'inputs': cls.inputs,
|
||||
'outputs': cls.outputs,
|
||||
'supports_branches': cls.supports_branches,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
获取节点配置 Schema(供前端表单渲染)
|
||||
|
||||
Returns:
|
||||
配置 Schema
|
||||
"""
|
||||
return {}
|
||||
|
||||
def resolve_require_confirmation(self, context: NodeContext, default: bool = True) -> bool:
|
||||
"""
|
||||
解析 require_confirmation 配置
|
||||
|
||||
支持三种模式:
|
||||
1. 布尔值:直接使用 True/False
|
||||
2. 字符串 'always'/'never':始终确认/从不确认
|
||||
3. 变量引用:{{variable_name}} 格式,解析变量值作为布尔值
|
||||
|
||||
Args:
|
||||
context: 节点执行上下文
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
是否需要确认
|
||||
"""
|
||||
value = self.config.get('require_confirmation', default)
|
||||
|
||||
# 布尔值直接返回
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(value, str):
|
||||
value_lower = value.lower().strip()
|
||||
|
||||
# 固定模式
|
||||
if value_lower in ('always', 'true', '1', 'yes'):
|
||||
return True
|
||||
if value_lower in ('never', 'false', '0', 'no'):
|
||||
return False
|
||||
|
||||
# 变量引用模式:{{variable_name}}
|
||||
if '{{' in value and '}}' in value:
|
||||
resolved = context.resolve_template(value)
|
||||
# 解析后的值转换为布尔值
|
||||
if isinstance(resolved, bool):
|
||||
return resolved
|
||||
if isinstance(resolved, str):
|
||||
resolved_lower = resolved.lower().strip()
|
||||
if resolved_lower in ('true', '1', 'yes'):
|
||||
return True
|
||||
if resolved_lower in ('false', '0', 'no'):
|
||||
return False
|
||||
# 非空字符串视为 True
|
||||
return bool(resolved and resolved != value)
|
||||
|
||||
# 其他情况返回默认值
|
||||
return default
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
内置节点
|
||||
"""
|
||||
|
||||
# Node modules are imported by ai_platform.nodes.registry so optional nodes can
|
||||
# degrade independently when their runtime dependencies are unavailable.
|
||||
|
||||
__all__ = [
|
||||
'StartNode',
|
||||
'EndNode',
|
||||
'LLMNode',
|
||||
'ConditionNode',
|
||||
'CodeNode',
|
||||
'HttpNode',
|
||||
'TemplateNode',
|
||||
'VariableNode',
|
||||
'ParallelNode',
|
||||
'MergeNode',
|
||||
'BaseDatabaseNode',
|
||||
'DbInsertNode',
|
||||
'DbUpdateNode',
|
||||
'DbQueryNode',
|
||||
'DbDeleteNode',
|
||||
'DbSqlNode',
|
||||
# 对话流节点
|
||||
'QuestionNode',
|
||||
'ChoiceNode',
|
||||
'MessageNode',
|
||||
'ConfirmNode',
|
||||
'IntentNode',
|
||||
# Snowflake Cortex 节点
|
||||
'SnowflakeCortexLLMNode',
|
||||
'SnowflakeCortexAnalystNode',
|
||||
# 循环节点
|
||||
'LoopNode',
|
||||
# 表单节点
|
||||
'FormBasicInfoNode',
|
||||
'FormDatabaseDesignNode',
|
||||
'FormDatabaseCreateNode',
|
||||
'FormUIDesignNode',
|
||||
'FormListDesignNode',
|
||||
'FormCreateNode',
|
||||
'FormPublishNode',
|
||||
# 应用节点
|
||||
'AppCreateNode',
|
||||
'AppDesignNode',
|
||||
'AppSettingsNode',
|
||||
'AppUpdateNode',
|
||||
# 仪表盘节点
|
||||
'DashboardBasicInfoNode',
|
||||
'DashboardDesignNode',
|
||||
'DashboardCreateNode',
|
||||
'DashboardPublishNode',
|
||||
# 系统总结节点
|
||||
'SystemSummaryNode',
|
||||
# 子流程节点
|
||||
'SubflowNode',
|
||||
# Text-to-SQL 节点
|
||||
'TextToSqlNode',
|
||||
# 表单数据节点
|
||||
'FormDataCreateNode',
|
||||
'FormDataReadNode',
|
||||
'FormDataUpdateNode',
|
||||
'FormDataDeleteNode',
|
||||
'FormDataListNode',
|
||||
'FormSchemaToLLMNode',
|
||||
# 知识库节点
|
||||
'KnowledgeRetrievalNode',
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
代码执行节点
|
||||
"""
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class CodeNode(BaseNode):
|
||||
"""
|
||||
代码执行节点
|
||||
|
||||
执行 Python 代码片段
|
||||
"""
|
||||
|
||||
node_type = 'code'
|
||||
node_name = '代码'
|
||||
node_category = 'logic'
|
||||
node_icon = 'code'
|
||||
node_description = '执行 Python 代码片段'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'inputs',
|
||||
'type': 'object',
|
||||
'description': '输入变量',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'result',
|
||||
'type': 'any',
|
||||
'description': '执行结果',
|
||||
},
|
||||
]
|
||||
|
||||
# 安全的内置函数白名单
|
||||
SAFE_BUILTINS = {
|
||||
'abs', 'all', 'any', 'bool', 'dict', 'enumerate', 'filter',
|
||||
'float', 'int', 'len', 'list', 'map', 'max', 'min', 'range',
|
||||
'round', 'set', 'sorted', 'str', 'sum', 'tuple', 'zip',
|
||||
'True', 'False', 'None',
|
||||
}
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行代码(线程池隔离,防止死循环卡死主流程)"""
|
||||
timeout = self.config.get('timeout', 30)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
||||
future = executor.submit(self._execute_sync, context)
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
future.cancel()
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'代码执行超时({timeout}秒),请检查是否存在死循环',
|
||||
elapsed_time=timeout * 1000,
|
||||
)
|
||||
|
||||
def _execute_sync(self, context: NodeContext) -> NodeResult:
|
||||
"""同步执行代码(在子线程中运行)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
code = self.config.get('code', '')
|
||||
input_variables = self.config.get('inputs', []) or self.config.get('input_variables', [])
|
||||
output_variable = self.config.get('output_variable', 'result')
|
||||
|
||||
if not code:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='代码不能为空',
|
||||
)
|
||||
|
||||
safe_globals = {
|
||||
'__builtins__': {k: getattr(__builtins__, k) if hasattr(__builtins__, k) else __builtins__[k]
|
||||
for k in self.SAFE_BUILTINS if hasattr(__builtins__, k) or k in __builtins__},
|
||||
}
|
||||
|
||||
import json
|
||||
import re
|
||||
import math
|
||||
safe_globals['json'] = json
|
||||
safe_globals['re'] = re
|
||||
safe_globals['math'] = math
|
||||
|
||||
local_vars = {}
|
||||
for var_config in input_variables:
|
||||
if isinstance(var_config, dict):
|
||||
var_name = var_config.get('variable', '')
|
||||
default_value = var_config.get('default_value', None)
|
||||
if var_name:
|
||||
value = context.get_variable(var_name)
|
||||
if value is not None:
|
||||
local_vars[var_name] = value
|
||||
elif default_value:
|
||||
local_vars[var_name] = context.resolve_template(str(default_value))
|
||||
else:
|
||||
local_vars[var_name] = None
|
||||
elif isinstance(var_config, str):
|
||||
local_vars[var_config] = context.get_variable(var_config)
|
||||
|
||||
local_vars['user_input'] = context.user_input
|
||||
local_vars['variables'] = context.variables.copy()
|
||||
|
||||
exec(code, safe_globals, local_vars)
|
||||
|
||||
if 'main' in local_vars and callable(local_vars['main']):
|
||||
inputs_dict = {
|
||||
'user_input': context.user_input,
|
||||
**context.variables,
|
||||
**local_vars,
|
||||
}
|
||||
main_result = local_vars['main'](inputs_dict)
|
||||
if isinstance(main_result, dict):
|
||||
result = main_result.get('result', main_result)
|
||||
else:
|
||||
result = main_result
|
||||
else:
|
||||
result = local_vars.get('result', None)
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=result,
|
||||
output_variables={output_variable: result},
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'代码节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'code': {
|
||||
'type': 'string',
|
||||
'title': '代码',
|
||||
'description': 'Python 代码,结果存储在 result 变量中',
|
||||
'format': 'code',
|
||||
'default': '# 在这里编写代码\n# 可用变量: user_input, variables\n# 将结果赋值给 result\n\nresult = user_input.upper()',
|
||||
},
|
||||
'input_variables': {
|
||||
'type': 'array',
|
||||
'title': '输入变量',
|
||||
'items': {'type': 'string'},
|
||||
'description': '需要传入代码的变量名列表',
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'code_result',
|
||||
},
|
||||
'timeout': {
|
||||
'type': 'integer',
|
||||
'title': '超时时间(秒)',
|
||||
'default': 30,
|
||||
'minimum': 1,
|
||||
'maximum': 300,
|
||||
},
|
||||
},
|
||||
'required': ['code'],
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
条件分支节点
|
||||
"""
|
||||
import logging
|
||||
import operator
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class ConditionNode(BaseNode):
|
||||
"""
|
||||
条件分支节点
|
||||
|
||||
根据条件判断选择不同的分支
|
||||
"""
|
||||
|
||||
node_type = 'condition'
|
||||
node_name = '条件分支'
|
||||
node_category = 'logic'
|
||||
node_icon = 'git-branch'
|
||||
node_description = '根据条件判断选择不同的执行分支'
|
||||
|
||||
supports_branches = True
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'value',
|
||||
'type': 'any',
|
||||
'description': '要判断的值',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'branch',
|
||||
'type': 'string',
|
||||
'description': '选中的分支',
|
||||
},
|
||||
]
|
||||
|
||||
# 支持的操作符
|
||||
OPERATORS = {
|
||||
'eq': operator.eq, # 等于
|
||||
'ne': operator.ne, # 不等于
|
||||
'gt': operator.gt, # 大于
|
||||
'gte': operator.ge, # 大于等于
|
||||
'lt': operator.lt, # 小于
|
||||
'lte': operator.le, # 小于等于
|
||||
'contains': lambda a, b: b in str(a), # 包含
|
||||
'not_contains': lambda a, b: b not in str(a), # 不包含
|
||||
'starts_with': lambda a, b: str(a).startswith(str(b)), # 开头是
|
||||
'ends_with': lambda a, b: str(a).endswith(str(b)), # 结尾是
|
||||
'is_empty': lambda a, _: not a, # 为空
|
||||
'is_not_empty': lambda a, _: bool(a), # 不为空
|
||||
}
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行条件判断"""
|
||||
try:
|
||||
branches = self.config.get('branches', [])
|
||||
|
||||
# 遍历所有 IF 分支
|
||||
for branch in branches:
|
||||
branch_id = branch.get('id')
|
||||
conditions = branch.get('conditions', [])
|
||||
|
||||
# 评估该分支的所有条件
|
||||
if self._evaluate_branch(conditions, context):
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=branch_id,
|
||||
next_node_id=branch_id,
|
||||
metadata={'matched_branch': branch_id},
|
||||
)
|
||||
|
||||
# 没有匹配的条件,走 ELSE 分支
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output='else',
|
||||
next_node_id='else',
|
||||
metadata={'matched_branch': 'else'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'条件节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _evaluate_branch(self, conditions: list, context: NodeContext) -> bool:
|
||||
"""评估分支的一组条件 (AND 关系)"""
|
||||
if not conditions:
|
||||
return True # 无条件默认为真
|
||||
|
||||
for condition in conditions:
|
||||
if not self._evaluate_condition(condition, context):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _resolve_variable(self, variable: str, context: NodeContext) -> Any:
|
||||
"""
|
||||
解析变量引用
|
||||
|
||||
支持格式:
|
||||
- {{variable_name}} - 直接变量引用
|
||||
- {{NodeID.key}} - 节点输出引用
|
||||
- 普通字符串 - 直接返回
|
||||
"""
|
||||
if not isinstance(variable, str):
|
||||
return variable
|
||||
|
||||
# 检查是否是 {{...}} 格式
|
||||
if not (variable.startswith('{{') and variable.endswith('}}')):
|
||||
return variable
|
||||
|
||||
content = variable[2:-2].strip()
|
||||
|
||||
logger.info(f'解析变量: {variable}, content={content}, 上下文变量keys={list(context.variables.keys())}')
|
||||
|
||||
# 尝试解析 NodeID.key 格式
|
||||
if '.' in content:
|
||||
parts = content.split('.', 1)
|
||||
node_id = parts[0]
|
||||
var_key = parts[1]
|
||||
|
||||
logger.info(f'解析节点变量: node_id={node_id}, var_key={var_key}')
|
||||
|
||||
# 先尝试从节点输出命名空间获取
|
||||
node_outputs = context.get_variable(f'_node_{node_id}')
|
||||
logger.info(f'节点输出 _node_{node_id}: {node_outputs}')
|
||||
if isinstance(node_outputs, dict) and var_key in node_outputs:
|
||||
return node_outputs[var_key]
|
||||
|
||||
# 回退:尝试直接从变量获取
|
||||
node_data = context.get_variable(node_id)
|
||||
logger.info(f'直接变量 {node_id}: {node_data}')
|
||||
if isinstance(node_data, dict) and var_key in node_data:
|
||||
return node_data[var_key]
|
||||
|
||||
# 直接变量引用
|
||||
value = context.get_variable(content)
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
def _evaluate_condition(self, condition: Dict, context: NodeContext) -> bool:
|
||||
"""评估单个条件"""
|
||||
variable = condition.get('variable', '')
|
||||
op_name = condition.get('operator', 'equals')
|
||||
value = condition.get('value', '')
|
||||
|
||||
# 解析变量和值
|
||||
left_val = self._resolve_variable(variable, context)
|
||||
right_val = self._resolve_variable(value, context)
|
||||
|
||||
logger.info(f'条件评估: variable={variable}, left_val={left_val}, op={op_name}, right_val={right_val}')
|
||||
|
||||
# 映射操作符
|
||||
op_mapping = {
|
||||
'equals': 'eq',
|
||||
'not_equals': 'ne',
|
||||
}
|
||||
op_key = op_mapping.get(op_name, op_name)
|
||||
op_func = self.OPERATORS.get(op_key, operator.eq)
|
||||
|
||||
try:
|
||||
# 特殊处理空值判断,不需要右值
|
||||
if op_key in ['is_empty', 'is_not_empty']:
|
||||
return op_func(left_val, None)
|
||||
|
||||
# 布尔值比较:将字符串 "true"/"false" 转换为布尔值
|
||||
if isinstance(left_val, bool) and isinstance(right_val, str):
|
||||
if right_val.lower() in ['true', '1', 'yes']:
|
||||
right_val = True
|
||||
elif right_val.lower() in ['false', '0', 'no']:
|
||||
right_val = False
|
||||
elif isinstance(right_val, bool) and isinstance(left_val, str):
|
||||
if left_val.lower() in ['true', '1', 'yes']:
|
||||
left_val = True
|
||||
elif left_val.lower() in ['false', '0', 'no']:
|
||||
left_val = False
|
||||
|
||||
# 尝试转换类型以进行比较 (如数字)
|
||||
if isinstance(left_val, (int, float)) and isinstance(right_val, str):
|
||||
try:
|
||||
if '.' in right_val:
|
||||
right_val = float(right_val)
|
||||
else:
|
||||
right_val = int(right_val)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 字符串比较忽略类型差异
|
||||
if op_key in ['contains', 'not_contains', 'starts_with', 'ends_with']:
|
||||
return op_func(str(left_val), str(right_val))
|
||||
|
||||
return op_func(left_val, right_val)
|
||||
except Exception as e:
|
||||
logger.warning(f'条件评估失败: {e}')
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'conditions': {
|
||||
'type': 'array',
|
||||
'title': '条件列表',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'variable': {
|
||||
'type': 'string',
|
||||
'title': '变量名',
|
||||
},
|
||||
'operator': {
|
||||
'type': 'string',
|
||||
'title': '操作符',
|
||||
'enum': list(cls.OPERATORS.keys()),
|
||||
'default': 'eq',
|
||||
},
|
||||
'value': {
|
||||
'type': 'string',
|
||||
'title': '比较值',
|
||||
},
|
||||
'branch_id': {
|
||||
'type': 'string',
|
||||
'title': '分支 ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'default_branch': {
|
||||
'type': 'string',
|
||||
'title': '默认分支',
|
||||
'description': '没有条件匹配时执行的分支',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
"""
|
||||
数据库操作节点
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
from ..utils.db_execution import (
|
||||
DbTarget,
|
||||
build_sql_param_dict,
|
||||
build_where_clause_platform,
|
||||
build_where_clause_raw,
|
||||
default_connection_write_warnings,
|
||||
format_limit_clause,
|
||||
format_select_sql,
|
||||
merge_result_metadata,
|
||||
normalize_return_fields,
|
||||
quote_table_for_target,
|
||||
resolve_db_target,
|
||||
resolve_handler_schema_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def serialize_value(value: Any) -> Any:
|
||||
"""将数据库值转换为可 JSON 序列化的格式"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, bytes):
|
||||
return value.decode('utf-8', errors='replace')
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [serialize_value(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: serialize_value(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def serialize_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""序列化数据库行"""
|
||||
return {k: serialize_value(v) for k, v in row.items()}
|
||||
|
||||
|
||||
def prepare_value_for_db(value: Any) -> Any:
|
||||
"""将值转换为数据库可接受的格式(dict/list 转为 JSON 字符串)"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
ALLOWED_TABLES = []
|
||||
|
||||
PROTECTED_FIELDS = ['password', 'token', 'secret', 'api_key', 'private_key']
|
||||
|
||||
|
||||
class BaseDatabaseNode(BaseNode):
|
||||
"""
|
||||
数据库操作节点基类
|
||||
|
||||
default 连接走平台 AsyncSession;第三方连接走 AsyncDatabaseManagerService。
|
||||
"""
|
||||
|
||||
node_type = 'database'
|
||||
node_name = '数据库操作'
|
||||
node_category = 'data'
|
||||
node_icon = 'database'
|
||||
node_description = '对数据库进行增删改查操作'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'data',
|
||||
'type': 'object',
|
||||
'description': '要操作的数据',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'result',
|
||||
'type': 'object',
|
||||
'description': '操作结果',
|
||||
},
|
||||
{
|
||||
'name': 'affected_rows',
|
||||
'type': 'number',
|
||||
'description': '影响的行数',
|
||||
},
|
||||
]
|
||||
|
||||
def _get_db_target(self) -> DbTarget:
|
||||
return resolve_db_target(self.config.get('db_config'))
|
||||
|
||||
def _build_full_table_name(self, table: str, target: Optional[DbTarget] = None) -> str:
|
||||
"""构建完整的表名(平台 PG 路径)"""
|
||||
db_config = self.config.get('db_config', {})
|
||||
schema = db_config.get('schema', '')
|
||||
if schema:
|
||||
return f'"{schema}"."{table}"'
|
||||
return f'"{table}"'
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, self.execute_async(context))
|
||||
return future.result()
|
||||
return loop.run_until_complete(self.execute_async(context))
|
||||
|
||||
async def execute_async(self, context: NodeContext) -> NodeResult:
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
operation = self.config.get('operation', 'select').lower()
|
||||
table = self.config.get('table', '')
|
||||
output_variable = self.config.get('output_variable', 'db_result')
|
||||
frontend_max_rows = int(self.config.get('frontend_max_rows', 100))
|
||||
target = self._get_db_target()
|
||||
|
||||
if not table:
|
||||
raise ValueError('未指定目标表')
|
||||
|
||||
if ALLOWED_TABLES and table not in ALLOWED_TABLES:
|
||||
is_allowed = any(allowed == '*' or table == allowed for allowed in ALLOWED_TABLES)
|
||||
if not is_allowed:
|
||||
raise ValueError(f'表 {table} 不在允许操作的白名单中')
|
||||
|
||||
if operation == 'insert':
|
||||
result = await self._execute_insert(table, context, target)
|
||||
elif operation == 'update':
|
||||
result = await self._execute_update(table, context, target)
|
||||
elif operation == 'upsert':
|
||||
result = await self._execute_upsert(table, context, target)
|
||||
elif operation == 'select':
|
||||
result = await self._execute_select(table, context, target)
|
||||
elif operation == 'delete':
|
||||
result = await self._execute_delete(table, context, target)
|
||||
else:
|
||||
raise ValueError(f'不支持的操作类型: {operation}')
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
full_data = result.get('data')
|
||||
affected_rows = result.get('affected_rows', 0)
|
||||
|
||||
output_variables = {
|
||||
output_variable: full_data,
|
||||
f'{output_variable}_count': affected_rows,
|
||||
}
|
||||
|
||||
frontend_output_variables = output_variables
|
||||
frontend_output = full_data
|
||||
|
||||
if operation == 'select' and isinstance(full_data, list) and len(full_data) > frontend_max_rows:
|
||||
truncated = full_data[:frontend_max_rows]
|
||||
frontend_output = truncated
|
||||
frontend_output_variables = {
|
||||
output_variable: truncated,
|
||||
f'{output_variable}_count': affected_rows,
|
||||
f'{output_variable}_total': len(full_data),
|
||||
}
|
||||
|
||||
warnings = default_connection_write_warnings(operation, target)
|
||||
metadata = merge_result_metadata(
|
||||
{'frontend_output_variables': frontend_output_variables},
|
||||
warnings,
|
||||
)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=frontend_output,
|
||||
output_variables=output_variables,
|
||||
metadata=metadata,
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'数据库节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
async def _create_db_service(self, context: NodeContext, target: DbTarget):
|
||||
from core.database_manager.service import AsyncDatabaseManagerService
|
||||
|
||||
try:
|
||||
return await AsyncDatabaseManagerService.create(target.db_name, context.db_session)
|
||||
except Exception as e:
|
||||
raise ValueError(f'无法连接数据库 {target.db_name}: {e}') from e
|
||||
|
||||
def _resolve_field_mapping(self, context: NodeContext) -> Dict[str, Any]:
|
||||
field_mapping = self.config.get('field_mapping', {})
|
||||
resolved = {}
|
||||
|
||||
for field, value in field_mapping.items():
|
||||
if field.lower() in PROTECTED_FIELDS:
|
||||
logger.warning(f'跳过保护字段: {field}')
|
||||
continue
|
||||
|
||||
if isinstance(value, str):
|
||||
resolved_value = context.resolve_template(value)
|
||||
try:
|
||||
resolved[field] = json.loads(resolved_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
resolved[field] = resolved_value
|
||||
else:
|
||||
resolved[field] = value
|
||||
|
||||
return resolved
|
||||
|
||||
def _resolve_conditions(self, context: NodeContext) -> List[Dict[str, Any]]:
|
||||
conditions = self.config.get('where_conditions', [])
|
||||
resolved = []
|
||||
|
||||
for condition in conditions:
|
||||
field = condition.get('field', '')
|
||||
operator = condition.get('operator', '=')
|
||||
value = condition.get('value', '')
|
||||
|
||||
if isinstance(value, str):
|
||||
resolved_value = context.resolve_template(value)
|
||||
try:
|
||||
resolved_value = json.loads(resolved_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
else:
|
||||
resolved_value = value
|
||||
|
||||
resolved.append({
|
||||
'field': field,
|
||||
'operator': operator,
|
||||
'value': resolved_value,
|
||||
})
|
||||
|
||||
return resolved
|
||||
|
||||
async def _execute_insert(
|
||||
self,
|
||||
table: str,
|
||||
context: NodeContext,
|
||||
target: DbTarget,
|
||||
) -> Dict[str, Any]:
|
||||
data = self._resolve_field_mapping(context)
|
||||
if not data:
|
||||
raise ValueError('没有要插入的数据')
|
||||
if 'id' not in data:
|
||||
data['id'] = str(uuid.uuid4())
|
||||
|
||||
if target.is_external:
|
||||
db_service = await self._create_db_service(context, target)
|
||||
schema_name = await resolve_handler_schema_name(db_service, target)
|
||||
payload = {k: prepare_value_for_db(v) for k, v in data.items()}
|
||||
result = await db_service.insert_data(table, payload, schema_name)
|
||||
if not result.get('success'):
|
||||
raise ValueError(result.get('message', '插入失败'))
|
||||
return {
|
||||
'data': {'id': data['id'], **data},
|
||||
'affected_rows': result.get('affected_rows', 1),
|
||||
}
|
||||
|
||||
db = context.db_session
|
||||
if not db:
|
||||
raise ValueError('数据库会话不可用,请确保工作流已配置数据库连接')
|
||||
|
||||
full_table_name = self._build_full_table_name(table)
|
||||
fields = list(data.keys())
|
||||
params = {f: prepare_value_for_db(v) for f, v in data.items()}
|
||||
placeholders = ', '.join([f':{f}' for f in fields])
|
||||
field_names = ', '.join([f'"{f}"' for f in fields])
|
||||
sql = f'INSERT INTO {full_table_name} ({field_names}) VALUES ({placeholders})'
|
||||
await db.execute(text(sql), params)
|
||||
return {
|
||||
'data': {'id': data['id'], **data},
|
||||
'affected_rows': 1,
|
||||
}
|
||||
|
||||
async def _execute_update(
|
||||
self,
|
||||
table: str,
|
||||
context: NodeContext,
|
||||
target: DbTarget,
|
||||
) -> Dict[str, Any]:
|
||||
data = self._resolve_field_mapping(context)
|
||||
conditions = self._resolve_conditions(context)
|
||||
|
||||
if not data:
|
||||
raise ValueError('没有要更新的数据')
|
||||
if not conditions:
|
||||
raise ValueError('UPDATE 操作必须指定条件,防止误更新全表')
|
||||
|
||||
if target.is_external:
|
||||
db_service = await self._create_db_service(context, target)
|
||||
schema_name = await resolve_handler_schema_name(db_service, target)
|
||||
where_raw = build_where_clause_raw(conditions, db_service.db_type)
|
||||
payload = {k: prepare_value_for_db(v) for k, v in data.items()}
|
||||
result = await db_service.update_data(table, payload, where_raw, schema_name)
|
||||
if not result.get('success'):
|
||||
raise ValueError(result.get('message', '更新失败'))
|
||||
affected_rows = result.get('affected_rows', 0)
|
||||
return {
|
||||
'data': {'updated': True, 'affected_rows': affected_rows, **data},
|
||||
'affected_rows': affected_rows,
|
||||
}
|
||||
|
||||
db = context.db_session
|
||||
if not db:
|
||||
raise ValueError('数据库会话不可用')
|
||||
|
||||
set_clauses = []
|
||||
params = {}
|
||||
for field, value in data.items():
|
||||
param_name = f's_{field}'
|
||||
set_clauses.append(f'"{field}" = :{param_name}')
|
||||
params[param_name] = prepare_value_for_db(value)
|
||||
|
||||
where_clause, where_params = build_where_clause_platform(conditions)
|
||||
params.update(where_params)
|
||||
full_table_name = self._build_full_table_name(table)
|
||||
sql = f'UPDATE {full_table_name} SET {", ".join(set_clauses)} {where_clause}'
|
||||
result = await db.execute(text(sql), params)
|
||||
affected_rows = result.rowcount
|
||||
return {
|
||||
'data': {'updated': True, 'affected_rows': affected_rows, **data},
|
||||
'affected_rows': affected_rows,
|
||||
}
|
||||
|
||||
async def _execute_upsert(
|
||||
self,
|
||||
table: str,
|
||||
context: NodeContext,
|
||||
target: DbTarget,
|
||||
) -> Dict[str, Any]:
|
||||
data = self._resolve_field_mapping(context)
|
||||
conditions = self._resolve_conditions(context)
|
||||
if not data:
|
||||
raise ValueError('没有要操作的数据')
|
||||
|
||||
if conditions:
|
||||
if target.is_external:
|
||||
db_service = await self._create_db_service(context, target)
|
||||
schema_name = await resolve_handler_schema_name(db_service, target)
|
||||
where_raw = build_where_clause_raw(conditions, db_service.db_type)
|
||||
full_table = quote_table_for_target(table, target)
|
||||
check_sql = f'SELECT 1 FROM {full_table}'
|
||||
if where_raw:
|
||||
check_sql += f' WHERE {where_raw}'
|
||||
check_sql += format_limit_clause(db_service.db_type, 1)
|
||||
check_result = await db_service.execute_sql(check_sql, is_query=True)
|
||||
rows = check_result.get('rows') or check_result.get('data') or []
|
||||
if rows:
|
||||
return await self._execute_update(table, context, target)
|
||||
else:
|
||||
db = context.db_session
|
||||
if not db:
|
||||
raise ValueError('数据库会话不可用')
|
||||
full_table_name = self._build_full_table_name(table)
|
||||
where_clause, where_params = build_where_clause_platform(conditions)
|
||||
check_sql = f'SELECT id FROM {full_table_name} {where_clause} LIMIT 1'
|
||||
result = await db.execute(text(check_sql), where_params)
|
||||
if result.fetchone():
|
||||
return await self._execute_update(table, context, target)
|
||||
|
||||
return await self._execute_insert(table, context, target)
|
||||
|
||||
async def _execute_select(
|
||||
self,
|
||||
table: str,
|
||||
context: NodeContext,
|
||||
target: DbTarget,
|
||||
) -> Dict[str, Any]:
|
||||
conditions = self._resolve_conditions(context)
|
||||
return_fields = self.config.get('return_fields', ['*'])
|
||||
limit = self.config.get('limit', 100)
|
||||
order_by = self.config.get('order_by', '')
|
||||
|
||||
if target.is_external:
|
||||
db_service = await self._create_db_service(context, target)
|
||||
sql = format_select_sql(
|
||||
table,
|
||||
target,
|
||||
return_fields=return_fields,
|
||||
conditions=conditions,
|
||||
order_by=order_by,
|
||||
limit=int(limit),
|
||||
)
|
||||
result_data = await db_service.execute_sql(sql, is_query=True)
|
||||
if result_data.get('success') is False:
|
||||
raise ValueError(result_data.get('message') or '查询失败')
|
||||
rows = result_data.get('rows') or result_data.get('data') or []
|
||||
result_data_list = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
result_data_list.append(serialize_row(row))
|
||||
else:
|
||||
result_data_list.append(serialize_row(dict(row)))
|
||||
return {
|
||||
'data': result_data_list,
|
||||
'affected_rows': len(result_data_list),
|
||||
}
|
||||
|
||||
db = context.db_session
|
||||
if not db:
|
||||
raise ValueError('数据库会话不可用')
|
||||
|
||||
normalized_fields = normalize_return_fields(return_fields)
|
||||
if normalized_fields == '*':
|
||||
field_list = '*'
|
||||
else:
|
||||
field_list = ', '.join([f'"{f}"' for f in normalized_fields])
|
||||
|
||||
where_clause, params = build_where_clause_platform(conditions)
|
||||
full_table_name = self._build_full_table_name(table)
|
||||
sql = f'SELECT {field_list} FROM {full_table_name} {where_clause}'
|
||||
if order_by:
|
||||
sql += f' ORDER BY {order_by}'
|
||||
sql += f' LIMIT {int(limit)}'
|
||||
|
||||
result = await db.execute(text(sql), params)
|
||||
columns = result.keys()
|
||||
rows = result.fetchall()
|
||||
result_data = [serialize_row(dict(zip(columns, row))) for row in rows]
|
||||
return {
|
||||
'data': result_data,
|
||||
'affected_rows': len(result_data),
|
||||
}
|
||||
|
||||
async def _execute_delete(
|
||||
self,
|
||||
table: str,
|
||||
context: NodeContext,
|
||||
target: DbTarget,
|
||||
) -> Dict[str, Any]:
|
||||
conditions = self._resolve_conditions(context)
|
||||
if not conditions:
|
||||
raise ValueError('DELETE 操作必须指定条件,防止误删全表')
|
||||
|
||||
if target.is_external:
|
||||
db_service = await self._create_db_service(context, target)
|
||||
schema_name = await resolve_handler_schema_name(db_service, target)
|
||||
where_raw = build_where_clause_raw(conditions, db_service.db_type)
|
||||
result = await db_service.delete_data(table, where_raw, schema_name)
|
||||
if not result.get('success'):
|
||||
raise ValueError(result.get('message', '删除失败'))
|
||||
affected_rows = result.get('affected_rows', 0)
|
||||
return {
|
||||
'data': {'deleted': True, 'affected_rows': affected_rows},
|
||||
'affected_rows': affected_rows,
|
||||
}
|
||||
|
||||
db = context.db_session
|
||||
if not db:
|
||||
raise ValueError('数据库会话不可用')
|
||||
|
||||
where_clause, params = build_where_clause_platform(conditions)
|
||||
full_table_name = self._build_full_table_name(table)
|
||||
sql = f'DELETE FROM {full_table_name} {where_clause}'
|
||||
result = await db.execute(text(sql), params)
|
||||
affected_rows = result.rowcount
|
||||
return {
|
||||
'data': {'deleted': True, 'affected_rows': affected_rows},
|
||||
'affected_rows': affected_rows,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'operation': {
|
||||
'type': 'string',
|
||||
'title': '操作类型',
|
||||
'enum': ['insert', 'update', 'upsert', 'select', 'delete'],
|
||||
'enumNames': ['插入', '更新', '插入或更新', '查询', '删除'],
|
||||
'default': 'insert',
|
||||
},
|
||||
'table': {
|
||||
'type': 'string',
|
||||
'title': '目标表',
|
||||
'description': '数据库表名',
|
||||
},
|
||||
'field_mapping': {
|
||||
'type': 'object',
|
||||
'title': '字段映射',
|
||||
'description': '数据库字段与变量的映射关系',
|
||||
'additionalProperties': {'type': 'string'},
|
||||
},
|
||||
'where_conditions': {
|
||||
'type': 'array',
|
||||
'title': '条件',
|
||||
'description': '查询/更新/删除的条件',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'field': {'type': 'string', 'title': '字段'},
|
||||
'operator': {
|
||||
'type': 'string',
|
||||
'title': '操作符',
|
||||
'enum': ['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'is_null', 'is_not_null'],
|
||||
'default': '=',
|
||||
},
|
||||
'value': {'type': 'string', 'title': '值'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'return_fields': {
|
||||
'type': 'array',
|
||||
'title': '返回字段',
|
||||
'description': '查询时返回的字段列表',
|
||||
'items': {'type': 'string'},
|
||||
'default': ['*'],
|
||||
},
|
||||
'limit': {
|
||||
'type': 'integer',
|
||||
'title': '限制条数',
|
||||
'description': 'SQL 查询时的最大返回条数',
|
||||
'default': 100,
|
||||
},
|
||||
'frontend_max_rows': {
|
||||
'type': 'integer',
|
||||
'title': '前端返回最大条数',
|
||||
'description': '前端 SSE 事件中返回的最大数据条数(默认100),超过此值仅截断前端传输,后续节点仍可获取全量数据',
|
||||
'default': 100,
|
||||
'minimum': 1,
|
||||
'maximum': 10000,
|
||||
},
|
||||
'order_by': {
|
||||
'type': 'string',
|
||||
'title': '排序',
|
||||
'description': '排序字段,如 created_at DESC',
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'db_result',
|
||||
},
|
||||
},
|
||||
'required': ['operation', 'table'],
|
||||
}
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class DbInsertNode(BaseDatabaseNode):
|
||||
node_type = 'db_insert'
|
||||
node_name = 'DB 插入'
|
||||
node_icon = 'database-zap'
|
||||
node_description = '向数据库插入数据'
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
super().__init__(config)
|
||||
if config:
|
||||
self.config['operation'] = 'upsert' if config.get('upsert') else 'insert'
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class DbUpdateNode(BaseDatabaseNode):
|
||||
node_type = 'db_update'
|
||||
node_name = 'DB 更新'
|
||||
node_icon = 'database-backup'
|
||||
node_description = '更新数据库记录'
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
super().__init__(config)
|
||||
if config:
|
||||
self.config['operation'] = 'update'
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class DbQueryNode(BaseDatabaseNode):
|
||||
node_type = 'db_query'
|
||||
node_name = 'DB 查询'
|
||||
node_icon = 'search'
|
||||
node_description = '从数据库查询数据'
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
super().__init__(config)
|
||||
if config:
|
||||
self.config['operation'] = 'select'
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class DbDeleteNode(BaseDatabaseNode):
|
||||
node_type = 'db_delete'
|
||||
node_name = 'DB 删除'
|
||||
node_icon = 'trash-2'
|
||||
node_description = '从数据库删除数据'
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
super().__init__(config)
|
||||
if config:
|
||||
self.config['operation'] = 'delete'
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class DbSqlNode(BaseNode):
|
||||
"""自定义 SQL 执行节点"""
|
||||
|
||||
node_type = 'db_sql'
|
||||
node_name = 'SQL 执行'
|
||||
node_category = 'data'
|
||||
node_icon = 'database'
|
||||
node_description = '执行自定义 SQL 语句'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'data',
|
||||
'type': 'object',
|
||||
'description': '输入数据',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'result',
|
||||
'type': 'any',
|
||||
'description': 'SQL 执行结果',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, self.execute_async(context))
|
||||
return future.result()
|
||||
return loop.run_until_complete(self.execute_async(context))
|
||||
|
||||
async def execute_async(self, context: NodeContext) -> NodeResult:
|
||||
start_time = time.time()
|
||||
|
||||
sql_type = self.config.get('sql_type', 'query')
|
||||
sql = self.config.get('sql', '')
|
||||
target = resolve_db_target(self.config.get('db_config'))
|
||||
output_variable = self.config.get('output_variable', 'sql_result')
|
||||
is_query = sql_type == 'query'
|
||||
|
||||
if not sql:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='SQL 语句不能为空',
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
try:
|
||||
resolved_sql = context.resolve_template(sql)
|
||||
param_dict = build_sql_param_dict(self.config.get('params'), context)
|
||||
logger.info(
|
||||
'执行 SQL [%s]: %s, params=%s',
|
||||
target.db_name,
|
||||
resolved_sql,
|
||||
list(param_dict.keys()),
|
||||
)
|
||||
|
||||
operation = 'query' if is_query else 'execute'
|
||||
warnings = default_connection_write_warnings(operation, target)
|
||||
|
||||
if not target.is_external:
|
||||
db = context.db_session
|
||||
if not db:
|
||||
raise ValueError('数据库会话不可用')
|
||||
result = await db.execute(text(resolved_sql), param_dict)
|
||||
if is_query:
|
||||
rows = result.mappings().all()
|
||||
output_result = [serialize_row(dict(row)) for row in rows]
|
||||
row_count = len(output_result)
|
||||
else:
|
||||
row_count = max(result.rowcount or 0, 0)
|
||||
output_result = row_count
|
||||
else:
|
||||
from core.database_manager.service import AsyncDatabaseManagerService
|
||||
from utils.sql_param_compile import compile_sql_with_named_params
|
||||
|
||||
try:
|
||||
db_service = await AsyncDatabaseManagerService.create(
|
||||
target.db_name,
|
||||
context.db_session,
|
||||
)
|
||||
except Exception as e:
|
||||
raise ValueError(f'无法连接数据库 {target.db_name}: {e}') from e
|
||||
|
||||
executable_sql = compile_sql_with_named_params(
|
||||
resolved_sql,
|
||||
param_dict,
|
||||
target.db_type,
|
||||
)
|
||||
result_data = await db_service.execute_sql(executable_sql, is_query=is_query)
|
||||
if result_data.get('success') is False:
|
||||
raise Exception(result_data.get('message') or 'SQL 执行失败')
|
||||
|
||||
if is_query:
|
||||
rows = result_data.get('rows') or result_data.get('data') or []
|
||||
output_result = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
output_result.append(serialize_row(row))
|
||||
else:
|
||||
output_result.append(serialize_row(dict(row)))
|
||||
row_count = len(output_result)
|
||||
else:
|
||||
output_result = result_data.get('affected_rows', 0)
|
||||
row_count = output_result
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
metadata = merge_result_metadata({}, warnings)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=output_result,
|
||||
output_variables={
|
||||
output_variable: output_result,
|
||||
f'{output_variable}_count': row_count,
|
||||
},
|
||||
metadata=metadata,
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('SQL 执行失败: %s', e)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'SQL 执行失败: {str(e)}',
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'sql_type': {
|
||||
'type': 'string',
|
||||
'title': '执行类型',
|
||||
'enum': ['query', 'execute'],
|
||||
'default': 'query',
|
||||
'description': 'query: 查询返回结果, execute: 执行不返回结果',
|
||||
},
|
||||
'sql': {
|
||||
'type': 'string',
|
||||
'title': 'SQL 语句',
|
||||
'description': '要执行的 SQL 语句,使用 :param_name 作为命名参数占位符',
|
||||
},
|
||||
'params': {
|
||||
'type': 'array',
|
||||
'title': '参数列表',
|
||||
'description': 'SQL 命名参数,与 SQL 中 :param_name 对应',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'title': '参数名'},
|
||||
'type': {
|
||||
'type': 'string',
|
||||
'enum': ['string', 'integer', 'float', 'boolean', 'date', 'datetime'],
|
||||
'default': 'string',
|
||||
},
|
||||
'value': {'type': 'string', 'title': '参数值'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'sql_result',
|
||||
},
|
||||
},
|
||||
'required': ['sql'],
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
"""
|
||||
对话流节点
|
||||
|
||||
用于对话流模式的智能体,支持与用户的交互式对话
|
||||
"""
|
||||
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,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
结束节点
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class EndNode(BaseNode):
|
||||
"""
|
||||
结束节点
|
||||
|
||||
工作流的出口节点,输出最终结果
|
||||
"""
|
||||
|
||||
node_type = 'end'
|
||||
node_name = '结束'
|
||||
node_category = 'basic'
|
||||
node_icon = 'stop-circle'
|
||||
node_description = '工作流的结束节点,输出最终结果'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'output',
|
||||
'type': 'any',
|
||||
'description': '输出内容',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = []
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行结束节点"""
|
||||
# 获取输出内容配置(前端保存的是字符串模板,如 "{{llm_response}}")
|
||||
output_template = self.config.get('output', '')
|
||||
|
||||
if output_template and isinstance(output_template, str) and output_template.strip():
|
||||
# 如果配置了输出模板,解析模板中的变量
|
||||
output = context.resolve_template(output_template)
|
||||
else:
|
||||
# 没有配置输出模板,不输出任何内容
|
||||
output = None
|
||||
|
||||
# 处理输出变量(用于结构化输出)
|
||||
# 只有当 output 不为 None 时才处理结构化输出
|
||||
outputs_config = self.config.get('outputs', [])
|
||||
if output is not None and outputs_config and isinstance(outputs_config, list):
|
||||
# 如果定义了输出变量,构建结构化输出
|
||||
structured_output = {}
|
||||
for out_var in outputs_config:
|
||||
if isinstance(out_var, dict):
|
||||
var_name = out_var.get('variable', '')
|
||||
if var_name:
|
||||
# 从上下文获取变量值
|
||||
structured_output[var_name] = context.get_variable(var_name, None)
|
||||
|
||||
# 如果有结构化输出,合并到结果中
|
||||
if structured_output:
|
||||
if isinstance(output, dict):
|
||||
output = {**output, **structured_output}
|
||||
else:
|
||||
output = {
|
||||
'result': output,
|
||||
**structured_output
|
||||
}
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=output,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'output': {
|
||||
'type': 'object',
|
||||
'title': '输出配置',
|
||||
'properties': {
|
||||
'type': {
|
||||
'type': 'string',
|
||||
'title': '输出类型',
|
||||
'enum': ['variable', 'template', 'previous'],
|
||||
'default': 'previous',
|
||||
},
|
||||
'value': {
|
||||
'type': 'string',
|
||||
'title': '输出值',
|
||||
'description': '变量名或模板',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
HTTP 请求节点
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class HttpNode(BaseNode):
|
||||
"""
|
||||
HTTP 请求节点
|
||||
|
||||
发送 HTTP 请求并获取响应
|
||||
"""
|
||||
|
||||
node_type = 'http'
|
||||
node_name = 'HTTP 请求'
|
||||
node_category = 'data'
|
||||
node_icon = 'globe'
|
||||
node_description = '发送 HTTP 请求并获取响应'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'url',
|
||||
'type': 'string',
|
||||
'description': '请求 URL',
|
||||
},
|
||||
{
|
||||
'name': 'body',
|
||||
'type': 'object',
|
||||
'description': '请求体',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'response',
|
||||
'type': 'object',
|
||||
'description': '响应数据',
|
||||
},
|
||||
{
|
||||
'name': 'status_code',
|
||||
'type': 'number',
|
||||
'description': '状态码',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行 HTTP 请求"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
method = self.config.get('method', 'GET').upper()
|
||||
url = self.config.get('url', '')
|
||||
headers = self.config.get('headers', {})
|
||||
params = self.config.get('params', {})
|
||||
body = self.config.get('body', {})
|
||||
timeout = self.config.get('timeout', 30)
|
||||
output_variable = self.config.get('output_variable', 'http_response')
|
||||
|
||||
# 解析模板变量
|
||||
url = context.resolve_template(url)
|
||||
|
||||
# 解析 headers 中的变量
|
||||
resolved_headers = {}
|
||||
for key, value in headers.items():
|
||||
resolved_headers[key] = context.resolve_template(str(value))
|
||||
|
||||
# 解析 params 中的变量
|
||||
resolved_params = {}
|
||||
for key, value in params.items():
|
||||
resolved_params[key] = context.resolve_template(str(value))
|
||||
|
||||
# 解析 body 中的变量
|
||||
resolved_body = self._resolve_body(body, context)
|
||||
|
||||
# 发送请求
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
if method in ('GET', 'DELETE'):
|
||||
response = client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=resolved_headers,
|
||||
params=resolved_params,
|
||||
)
|
||||
else:
|
||||
response = client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=resolved_headers,
|
||||
params=resolved_params,
|
||||
json=resolved_body,
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
try:
|
||||
response_data = response.json()
|
||||
except Exception:
|
||||
response_data = response.text
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
return NodeResult(
|
||||
success=response.is_success,
|
||||
output=response_data,
|
||||
output_variables={
|
||||
output_variable: response_data,
|
||||
f'{output_variable}_status': response.status_code,
|
||||
},
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'status_code': response.status_code,
|
||||
'headers': dict(response.headers),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'HTTP 节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
def _resolve_body(self, body: Any, context: NodeContext) -> Any:
|
||||
"""递归解析 body 中的变量"""
|
||||
if isinstance(body, str):
|
||||
return context.resolve_template(body)
|
||||
elif isinstance(body, dict):
|
||||
return {k: self._resolve_body(v, context) for k, v in body.items()}
|
||||
elif isinstance(body, list):
|
||||
return [self._resolve_body(item, context) for item in body]
|
||||
return body
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'method': {
|
||||
'type': 'string',
|
||||
'title': '请求方法',
|
||||
'enum': ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
'default': 'GET',
|
||||
},
|
||||
'url': {
|
||||
'type': 'string',
|
||||
'title': 'URL',
|
||||
'description': '支持变量引用,如 {{api_url}}',
|
||||
},
|
||||
'headers': {
|
||||
'type': 'object',
|
||||
'title': '请求头',
|
||||
'additionalProperties': {'type': 'string'},
|
||||
},
|
||||
'params': {
|
||||
'type': 'object',
|
||||
'title': 'URL 参数',
|
||||
'additionalProperties': {'type': 'string'},
|
||||
},
|
||||
'body': {
|
||||
'type': 'object',
|
||||
'title': '请求体',
|
||||
'description': 'POST/PUT/PATCH 请求的 JSON 数据',
|
||||
},
|
||||
'timeout': {
|
||||
'type': 'integer',
|
||||
'title': '超时时间(秒)',
|
||||
'default': 30,
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'http_response',
|
||||
},
|
||||
},
|
||||
'required': ['url'],
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
意图识别节点
|
||||
|
||||
使用 LLM 进行智能意图分类,根据用户输入自动路由到对应分支
|
||||
支持原生 Function Calling(更准确)和文本解析(回退方案)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class IntentNode(BaseNode):
|
||||
"""
|
||||
意图识别节点
|
||||
|
||||
使用 LLM 分析用户输入,识别用户意图,并路由到对应分支
|
||||
支持定义多个意图,每个意图可配置名称、描述和示例
|
||||
"""
|
||||
|
||||
node_type = 'intent'
|
||||
node_name = '意图识别'
|
||||
node_category = 'logic'
|
||||
node_icon = 'brain'
|
||||
node_description = '使用 AI 识别用户意图,自动路由到对应分支'
|
||||
|
||||
supports_branches = True
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'user_input',
|
||||
'type': 'string',
|
||||
'description': '用户输入文本',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'intent',
|
||||
'type': 'string',
|
||||
'description': '识别到的意图名称',
|
||||
},
|
||||
{
|
||||
'name': 'confidence',
|
||||
'type': 'number',
|
||||
'description': '置信度(0-1)',
|
||||
},
|
||||
]
|
||||
|
||||
# 默认的意图识别 prompt 模板
|
||||
DEFAULT_SYSTEM_PROMPT = """你是一个意图分类器。根据用户输入,判断用户的意图属于以下哪个类别。
|
||||
|
||||
可选的意图类别:
|
||||
{intents_description}
|
||||
|
||||
请严格按照以下 JSON 格式输出,不要输出其他任何内容:
|
||||
{{"intent": "意图名称", "confidence": 0.95}}
|
||||
|
||||
注意:
|
||||
1. intent 必须是上述意图类别中的一个名称,如果都不匹配则输出 "other"
|
||||
2. confidence 是你对这个分类的置信度,范围 0-1
|
||||
3. 只输出 JSON,不要有任何解释"""
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行意图识别(同步方法,通过运行异步方法实现)"""
|
||||
import asyncio
|
||||
|
||||
# 在同步方法中运行异步代码
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# 如果已有事件循环在运行,创建新任务
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, self.execute_async(context))
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(self.execute_async(context))
|
||||
|
||||
async def execute_async(self, context: NodeContext) -> NodeResult:
|
||||
"""异步执行意图识别"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
from ai_platform.models import LLMModel
|
||||
|
||||
# 获取配置
|
||||
model_id = self.config.get('model_id', '')
|
||||
intents = self.config.get('intents', [])
|
||||
input_variable = self.config.get('input_variable', 'user_input')
|
||||
confidence_threshold = self.config.get('confidence_threshold', 0.6)
|
||||
|
||||
# 获取用户输入
|
||||
user_input = context.get_variable(input_variable)
|
||||
if not user_input:
|
||||
# 尝试从 __user_input__ 获取
|
||||
user_input = context.get_variable('__user_input__')
|
||||
if not user_input:
|
||||
user_input = context.user_input
|
||||
|
||||
if not user_input:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='未获取到用户输入',
|
||||
)
|
||||
|
||||
if not intents:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请至少配置一个意图',
|
||||
)
|
||||
|
||||
if not model_id:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请选择用于意图识别的模型',
|
||||
)
|
||||
|
||||
# 检查模型是否支持 Function Calling
|
||||
supports_fc = self._check_model_supports_function_call(model_id)
|
||||
|
||||
llm_service = LLMService(context.db_session)
|
||||
|
||||
if supports_fc:
|
||||
# 使用 Function Calling 方式(更准确)
|
||||
intent_name, confidence, total_tokens = await self._execute_with_function_calling_async(
|
||||
llm_service, model_id, user_input, intents
|
||||
)
|
||||
else:
|
||||
# 回退到文本解析方式
|
||||
intent_name, confidence, total_tokens = await self._execute_with_text_parsing_async(
|
||||
llm_service, model_id, user_input, intents
|
||||
)
|
||||
|
||||
logger.info(f'意图识别结果: intent_name={intent_name}, confidence={confidence}, use_fc={supports_fc}')
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 判断是否达到置信度阈值
|
||||
if confidence < confidence_threshold:
|
||||
logger.info(f'置信度 {confidence} 低于阈值 {confidence_threshold},走 other 分支')
|
||||
intent_name = 'other'
|
||||
|
||||
# 查找对应的分支 ID
|
||||
next_node_id = self._get_branch_id(intent_name, intents)
|
||||
logger.info(f'意图识别结果: intent={intent_name}, next_node_id={next_node_id}')
|
||||
|
||||
# 设置输出变量
|
||||
output_var = self.config.get('output_variable', 'intent_result')
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=intent_name,
|
||||
next_node_id=next_node_id,
|
||||
output_variables={
|
||||
output_var: intent_name,
|
||||
f'{output_var}_confidence': confidence,
|
||||
f'{output_var}_input': user_input,
|
||||
},
|
||||
tokens_used=total_tokens,
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'intent': intent_name,
|
||||
'confidence': confidence,
|
||||
'matched_branch': next_node_id,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'意图识别节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
def _check_model_supports_function_call(self, model_id) -> bool:
|
||||
"""检查模型是否支持 Function Calling
|
||||
|
||||
TODO: 此方法需要改造为异步版本,当前使用同步查询作为临时方案
|
||||
"""
|
||||
# 临时方案:默认返回True,让调用方尝试使用Function Calling
|
||||
# 实际使用时应通过上下文传入模型信息
|
||||
return True
|
||||
|
||||
async def _execute_with_function_calling_async(
|
||||
self,
|
||||
llm_service,
|
||||
model_id: str,
|
||||
user_input: str,
|
||||
intents: List[Dict],
|
||||
) -> tuple:
|
||||
"""使用 Function Calling 执行意图识别(更准确,异步版本)"""
|
||||
# 构建意图名称列表(用于 enum)
|
||||
intent_names = [intent.get('name') for intent in intents] + ['other']
|
||||
|
||||
# 构建意图描述(用于 LLM 理解)
|
||||
intents_description = self._build_intents_description(intents)
|
||||
|
||||
# 构建 Function Calling 工具定义
|
||||
tools = [{
|
||||
'name': 'classify_intent',
|
||||
'description': f'根据用户输入对意图进行分类。可选的意图类别:\n{intents_description}',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'intent': {
|
||||
'type': 'string',
|
||||
'enum': intent_names,
|
||||
'description': '识别到的意图名称',
|
||||
},
|
||||
'confidence': {
|
||||
'type': 'number',
|
||||
'description': '置信度,范围 0-1',
|
||||
},
|
||||
},
|
||||
'required': ['intent', 'confidence'],
|
||||
},
|
||||
}]
|
||||
|
||||
# 简化的系统提示词
|
||||
system_prompt = "你是一个意图分类器。分析用户输入,调用 classify_intent 函数返回分类结果。"
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_input},
|
||||
]
|
||||
|
||||
# 调用 LLM(带 Function Calling,异步)
|
||||
response = await llm_service.chat_async(
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=100,
|
||||
tools=tools,
|
||||
tool_choice='required', # 强制使用 Function Calling
|
||||
)
|
||||
|
||||
# 从 tool_calls 中提取结果
|
||||
if response.has_tool_calls and response.tool_calls:
|
||||
tool_call = response.tool_calls[0]
|
||||
args = tool_call.arguments
|
||||
intent_name = args.get('intent', 'other')
|
||||
confidence = float(args.get('confidence', 0.8))
|
||||
|
||||
# 验证意图名称
|
||||
if intent_name not in intent_names:
|
||||
intent_name = 'other'
|
||||
confidence = 0.5
|
||||
|
||||
return intent_name, confidence, response.total_tokens
|
||||
else:
|
||||
# Function Calling 失败,返回默认值
|
||||
logger.warning('Function Calling 未返回 tool_calls,回退到 other')
|
||||
return 'other', 0.5, response.total_tokens
|
||||
|
||||
async def _execute_with_text_parsing_async(
|
||||
self,
|
||||
llm_service,
|
||||
model_id: str,
|
||||
user_input: str,
|
||||
intents: List[Dict],
|
||||
) -> tuple:
|
||||
"""使用文本解析执行意图识别(回退方案,异步版本)"""
|
||||
# 构建意图描述
|
||||
intents_description = self._build_intents_description(intents)
|
||||
|
||||
# 构建 prompt
|
||||
system_prompt = self.DEFAULT_SYSTEM_PROMPT.format(
|
||||
intents_description=intents_description
|
||||
)
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_input},
|
||||
]
|
||||
|
||||
# 调用 LLM(异步)
|
||||
response = await llm_service.chat_async(
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
intent_name, confidence = self._parse_response(response.content, intents)
|
||||
|
||||
return intent_name, confidence, response.total_tokens
|
||||
|
||||
def _build_intents_description(self, intents: List[Dict]) -> str:
|
||||
"""构建意图描述文本"""
|
||||
lines = []
|
||||
for i, intent in enumerate(intents, 1):
|
||||
name = intent.get('name', '')
|
||||
description = intent.get('description', '')
|
||||
examples = intent.get('examples', [])
|
||||
|
||||
line = f"{i}. {name}"
|
||||
if description:
|
||||
line += f" - {description}"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
# 添加示例
|
||||
if examples:
|
||||
for example in examples[:3]: # 最多3个示例
|
||||
lines.append(f" 示例: \"{example}\"")
|
||||
|
||||
# 添加 other 选项
|
||||
lines.append(f"{len(intents) + 1}. other - 以上都不匹配时选择此项")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _parse_response(self, response: str, intents: List[Dict]) -> tuple:
|
||||
"""解析 LLM 响应"""
|
||||
logger.info(f'开始解析 LLM 响应: {response}')
|
||||
try:
|
||||
# 尝试提取 JSON
|
||||
response = response.strip()
|
||||
|
||||
# 处理可能的 markdown 代码块
|
||||
if response.startswith('```'):
|
||||
lines = response.split('\n')
|
||||
json_lines = []
|
||||
in_json = False
|
||||
for line in lines:
|
||||
if line.startswith('```') and not in_json:
|
||||
in_json = True
|
||||
continue
|
||||
elif line.startswith('```') and in_json:
|
||||
break
|
||||
elif in_json:
|
||||
json_lines.append(line)
|
||||
response = '\n'.join(json_lines)
|
||||
|
||||
# 解析 JSON
|
||||
result = json.loads(response)
|
||||
intent_name = result.get('intent', 'other')
|
||||
confidence = float(result.get('confidence', 0.5))
|
||||
|
||||
# 验证意图名称是否有效
|
||||
valid_names = [intent.get('name') for intent in intents] + ['other']
|
||||
if intent_name not in valid_names:
|
||||
# 尝试模糊匹配
|
||||
intent_name_lower = intent_name.lower()
|
||||
for valid_name in valid_names:
|
||||
if valid_name.lower() == intent_name_lower:
|
||||
intent_name = valid_name
|
||||
break
|
||||
else:
|
||||
intent_name = 'other'
|
||||
confidence = 0.5
|
||||
|
||||
return intent_name, confidence
|
||||
|
||||
except (json.JSONDecodeError, ValueError, KeyError) as e:
|
||||
logger.warning(f'解析意图识别响应失败: {e}, response: {response}')
|
||||
return 'other', 0.5
|
||||
|
||||
def _get_branch_id(self, intent_name: str, intents: List[Dict]) -> str:
|
||||
"""获取意图对应的分支 ID"""
|
||||
logger.info(f'查找意图分支: intent_name={intent_name}, intents={intents}')
|
||||
for intent in intents:
|
||||
if intent.get('name') == intent_name:
|
||||
branch_id = intent.get('branch_id', intent_name)
|
||||
logger.info(f'找到匹配意图: {intent}, 返回 branch_id={branch_id}')
|
||||
return branch_id
|
||||
logger.info(f'未找到匹配意图,返回 other')
|
||||
return 'other'
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'model_id': {
|
||||
'type': 'string',
|
||||
'title': '模型',
|
||||
'description': '选择用于意图识别的 LLM 模型',
|
||||
},
|
||||
'input_variable': {
|
||||
'type': 'string',
|
||||
'title': '输入变量',
|
||||
'description': '包含用户输入的变量名',
|
||||
'default': 'user_input',
|
||||
},
|
||||
'intents': {
|
||||
'type': 'array',
|
||||
'title': '意图列表',
|
||||
'description': '定义要识别的意图',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'title': '意图名称',
|
||||
'description': '唯一标识,如 consult, complaint',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'title': '意图描述',
|
||||
'description': '描述这个意图的含义',
|
||||
},
|
||||
'examples': {
|
||||
'type': 'array',
|
||||
'title': '示例',
|
||||
'description': '用户可能的输入示例',
|
||||
'items': {'type': 'string'},
|
||||
},
|
||||
'branch_id': {
|
||||
'type': 'string',
|
||||
'title': '分支 ID',
|
||||
'description': '匹配时跳转的分支',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
},
|
||||
},
|
||||
'confidence_threshold': {
|
||||
'type': 'number',
|
||||
'title': '置信度阈值',
|
||||
'description': '低于此阈值将走 other 分支',
|
||||
'default': 0.6,
|
||||
'minimum': 0,
|
||||
'maximum': 1,
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'intent_result',
|
||||
},
|
||||
},
|
||||
'required': ['model_id', 'intents'],
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
知识库检索节点
|
||||
|
||||
在 AI 工作流中检索知识库,返回与查询最相关的文档分段
|
||||
支持向量检索、全文检索和混合检索
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class KnowledgeRetrievalNode(BaseNode):
|
||||
"""
|
||||
知识库检索节点
|
||||
|
||||
从指定知识库中检索与查询文本最相关的文档分段,
|
||||
输出可直接作为 LLM 节点的上下文使用
|
||||
"""
|
||||
|
||||
node_type = 'knowledge_retrieval'
|
||||
node_name = '知识库检索'
|
||||
node_category = 'knowledge'
|
||||
node_icon = 'BookOpen'
|
||||
node_description = '从知识库中检索相关文档,为 LLM 提供上下文'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'query',
|
||||
'type': 'string',
|
||||
'description': '检索查询文本',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'results',
|
||||
'type': 'array',
|
||||
'description': '检索结果列表',
|
||||
},
|
||||
{
|
||||
'name': 'context',
|
||||
'type': 'string',
|
||||
'description': '拼接后的上下文文本',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""同步执行(不支持,需要异步)"""
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='知识库检索节点必须异步执行',
|
||||
)
|
||||
|
||||
async def execute_async(self, context: NodeContext) -> NodeResult:
|
||||
"""异步执行知识库检索"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 读取配置
|
||||
knowledge_base_ids = self.config.get('knowledge_base_ids', [])
|
||||
query_template = self.config.get('query', '')
|
||||
top_k = self.config.get('top_k', 5)
|
||||
score_threshold = self.config.get('score_threshold', 0.5)
|
||||
retrieval_mode = self.config.get('retrieval_mode', None)
|
||||
rerank_enabled = self.config.get('rerank_enabled', None)
|
||||
rerank_model_id = self.config.get('rerank_model_id', None)
|
||||
output_variable = self.config.get('output_variable', 'knowledge_results')
|
||||
context_variable = self.config.get('context_variable', 'knowledge_context')
|
||||
context_template = self.config.get('context_template', '')
|
||||
|
||||
if not knowledge_base_ids:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='未配置知识库',
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
# 解析查询模板中的变量
|
||||
query = context.resolve_template(query_template) if query_template else context.user_input
|
||||
if not query or not query.strip():
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='检索查询文本为空',
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
# 调用检索服务
|
||||
db = context.db_session
|
||||
if not db:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='数据库会话不可用',
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
from ai_platform.knowledge.services.retrieval_service import RetrievalService
|
||||
|
||||
service = RetrievalService(db)
|
||||
results = await service.retrieve(
|
||||
query=query,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
top_k=top_k,
|
||||
score_threshold=score_threshold,
|
||||
retrieval_mode=retrieval_mode,
|
||||
rerank_enabled=rerank_enabled,
|
||||
rerank_model_id=rerank_model_id,
|
||||
)
|
||||
|
||||
# 构建结果列表
|
||||
result_list = []
|
||||
for r in results:
|
||||
result_list.append({
|
||||
'segment_id': r.segment_id,
|
||||
'document_id': r.document_id,
|
||||
'document_name': r.document_name,
|
||||
'knowledge_base_id': r.knowledge_base_id,
|
||||
'knowledge_base_name': r.knowledge_base_name,
|
||||
'content': r.content,
|
||||
'score': r.score,
|
||||
'token_count': r.token_count,
|
||||
})
|
||||
|
||||
# 构建上下文文本
|
||||
if context_template:
|
||||
# 自定义模板
|
||||
context_text = context.resolve_template(context_template)
|
||||
else:
|
||||
# 默认:拼接所有检索结果内容
|
||||
context_parts = []
|
||||
for i, r in enumerate(result_list, 1):
|
||||
context_parts.append(
|
||||
f"[{i}] (来源: {r['document_name']}, 相似度: {r['score']:.2f})\n{r['content']}"
|
||||
)
|
||||
context_text = '\n\n'.join(context_parts)
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output={
|
||||
'results': result_list,
|
||||
'context': context_text,
|
||||
'total': len(result_list),
|
||||
'query': query,
|
||||
},
|
||||
output_variables={
|
||||
output_variable: result_list,
|
||||
context_variable: context_text,
|
||||
f'{output_variable}_total': len(result_list),
|
||||
},
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'query': query,
|
||||
'knowledge_base_ids': knowledge_base_ids,
|
||||
'top_k': top_k,
|
||||
'score_threshold': score_threshold,
|
||||
'retrieval_mode': retrieval_mode,
|
||||
'rerank_enabled': rerank_enabled,
|
||||
'result_count': len(result_list),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'知识库检索节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'检索失败: {str(e)}',
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'knowledge_base_ids': {
|
||||
'type': 'array',
|
||||
'title': '知识库',
|
||||
'description': '选择要检索的知识库',
|
||||
'items': {'type': 'string'},
|
||||
},
|
||||
'query': {
|
||||
'type': 'string',
|
||||
'title': '查询文本',
|
||||
'description': '支持变量引用,如 {{user_input}},留空则使用用户输入',
|
||||
},
|
||||
'top_k': {
|
||||
'type': 'integer',
|
||||
'title': '返回数量',
|
||||
'default': 5,
|
||||
'minimum': 1,
|
||||
'maximum': 20,
|
||||
},
|
||||
'score_threshold': {
|
||||
'type': 'number',
|
||||
'title': '相似度阈值',
|
||||
'default': 0.5,
|
||||
'minimum': 0,
|
||||
'maximum': 1,
|
||||
},
|
||||
'retrieval_mode': {
|
||||
'type': 'string',
|
||||
'title': '检索模式',
|
||||
'description': '留空则使用知识库默认配置',
|
||||
'enum': ['vector', 'fulltext', 'hybrid'],
|
||||
},
|
||||
'rerank_enabled': {
|
||||
'type': 'boolean',
|
||||
'title': '启用重排序',
|
||||
'description': '不设置则使用知识库默认配置',
|
||||
'default': None,
|
||||
},
|
||||
'rerank_model_id': {
|
||||
'type': 'string',
|
||||
'title': '重排序模型',
|
||||
'description': '不设置则使用知识库默认配置',
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '结果变量名',
|
||||
'default': 'knowledge_results',
|
||||
},
|
||||
'context_variable': {
|
||||
'type': 'string',
|
||||
'title': '上下文变量名',
|
||||
'default': 'knowledge_context',
|
||||
},
|
||||
},
|
||||
'required': ['knowledge_base_ids'],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
循环节点
|
||||
|
||||
支持两种循环模式:
|
||||
1. for_each - 遍历数组,对每个元素执行循环体
|
||||
2. while - 条件循环,满足条件时持续执行
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class LoopNode(BaseNode):
|
||||
"""
|
||||
循环节点
|
||||
|
||||
支持 for_each 和 while 两种循环模式
|
||||
"""
|
||||
|
||||
node_type = 'loop'
|
||||
node_name = '循环'
|
||||
node_category = 'logic'
|
||||
node_icon = 'repeat'
|
||||
node_description = '循环执行一组节点,支持遍历数组或条件循环'
|
||||
|
||||
supports_branches = True
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'items',
|
||||
'type': 'array',
|
||||
'description': '要遍历的数组(for_each 模式)',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'results',
|
||||
'type': 'array',
|
||||
'description': '每次循环的结果数组',
|
||||
},
|
||||
{
|
||||
'name': 'current_item',
|
||||
'type': 'any',
|
||||
'description': '当前循环项(循环体内可用)',
|
||||
},
|
||||
{
|
||||
'name': 'current_index',
|
||||
'type': 'number',
|
||||
'description': '当前循环索引(从 0 开始)',
|
||||
},
|
||||
]
|
||||
|
||||
# 支持的操作符(用于 while 条件判断)
|
||||
OPERATORS = {
|
||||
'eq': operator.eq, # 等于
|
||||
'ne': operator.ne, # 不等于
|
||||
'gt': operator.gt, # 大于
|
||||
'gte': operator.ge, # 大于等于
|
||||
'lt': operator.lt, # 小于
|
||||
'lte': operator.le, # 小于等于
|
||||
'is_empty': lambda a, _: not a, # 为空
|
||||
'is_not_empty': lambda a, _: bool(a), # 不为空
|
||||
'is_true': lambda a, _: bool(a), # 为真
|
||||
'is_false': lambda a, _: not bool(a), # 为假
|
||||
}
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""
|
||||
执行循环节点
|
||||
|
||||
循环节点本身只负责初始化循环状态,实际的循环执行由 WorkflowService 处理
|
||||
"""
|
||||
try:
|
||||
loop_mode = self.config.get('loop_mode', 'for_each')
|
||||
max_iterations = self.config.get('max_iterations', 100)
|
||||
|
||||
if loop_mode == 'for_each':
|
||||
return self._init_for_each(context, max_iterations)
|
||||
elif loop_mode == 'while':
|
||||
return self._init_while(context, max_iterations)
|
||||
else:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'不支持的循环模式: {loop_mode}',
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'循环节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _init_for_each(self, context: NodeContext, max_iterations: int) -> NodeResult:
|
||||
"""初始化 for_each 循环"""
|
||||
items_var = self.config.get('items_variable', '')
|
||||
|
||||
logger.info(f'[LoopNode] 初始化 for_each 循环,items_variable={items_var}')
|
||||
|
||||
# 解析数组变量
|
||||
items = self._resolve_variable(items_var, context)
|
||||
|
||||
logger.info(f'[LoopNode] 解析后的 items 类型: {type(items).__name__}, 值: {items}')
|
||||
|
||||
# 如果是字符串,尝试解析为 JSON 或 Python 字面量
|
||||
if isinstance(items, str):
|
||||
try:
|
||||
items = json.loads(items)
|
||||
logger.info(f'[LoopNode] JSON 解析成功,items={items}')
|
||||
except json.JSONDecodeError:
|
||||
# 尝试使用 ast.literal_eval 解析 Python 字面量(如 str() 输出的列表)
|
||||
try:
|
||||
items = ast.literal_eval(items)
|
||||
logger.info(f'[LoopNode] Python 字面量解析成功,items={items}')
|
||||
except (ValueError, SyntaxError):
|
||||
# 尝试按逗号分割(仅当不是列表/字典格式时)
|
||||
if not (items.strip().startswith('[') or items.strip().startswith('{')):
|
||||
items = [item.strip() for item in items.split(',') if item.strip()]
|
||||
logger.info(f'[LoopNode] 按逗号分割,items={items}')
|
||||
else:
|
||||
logger.error(f'[LoopNode] 无法解析 items 字符串: {items[:200]}...')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'无法解析 items 变量,格式不正确',
|
||||
)
|
||||
|
||||
if not isinstance(items, (list, tuple)):
|
||||
error_msg = f'items 必须是数组,当前类型: {type(items).__name__}, 值: {items}, items_variable={items_var}'
|
||||
logger.error(f'[LoopNode] {error_msg}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
# 检查数组是否为空
|
||||
if len(items) == 0:
|
||||
logger.warning(f'[LoopNode] items 数组为空,items_variable={items_var}')
|
||||
# 返回成功但不执行循环体
|
||||
|
||||
# 限制最大迭代次数
|
||||
if len(items) > max_iterations:
|
||||
logger.warning(f'数组长度 {len(items)} 超过最大迭代次数 {max_iterations},将被截断')
|
||||
items = list(items)[:max_iterations]
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output={
|
||||
'items': list(items),
|
||||
'total_count': len(items),
|
||||
},
|
||||
output_variables={
|
||||
'_loop_items': list(items),
|
||||
'_loop_index': 0,
|
||||
'_loop_total': len(items),
|
||||
'_loop_mode': 'for_each',
|
||||
'_loop_results': [],
|
||||
},
|
||||
metadata={
|
||||
'is_loop': True,
|
||||
'loop_mode': 'for_each',
|
||||
'total_iterations': len(items),
|
||||
'max_iterations': max_iterations,
|
||||
},
|
||||
)
|
||||
|
||||
def _init_while(self, context: NodeContext, max_iterations: int) -> NodeResult:
|
||||
"""初始化 while 循环"""
|
||||
# 检查初始条件
|
||||
condition_met = self._evaluate_condition(context)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output={
|
||||
'condition_met': condition_met,
|
||||
},
|
||||
output_variables={
|
||||
'_loop_index': 0,
|
||||
'_loop_mode': 'while',
|
||||
'_loop_condition_met': condition_met,
|
||||
'_loop_results': [],
|
||||
'_loop_max_iterations': max_iterations,
|
||||
},
|
||||
metadata={
|
||||
'is_loop': True,
|
||||
'loop_mode': 'while',
|
||||
'condition_met': condition_met,
|
||||
'max_iterations': max_iterations,
|
||||
},
|
||||
)
|
||||
|
||||
def check_continue(self, context: NodeContext) -> bool:
|
||||
"""
|
||||
检查是否继续循环
|
||||
|
||||
由 WorkflowService 在每次循环迭代后调用
|
||||
"""
|
||||
loop_mode = context.get_variable('_loop_mode')
|
||||
current_index = context.get_variable('_loop_index', 0)
|
||||
max_iterations = self.config.get('max_iterations', 100)
|
||||
|
||||
# 检查最大迭代次数
|
||||
if current_index >= max_iterations:
|
||||
logger.warning(f'达到最大迭代次数 {max_iterations},停止循环')
|
||||
return False
|
||||
|
||||
if loop_mode == 'for_each':
|
||||
total = context.get_variable('_loop_total', 0)
|
||||
return current_index < total
|
||||
elif loop_mode == 'while':
|
||||
return self._evaluate_condition(context)
|
||||
|
||||
return False
|
||||
|
||||
def get_current_item(self, context: NodeContext) -> Any:
|
||||
"""获取当前循环项(for_each 模式)"""
|
||||
items = context.get_variable('_loop_items', [])
|
||||
index = context.get_variable('_loop_index', 0)
|
||||
|
||||
if 0 <= index < len(items):
|
||||
return items[index]
|
||||
return None
|
||||
|
||||
def increment_index(self, context: NodeContext) -> int:
|
||||
"""增加循环索引"""
|
||||
current_index = context.get_variable('_loop_index', 0)
|
||||
new_index = current_index + 1
|
||||
context.set_variable('_loop_index', new_index)
|
||||
return new_index
|
||||
|
||||
def add_result(self, context: NodeContext, result: Any) -> None:
|
||||
"""添加循环结果"""
|
||||
results = context.get_variable('_loop_results', [])
|
||||
results.append(result)
|
||||
context.set_variable('_loop_results', results)
|
||||
|
||||
def _resolve_variable(self, variable: str, context: NodeContext) -> Any:
|
||||
"""解析变量引用,支持多层路径访问"""
|
||||
if not isinstance(variable, str):
|
||||
return variable
|
||||
|
||||
# 如果是空字符串,返回 None
|
||||
if not variable.strip():
|
||||
return None
|
||||
|
||||
# 检查是否是 {{...}} 格式的变量引用
|
||||
if not (variable.startswith('{{') and variable.endswith('}}')):
|
||||
# 不是变量引用,直接返回原始字符串值(可能是硬编码的 JSON 数组或逗号分隔的值)
|
||||
return variable
|
||||
|
||||
# 使用 resolve_template 解析变量,它支持多层路径访问(如 node.key.subkey)
|
||||
resolved = context.resolve_template(variable)
|
||||
|
||||
# 如果解析结果与原始变量相同,说明变量不存在或解析失败
|
||||
if resolved == variable:
|
||||
return None
|
||||
|
||||
# 如果解析结果是字符串,尝试解析为 JSON
|
||||
if isinstance(resolved, str):
|
||||
try:
|
||||
import json
|
||||
return json.loads(resolved)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return resolved
|
||||
|
||||
return resolved
|
||||
|
||||
def _evaluate_condition(self, context: NodeContext) -> bool:
|
||||
"""评估 while 条件"""
|
||||
conditions = self.config.get('conditions', [])
|
||||
logic = self.config.get('condition_logic', 'and') # and 或 or
|
||||
|
||||
if not conditions:
|
||||
return False # 无条件默认不继续
|
||||
|
||||
results = []
|
||||
for condition in conditions:
|
||||
result = self._evaluate_single_condition(condition, context)
|
||||
results.append(result)
|
||||
|
||||
if logic == 'or':
|
||||
return any(results)
|
||||
else: # and
|
||||
return all(results)
|
||||
|
||||
def _evaluate_single_condition(self, condition: Dict, context: NodeContext) -> bool:
|
||||
"""评估单个条件"""
|
||||
variable = condition.get('variable', '')
|
||||
op_name = condition.get('operator', 'eq')
|
||||
value = condition.get('value', '')
|
||||
|
||||
# 解析变量和值
|
||||
left_val = self._resolve_variable(variable, context)
|
||||
right_val = self._resolve_variable(value, context) if value else None
|
||||
|
||||
# 获取操作符函数
|
||||
op_func = self.OPERATORS.get(op_name, operator.eq)
|
||||
|
||||
try:
|
||||
# 特殊处理不需要右值的操作符
|
||||
if op_name in ['is_empty', 'is_not_empty', 'is_true', 'is_false']:
|
||||
return op_func(left_val, None)
|
||||
|
||||
# 尝试类型转换
|
||||
if isinstance(left_val, (int, float)) and isinstance(right_val, str):
|
||||
try:
|
||||
if '.' in right_val:
|
||||
right_val = float(right_val)
|
||||
else:
|
||||
right_val = int(right_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return op_func(left_val, right_val)
|
||||
except Exception as e:
|
||||
logger.warning(f'条件评估失败: {e}')
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'loop_mode': {
|
||||
'type': 'string',
|
||||
'title': '循环模式',
|
||||
'enum': ['for_each', 'while'],
|
||||
'enumNames': ['遍历数组 (For Each)', '条件循环 (While)'],
|
||||
'default': 'for_each',
|
||||
},
|
||||
'items_variable': {
|
||||
'type': 'string',
|
||||
'title': '数组变量',
|
||||
'description': '要遍历的数组变量名(for_each 模式)',
|
||||
},
|
||||
'item_variable_name': {
|
||||
'type': 'string',
|
||||
'title': '循环项变量名',
|
||||
'description': '当前循环项的变量名,默认为 item',
|
||||
'default': 'item',
|
||||
},
|
||||
'index_variable_name': {
|
||||
'type': 'string',
|
||||
'title': '索引变量名',
|
||||
'description': '当前索引的变量名,默认为 index',
|
||||
'default': 'index',
|
||||
},
|
||||
'conditions': {
|
||||
'type': 'array',
|
||||
'title': '循环条件',
|
||||
'description': 'while 模式的循环条件',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'variable': {
|
||||
'type': 'string',
|
||||
'title': '变量',
|
||||
},
|
||||
'operator': {
|
||||
'type': 'string',
|
||||
'title': '操作符',
|
||||
'enum': list(cls.OPERATORS.keys()),
|
||||
'default': 'eq',
|
||||
},
|
||||
'value': {
|
||||
'type': 'string',
|
||||
'title': '比较值',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'condition_logic': {
|
||||
'type': 'string',
|
||||
'title': '条件逻辑',
|
||||
'enum': ['and', 'or'],
|
||||
'enumNames': ['全部满足 (AND)', '任一满足 (OR)'],
|
||||
'default': 'and',
|
||||
},
|
||||
'max_iterations': {
|
||||
'type': 'integer',
|
||||
'title': '最大迭代次数',
|
||||
'description': '防止无限循环,默认 100 次',
|
||||
'default': 100,
|
||||
'minimum': 1,
|
||||
'maximum': 10000,
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'description': '存储所有循环结果的变量名',
|
||||
'default': 'loop_results',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
合并节点
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class MergeNode(BaseNode):
|
||||
"""
|
||||
合并节点
|
||||
|
||||
等待所有并行分支执行完成后,合并结果继续执行
|
||||
"""
|
||||
|
||||
node_type = 'merge'
|
||||
node_name = '合并'
|
||||
node_category = 'logic'
|
||||
node_icon = 'git-merge'
|
||||
node_description = '等待所有并行分支完成后合并结果'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'branch_results',
|
||||
'type': 'array',
|
||||
'description': '各分支的执行结果',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'merged_result',
|
||||
'type': 'object',
|
||||
'description': '合并后的结果',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""
|
||||
执行合并节点
|
||||
|
||||
从上下文中获取所有并行分支的结果并合并
|
||||
"""
|
||||
try:
|
||||
# 获取合并模式
|
||||
merge_mode = self.config.get('merge_mode', 'object')
|
||||
output_variable = self.config.get('output_variable', 'merged_result')
|
||||
|
||||
# 从上下文获取并行分支结果
|
||||
# 并行执行时,每个分支的结果会存储在 _parallel_results 中
|
||||
parallel_results = context.get_variable('_parallel_results', {})
|
||||
|
||||
if merge_mode == 'object':
|
||||
# 对象模式:将各分支结果合并为一个对象
|
||||
merged = {}
|
||||
for branch_id, result in parallel_results.items():
|
||||
merged[branch_id] = result
|
||||
elif merge_mode == 'array':
|
||||
# 数组模式:将各分支结果合并为数组
|
||||
merged = list(parallel_results.values())
|
||||
elif merge_mode == 'first':
|
||||
# 取第一个完成的结果
|
||||
merged = list(parallel_results.values())[0] if parallel_results else None
|
||||
elif merge_mode == 'concat':
|
||||
# 字符串拼接模式
|
||||
separator = self.config.get('separator', '\n')
|
||||
merged = separator.join(str(v) for v in parallel_results.values())
|
||||
else:
|
||||
merged = parallel_results
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=merged,
|
||||
output_variables={
|
||||
output_variable: merged,
|
||||
'branch_count': len(parallel_results),
|
||||
},
|
||||
metadata={
|
||||
'merge_mode': merge_mode,
|
||||
'branch_ids': list(parallel_results.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'合并节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'merge_mode': {
|
||||
'type': 'string',
|
||||
'title': '合并模式',
|
||||
'description': '如何合并各分支的结果',
|
||||
'enum': ['object', 'array', 'first', 'concat'],
|
||||
'enumNames': ['对象(按分支ID)', '数组', '取第一个', '字符串拼接'],
|
||||
'default': 'object',
|
||||
},
|
||||
'separator': {
|
||||
'type': 'string',
|
||||
'title': '分隔符',
|
||||
'description': '字符串拼接模式的分隔符',
|
||||
'default': '\n',
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'merged_result',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
并行分支节点
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class ParallelNode(BaseNode):
|
||||
"""
|
||||
并行分支节点
|
||||
|
||||
将工作流分成多个并行分支同时执行
|
||||
"""
|
||||
|
||||
node_type = 'parallel'
|
||||
node_name = '并行分支'
|
||||
node_category = 'logic'
|
||||
node_icon = 'git-fork'
|
||||
node_description = '将工作流分成多个并行分支同时执行'
|
||||
|
||||
supports_branches = True
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'input',
|
||||
'type': 'any',
|
||||
'description': '输入数据',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'branches',
|
||||
'type': 'array',
|
||||
'description': '并行分支 ID 列表',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""
|
||||
执行并行分支节点
|
||||
|
||||
返回所有需要并行执行的分支 ID 列表
|
||||
"""
|
||||
try:
|
||||
# 获取配置的分支
|
||||
branches = self.config.get('branches', [])
|
||||
|
||||
if not branches:
|
||||
# 如果没有配置分支,返回默认的两个分支
|
||||
branches = [
|
||||
{'id': 'branch_1', 'name': '分支 1'},
|
||||
{'id': 'branch_2', 'name': '分支 2'},
|
||||
]
|
||||
|
||||
branch_ids = [b.get('id') for b in branches if b.get('id')]
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=branch_ids,
|
||||
output_variables={
|
||||
'parallel_branches': branch_ids,
|
||||
},
|
||||
metadata={
|
||||
'is_parallel': True,
|
||||
'branch_count': len(branch_ids),
|
||||
'branches': branches,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'并行分支节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'branches': {
|
||||
'type': 'array',
|
||||
'title': '并行分支',
|
||||
'description': '定义并行执行的分支',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'id': {
|
||||
'type': 'string',
|
||||
'title': '分支 ID',
|
||||
},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'title': '分支名称',
|
||||
},
|
||||
},
|
||||
'required': ['id'],
|
||||
},
|
||||
'default': [
|
||||
{'id': 'branch_1', 'name': '分支 1'},
|
||||
{'id': 'branch_2', 'name': '分支 2'},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
"""
|
||||
Snowflake Cortex AI 节点
|
||||
|
||||
包含两个节点:
|
||||
1. SnowflakeCortexLLMNode - Cortex LLM Functions (COMPLETE, SUMMARIZE, TRANSLATE 等)
|
||||
2. SnowflakeCortexAnalystNode - Cortex Analyst (自然语言查询数据)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SnowflakeConnectionMixin:
|
||||
"""Snowflake 连接混入类"""
|
||||
|
||||
# 支持的认证方式
|
||||
AUTH_TYPES = [
|
||||
('password', '用户名/密码'),
|
||||
('key_pair', '密钥对 (Key Pair)'),
|
||||
('externalbrowser', '外部浏览器 SSO'),
|
||||
]
|
||||
|
||||
def _get_connection(self, config: Dict, context: NodeContext):
|
||||
"""
|
||||
获取 Snowflake 连接
|
||||
|
||||
支持多种认证方式:
|
||||
- password: 用户名密码认证
|
||||
- key_pair: RSA 密钥对认证(推荐生产环境使用)
|
||||
- externalbrowser: 外部浏览器 SSO 认证
|
||||
|
||||
Args:
|
||||
config: 节点配置
|
||||
context: 执行上下文
|
||||
|
||||
Returns:
|
||||
snowflake.connector.connection
|
||||
"""
|
||||
try:
|
||||
import snowflake.connector
|
||||
except ImportError:
|
||||
raise ImportError('请安装 snowflake-connector-python: pip install snowflake-connector-python')
|
||||
|
||||
# 从配置获取连接信息
|
||||
connection_config = config.get('connection', {})
|
||||
|
||||
# 认证方式,默认为密码认证
|
||||
auth_type = connection_config.get('auth_type', 'password')
|
||||
|
||||
# 支持变量引用 - 基础参数
|
||||
account = context.resolve_template(connection_config.get('account', ''))
|
||||
user = context.resolve_template(connection_config.get('user', ''))
|
||||
warehouse = context.resolve_template(connection_config.get('warehouse', ''))
|
||||
database = context.resolve_template(connection_config.get('database', ''))
|
||||
schema = context.resolve_template(connection_config.get('schema', 'PUBLIC'))
|
||||
role = context.resolve_template(connection_config.get('role', ''))
|
||||
|
||||
# 验证必填参数
|
||||
if not account:
|
||||
raise ValueError('Snowflake 连接缺少 account 配置')
|
||||
if not user:
|
||||
raise ValueError('Snowflake 连接缺少 user 配置')
|
||||
if not warehouse:
|
||||
raise ValueError('Snowflake 连接缺少 warehouse 配置')
|
||||
if not database:
|
||||
raise ValueError('Snowflake 连接缺少 database 配置')
|
||||
|
||||
conn_params = {
|
||||
'account': account,
|
||||
'user': user,
|
||||
'warehouse': warehouse,
|
||||
'database': database,
|
||||
'schema': schema,
|
||||
}
|
||||
|
||||
if role:
|
||||
conn_params['role'] = role
|
||||
|
||||
# 根据认证方式设置认证参数
|
||||
if auth_type == 'password':
|
||||
password = context.resolve_template(connection_config.get('password', ''))
|
||||
if not password:
|
||||
raise ValueError('密码认证方式需要配置 password')
|
||||
conn_params['password'] = password
|
||||
|
||||
elif auth_type == 'key_pair':
|
||||
# 密钥对认证
|
||||
private_key = context.resolve_template(connection_config.get('private_key', ''))
|
||||
private_key_path = context.resolve_template(connection_config.get('private_key_path', ''))
|
||||
private_key_passphrase = context.resolve_template(connection_config.get('private_key_passphrase', ''))
|
||||
|
||||
if private_key:
|
||||
# 直接使用私钥内容
|
||||
conn_params['private_key'] = self._load_private_key_from_string(
|
||||
private_key, private_key_passphrase
|
||||
)
|
||||
elif private_key_path:
|
||||
# 从文件路径加载私钥
|
||||
conn_params['private_key'] = self._load_private_key_from_file(
|
||||
private_key_path, private_key_passphrase
|
||||
)
|
||||
else:
|
||||
raise ValueError('密钥对认证需要配置 private_key 或 private_key_path')
|
||||
|
||||
elif auth_type == 'externalbrowser':
|
||||
# 外部浏览器 SSO 认证
|
||||
conn_params['authenticator'] = 'externalbrowser'
|
||||
|
||||
else:
|
||||
raise ValueError(f'不支持的认证方式: {auth_type}')
|
||||
|
||||
logger.info(f'Snowflake 连接: account={account}, user={user}, auth_type={auth_type}')
|
||||
|
||||
return snowflake.connector.connect(**conn_params)
|
||||
|
||||
def _load_private_key_from_string(self, private_key_str: str, passphrase: str = '') -> bytes:
|
||||
"""从字符串加载 RSA 私钥"""
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
# 处理可能的转义换行符
|
||||
private_key_str = private_key_str.replace('\\n', '\n')
|
||||
|
||||
p_key = serialization.load_pem_private_key(
|
||||
private_key_str.encode('utf-8'),
|
||||
password=passphrase.encode('utf-8') if passphrase else None,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
return p_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
def _load_private_key_from_file(self, file_path: str, passphrase: str = '') -> bytes:
|
||||
"""从文件加载 RSA 私钥"""
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
with open(file_path, 'rb') as key_file:
|
||||
p_key = serialization.load_pem_private_key(
|
||||
key_file.read(),
|
||||
password=passphrase.encode('utf-8') if passphrase else None,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
return p_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class SnowflakeCortexLLMNode(BaseNode, SnowflakeConnectionMixin):
|
||||
"""
|
||||
Snowflake Cortex LLM Functions 节点
|
||||
|
||||
支持的功能:
|
||||
- COMPLETE: LLM 文本生成
|
||||
- SUMMARIZE: 文本摘要
|
||||
- TRANSLATE: 翻译
|
||||
- SENTIMENT: 情感分析
|
||||
- EXTRACT_ANSWER: 问答提取
|
||||
"""
|
||||
|
||||
node_type = 'snowflake_cortex_llm'
|
||||
node_name = 'Snowflake Cortex LLM'
|
||||
node_category = 'ai'
|
||||
node_icon = 'snowflake'
|
||||
node_description = 'Snowflake Cortex AI 函数(LLM 生成、摘要、翻译、情感分析等)'
|
||||
|
||||
# 支持的 Cortex 模型
|
||||
CORTEX_MODELS = [
|
||||
'mistral-large',
|
||||
'mistral-large2',
|
||||
'mistral-7b',
|
||||
'mixtral-8x7b',
|
||||
'llama3-8b',
|
||||
'llama3-70b',
|
||||
'llama3.1-8b',
|
||||
'llama3.1-70b',
|
||||
'llama3.1-405b',
|
||||
'llama3.2-1b',
|
||||
'llama3.2-3b',
|
||||
'snowflake-arctic',
|
||||
'reka-core',
|
||||
'reka-flash',
|
||||
'jamba-instruct',
|
||||
'jamba-1.5-mini',
|
||||
'jamba-1.5-large',
|
||||
'gemma-7b',
|
||||
]
|
||||
|
||||
# 支持的功能类型
|
||||
FUNCTION_TYPES = [
|
||||
('complete', 'LLM 生成 (COMPLETE)'),
|
||||
('summarize', '文本摘要 (SUMMARIZE)'),
|
||||
('translate', '翻译 (TRANSLATE)'),
|
||||
('sentiment', '情感分析 (SENTIMENT)'),
|
||||
('extract_answer', '问答提取 (EXTRACT_ANSWER)'),
|
||||
]
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'text',
|
||||
'type': 'string',
|
||||
'description': '输入文本',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'result',
|
||||
'type': 'string',
|
||||
'description': 'Cortex 输出结果',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行 Cortex LLM 函数"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
function_type = self.config.get('function', 'complete')
|
||||
|
||||
# 根据功能类型执行
|
||||
if function_type == 'complete':
|
||||
result = self._execute_complete(context)
|
||||
elif function_type == 'summarize':
|
||||
result = self._execute_summarize(context)
|
||||
elif function_type == 'translate':
|
||||
result = self._execute_translate(context)
|
||||
elif function_type == 'sentiment':
|
||||
result = self._execute_sentiment(context)
|
||||
elif function_type == 'extract_answer':
|
||||
result = self._execute_extract_answer(context)
|
||||
else:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'不支持的功能类型: {function_type}',
|
||||
)
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
output_var = self.config.get('output_variable', 'cortex_result')
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=result,
|
||||
output_variables={output_var: result},
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={'function': function_type},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'Snowflake Cortex LLM 节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
def _execute_complete(self, context: NodeContext) -> str:
|
||||
"""
|
||||
执行 COMPLETE 函数 - 使用 REST API 方式
|
||||
|
||||
API 端点: /api/v2/cortex/inference:complete
|
||||
"""
|
||||
import requests
|
||||
|
||||
config = self.config.get('complete', {})
|
||||
model = config.get('model', 'mistral-large')
|
||||
prompt = context.resolve_template(config.get('prompt', ''))
|
||||
system_prompt = context.resolve_template(config.get('system_prompt', ''))
|
||||
temperature = config.get('temperature', 0.7)
|
||||
max_tokens = config.get('max_tokens', 1024)
|
||||
|
||||
if not prompt:
|
||||
raise ValueError('COMPLETE 功能需要配置 prompt')
|
||||
|
||||
connection_config = self.config.get('connection', {})
|
||||
account = context.resolve_template(connection_config.get('account', ''))
|
||||
|
||||
# 获取连接以获取 session token
|
||||
conn = self._get_connection(self.config, context)
|
||||
|
||||
try:
|
||||
# 从连接中获取 REST session token
|
||||
rest_token = conn.rest.token
|
||||
|
||||
# 构建 API URL
|
||||
if '.snowflakecomputing.com' in account:
|
||||
host = account
|
||||
else:
|
||||
host = conn.host if hasattr(conn, 'host') else f'{account}.snowflakecomputing.com'
|
||||
|
||||
api_url = f"https://{host}/api/v2/cortex/inference:complete"
|
||||
|
||||
# 构建请求头
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': f'Snowflake Token="{rest_token}"',
|
||||
}
|
||||
|
||||
# 构建消息
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({'role': 'system', 'content': system_prompt})
|
||||
messages.append({'role': 'user', 'content': prompt})
|
||||
|
||||
# 构建请求体
|
||||
payload = {
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'temperature': temperature,
|
||||
'max_tokens': max_tokens,
|
||||
}
|
||||
|
||||
logger.info(f'调用 Cortex LLM API: {api_url}')
|
||||
logger.info(f'Model: {model}, Temperature: {temperature}, Max Tokens: {max_tokens}')
|
||||
|
||||
response = requests.post(
|
||||
api_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
logger.info(f'Cortex LLM 响应状态: {response.status_code}')
|
||||
|
||||
if response.status_code != 200:
|
||||
error_text = response.text
|
||||
logger.error(f'Cortex LLM API 错误: {error_text}')
|
||||
raise ValueError(f'Cortex LLM API 调用失败: {response.status_code} - {error_text}')
|
||||
|
||||
# 解析响应 - REST API 返回 SSE 格式
|
||||
result_content = ''
|
||||
response_text = response.text
|
||||
|
||||
# 解析 SSE 格式的响应
|
||||
for line in response_text.split('\n'):
|
||||
if line.startswith('data:'):
|
||||
data_str = line[5:].strip()
|
||||
if data_str:
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
if 'choices' in data and len(data['choices']) > 0:
|
||||
choice = data['choices'][0]
|
||||
if 'delta' in choice and 'content' in choice['delta']:
|
||||
result_content += choice['delta']['content']
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return result_content if result_content else response_text
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _execute_summarize(self, context: NodeContext) -> str:
|
||||
"""执行 SUMMARIZE 函数"""
|
||||
config = self.config.get('summarize', {})
|
||||
text = context.resolve_template(config.get('text', ''))
|
||||
|
||||
if not text:
|
||||
raise ValueError('SUMMARIZE 功能需要配置 text')
|
||||
|
||||
conn = self._get_connection(self.config, context)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
sql = "SELECT SNOWFLAKE.CORTEX.SUMMARIZE(%s)"
|
||||
cursor.execute(sql, (text,))
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else ''
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _execute_translate(self, context: NodeContext) -> str:
|
||||
"""执行 TRANSLATE 函数"""
|
||||
config = self.config.get('translate', {})
|
||||
text = context.resolve_template(config.get('text', ''))
|
||||
source_language = config.get('source_language', 'en')
|
||||
target_language = config.get('target_language', 'zh')
|
||||
|
||||
if not text:
|
||||
raise ValueError('TRANSLATE 功能需要配置 text')
|
||||
|
||||
conn = self._get_connection(self.config, context)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
sql = "SELECT SNOWFLAKE.CORTEX.TRANSLATE(%s, %s, %s)"
|
||||
cursor.execute(sql, (text, source_language, target_language))
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else ''
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _execute_sentiment(self, context: NodeContext) -> str:
|
||||
"""执行 SENTIMENT 函数"""
|
||||
config = self.config.get('sentiment', {})
|
||||
text = context.resolve_template(config.get('text', ''))
|
||||
|
||||
if not text:
|
||||
raise ValueError('SENTIMENT 功能需要配置 text')
|
||||
|
||||
conn = self._get_connection(self.config, context)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
sql = "SELECT SNOWFLAKE.CORTEX.SENTIMENT(%s)"
|
||||
cursor.execute(sql, (text,))
|
||||
row = cursor.fetchone()
|
||||
# SENTIMENT 返回 -1 到 1 的数值
|
||||
result = row[0] if row else 0
|
||||
return str(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _execute_extract_answer(self, context: NodeContext) -> str:
|
||||
"""执行 EXTRACT_ANSWER 函数"""
|
||||
config = self.config.get('extract_answer', {})
|
||||
document = context.resolve_template(config.get('document', ''))
|
||||
question = context.resolve_template(config.get('question', ''))
|
||||
|
||||
if not document or not question:
|
||||
raise ValueError('EXTRACT_ANSWER 功能需要配置 document 和 question')
|
||||
|
||||
conn = self._get_connection(self.config, context)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
sql = "SELECT SNOWFLAKE.CORTEX.EXTRACT_ANSWER(%s, %s)"
|
||||
cursor.execute(sql, (document, question))
|
||||
row = cursor.fetchone()
|
||||
result = row[0] if row else ''
|
||||
|
||||
# 解析 JSON 结果
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
parsed = json.loads(result)
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
# 返回第一个答案
|
||||
return parsed[0].get('answer', result)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'connection': {
|
||||
'type': 'object',
|
||||
'title': 'Snowflake 连接',
|
||||
'properties': {
|
||||
'account': {'type': 'string', 'title': 'Account'},
|
||||
'user': {'type': 'string', 'title': 'User'},
|
||||
'password': {'type': 'string', 'title': 'Password', 'format': 'password'},
|
||||
'warehouse': {'type': 'string', 'title': 'Warehouse'},
|
||||
'database': {'type': 'string', 'title': 'Database'},
|
||||
'schema': {'type': 'string', 'title': 'Schema', 'default': 'PUBLIC'},
|
||||
'role': {'type': 'string', 'title': 'Role'},
|
||||
},
|
||||
'required': ['account', 'user', 'password', 'warehouse', 'database'],
|
||||
},
|
||||
'function': {
|
||||
'type': 'string',
|
||||
'title': '功能类型',
|
||||
'enum': ['complete', 'summarize', 'translate', 'sentiment', 'extract_answer'],
|
||||
'default': 'complete',
|
||||
},
|
||||
'complete': {
|
||||
'type': 'object',
|
||||
'title': 'COMPLETE 配置',
|
||||
'properties': {
|
||||
'model': {'type': 'string', 'title': '模型', 'default': 'mistral-large'},
|
||||
'prompt': {'type': 'string', 'title': '提示词', 'format': 'textarea'},
|
||||
'system_prompt': {'type': 'string', 'title': '系统提示词', 'format': 'textarea'},
|
||||
'temperature': {'type': 'number', 'title': '温度', 'default': 0.7},
|
||||
'max_tokens': {'type': 'integer', 'title': '最大 Token', 'default': 1024},
|
||||
},
|
||||
},
|
||||
'summarize': {
|
||||
'type': 'object',
|
||||
'title': 'SUMMARIZE 配置',
|
||||
'properties': {
|
||||
'text': {'type': 'string', 'title': '待摘要文本', 'format': 'textarea'},
|
||||
},
|
||||
},
|
||||
'translate': {
|
||||
'type': 'object',
|
||||
'title': 'TRANSLATE 配置',
|
||||
'properties': {
|
||||
'text': {'type': 'string', 'title': '待翻译文本', 'format': 'textarea'},
|
||||
'source_language': {'type': 'string', 'title': '源语言', 'default': 'en'},
|
||||
'target_language': {'type': 'string', 'title': '目标语言', 'default': 'zh'},
|
||||
},
|
||||
},
|
||||
'sentiment': {
|
||||
'type': 'object',
|
||||
'title': 'SENTIMENT 配置',
|
||||
'properties': {
|
||||
'text': {'type': 'string', 'title': '待分析文本', 'format': 'textarea'},
|
||||
},
|
||||
},
|
||||
'extract_answer': {
|
||||
'type': 'object',
|
||||
'title': 'EXTRACT_ANSWER 配置',
|
||||
'properties': {
|
||||
'document': {'type': 'string', 'title': '文档内容', 'format': 'textarea'},
|
||||
'question': {'type': 'string', 'title': '问题'},
|
||||
},
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'cortex_result',
|
||||
},
|
||||
},
|
||||
'required': ['connection', 'function'],
|
||||
}
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class SnowflakeCortexAnalystNode(BaseNode, SnowflakeConnectionMixin):
|
||||
"""
|
||||
Snowflake Cortex Analyst 节点
|
||||
|
||||
使用自然语言查询数据,自动生成 SQL 并返回结果
|
||||
"""
|
||||
|
||||
node_type = 'snowflake_cortex_analyst'
|
||||
node_name = 'Snowflake Cortex Analyst'
|
||||
node_category = 'ai'
|
||||
node_icon = 'snowflake'
|
||||
node_description = 'Snowflake Cortex Analyst - 自然语言查询数据'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'question',
|
||||
'type': 'string',
|
||||
'description': '自然语言问题',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'result',
|
||||
'type': 'object',
|
||||
'description': '查询结果',
|
||||
},
|
||||
{
|
||||
'name': 'sql',
|
||||
'type': 'string',
|
||||
'description': '生成的 SQL',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行 Cortex Analyst 查询"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 获取配置
|
||||
question = context.resolve_template(self.config.get('question', ''))
|
||||
semantic_model_file = self.config.get('semantic_model_file', '')
|
||||
|
||||
if not question:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请配置查询问题',
|
||||
)
|
||||
|
||||
if not semantic_model_file:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请配置语义模型文件路径',
|
||||
)
|
||||
|
||||
# 调用 Cortex Analyst API
|
||||
result = self._call_cortex_analyst(context, question, semantic_model_file)
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
output_var = self.config.get('output_variable', 'analyst_result')
|
||||
sql_var = self.config.get('sql_variable', 'analyst_sql')
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=result,
|
||||
output_variables={
|
||||
output_var: result.get('data', []),
|
||||
sql_var: result.get('sql', ''),
|
||||
f'{output_var}_raw': result,
|
||||
},
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={'question': question},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'Snowflake Cortex Analyst 节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
def _call_cortex_analyst(
|
||||
self,
|
||||
context: NodeContext,
|
||||
question: str,
|
||||
semantic_model: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
调用 Cortex Analyst API
|
||||
|
||||
Cortex Analyst 必须通过 REST API 调用,不支持 SQL 函数方式。
|
||||
|
||||
支持两种语义模型来源:
|
||||
1. Semantic View: database.schema.semantic_view_name (如 AI_TEST.PUBLIC.CAR_AI_TEST)
|
||||
2. Stage 文件: @database.schema.stage/file.yaml
|
||||
|
||||
Args:
|
||||
context: 节点上下文
|
||||
question: 用户问题
|
||||
semantic_model: 语义模型路径(Semantic View 或 Stage 文件)
|
||||
"""
|
||||
import requests
|
||||
|
||||
connection_config = self.config.get('connection', {})
|
||||
account = context.resolve_template(connection_config.get('account', ''))
|
||||
database = context.resolve_template(connection_config.get('database', ''))
|
||||
schema = context.resolve_template(connection_config.get('schema', 'PUBLIC'))
|
||||
|
||||
# 获取连接以获取 session token
|
||||
conn = self._get_connection(self.config, context)
|
||||
|
||||
try:
|
||||
# 从连接中获取 REST session token
|
||||
# Snowflake Python Connector 可以提供 REST session token
|
||||
rest_token = conn.rest.token
|
||||
master_token = conn.rest.master_token
|
||||
|
||||
# 构建 API URL
|
||||
# 处理 account 格式:可能是 xxx.snowflakecomputing.com 或 account_identifier
|
||||
if '.snowflakecomputing.com' in account:
|
||||
host = account
|
||||
else:
|
||||
# 从连接获取实际 host
|
||||
host = conn.host if hasattr(conn, 'host') else f'{account}.snowflakecomputing.com'
|
||||
|
||||
base_url = f"https://{host}"
|
||||
api_url = f"{base_url}/api/v2/cortex/analyst/message"
|
||||
|
||||
# 构建请求头
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': f'Snowflake Token="{rest_token}"',
|
||||
}
|
||||
|
||||
# 判断是 Semantic View 还是 Stage 文件
|
||||
is_stage_file = semantic_model.startswith('@')
|
||||
|
||||
# 构建请求体
|
||||
messages = [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [{'type': 'text', 'text': question}]
|
||||
}
|
||||
]
|
||||
|
||||
if is_stage_file:
|
||||
# Stage 文件格式: @database.schema.stage/file.yaml
|
||||
payload = {
|
||||
'messages': messages,
|
||||
'semantic_model_file': semantic_model,
|
||||
}
|
||||
else:
|
||||
# Semantic View 格式: database.schema.view_name
|
||||
# 使用 semantic_models 数组包含 semantic_view
|
||||
payload = {
|
||||
'messages': messages,
|
||||
'semantic_models': [
|
||||
{'semantic_view': semantic_model}
|
||||
],
|
||||
}
|
||||
|
||||
logger.info(f'调用 Cortex Analyst API: {api_url}')
|
||||
logger.info(f'Payload: {payload}')
|
||||
|
||||
response = requests.post(
|
||||
api_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
logger.info(f'Cortex Analyst 响应状态: {response.status_code}')
|
||||
|
||||
if response.status_code != 200:
|
||||
error_text = response.text
|
||||
logger.error(f'Cortex Analyst API 错误: {error_text}')
|
||||
raise ValueError(f'Cortex Analyst API 调用失败: {response.status_code} - {error_text}')
|
||||
|
||||
result = response.json()
|
||||
logger.info(f'Cortex Analyst 返回: {result}')
|
||||
|
||||
# 解析响应,提取 SQL 和执行结果
|
||||
return self._parse_analyst_response(conn, result)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.exception(f'Cortex Analyst REST API 调用失败: {e}')
|
||||
raise ValueError(f'Cortex Analyst API 调用失败: {e}')
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _parse_analyst_response(self, conn, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""解析 Cortex Analyst 响应并执行生成的 SQL"""
|
||||
generated_sql = None
|
||||
answer_text = None
|
||||
|
||||
# 尝试从响应中提取 SQL
|
||||
if isinstance(result, dict):
|
||||
# 格式1: message.content 数组
|
||||
if 'message' in result and 'content' in result['message']:
|
||||
content = result['message']['content']
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get('type') == 'sql':
|
||||
generated_sql = item.get('statement', item.get('text', ''))
|
||||
elif item.get('type') == 'text':
|
||||
answer_text = item.get('text', '')
|
||||
elif isinstance(content, str):
|
||||
answer_text = content
|
||||
|
||||
# 格式2: 直接 sql 字段
|
||||
elif 'sql' in result:
|
||||
generated_sql = result['sql']
|
||||
|
||||
# 格式3: choices 数组
|
||||
elif 'choices' in result and len(result['choices']) > 0:
|
||||
choice = result['choices'][0]
|
||||
if 'message' in choice and 'content' in choice['message']:
|
||||
content = choice['message']['content']
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get('type') == 'sql':
|
||||
generated_sql = item.get('statement', '')
|
||||
elif item.get('type') == 'text':
|
||||
answer_text = item.get('text', '')
|
||||
|
||||
# 如果有生成的 SQL,执行它获取数据
|
||||
if generated_sql:
|
||||
logger.info(f'执行生成的 SQL: {generated_sql}')
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(generated_sql)
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
rows = cursor.fetchall()
|
||||
data = [dict(zip(columns, r)) for r in rows]
|
||||
|
||||
return {
|
||||
'sql': generated_sql,
|
||||
'data': data,
|
||||
'columns': columns,
|
||||
'row_count': len(data),
|
||||
'answer': answer_text,
|
||||
}
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
# 没有 SQL,返回文本回答
|
||||
return {
|
||||
'sql': '',
|
||||
'data': [],
|
||||
'columns': [],
|
||||
'row_count': 0,
|
||||
'answer': answer_text or str(result),
|
||||
'raw_response': result,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'connection': {
|
||||
'type': 'object',
|
||||
'title': 'Snowflake 连接',
|
||||
'properties': {
|
||||
'account': {'type': 'string', 'title': 'Account'},
|
||||
'user': {'type': 'string', 'title': 'User'},
|
||||
'password': {'type': 'string', 'title': 'Password', 'format': 'password'},
|
||||
'warehouse': {'type': 'string', 'title': 'Warehouse'},
|
||||
'database': {'type': 'string', 'title': 'Database'},
|
||||
'schema': {'type': 'string', 'title': 'Schema', 'default': 'PUBLIC'},
|
||||
'role': {'type': 'string', 'title': 'Role'},
|
||||
},
|
||||
'required': ['account', 'user', 'password', 'warehouse', 'database'],
|
||||
},
|
||||
'question': {
|
||||
'type': 'string',
|
||||
'title': '查询问题',
|
||||
'description': '用自然语言描述你想查询的数据',
|
||||
'format': 'textarea',
|
||||
},
|
||||
'semantic_model_file': {
|
||||
'type': 'string',
|
||||
'title': '语义模型文件',
|
||||
'description': '语义模型文件路径,如 @my_db.my_schema.my_stage/semantic_model.yaml',
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'analyst_result',
|
||||
},
|
||||
},
|
||||
'required': ['connection', 'question', 'semantic_model_file'],
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
开始节点
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class StartNode(BaseNode):
|
||||
"""
|
||||
开始节点
|
||||
|
||||
工作流的入口节点,接收用户输入
|
||||
"""
|
||||
|
||||
node_type = 'start'
|
||||
node_name = '开始'
|
||||
node_category = 'basic'
|
||||
node_icon = 'play-circle'
|
||||
node_description = '工作流的开始节点,接收用户输入'
|
||||
|
||||
inputs = []
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'user_input',
|
||||
'type': 'string',
|
||||
'description': '用户输入',
|
||||
},
|
||||
{
|
||||
'name': 'application_id',
|
||||
'type': 'string',
|
||||
'description': '子应用ID(在子应用模式下自动注入)',
|
||||
},
|
||||
{
|
||||
'name': 'application_code',
|
||||
'type': 'string',
|
||||
'description': '子应用编码(在子应用模式下自动注入)',
|
||||
},
|
||||
{
|
||||
'name': 'form_code',
|
||||
'type': 'string',
|
||||
'description': '表单编码(从表单列表调用时自动注入)',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行开始节点"""
|
||||
output_variables = {}
|
||||
|
||||
# 调试日志
|
||||
logger.info(f"StartNode execute - context.variables: {list(context.variables.keys())}")
|
||||
logger.info(f"StartNode execute - application_id in variables: {'application_id' in context.variables}")
|
||||
if 'application_id' in context.variables:
|
||||
logger.info(f"StartNode execute - application_id value: {context.variables['application_id']}")
|
||||
|
||||
# 首先将 user_input 作为开始节点的输出变量
|
||||
# 这样下游节点可以通过 {{start-xxx.user_input}} 引用
|
||||
if context.user_input:
|
||||
output_variables['user_input'] = context.user_input
|
||||
elif 'user_input' in context.variables:
|
||||
output_variables['user_input'] = context.variables['user_input']
|
||||
|
||||
# 将 application_id 作为开始节点的输出变量
|
||||
# 这样下游节点可以通过 {{start-xxx.application_id}} 引用
|
||||
# 主应用模式下为空字符串,子应用模式下为实际的应用ID
|
||||
output_variables['application_id'] = context.variables.get('application_id', 'main')
|
||||
|
||||
# 将 application_code 作为开始节点的输出变量
|
||||
# 这样下游节点可以通过 {{start-xxx.application_code}} 引用
|
||||
# 主应用模式下为空字符串,子应用模式下为实际的应用编码
|
||||
output_variables['application_code'] = context.variables.get('application_code', '')
|
||||
|
||||
# 将 form_code 作为开始节点的输出变量
|
||||
# 这样下游节点可以通过 {{start-xxx.form_code}} 引用
|
||||
# 从表单列表调用时会自动注入,否则为空字符串
|
||||
output_variables['form_code'] = context.variables.get('form_code', '')
|
||||
|
||||
logger.info(f"StartNode execute - output_variables: {output_variables}")
|
||||
|
||||
# 处理前端定义的变量(variables 数组)
|
||||
# 格式: [{ variable: 'name', type: 'string', label: '名称', required: true, default_value: '' }]
|
||||
variables = self.config.get('variables', [])
|
||||
for var in variables:
|
||||
var_name = var.get('variable', '')
|
||||
if not var_name:
|
||||
continue
|
||||
|
||||
default_value = var.get('default_value', '')
|
||||
var_type = var.get('type', 'string')
|
||||
|
||||
# 如果 context 中已有该变量(从 inputs 传入),使用传入的值
|
||||
# 否则使用默认值
|
||||
if var_name in context.variables and context.variables[var_name]:
|
||||
value = context.variables[var_name]
|
||||
else:
|
||||
value = default_value
|
||||
|
||||
# 类型转换
|
||||
if var_type == 'number' and value:
|
||||
try:
|
||||
value = float(value) if '.' in str(value) else int(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif var_type == 'boolean':
|
||||
if isinstance(value, str):
|
||||
value = value.lower() in ('true', '1', 'yes')
|
||||
|
||||
context.set_variable(var_name, value)
|
||||
output_variables[var_name] = value
|
||||
|
||||
# 处理自定义输入变量(兼容旧格式 input_variables)
|
||||
input_variables = self.config.get('input_variables', [])
|
||||
for var in input_variables:
|
||||
var_name = var.get('name', '')
|
||||
var_value = var.get('default', '')
|
||||
if var_name:
|
||||
# 如果 context 中没有该变量,使用默认值
|
||||
if var_name not in context.variables or not context.variables[var_name]:
|
||||
context.set_variable(var_name, var_value)
|
||||
output_variables[var_name] = context.get_variable(var_name)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=output_variables,
|
||||
output_variables=output_variables,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'input_variables': {
|
||||
'type': 'array',
|
||||
'title': '输入变量',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'title': '变量名'},
|
||||
'type': {'type': 'string', 'title': '类型', 'enum': ['string', 'number', 'boolean']},
|
||||
'description': {'type': 'string', 'title': '描述'},
|
||||
'default': {'type': 'string', 'title': '默认值'},
|
||||
'required': {'type': 'boolean', 'title': '是否必填'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
模板渲染节点
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class TemplateNode(BaseNode):
|
||||
"""
|
||||
模板渲染节点
|
||||
|
||||
使用变量渲染模板字符串
|
||||
"""
|
||||
|
||||
node_type = 'template'
|
||||
node_name = '模板'
|
||||
node_category = 'data'
|
||||
node_icon = 'file-text'
|
||||
node_description = '使用变量渲染模板字符串'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'template',
|
||||
'type': 'string',
|
||||
'description': '模板字符串',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'result',
|
||||
'type': 'string',
|
||||
'description': '渲染结果',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行模板渲染"""
|
||||
try:
|
||||
template = self.config.get('template', '')
|
||||
output_variable = self.config.get('output_variable', 'template_result')
|
||||
|
||||
# 渲染模板
|
||||
result = context.resolve_template(template)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=result,
|
||||
output_variables={output_variable: result},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'模板节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'template': {
|
||||
'type': 'string',
|
||||
'title': '模板',
|
||||
'description': '支持变量引用,如 {{variable_name}}',
|
||||
'format': 'textarea',
|
||||
},
|
||||
'output_variable': {
|
||||
'type': 'string',
|
||||
'title': '输出变量名',
|
||||
'default': 'template_result',
|
||||
},
|
||||
},
|
||||
'required': ['template'],
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
"""
|
||||
Text-to-SQL 节点
|
||||
|
||||
将自然语言转换为 SQL 查询并执行,支持流式输出和图表推荐
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple
|
||||
|
||||
import sqlparse
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextToSqlStreamEvent:
|
||||
"""Text-to-SQL 流式输出事件"""
|
||||
event_type: str = '' # thought, sql_chunk, sql_complete, executing, result
|
||||
content: str = ''
|
||||
is_finished: bool = False
|
||||
data: Any = None
|
||||
|
||||
|
||||
# Text-to-SQL Function Calling 工具定义
|
||||
TEXT_TO_SQL_TOOL = {
|
||||
'name': 'generate_sql',
|
||||
'description': '根据用户的自然语言问题生成 SQL 查询语句',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'thought': {
|
||||
'type': 'string',
|
||||
'description': '分析思路,简要描述如何理解用户问题并设计 SQL',
|
||||
},
|
||||
'sql': {
|
||||
'type': 'string',
|
||||
'description': '生成的 SQL 查询语句,必须是有效的 SELECT 语句,必须格式化,如果表结构中包含 schema 信息,SQL 中的表名必须使用完整格式:schema.table_name',
|
||||
},
|
||||
},
|
||||
'required': ['thought', 'sql'],
|
||||
},
|
||||
}
|
||||
|
||||
# Text-to-SQL Function Calling 版 System Prompt(简化版)
|
||||
TEXT_TO_SQL_SYSTEM_PROMPT_FC = """你是一个专业的数据分析助手,擅长将自然语言转换为 SQL 查询。
|
||||
|
||||
## 数据库信息
|
||||
数据库类型: {db_type}
|
||||
当前日期: {current_date}
|
||||
|
||||
## 可用的表结构
|
||||
{schema_context}
|
||||
|
||||
## 任务要求
|
||||
1. 根据用户问题生成准确的 SQL 查询
|
||||
2. 只生成 SELECT 语句,禁止生成 INSERT/UPDATE/DELETE/DROP 等修改语句
|
||||
3. 使用表的注释理解业务含义
|
||||
4. 添加必要的 WHERE 条件和 ORDER BY
|
||||
5. 如果表结构中包含 schema 信息,SQL 中的表名必须使用完整格式:schema.table_name
|
||||
|
||||
## 注意事项
|
||||
- SQL 必须是有效的 {db_type} 语法
|
||||
- 避免使用 SELECT *,明确指定需要的字段
|
||||
- 对于大数据量查询,添加 LIMIT 限制
|
||||
- 使用 DISTINCT 时,ORDER BY 的列必须出现在 SELECT 列表中
|
||||
|
||||
请使用 generate_sql 函数返回结果。
|
||||
"""
|
||||
|
||||
# Text-to-SQL 专用 System Prompt(原有版本,作为 fallback)
|
||||
TEXT_TO_SQL_SYSTEM_PROMPT = """你是一个专业的数据分析助手,擅长将自然语言转换为 SQL 查询。
|
||||
|
||||
## 数据库信息
|
||||
数据库类型: {db_type}
|
||||
当前日期: {current_date}
|
||||
|
||||
## 可用的表结构
|
||||
{schema_context}
|
||||
|
||||
## 任务要求
|
||||
1. 根据用户问题生成准确的 SQL 查询
|
||||
2. 只生成 SELECT 语句,禁止生成 INSERT/UPDATE/DELETE/DROP 等修改语句
|
||||
3. 使用表的注释理解业务含义
|
||||
4. 添加必要的 WHERE 条件和 ORDER BY
|
||||
5. 如果表结构中包含 schema 信息,SQL 中的表名必须使用完整格式:schema.table_name
|
||||
6. 如果需要,推荐最合适的图表类型
|
||||
|
||||
## 输出格式
|
||||
严格按照以下 JSON 格式输出,不要输出其他内容:
|
||||
{{
|
||||
"thought": "你的分析思路(简洁描述)",
|
||||
"sql": "生成的 SQL 语句",
|
||||
"chart_type": "bar|line|pie|scatter|radar|table|none",
|
||||
"chart_config": {{
|
||||
"x_field": "X轴字段名(用于 bar/line/scatter)",
|
||||
"y_field": "Y轴字段名(用于 scatter)",
|
||||
"series_fields": ["系列字段1", "系列字段2"],
|
||||
"name_field": "名称字段(用于 pie)",
|
||||
"value_field": "数值字段(用于 pie/gauge)",
|
||||
"title": "图表标题"
|
||||
}}
|
||||
}}
|
||||
|
||||
## 图表类型选择指南
|
||||
- bar(柱状图): 分类对比,如各部门销售额
|
||||
- line(折线图): 时间趋势,如每日销售额变化
|
||||
- pie(饼图): 占比分析,如各类别占比
|
||||
- scatter(散点图): 分布分析,如价格与销量关系
|
||||
- radar(雷达图): 多维对比,如产品多指标评分
|
||||
- table(表格): 详细数据展示
|
||||
- none(无图表): 不适合可视化的数据
|
||||
|
||||
## 注意事项
|
||||
- SQL 必须是有效的 {db_type} 语法
|
||||
- 避免使用 SELECT *,明确指定需要的字段
|
||||
- 对于大数据量查询,添加 LIMIT 限制
|
||||
- 时间字段使用标准格式
|
||||
- 使用 DISTINCT 时,ORDER BY 的列必须出现在 SELECT 列表中
|
||||
- 如需去重并排序,考虑使用子查询或窗口函数
|
||||
"""
|
||||
|
||||
|
||||
def serialize_db_value(value: Any) -> Any:
|
||||
"""将数据库值转换为可 JSON 序列化的格式"""
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, bytes):
|
||||
return value.decode('utf-8', errors='replace')
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [serialize_db_value(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: serialize_db_value(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class TextToSqlNode(BaseNode):
|
||||
"""
|
||||
Text-to-SQL 节点
|
||||
|
||||
将自然语言转换为 SQL 查询并执行
|
||||
"""
|
||||
|
||||
node_type = 'text_to_sql'
|
||||
node_name = 'Text-to-SQL'
|
||||
node_category = 'data'
|
||||
node_icon = 'database-zap'
|
||||
node_description = '将自然语言转换为 SQL 查询并执行,支持图表推荐'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'user_question',
|
||||
'type': 'string',
|
||||
'description': '用户的自然语言问题',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'text_to_sql_result_sql',
|
||||
'type': 'string',
|
||||
'description': '生成的 SQL 语句',
|
||||
},
|
||||
{
|
||||
'name': 'text_to_sql_result_thought',
|
||||
'type': 'string',
|
||||
'description': 'SQL 生成思路',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行 Text-to-SQL(同步方法)"""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, self.execute_async(context))
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(self.execute_async(context))
|
||||
|
||||
async def execute_async(self, context: NodeContext) -> NodeResult:
|
||||
"""异步执行 Text-to-SQL"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 获取配置
|
||||
user_question = self.config.get('user_question', '')
|
||||
db_config = self.config.get('db_config') or {}
|
||||
db_connection = db_config.get('dbName', 'default')
|
||||
schema_name = db_config.get('schema', 'public')
|
||||
model_id = self.config.get('model_id', '')
|
||||
selected_tables = self.config.get('selected_tables', [])
|
||||
table_relations = self.config.get('table_relations', []) # 手动指定的表关系
|
||||
output_variable = self.config.get('output_variable', 'text_to_sql_result')
|
||||
include_relations = self.config.get('include_table_relations', True)
|
||||
|
||||
# 解析变量
|
||||
if user_question:
|
||||
user_question = context.resolve_template(user_question)
|
||||
|
||||
if not user_question:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请输入要查询的问题',
|
||||
)
|
||||
|
||||
if not model_id:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请选择 LLM 模型',
|
||||
)
|
||||
|
||||
# Step 1: 获取数据库 Schema
|
||||
|
||||
schema_context = await self._get_schema_context(
|
||||
db_connection,
|
||||
schema_name,
|
||||
selected_tables,
|
||||
include_relations,
|
||||
table_relations
|
||||
)
|
||||
if not schema_context:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'无法获取数据库 {db_connection} 的表结构信息',
|
||||
)
|
||||
|
||||
# Step 2: 调用 LLM 生成 SQL
|
||||
from datetime import datetime
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
|
||||
db_type = await self._get_db_type(db_connection)
|
||||
llm_service = LLMService(context.db_session)
|
||||
llm_result = None
|
||||
use_function_calling = self.config.get('use_function_calling', True)
|
||||
|
||||
# 优先尝试 Function Calling
|
||||
if use_function_calling:
|
||||
try:
|
||||
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT_FC.format(
|
||||
db_type=db_type,
|
||||
current_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
schema_context=schema_context,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': f'用户问题: {user_question}'},
|
||||
]
|
||||
|
||||
response = await llm_service.chat_async(
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=2048,
|
||||
tools=[TEXT_TO_SQL_TOOL],
|
||||
tool_choice='required',
|
||||
)
|
||||
|
||||
# 解析 Function Calling 响应
|
||||
if response.tool_calls and len(response.tool_calls) > 0:
|
||||
tool_call = response.tool_calls[0]
|
||||
if tool_call.name == 'generate_sql':
|
||||
import json
|
||||
arguments = tool_call.arguments
|
||||
if isinstance(arguments, str):
|
||||
llm_result = json.loads(arguments)
|
||||
else:
|
||||
llm_result = arguments
|
||||
logger.info(f'Function Calling 成功生成 SQL')
|
||||
except Exception as fc_error:
|
||||
logger.warning(f'Function Calling 失败,回退到 JSON 解析模式: {fc_error}')
|
||||
llm_result = None
|
||||
|
||||
# Fallback: 使用 JSON 解析方式
|
||||
if not llm_result:
|
||||
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT.format(
|
||||
db_type=db_type,
|
||||
current_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
schema_context=schema_context,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': f'用户问题: {user_question}'},
|
||||
]
|
||||
|
||||
response = await llm_service.chat_async(
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
# 解析 LLM 响应
|
||||
llm_result = self._parse_llm_response(response.content)
|
||||
if not llm_result:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='LLM 响应解析失败,请重试',
|
||||
metadata={'raw_response': response.content},
|
||||
)
|
||||
|
||||
sql = llm_result.get('sql', '')
|
||||
thought = llm_result.get('thought', '')
|
||||
|
||||
# Step 3: 验证 SQL 安全性
|
||||
if not self._validate_sql(sql):
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='生成的 SQL 不安全或不是 SELECT 语句',
|
||||
metadata={'sql': sql},
|
||||
)
|
||||
|
||||
# Step 4: 格式化 SQL
|
||||
sql = self._format_sql(sql)
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 构建输出
|
||||
result_data = {
|
||||
'sql': sql,
|
||||
'thought': thought,
|
||||
}
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=result_data,
|
||||
output_variables={
|
||||
f'{output_variable}_sql': sql,
|
||||
f'{output_variable}_thought': thought,
|
||||
},
|
||||
tokens_used=response.total_tokens,
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'model': response.model,
|
||||
'thought': thought,
|
||||
'suggested_next_node': 'db_sql',
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'Text-to-SQL 节点执行失败: {e}')
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=elapsed_time,
|
||||
)
|
||||
|
||||
def execute_stream(self, context: NodeContext) -> Generator[TextToSqlStreamEvent, None, NodeResult]:
|
||||
"""
|
||||
流式执行 Text-to-SQL
|
||||
|
||||
Yields:
|
||||
TextToSqlStreamEvent: 流式输出事件
|
||||
|
||||
Returns:
|
||||
NodeResult: 最终执行结果
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 获取配置
|
||||
user_question = self.config.get('user_question', '')
|
||||
db_config = self.config.get('db_config') or {}
|
||||
db_connection = db_config.get('dbName', 'default')
|
||||
schema_name = db_config.get('schema', 'public')
|
||||
model_id = self.config.get('model_id', '')
|
||||
selected_tables = self.config.get('selected_tables', [])
|
||||
output_variable = self.config.get('output_variable', 'text_to_sql_result')
|
||||
include_relations = self.config.get('include_table_relations', True)
|
||||
|
||||
# 解析变量
|
||||
if user_question:
|
||||
user_question = context.resolve_template(user_question)
|
||||
|
||||
if not user_question:
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content='请输入要查询的问题',
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(success=False, error='请输入要查询的问题')
|
||||
|
||||
if not model_id:
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content='请选择 LLM 模型',
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(success=False, error='请选择 LLM 模型')
|
||||
|
||||
# Step 1: 获取数据库 Schema
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='thought',
|
||||
content='正在获取数据库表结构...',
|
||||
)
|
||||
|
||||
table_relations = self.config.get('table_relations', []) # 手动指定的表关系
|
||||
|
||||
import asyncio
|
||||
schema_context = asyncio.get_event_loop().run_until_complete(
|
||||
self._get_schema_context(
|
||||
db_connection,
|
||||
schema_name,
|
||||
selected_tables,
|
||||
include_relations,
|
||||
table_relations
|
||||
)
|
||||
)
|
||||
|
||||
if not schema_context:
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content=f'无法获取数据库 {db_connection} 的表结构信息',
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=f'无法获取数据库 {db_connection} 的表结构信息',
|
||||
)
|
||||
|
||||
# Step 2: 调用 LLM 生成 SQL(流式)
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='thought',
|
||||
content='正在分析问题并生成 SQL...',
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
|
||||
db_type = asyncio.get_event_loop().run_until_complete(
|
||||
self._get_db_type(db_connection)
|
||||
)
|
||||
|
||||
llm_service = LLMService()
|
||||
accumulated_content = ''
|
||||
total_tokens = 0
|
||||
llm_result = None
|
||||
use_function_calling = self.config.get('use_function_calling', True)
|
||||
|
||||
# 优先尝试 Function Calling
|
||||
if use_function_calling:
|
||||
try:
|
||||
llm_result, total_tokens = yield from self._call_llm_with_function_calling(
|
||||
llm_service, model_id, db_type, schema_context, user_question
|
||||
)
|
||||
logger.info(f'Function Calling 成功: {llm_result}')
|
||||
except Exception as fc_error:
|
||||
logger.warning(f'Function Calling 失败,回退到 JSON 解析模式: {fc_error}')
|
||||
llm_result = None
|
||||
|
||||
# Fallback: 使用原有的 JSON 解析方式
|
||||
if not llm_result:
|
||||
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT.format(
|
||||
db_type=db_type,
|
||||
current_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
schema_context=schema_context,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': f'用户问题: {user_question}'},
|
||||
]
|
||||
|
||||
for chunk in llm_service.chat_stream_sync(
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=2048,
|
||||
):
|
||||
accumulated_content += chunk.content
|
||||
|
||||
if chunk.is_finished and chunk.total_tokens > 0:
|
||||
total_tokens = chunk.total_tokens
|
||||
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='sql_chunk',
|
||||
content=chunk.content,
|
||||
is_finished=chunk.is_finished,
|
||||
)
|
||||
|
||||
# 解析 LLM 响应
|
||||
llm_result = self._parse_llm_response(accumulated_content)
|
||||
if not llm_result:
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content='LLM 响应解析失败',
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='LLM 响应解析失败',
|
||||
metadata={'raw_response': accumulated_content},
|
||||
)
|
||||
|
||||
sql = llm_result.get('sql', '')
|
||||
thought = llm_result.get('thought', '')
|
||||
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='sql_complete',
|
||||
content=sql,
|
||||
data={'thought': thought},
|
||||
)
|
||||
|
||||
# Step 3: 验证 SQL 安全性
|
||||
if not self._validate_sql(sql):
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content='生成的 SQL 不安全或不是 SELECT 语句',
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='生成的 SQL 不安全或不是 SELECT 语句',
|
||||
metadata={'sql': sql},
|
||||
)
|
||||
|
||||
# Step 4: 格式化 SQL
|
||||
sql = self._format_sql(sql)
|
||||
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 构建输出
|
||||
result_data = {
|
||||
'sql': sql,
|
||||
'thought': thought,
|
||||
}
|
||||
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='result',
|
||||
content='SQL 生成完成',
|
||||
is_finished=True,
|
||||
data=result_data,
|
||||
)
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=result_data,
|
||||
output_variables={
|
||||
f'{output_variable}_sql': sql,
|
||||
f'{output_variable}_thought': thought,
|
||||
},
|
||||
tokens_used=total_tokens,
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'thought': thought,
|
||||
'suggested_next_node': 'db_sql',
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'Text-to-SQL 流式执行失败: {e}')
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content=str(e),
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
elapsed_time=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
async def _get_schema_context(
|
||||
self,
|
||||
db_connection: str,
|
||||
schema_name: str = 'public',
|
||||
selected_tables: List[str] = None,
|
||||
include_relations: bool = True,
|
||||
manual_relations: List[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""获取数据库 Schema 上下文"""
|
||||
try:
|
||||
from core.database_manager.service import AsyncDatabaseManagerService
|
||||
|
||||
# 创建数据库服务实例
|
||||
db_service = await AsyncDatabaseManagerService.create(db_connection)
|
||||
schema_name = self._resolve_schema_name(db_service, schema_name)
|
||||
|
||||
# 获取表列表
|
||||
if selected_tables and len(selected_tables) > 0:
|
||||
tables = selected_tables
|
||||
else:
|
||||
# 获取所有表
|
||||
tables_info = await db_service.get_tables(schema_name=schema_name)
|
||||
tables = [
|
||||
t.get('table_name') or t.get('name')
|
||||
for t in tables_info[:20]
|
||||
if t.get('table_name') or t.get('name')
|
||||
] # 限制最多 20 个表
|
||||
|
||||
schema_parts = []
|
||||
table_relations = []
|
||||
|
||||
for table_name in tables:
|
||||
try:
|
||||
columns = await db_service.get_table_columns(table_name, schema_name)
|
||||
|
||||
col_desc = []
|
||||
for col in columns:
|
||||
col_name = col.get('column_name') or col.get('name', '')
|
||||
col_type = col.get('data_type') or col.get('type', '')
|
||||
col_str = f" - {col_name} ({col_type})"
|
||||
if col.get('description') or col.get('comment'):
|
||||
col_str += f" -- {col.get('description') or col.get('comment')}"
|
||||
if col.get('is_primary_key'):
|
||||
col_str += " [PK]"
|
||||
if col.get('is_foreign_key'):
|
||||
col_str += " [FK]"
|
||||
col_desc.append(col_str)
|
||||
|
||||
full_table_name = self._format_schema_table_name(
|
||||
db_service, schema_name, table_name
|
||||
)
|
||||
table_schema = f"表: {full_table_name}\n" + "\n".join(col_desc)
|
||||
schema_parts.append(table_schema)
|
||||
|
||||
# 获取外键关系(从数据库)
|
||||
if include_relations:
|
||||
try:
|
||||
constraints = await db_service.get_table_constraints(table_name, schema_name)
|
||||
for constraint in constraints:
|
||||
constraint_type = (
|
||||
constraint.get('constraint_type')
|
||||
or constraint.get('type', '')
|
||||
)
|
||||
if constraint_type != 'FOREIGN KEY':
|
||||
continue
|
||||
source_full_name = self._format_schema_table_name(
|
||||
db_service, schema_name, table_name
|
||||
)
|
||||
foreign_table = (
|
||||
constraint.get('referenced_table')
|
||||
or constraint.get('foreign_table')
|
||||
or ''
|
||||
)
|
||||
target_full_name = self._format_schema_table_name(
|
||||
db_service, schema_name, foreign_table
|
||||
)
|
||||
source_columns = (
|
||||
constraint.get('columns')
|
||||
or constraint.get('column')
|
||||
or ''
|
||||
)
|
||||
foreign_columns = (
|
||||
constraint.get('referenced_columns')
|
||||
or constraint.get('foreign_column')
|
||||
or ''
|
||||
)
|
||||
relation = (
|
||||
f" {source_full_name}.{source_columns} → "
|
||||
f"{target_full_name}.{foreign_columns}"
|
||||
)
|
||||
table_relations.append(relation)
|
||||
except Exception as e:
|
||||
logger.debug(f'获取表 {table_name} 外键关系失败: {e}')
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'获取表 {table_name} 结构失败: {e}')
|
||||
|
||||
# 添加手动指定的表关系(逻辑外键)
|
||||
if manual_relations:
|
||||
for rel in manual_relations:
|
||||
source_table = rel.get('sourceTable', '')
|
||||
source_field = rel.get('sourceField', '')
|
||||
target_table = rel.get('targetTable', '')
|
||||
target_field = rel.get('targetField', '')
|
||||
if source_table and source_field and target_table and target_field:
|
||||
relation = f" {source_table}.{source_field} → {target_table}.{target_field} (逻辑外键)"
|
||||
table_relations.append(relation)
|
||||
|
||||
# 组装最终的 Schema 上下文
|
||||
result = "\n\n".join(schema_parts)
|
||||
|
||||
if table_relations:
|
||||
result += "\n\n## 表关系(外键)\n" + "\n".join(table_relations)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'获取 Schema 上下文失败: {e}')
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def _resolve_schema_name(db_service, schema_name: str) -> str:
|
||||
"""按连接类型解析有效 schema,避免非 PG 库误用 public。"""
|
||||
if db_service._uses_schema_layer():
|
||||
if not schema_name or (
|
||||
schema_name == 'public' and db_service.db_type != 'postgresql'
|
||||
):
|
||||
return db_service._default_schema()
|
||||
return schema_name
|
||||
return schema_name or ''
|
||||
|
||||
@staticmethod
|
||||
def _format_schema_table_name(db_service, schema_name: str, table_name: str) -> str:
|
||||
if not table_name:
|
||||
return table_name
|
||||
if db_service._uses_schema_layer() and schema_name:
|
||||
return f"{schema_name}.{table_name}"
|
||||
return table_name
|
||||
|
||||
async def _get_db_type(self, db_connection: str) -> str:
|
||||
"""获取数据库类型"""
|
||||
try:
|
||||
from core.database_manager.service import AsyncDatabaseManagerService
|
||||
db_service = await AsyncDatabaseManagerService.create(db_connection)
|
||||
return db_service.db_type.upper()
|
||||
except Exception:
|
||||
return 'PostgreSQL'
|
||||
|
||||
def _call_llm_with_function_calling(
|
||||
self,
|
||||
llm_service,
|
||||
model_id: str,
|
||||
db_type: str,
|
||||
schema_context: str,
|
||||
user_question: str
|
||||
) -> Generator[TextToSqlStreamEvent, None, Tuple[Dict[str, Any], int]]:
|
||||
"""
|
||||
使用 Function Calling 调用 LLM 生成 SQL
|
||||
|
||||
Args:
|
||||
llm_service: LLM 服务实例
|
||||
model_id: 模型 ID
|
||||
db_type: 数据库类型
|
||||
schema_context: Schema 上下文
|
||||
user_question: 用户问题
|
||||
|
||||
Yields:
|
||||
TextToSqlStreamEvent: 流式输出事件
|
||||
|
||||
Returns:
|
||||
tuple: (llm_result, total_tokens)
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
system_prompt = TEXT_TO_SQL_SYSTEM_PROMPT_FC.format(
|
||||
db_type=db_type,
|
||||
current_date=datetime.now().strftime('%Y-%m-%d'),
|
||||
schema_context=schema_context,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': f'用户问题: {user_question}'},
|
||||
]
|
||||
|
||||
# 定义工具
|
||||
tools = [TEXT_TO_SQL_TOOL]
|
||||
|
||||
# 累积工具调用参数
|
||||
accumulated_arguments = ''
|
||||
total_tokens = 0
|
||||
tool_call_received = False
|
||||
|
||||
for chunk in llm_service.chat_stream_sync(
|
||||
model_id=model_id,
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
max_tokens=2048,
|
||||
tools=tools,
|
||||
tool_choice='required', # 强制使用工具
|
||||
):
|
||||
# 处理工具调用增量
|
||||
if chunk.tool_call_delta:
|
||||
delta_args = chunk.tool_call_delta.get('arguments', '')
|
||||
if delta_args:
|
||||
accumulated_arguments += delta_args
|
||||
# 流式输出工具调用参数
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='sql_chunk',
|
||||
content=delta_args,
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
# 处理完整的工具调用
|
||||
if chunk.tool_calls:
|
||||
tool_call_received = True
|
||||
for tool_call in chunk.tool_calls:
|
||||
if tool_call.name == 'generate_sql':
|
||||
# 解析工具调用参数
|
||||
arguments = tool_call.arguments
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(arguments)
|
||||
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='sql_chunk',
|
||||
content='',
|
||||
is_finished=True,
|
||||
)
|
||||
|
||||
return (arguments, total_tokens)
|
||||
|
||||
# 记录 token 使用
|
||||
if chunk.is_finished and chunk.total_tokens > 0:
|
||||
total_tokens = chunk.total_tokens
|
||||
|
||||
# 如果没有收到工具调用,尝试从累积的参数中解析
|
||||
if accumulated_arguments and not tool_call_received:
|
||||
try:
|
||||
arguments = json.loads(accumulated_arguments)
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='sql_chunk',
|
||||
content='',
|
||||
is_finished=True,
|
||||
)
|
||||
return (arguments, total_tokens)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Function Calling 失败
|
||||
raise ValueError('Function Calling 未返回有效的工具调用')
|
||||
|
||||
def _parse_llm_response(self, content: str) -> Optional[Dict[str, Any]]:
|
||||
"""解析 LLM 响应(Fallback 方式)"""
|
||||
try:
|
||||
# 尝试直接解析 JSON
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试从 markdown 代码块中提取 JSON
|
||||
import re
|
||||
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', content)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试提取 {...} 部分
|
||||
brace_match = re.search(r'\{[\s\S]*\}', content)
|
||||
if brace_match:
|
||||
try:
|
||||
return json.loads(brace_match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning(f'无法解析 LLM 响应: {content[:500]}')
|
||||
return None
|
||||
|
||||
def _validate_sql(self, sql: str) -> bool:
|
||||
"""验证 SQL 安全性"""
|
||||
if not sql:
|
||||
logger.warning("SQL 验证失败: SQL 为空")
|
||||
return False
|
||||
|
||||
# 去除前后空白和可能的 Markdown 代码块标记
|
||||
sql_cleaned = sql.strip()
|
||||
if sql_cleaned.startswith('```'):
|
||||
# 移除 Markdown 代码块
|
||||
lines = sql_cleaned.split('\n')
|
||||
# 移除第一行(```sql 或 ```)
|
||||
if lines:
|
||||
lines = lines[1:]
|
||||
# 移除最后一行(```)
|
||||
if lines and lines[-1].strip() == '```':
|
||||
lines = lines[:-1]
|
||||
sql_cleaned = '\n'.join(lines).strip()
|
||||
|
||||
sql_upper = sql_cleaned.upper().strip()
|
||||
|
||||
# 只允许 SELECT 语句(也允许 WITH ... SELECT 即 CTE)
|
||||
if not sql_upper.startswith('SELECT') and not sql_upper.startswith('WITH'):
|
||||
logger.warning(f"SQL 验证失败: 不是 SELECT/WITH 语句, SQL 开头: {sql_upper[:50]}")
|
||||
return False
|
||||
|
||||
# 禁止危险关键字(作为独立语句,不在子查询或 CTE 中)
|
||||
dangerous_keywords = [
|
||||
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE',
|
||||
'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE',
|
||||
]
|
||||
|
||||
for keyword in dangerous_keywords:
|
||||
# 检查是否作为独立关键字出现(前后有空格或在开头/结尾)
|
||||
if re.search(rf'\b{keyword}\b', sql_upper):
|
||||
logger.warning(f"SQL 验证失败: 包含危险关键字 {keyword}, SQL: {sql_cleaned[:200]}")
|
||||
return False
|
||||
|
||||
# 禁止多语句(分号后还有内容)
|
||||
if ';' in sql_cleaned:
|
||||
parts = sql_cleaned.split(';')
|
||||
if any(p.strip() for p in parts[1:]):
|
||||
logger.warning(f"SQL 验证失败: 包含多条语句")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _format_sql(self, sql: str) -> str:
|
||||
"""
|
||||
格式化 SQL 语句
|
||||
|
||||
Args:
|
||||
sql: 原始 SQL 语句
|
||||
|
||||
Returns:
|
||||
格式化后的 SQL 语句
|
||||
"""
|
||||
try:
|
||||
# 去除可能的 Markdown 代码块标记
|
||||
sql_cleaned = sql.strip()
|
||||
if sql_cleaned.startswith('```'):
|
||||
lines = sql_cleaned.split('\n')
|
||||
if lines:
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == '```':
|
||||
lines = lines[:-1]
|
||||
sql_cleaned = '\n'.join(lines).strip()
|
||||
|
||||
# 使用 sqlparse 格式化
|
||||
formatted_sql = sqlparse.format(
|
||||
sql_cleaned,
|
||||
reindent=True, # 重新缩进
|
||||
keyword_case='upper', # 关键字大写
|
||||
identifier_case='lower', # 标识符小写
|
||||
strip_comments=False, # 保留注释
|
||||
use_space_around_operators=True, # 操作符周围加空格
|
||||
)
|
||||
|
||||
return formatted_sql.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"SQL 格式化失败: {e}, 返回原始 SQL")
|
||||
return sql
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
变量赋值节点
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..base import BaseNode, NodeContext, NodeResult
|
||||
from ..registry import NodeRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@NodeRegistry.register
|
||||
class VariableNode(BaseNode):
|
||||
"""
|
||||
变量赋值节点
|
||||
|
||||
设置或修改变量的值
|
||||
"""
|
||||
|
||||
node_type = 'variable'
|
||||
node_name = '变量'
|
||||
node_category = 'data'
|
||||
node_icon = 'variable'
|
||||
node_description = '设置或修改变量的值'
|
||||
|
||||
inputs = [
|
||||
{
|
||||
'name': 'value',
|
||||
'type': 'any',
|
||||
'description': '要设置的值',
|
||||
},
|
||||
]
|
||||
|
||||
outputs = [
|
||||
{
|
||||
'name': 'value',
|
||||
'type': 'any',
|
||||
'description': '设置后的值',
|
||||
},
|
||||
]
|
||||
|
||||
def execute(self, context: NodeContext) -> NodeResult:
|
||||
"""执行变量赋值"""
|
||||
try:
|
||||
assignments = self.config.get('assignments', [])
|
||||
output_variables = {}
|
||||
|
||||
for assignment in assignments:
|
||||
var_name = assignment.get('name', '')
|
||||
value_type = assignment.get('type', 'static')
|
||||
value = assignment.get('value', '')
|
||||
|
||||
if not var_name:
|
||||
continue
|
||||
|
||||
# 根据类型处理值
|
||||
if value_type == 'static':
|
||||
# 静态值
|
||||
final_value = value
|
||||
elif value_type == 'variable':
|
||||
# 从其他变量获取
|
||||
final_value = context.get_variable(value, '')
|
||||
elif value_type == 'template':
|
||||
# 模板渲染
|
||||
final_value = context.resolve_template(value)
|
||||
elif value_type == 'json':
|
||||
# JSON 解析
|
||||
import json
|
||||
final_value = json.loads(value)
|
||||
elif value_type == 'expression':
|
||||
# 简单表达式(仅支持基本运算)
|
||||
final_value = self._evaluate_expression(value, context)
|
||||
else:
|
||||
final_value = value
|
||||
|
||||
# 设置变量
|
||||
context.set_variable(var_name, final_value)
|
||||
output_variables[var_name] = final_value
|
||||
|
||||
return NodeResult(
|
||||
success=True,
|
||||
output=output_variables,
|
||||
output_variables=output_variables,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'变量节点执行失败: {e}')
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _evaluate_expression(self, expression: str, context: NodeContext) -> Any:
|
||||
"""
|
||||
评估简单表达式
|
||||
|
||||
仅支持基本的数学运算和字符串操作
|
||||
"""
|
||||
# 替换变量引用
|
||||
resolved = context.resolve_template(expression)
|
||||
|
||||
# 安全的评估环境
|
||||
safe_dict = {
|
||||
'abs': abs,
|
||||
'int': int,
|
||||
'float': float,
|
||||
'str': str,
|
||||
'len': len,
|
||||
'min': min,
|
||||
'max': max,
|
||||
'sum': sum,
|
||||
'round': round,
|
||||
}
|
||||
|
||||
try:
|
||||
return eval(resolved, {"__builtins__": {}}, safe_dict)
|
||||
except Exception:
|
||||
return resolved
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
"""获取配置 Schema"""
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'assignments': {
|
||||
'type': 'array',
|
||||
'title': '变量赋值',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'title': '变量名',
|
||||
},
|
||||
'type': {
|
||||
'type': 'string',
|
||||
'title': '值类型',
|
||||
'enum': ['static', 'variable', 'template', 'json', 'expression'],
|
||||
'default': 'static',
|
||||
},
|
||||
'value': {
|
||||
'type': 'string',
|
||||
'title': '值',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
节点注册中心
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
from .base import BaseNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NodeRegistry:
|
||||
"""
|
||||
节点注册中心
|
||||
|
||||
管理所有工作流节点的注册和获取
|
||||
"""
|
||||
|
||||
_nodes: Dict[str, Type[BaseNode]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, node_class: Type[BaseNode]) -> Type[BaseNode]:
|
||||
"""
|
||||
注册节点(可作为装饰器使用)
|
||||
|
||||
Args:
|
||||
node_class: 节点类
|
||||
|
||||
Returns:
|
||||
节点类
|
||||
"""
|
||||
node_type = node_class.node_type
|
||||
if not node_type:
|
||||
raise ValueError(f'Node {node_class.__name__} must have a node_type')
|
||||
|
||||
cls._nodes[node_type] = node_class
|
||||
logger.info(f'Registered AI workflow node: {node_type}')
|
||||
return node_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, node_type: str) -> Optional[Type[BaseNode]]:
|
||||
"""
|
||||
获取节点类
|
||||
|
||||
Args:
|
||||
node_type: 节点类型
|
||||
|
||||
Returns:
|
||||
节点类或 None
|
||||
"""
|
||||
return cls._nodes.get(node_type)
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, node_type: str, config: Dict = None) -> Optional[BaseNode]:
|
||||
"""
|
||||
创建节点实例
|
||||
|
||||
Args:
|
||||
node_type: 节点类型
|
||||
config: 节点配置
|
||||
|
||||
Returns:
|
||||
节点实例或 None
|
||||
"""
|
||||
node_class = cls.get(node_type)
|
||||
if not node_class:
|
||||
logger.warning(f'Unknown node type: {node_type}')
|
||||
return None
|
||||
|
||||
return node_class(config=config)
|
||||
|
||||
@classmethod
|
||||
def get_all_schemas(cls) -> List[Dict]:
|
||||
"""
|
||||
获取所有节点 Schema
|
||||
|
||||
Returns:
|
||||
节点 Schema 列表
|
||||
"""
|
||||
return [node.get_schema() for node in cls._nodes.values()]
|
||||
|
||||
@classmethod
|
||||
def get_schemas_by_category(cls) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
按分类获取节点 Schema
|
||||
|
||||
Returns:
|
||||
按分类分组的节点 Schema
|
||||
"""
|
||||
result = {}
|
||||
for node in cls._nodes.values():
|
||||
category = node.node_category
|
||||
if category not in result:
|
||||
result[category] = []
|
||||
result[category].append(node.get_schema())
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_all_types(cls) -> List[str]:
|
||||
"""
|
||||
获取所有已注册的节点类型
|
||||
|
||||
Returns:
|
||||
节点类型列表
|
||||
"""
|
||||
return list(cls._nodes.keys())
|
||||
|
||||
|
||||
# 自动加载所有内置节点
|
||||
def _load_builtin_nodes():
|
||||
"""加载所有内置节点"""
|
||||
required_modules = [
|
||||
'start_node',
|
||||
'end_node',
|
||||
'condition_node',
|
||||
'template_node',
|
||||
'parallel_node',
|
||||
'merge_node',
|
||||
]
|
||||
optional_modules = [
|
||||
'llm_node',
|
||||
'code_node',
|
||||
'http_node',
|
||||
'variable_node',
|
||||
'database_node',
|
||||
'dialog_nodes',
|
||||
'intent_node',
|
||||
'loop_node',
|
||||
'subflow_node',
|
||||
'text_to_sql_node',
|
||||
'snowflake_cortex_node',
|
||||
'knowledge_retrieval_node',
|
||||
]
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
for module_name in required_modules:
|
||||
import_module(f'{__package__}.builtin.{module_name}')
|
||||
|
||||
for module_name in optional_modules:
|
||||
try:
|
||||
import_module(f'{__package__}.builtin.{module_name}')
|
||||
except ImportError as e:
|
||||
logger.warning(
|
||||
'Skipped optional AI workflow node module %s because dependencies are unavailable: %s',
|
||||
module_name,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
# 延迟加载
|
||||
try:
|
||||
_load_builtin_nodes()
|
||||
except ImportError as e:
|
||||
logger.warning(f'Failed to load some builtin nodes: {e}')
|
||||
@@ -0,0 +1 @@
|
||||
"""AI workflow node utilities."""
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
节点配置解析工具
|
||||
|
||||
从节点配置或上下文变量中解析 object / list,避免 dict 被 stringify 后无法反序列化。
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from ai_platform.nodes.base import NodeContext
|
||||
|
||||
_VAR_REF_PATTERN = re.compile(r'^\{\{\s*([^}]+)\s*\}\}$')
|
||||
|
||||
|
||||
def _parse_structured_string(value: str) -> Any:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
try:
|
||||
return ast.literal_eval(text)
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def resolve_config_value(context: NodeContext, raw: Any) -> Any:
|
||||
"""解析配置值:支持 dict/list 直传、{{var}} 引用、JSON/Python 字面量字符串。"""
|
||||
if raw is None or raw == '':
|
||||
return None
|
||||
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
|
||||
if not isinstance(raw, str):
|
||||
return raw
|
||||
|
||||
stripped = raw.strip()
|
||||
var_match = _VAR_REF_PATTERN.match(stripped)
|
||||
if var_match:
|
||||
var_name = var_match.group(1).strip()
|
||||
if var_name in context.variables:
|
||||
return context.variables[var_name]
|
||||
|
||||
resolved = context.resolve_template(raw)
|
||||
if isinstance(resolved, (dict, list)):
|
||||
return resolved
|
||||
|
||||
if isinstance(resolved, str):
|
||||
return _parse_structured_string(resolved)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_object_config(context: NodeContext, raw: Any) -> Optional[dict]:
|
||||
value = resolve_config_value(context, raw)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def resolve_list_config(context: NodeContext, raw: Any) -> Optional[List[Any]]:
|
||||
value = resolve_config_value(context, raw)
|
||||
return value if isinstance(value, list) else None
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
工作流数据库节点:连接解析与方言 SQL 构建
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from core.database_manager.sql_utils import quote_identifier, quote_table
|
||||
|
||||
DEFAULT_CONNECTION_CODE = "default"
|
||||
DEFAULT_CONNECTION_WRITE_WARNING = "default_connection_write"
|
||||
|
||||
OPERATOR_MAP = {
|
||||
"=": "=",
|
||||
"!=": "!=",
|
||||
">": ">",
|
||||
">=": ">=",
|
||||
"<": "<",
|
||||
"<=": "<=",
|
||||
"like": "LIKE",
|
||||
"in": "IN",
|
||||
"is_null": "IS NULL",
|
||||
"is_not_null": "IS NOT NULL",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DbTarget:
|
||||
"""工作流节点数据库目标"""
|
||||
|
||||
db_name: str = DEFAULT_CONNECTION_CODE
|
||||
db_type: str = "postgresql"
|
||||
database: str = ""
|
||||
schema: str = ""
|
||||
|
||||
@property
|
||||
def is_external(self) -> bool:
|
||||
return is_external_connection(self.db_name)
|
||||
|
||||
|
||||
def is_external_connection(db_name: Optional[str]) -> bool:
|
||||
code = (db_name or DEFAULT_CONNECTION_CODE).strip() or DEFAULT_CONNECTION_CODE
|
||||
return code != DEFAULT_CONNECTION_CODE
|
||||
|
||||
|
||||
def resolve_db_target(config: Optional[Dict[str, Any]]) -> DbTarget:
|
||||
"""从节点 db_config 解析连接目标"""
|
||||
db_config = config or {}
|
||||
db_name = (db_config.get("dbName") or DEFAULT_CONNECTION_CODE).strip() or DEFAULT_CONNECTION_CODE
|
||||
db_type = (db_config.get("dbType") or "postgresql").lower()
|
||||
database = (db_config.get("database") or "").strip()
|
||||
schema = (db_config.get("schema") or "").strip()
|
||||
return DbTarget(
|
||||
db_name=db_name,
|
||||
db_type=db_type,
|
||||
database=database,
|
||||
schema=schema,
|
||||
)
|
||||
|
||||
|
||||
def default_connection_write_warnings(operation: str, target: DbTarget) -> List[str]:
|
||||
"""default 连接写操作返回运行时警告码"""
|
||||
if target.is_external:
|
||||
return []
|
||||
write_ops = {"insert", "update", "delete", "upsert", "execute"}
|
||||
if operation.lower() in write_ops:
|
||||
return [DEFAULT_CONNECTION_WRITE_WARNING]
|
||||
return []
|
||||
|
||||
|
||||
def resolve_schema_for_handler(db_type: str, schema: str, default_schema: str = "") -> str:
|
||||
"""解析 handler 使用的 schema/database 参数"""
|
||||
db = (db_type or "postgresql").lower()
|
||||
if db == "mysql":
|
||||
return schema or default_schema or ""
|
||||
if not schema or (schema == "public" and db != "postgresql"):
|
||||
return default_schema or schema or ""
|
||||
return schema
|
||||
|
||||
|
||||
async def resolve_handler_schema_name(db_service, target: DbTarget) -> str:
|
||||
"""结合 AsyncDatabaseManagerService 解析 schema"""
|
||||
db_type = (db_service.db_type or "postgresql").lower()
|
||||
if db_type == "mysql":
|
||||
default_schema = db_service._default_schema() if hasattr(db_service, "_default_schema") else ""
|
||||
return target.database or target.schema or default_schema or ""
|
||||
default_schema = ""
|
||||
if hasattr(db_service, "_default_schema"):
|
||||
default_schema = db_service._default_schema() or ""
|
||||
return resolve_schema_for_handler(db_type, target.schema, default_schema)
|
||||
|
||||
|
||||
def format_sql_literal(value: Any, db_type: str) -> str:
|
||||
"""将 Python 值格式化为 SQL 字面量(用于 handler raw WHERE)"""
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
if (db_type or "").lower() == "postgresql":
|
||||
return "TRUE" if value else "FALSE"
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return f"'{value.isoformat()}'"
|
||||
if isinstance(value, UUID):
|
||||
return f"'{value}'"
|
||||
if isinstance(value, (list, tuple)):
|
||||
inner = ", ".join(format_sql_literal(v, db_type) for v in value)
|
||||
return f"({inner})"
|
||||
if isinstance(value, (dict, list)):
|
||||
encoded = json.dumps(value, ensure_ascii=False)
|
||||
escaped = encoded.replace("'", "''")
|
||||
return f"'{escaped}'"
|
||||
escaped = str(value).replace("'", "''")
|
||||
return f"'{escaped}'"
|
||||
|
||||
|
||||
def build_where_clause_raw(
|
||||
conditions: List[Dict[str, Any]],
|
||||
db_type: str,
|
||||
) -> str:
|
||||
"""
|
||||
构建 raw WHERE 子句(不含 WHERE 关键字),供 database_manager handler 使用。
|
||||
"""
|
||||
if not conditions:
|
||||
return ""
|
||||
|
||||
clauses: List[str] = []
|
||||
for condition in conditions:
|
||||
field = condition.get("field", "")
|
||||
op = (condition.get("operator") or "=").lower()
|
||||
value = condition.get("value")
|
||||
sql_op = OPERATOR_MAP.get(op, "=")
|
||||
quoted_field = quote_identifier(field, db_type)
|
||||
|
||||
if op in ("is_null", "is_not_null"):
|
||||
clauses.append(f"{quoted_field} {sql_op}")
|
||||
elif op == "in":
|
||||
if isinstance(value, list):
|
||||
literals = ", ".join(format_sql_literal(v, db_type) for v in value)
|
||||
clauses.append(f"{quoted_field} IN ({literals})")
|
||||
else:
|
||||
clauses.append(f"{quoted_field} IN ({format_sql_literal(value, db_type)})")
|
||||
elif op == "like":
|
||||
clauses.append(f"{quoted_field} LIKE {format_sql_literal(value, db_type)}")
|
||||
else:
|
||||
clauses.append(f"{quoted_field} {sql_op} {format_sql_literal(value, db_type)}")
|
||||
|
||||
return " AND ".join(clauses)
|
||||
|
||||
|
||||
def build_where_clause_platform(
|
||||
conditions: List[Dict[str, Any]],
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建平台 PostgreSQL WHERE 子句(SQLAlchemy 命名参数)"""
|
||||
if not conditions:
|
||||
return "", {}
|
||||
|
||||
clauses: List[str] = []
|
||||
params: Dict[str, Any] = {}
|
||||
param_index = 0
|
||||
|
||||
for condition in conditions:
|
||||
field = condition["field"]
|
||||
op = condition["operator"].lower()
|
||||
value = condition["value"]
|
||||
sql_op = OPERATOR_MAP.get(op, "=")
|
||||
|
||||
if op in ("is_null", "is_not_null"):
|
||||
clauses.append(f'"{field}" {sql_op}')
|
||||
elif op == "in":
|
||||
if isinstance(value, list):
|
||||
param_placeholders = []
|
||||
for v in value:
|
||||
param_name = f"p{param_index}"
|
||||
param_placeholders.append(f":{param_name}")
|
||||
params[param_name] = v
|
||||
param_index += 1
|
||||
clauses.append(f'"{field}" IN ({", ".join(param_placeholders)})')
|
||||
else:
|
||||
param_name = f"p{param_index}"
|
||||
clauses.append(f'"{field}" {sql_op} :{param_name}')
|
||||
params[param_name] = value
|
||||
param_index += 1
|
||||
else:
|
||||
param_name = f"p{param_index}"
|
||||
clauses.append(f'"{field}" {sql_op} :{param_name}')
|
||||
params[param_name] = value
|
||||
param_index += 1
|
||||
|
||||
where_clause = " AND ".join(clauses)
|
||||
return f"WHERE {where_clause}", params
|
||||
|
||||
|
||||
def quote_table_for_target(table: str, target: DbTarget) -> str:
|
||||
"""按目标连接类型构建完整表名"""
|
||||
schema = target.schema or None
|
||||
if target.db_type == "mysql" and not schema and target.database:
|
||||
schema = target.database
|
||||
return quote_table(schema, table, target.db_type)
|
||||
|
||||
|
||||
def format_select_sql(
|
||||
table: str,
|
||||
target: DbTarget,
|
||||
return_fields: Any = None,
|
||||
conditions: Optional[List[Dict[str, Any]]] = None,
|
||||
order_by: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""按 dialect 生成 SELECT SQL"""
|
||||
normalized_fields = normalize_return_fields(return_fields)
|
||||
if normalized_fields == "*":
|
||||
field_list = "*"
|
||||
else:
|
||||
field_list = ", ".join(
|
||||
quote_identifier(f, target.db_type) for f in normalized_fields
|
||||
)
|
||||
|
||||
full_table = quote_table_for_target(table, target)
|
||||
where_raw = build_where_clause_raw(conditions or [], target.db_type)
|
||||
where_part = f" WHERE {where_raw}" if where_raw else ""
|
||||
|
||||
sql = f"SELECT {field_list} FROM {full_table}{where_part}"
|
||||
|
||||
db = (target.db_type or "postgresql").lower()
|
||||
if order_by:
|
||||
sql += f" ORDER BY {order_by}"
|
||||
elif db == "sqlserver":
|
||||
sql += " ORDER BY (SELECT NULL)"
|
||||
|
||||
if db == "sqlserver":
|
||||
sql += f" OFFSET 0 ROWS FETCH NEXT {int(limit)} ROWS ONLY"
|
||||
elif db == "oracle":
|
||||
sql += f" FETCH FIRST {int(limit)} ROWS ONLY"
|
||||
else:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
|
||||
return sql
|
||||
|
||||
|
||||
def format_limit_clause(db_type: str, limit: int = 1) -> str:
|
||||
db = (db_type or "postgresql").lower()
|
||||
if db == "sqlserver":
|
||||
return f" ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT {int(limit)} ROWS ONLY"
|
||||
if db == "oracle":
|
||||
return f" FETCH FIRST {int(limit)} ROWS ONLY"
|
||||
return f" LIMIT {int(limit)}"
|
||||
|
||||
|
||||
def normalize_return_fields(return_fields: Any) -> Any:
|
||||
"""将 return_fields 规范化为 * 或字段名列表"""
|
||||
if return_fields in (None, "", "*", ["*"]):
|
||||
return "*"
|
||||
if isinstance(return_fields, str):
|
||||
stripped = return_fields.strip()
|
||||
if not stripped or stripped == "*":
|
||||
return "*"
|
||||
return [field.strip() for field in stripped.split(",") if field.strip()]
|
||||
if isinstance(return_fields, list):
|
||||
if not return_fields or "*" in return_fields:
|
||||
return "*"
|
||||
return return_fields
|
||||
return "*"
|
||||
|
||||
|
||||
def merge_result_metadata(
|
||||
base_metadata: Optional[Dict[str, Any]],
|
||||
warnings: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
metadata = dict(base_metadata or {})
|
||||
if warnings:
|
||||
existing = list(metadata.get("warnings") or [])
|
||||
for code in warnings:
|
||||
if code not in existing:
|
||||
existing.append(code)
|
||||
metadata["warnings"] = existing
|
||||
return metadata
|
||||
|
||||
|
||||
def convert_sql_param_type(value: Any, param_type: str) -> Any:
|
||||
"""将参数值转换为指定 SQL 参数类型(与数据源 _convert_param_type 对齐)"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
if param_type == "integer":
|
||||
return int(value)
|
||||
if param_type == "float":
|
||||
return float(value)
|
||||
if param_type == "boolean":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ("true", "1", "yes")
|
||||
if param_type == "date":
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
return date.fromisoformat(str(value).strip()[:10])
|
||||
if param_type == "datetime":
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value).strip())
|
||||
return str(value)
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
|
||||
|
||||
def resolve_sql_param_value(raw: Any, context: Any) -> Any:
|
||||
"""解析单个 SQL 参数值:模板变量 + JSON 字面量"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
resolved = context.resolve_template(raw)
|
||||
if resolved == "":
|
||||
return None
|
||||
try:
|
||||
return json.loads(resolved)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return resolved
|
||||
return raw
|
||||
|
||||
|
||||
def build_sql_param_dict(
|
||||
param_defs: Optional[List[Dict[str, Any]]],
|
||||
context: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从节点 params 配置构建 SQLAlchemy 命名参数字典。
|
||||
|
||||
参数定义字段:name, type, value(或 default), required
|
||||
SQL 中使用 :name 占位符,与数据源一致。
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
for param in param_defs or []:
|
||||
name = (param.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
param_type = param.get("type") or "string"
|
||||
required = bool(param.get("required", False))
|
||||
raw = param.get("value")
|
||||
if raw is None or raw == "":
|
||||
raw = param.get("default")
|
||||
|
||||
if raw is None or raw == "":
|
||||
if required:
|
||||
raise ValueError(f"缺少必填 SQL 参数: {name}")
|
||||
result[name] = None
|
||||
continue
|
||||
|
||||
value = resolve_sql_param_value(raw, context)
|
||||
result[name] = convert_sql_param_type(value, param_type)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Tests for workflow database execution utilities."""
|
||||
import unittest
|
||||
|
||||
from ai_platform.nodes.utils.db_execution import (
|
||||
DbTarget,
|
||||
build_sql_param_dict,
|
||||
build_where_clause_platform,
|
||||
build_where_clause_raw,
|
||||
convert_sql_param_type,
|
||||
default_connection_write_warnings,
|
||||
format_select_sql,
|
||||
format_limit_clause,
|
||||
merge_result_metadata,
|
||||
normalize_return_fields,
|
||||
resolve_db_target,
|
||||
resolve_handler_schema_name,
|
||||
resolve_schema_for_handler,
|
||||
resolve_sql_param_value,
|
||||
)
|
||||
|
||||
|
||||
class DbExecutionTestCase(unittest.TestCase):
|
||||
def test_resolve_db_target_default(self):
|
||||
target = resolve_db_target({})
|
||||
self.assertEqual(target.db_name, "default")
|
||||
self.assertFalse(target.is_external)
|
||||
|
||||
def test_resolve_db_target_external(self):
|
||||
target = resolve_db_target(
|
||||
{
|
||||
"dbName": "erp_mysql",
|
||||
"dbType": "mysql",
|
||||
"database": "sales",
|
||||
"schema": "",
|
||||
}
|
||||
)
|
||||
self.assertEqual(target.db_name, "erp_mysql")
|
||||
self.assertTrue(target.is_external)
|
||||
|
||||
def test_default_connection_write_warnings(self):
|
||||
target = resolve_db_target({"dbName": "default"})
|
||||
self.assertEqual(
|
||||
default_connection_write_warnings("insert", target),
|
||||
["default_connection_write"],
|
||||
)
|
||||
self.assertEqual(default_connection_write_warnings("select", target), [])
|
||||
|
||||
def test_build_where_clause_raw_postgresql(self):
|
||||
where = build_where_clause_raw(
|
||||
[
|
||||
{"field": "status", "operator": "=", "value": "active"},
|
||||
{"field": "age", "operator": ">", "value": 18},
|
||||
],
|
||||
"postgresql",
|
||||
)
|
||||
self.assertIn('"status" = \'active\'', where)
|
||||
self.assertIn('"age" > 18', where)
|
||||
|
||||
def test_build_where_clause_raw_mysql_like(self):
|
||||
where = build_where_clause_raw(
|
||||
[{"field": "name", "operator": "like", "value": "张%"}],
|
||||
"mysql",
|
||||
)
|
||||
self.assertIn("`name` LIKE '张%'", where)
|
||||
|
||||
def test_build_where_clause_platform_named_params(self):
|
||||
clause, params = build_where_clause_platform(
|
||||
[{"field": "id", "operator": "=", "value": "1"}]
|
||||
)
|
||||
self.assertTrue(clause.startswith("WHERE"))
|
||||
self.assertEqual(params["p0"], "1")
|
||||
|
||||
def test_format_select_sql_postgresql(self):
|
||||
target = DbTarget(db_name="erp", db_type="postgresql", schema="public")
|
||||
sql = format_select_sql(
|
||||
"users",
|
||||
target,
|
||||
return_fields=["id", "name"],
|
||||
conditions=[{"field": "status", "operator": "=", "value": 1}],
|
||||
limit=10,
|
||||
)
|
||||
self.assertTrue(sql.startswith("SELECT"))
|
||||
self.assertIn('"public"."users"', sql)
|
||||
self.assertIn("LIMIT 10", sql)
|
||||
|
||||
def test_resolve_schema_for_handler_mysql(self):
|
||||
self.assertEqual(resolve_schema_for_handler("mysql", "", "sales_db"), "sales_db")
|
||||
|
||||
def test_merge_result_metadata(self):
|
||||
metadata = merge_result_metadata({}, ["default_connection_write"])
|
||||
self.assertEqual(metadata["warnings"], ["default_connection_write"])
|
||||
|
||||
def test_normalize_return_fields_comma_string(self):
|
||||
self.assertEqual(normalize_return_fields("id, name"), ["id", "name"])
|
||||
self.assertEqual(normalize_return_fields("*"), "*")
|
||||
|
||||
def test_format_select_sql_sqlserver_order_by(self):
|
||||
target = DbTarget(db_name="erp", db_type="sqlserver", schema="dbo")
|
||||
sql = format_select_sql("users", target, limit=10)
|
||||
self.assertIn("ORDER BY (SELECT NULL)", sql)
|
||||
self.assertIn("OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY", sql)
|
||||
|
||||
def test_format_limit_clause_sqlserver(self):
|
||||
clause = format_limit_clause("sqlserver", 1)
|
||||
self.assertIn("ORDER BY (SELECT NULL)", clause)
|
||||
self.assertIn("OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY", clause)
|
||||
|
||||
def test_resolve_handler_schema_mysql_prefers_database(self):
|
||||
class FakeService:
|
||||
db_type = "mysql"
|
||||
|
||||
def _default_schema(self):
|
||||
return "fallback"
|
||||
|
||||
target = DbTarget(db_name="erp", db_type="mysql", database="sales_db", schema="")
|
||||
import asyncio
|
||||
|
||||
schema = asyncio.run(resolve_handler_schema_name(FakeService(), target))
|
||||
self.assertEqual(schema, "sales_db")
|
||||
|
||||
def test_convert_sql_param_type(self):
|
||||
self.assertEqual(convert_sql_param_type("42", "integer"), 42)
|
||||
self.assertEqual(convert_sql_param_type("3.14", "float"), 3.14)
|
||||
self.assertTrue(convert_sql_param_type("true", "boolean"))
|
||||
self.assertEqual(convert_sql_param_type("2024-01-15", "date").isoformat(), "2024-01-15")
|
||||
|
||||
def test_build_sql_param_dict_basic(self):
|
||||
class FakeContext:
|
||||
def resolve_template(self, raw: str) -> str:
|
||||
return raw.replace("{{user_id}}", "99")
|
||||
|
||||
params = build_sql_param_dict(
|
||||
[
|
||||
{"name": "status", "type": "string", "value": "active"},
|
||||
{"name": "limit", "type": "integer", "value": "10"},
|
||||
{"name": "user_id", "type": "integer", "value": "{{user_id}}"},
|
||||
],
|
||||
FakeContext(),
|
||||
)
|
||||
self.assertEqual(params["status"], "active")
|
||||
self.assertEqual(params["limit"], 10)
|
||||
self.assertEqual(params["user_id"], 99)
|
||||
|
||||
def test_build_sql_param_dict_required_missing(self):
|
||||
class FakeContext:
|
||||
def resolve_template(self, raw: str) -> str:
|
||||
return raw
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
build_sql_param_dict(
|
||||
[{"name": "id", "type": "integer", "required": True}],
|
||||
FakeContext(),
|
||||
)
|
||||
self.assertIn("id", str(ctx.exception))
|
||||
|
||||
def test_build_sql_param_dict_uses_default(self):
|
||||
class FakeContext:
|
||||
def resolve_template(self, raw: str) -> str:
|
||||
return raw
|
||||
|
||||
params = build_sql_param_dict(
|
||||
[{"name": "offset", "type": "integer", "default": "0"}],
|
||||
FakeContext(),
|
||||
)
|
||||
self.assertEqual(params["offset"], 0)
|
||||
|
||||
def test_resolve_sql_param_value_json(self):
|
||||
class FakeContext:
|
||||
def resolve_template(self, raw: str) -> str:
|
||||
return '["a", "b"]'
|
||||
|
||||
value = resolve_sql_param_value("{{ids}}", FakeContext())
|
||||
self.assertEqual(value, ["a", "b"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user