Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,18 @@
"""
AI 工作流节点系统
提供可扩展的节点架构,支持:
- 内置节点(LLM、条件、代码等)
- 自定义节点扩展
- 知识库节点(预留)
- 工具节点(预留)
"""
from .base import BaseNode, NodeContext, NodeResult
from .registry import NodeRegistry
__all__ = [
'BaseNode',
'NodeContext',
'NodeResult',
'NodeRegistry',
]
+394
View File
@@ -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()