fix: 完善数据中枢导入与查询链路

- 支持常见 Excel 表头、工作簿校验及数据可靠落库

- 修复大整数 ID 精度和逻辑表 SQL 解析问题

- 为查询数据节点注入结构化上下文并兼容 SQL 代码块
This commit is contained in:
2026-08-03 11:39:14 +08:00
parent e3228837f4
commit 19dac5146c
26 changed files with 1399 additions and 82 deletions

View File

@@ -0,0 +1,217 @@
package tech.easyflow.ai.easyagentsflow.service;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* 工作流数据中枢内容准备服务测试。
*/
public class WorkflowDatacenterContentServiceTest {
private static final BigInteger SOURCE_ID = BigInteger.valueOf(1001L);
private static final BigInteger TABLE_ID = BigInteger.valueOf(2001L);
private WorkflowDatacenterContentService service;
private DatacenterDatasetRegistryService registryService;
/**
* 初始化服务及数据源元数据桩。
*
* @throws Exception 注入测试依赖失败时抛出
*/
@Before
public void setUp() throws Exception {
service = new WorkflowDatacenterContentService();
registryService = Mockito.mock(DatacenterDatasetRegistryService.class);
injectField(service, "registryService", registryService);
DatacenterSource source = Mockito.mock(DatacenterSource.class);
Mockito.when(source.getSourceName()).thenReturn("ama 实验基线模型预算");
Mockito.when(source.getSourceType()).thenReturn("EXCEL");
DatacenterTableField modelId = mockField("col_id", "模型ID", "VARCHAR");
DatacenterTableField inputPrice = mockField("token", "输入价格", "DECIMAL");
DatacenterTable table = Mockito.mock(DatacenterTable.class);
Mockito.when(table.getId()).thenReturn(TABLE_ID);
Mockito.when(table.getTableName()).thenReturn("Sheet1");
Mockito.when(table.getFields()).thenReturn(List.of(modelId, inputPrice));
Mockito.when(registryService.getSourceRequired(SOURCE_ID)).thenReturn(source);
Mockito.when(registryService.listManagedTables(SOURCE_ID, null))
.thenReturn(new ArrayList<>(List.of(table)));
Mockito.when(registryService.getTableWithFields(TABLE_ID)).thenReturn(table);
}
/**
* 验证查询上下文会自动且幂等地绑定到系统提示词。
*/
@Test
public void testPrepareRootShouldBindQueryContextToSystemPromptIdempotently() {
JSONObject root = buildWorkflowRoot();
JSONObject llmData = root.getJSONArray("nodes").getJSONObject(1).getJSONObject("data");
service.prepareRoot(root);
service.prepareRoot(root);
String systemPrompt = llmData.getString("systemPrompt");
Assert.assertEquals("请根据问题生成查询语句\n\n{{queryDataContext}}", systemPrompt);
Assert.assertEquals(1, countOccurrences(systemPrompt, "{{queryDataContext}}"));
JSONObject contextParameter = findParameter(llmData, "queryDataContext");
Assert.assertNotNull(contextParameter);
String contextValue = contextParameter.getString("value");
Assert.assertTrue(contextValue.contains("只输出 SQL"));
Assert.assertTrue(contextValue.contains("Sheet1"));
Assert.assertTrue(contextValue.contains("col_id"));
Assert.assertTrue(contextValue.contains("模型ID"));
Assert.assertTrue(contextValue.contains("token AS input_price"));
}
/**
* 验证关闭查询上下文后会同步清理参数和系统提示词占位符。
*/
@Test
public void testPrepareRootShouldRemoveQueryContextBindingWhenDisabled() {
JSONObject root = buildWorkflowRoot();
JSONObject llmData = root.getJSONArray("nodes").getJSONObject(1).getJSONObject("data");
service.prepareRoot(root);
llmData.put("queryContextNodeIds", new JSONArray());
service.prepareRoot(root);
Assert.assertEquals("请根据问题生成查询语句", llmData.getString("systemPrompt"));
Assert.assertNull(findParameter(llmData, "queryDataContext"));
}
/**
* 构造包含查询节点和大模型节点的最小工作流。
*
* @return 工作流根对象
*/
private JSONObject buildWorkflowRoot() {
JSONObject datasetRef = new JSONObject();
datasetRef.put("sourceId", SOURCE_ID);
JSONObject queryData = new JSONObject();
queryData.put("datasetRef", datasetRef);
queryData.put("querySql", "{{query}}");
JSONObject queryNode = buildNode(
"query-node",
WorkflowDatacenterContentService.SEARCH_NODE_TYPE,
queryData);
JSONArray queryContextNodeIds = new JSONArray();
queryContextNodeIds.add("query-node");
JSONObject llmData = new JSONObject();
llmData.put("systemPrompt", "请根据问题生成查询语句");
llmData.put("queryContextNodeIds", queryContextNodeIds);
llmData.put("parameters", new JSONArray());
JSONObject llmNode = buildNode(
"llm-node",
WorkflowDatacenterContentService.LLM_NODE_TYPE,
llmData);
JSONArray nodes = new JSONArray();
nodes.add(queryNode);
nodes.add(llmNode);
JSONObject root = new JSONObject();
root.put("nodes", nodes);
return root;
}
/**
* 构造工作流节点。
*
* @param id 节点标识
* @param type 节点类型
* @param data 节点数据
* @return 工作流节点
*/
private JSONObject buildNode(String id, String type, JSONObject data) {
JSONObject node = new JSONObject();
node.put("id", id);
node.put("type", type);
node.put("data", data);
return node;
}
/**
* 构造字段元数据桩。
*
* @param fieldName 字段名
* @param fieldDesc 字段描述
* @param jdbcType JDBC 类型
* @return 字段元数据
*/
private DatacenterTableField mockField(String fieldName, String fieldDesc, String jdbcType) {
DatacenterTableField field = Mockito.mock(DatacenterTableField.class);
Mockito.when(field.getFieldName()).thenReturn(fieldName);
Mockito.when(field.getFieldDesc()).thenReturn(fieldDesc);
Mockito.when(field.getJdbcType()).thenReturn(jdbcType);
return field;
}
/**
* 查找指定名称的节点参数。
*
* @param data 节点数据
* @param name 参数名
* @return 参数对象,不存在时返回 {@code null}
*/
private JSONObject findParameter(JSONObject data, String name) {
JSONArray parameters = data.getJSONArray("parameters");
if (parameters == null) {
return null;
}
for (int i = 0; i < parameters.size(); i++) {
JSONObject parameter = parameters.getJSONObject(i);
if (parameter != null && name.equals(parameter.getString("name"))) {
return parameter;
}
}
return null;
}
/**
* 统计子串出现次数。
*
* @param value 原始文本
* @param target 目标子串
* @return 出现次数
*/
private int countOccurrences(String value, String target) {
int count = 0;
int index = 0;
while ((index = value.indexOf(target, index)) >= 0) {
count++;
index += target.length();
}
return count;
}
/**
* 注入服务私有依赖。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
* @throws Exception 反射注入失败时抛出
*/
private void injectField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -65,6 +65,43 @@ public class SearchDatasetNodeTest {
}
}
/**
* 验证查询节点可接收大模型返回的单个 SQL Markdown 代码块。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void testResolveQuerySqlShouldUnwrapMarkdownSqlCodeBlock() throws Exception {
DatasetRef datasetRef = new DatasetRef();
datasetRef.setSourceId(BigInteger.valueOf(4004L));
SearchDatasetNode node = new SearchDatasetNode(datasetRef, "{{query}}");
Map<String, Object> params = new HashMap<>();
params.put("query", "```sql SELECT token FROM Sheet1 WHERE col_id = 'deepseek-v4-pro'; ```");
String sql = invokeResolveQuerySql(node, params);
Assert.assertEquals("SELECT token FROM Sheet1 WHERE col_id = 'deepseek-v4-pro';", sql);
}
/**
* 验证包含说明文本的模型输出不会被宽松清洗,从而继续由 SQL 解析器拒绝。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void testResolveQuerySqlShouldKeepTextOutsideSqlCodeBlock() throws Exception {
DatasetRef datasetRef = new DatasetRef();
datasetRef.setSourceId(BigInteger.valueOf(5005L));
SearchDatasetNode node = new SearchDatasetNode(datasetRef, "{{query}}");
Map<String, Object> params = new HashMap<>();
String generated = "查询语句如下:\n```sql\nSELECT token FROM Sheet1;\n```";
params.put("query", generated);
String sql = invokeResolveQuerySql(node, params);
Assert.assertEquals(generated, sql);
}
private String invokeResolveQuerySql(SearchDatasetNode node, Map<String, Object> params) throws Exception {
Method method = SearchDatasetNode.class.getDeclaredMethod("resolveQuerySql", Map.class);
method.setAccessible(true);