feat: 完善循环节点配置与作用域输出

- 支持次数与数组独立或组合配置并补齐检查规则

- 统一循环体临时变量与下游正式输出候选

- 稳定知识库对象数组字段并补充前后端测试
This commit is contained in:
2026-07-29 18:10:23 +08:00
parent 19c7b60a65
commit 766554bf63
13 changed files with 1737 additions and 62 deletions

View File

@@ -17,6 +17,9 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 为工作流知识库节点提供检索能力。
*/
@Component
public class KnowledgeProviderImpl implements KnowledgeProvider {
@@ -24,14 +27,23 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
private DocumentCollectionService documentCollectionService;
/**
* 获取知识库
* @param id 知识库id
* 获取知识库检索器。
*
* @param id 知识库 ID
* @return 知识库检索器
*/
@Override
public Knowledge getKnowledge(Object id) {
return new Knowledge() {
/**
* {@inheritDoc}
*/
@Override
public List<Map<String, Object>> search(String keyword, int limit, KnowledgeNode knowledgeNode, Chain chain) {
public List<Map<String, Object>> search(
String keyword,
int limit,
KnowledgeNode knowledgeNode,
Chain chain) {
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(new BigInteger(id.toString()));
request.setQuery(keyword);
@@ -45,10 +57,29 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
}
List<Map<String, Object>> res = new ArrayList<>();
for (Document document : documents) {
res.add(JSONObject.from(document));
res.add(toWorkflowDocument(document, id));
}
return res;
}
};
}
/**
* 将检索文档转换为工作流稳定对象,并保留旧序列化字段。
*
* @param document 检索文档
* @param knowledgeId 知识库 ID
* @return 工作流文档对象
*/
private Map<String, Object> toWorkflowDocument(
Document document, Object knowledgeId) {
JSONObject result = JSONObject.from(document);
result.put("title", document.getTitle());
result.put("content", document.getContent());
result.put(
"documentId",
document.getMetadata("documentId", document.getId()));
result.put("knowledgeId", knowledgeId);
return result;
}
}

View File

@@ -40,6 +40,9 @@ import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 校验工作流结构、节点配置和预执行约束。
*/
@Service
public class WorkflowCheckService {
private static final String LEVEL_ERROR = "ERROR";
@@ -240,7 +243,7 @@ public class WorkflowCheckService {
Set<String> issueKeys) {
for (NodeView node : nodes) {
checkConfiguredLoopCount(node, issues, issueKeys);
checkFixedExplicitLoopCount(node, issues, issueKeys);
checkExplicitLoopInputs(node, issues, issueKeys);
if (StringUtils.hasText(node.parentId)) {
NodeView parent = nodeMap.get(node.parentId);
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
@@ -255,6 +258,7 @@ public class WorkflowCheckService {
}
}
checkLoopParentCycle(node, nodeMap, issues, issueKeys);
checkLoopVariableScope(node, nodeMap, issues, issueKeys);
}
}
@@ -281,36 +285,293 @@ public class WorkflowCheckService {
}
/**
* 校验显式循环节点使用固定数值时的次数范围
* 校验显式循环节点的新旧输入结构
*
* @param node 节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkFixedExplicitLoopCount(
private void checkExplicitLoopInputs(
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
return;
}
JSONObject loopInputs = node.data.getJSONObject("loopInputs");
JSONArray loopVars = node.data.getJSONArray("loopVars");
if (loopVars == null || loopVars.isEmpty()) {
if (loopInputs != null) {
if (loopVars != null && !loopVars.isEmpty()) {
addIssue(
issues,
issueKeys,
"LOOP_INPUT_SCHEMA_CONFLICT",
"循环输入配置存在冲突,请重新保存循环节点",
node.id,
null,
node.name);
}
JSONObject count = loopInputs.getJSONObject("count");
JSONObject items = loopInputs.getJSONObject("items");
if (count == null && items == null) {
addIssue(
issues,
issueKeys,
"LOOP_INPUT_REQUIRED",
"请至少配置循环次数或输入数组",
node.id,
null,
node.name);
return;
}
checkExplicitCountParameter(count, node, issues, issueKeys);
checkExplicitItemsParameter(items, node, issues, issueKeys);
return;
}
if (loopVars == null || loopVars.isEmpty()) {
addIssue(
issues,
issueKeys,
"LOOP_INPUT_REQUIRED",
"请至少配置循环次数或输入数组",
node.id,
null,
node.name);
return;
}
JSONObject loopVar = loopVars.getJSONObject(0);
if (loopVar == null || !"fixed".equals(loopVar.getString("refType"))) {
if (loopVar == null) {
addIssue(
issues,
issueKeys,
"LOOP_INPUT_REQUIRED",
"请至少配置循环次数或输入数组",
node.id,
null,
node.name);
return;
}
if (!"fixed".equals(loopVar.getString("refType"))) {
return;
}
Object value = loopVar.get("value");
if (value != null && StringUtils.hasText(String.valueOf(value))) {
if (value == null || !StringUtils.hasText(String.valueOf(value))) {
addIssue(
issues,
issueKeys,
"EXPLICIT_LOOP_COUNT_INVALID",
"循环次数必须是 1300 的整数",
node.id,
null,
node.name);
return;
}
addLoopCountIssueIfInvalid(
value,
"EXPLICIT_LOOP_COUNT_INVALID",
node,
issues,
issueKeys);
}
/**
* 校验显式循环次数参数。
*
* @param count 次数参数
* @param node 节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkExplicitCountParameter(
JSONObject count,
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (count == null) {
return;
}
String refType = trimToNull(count.getString("refType"));
if ("fixed".equals(refType)) {
Object value = count.get("value");
if (value == null || !StringUtils.hasText(String.valueOf(value))) {
addIssue(
issues,
issueKeys,
"EXPLICIT_LOOP_COUNT_INVALID",
"循环次数必须是 1300 的整数",
node.id,
null,
node.name);
return;
}
addLoopCountIssueIfInvalid(
value,
"EXPLICIT_LOOP_COUNT_INVALID",
node,
issues,
issueKeys);
return;
}
if (!"ref".equals(refType)
|| !StringUtils.hasText(trimToNull(count.getString("ref")))
|| !"Number".equalsIgnoreCase(
safe(count.getString("dataType")))) {
addIssue(
issues,
issueKeys,
"EXPLICIT_LOOP_COUNT_INVALID",
"循环次数必须引用数值变量",
node.id,
null,
node.name);
}
}
/**
* 校验显式循环数组参数。
*
* @param items 数组参数
* @param node 节点
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkExplicitItemsParameter(
JSONObject items,
NodeView node,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (items == null) {
return;
}
String refType = trimToNull(items.getString("refType"));
String ref = trimToNull(items.getString("ref"));
String dataType = trimToNull(items.getString("dataType"));
if (!"ref".equals(refType)
|| !StringUtils.hasText(ref)
|| !isArrayDataType(dataType)) {
addIssue(
issues,
issueKeys,
"EXPLICIT_LOOP_ITEMS_TYPE_INVALID",
"输入数组必须引用数组变量",
node.id,
null,
node.name);
}
}
/**
* 判断参数类型是否为数组。
*
* @param dataType 参数类型
* @return 数组类型返回 {@code true}
*/
private boolean isArrayDataType(String dataType) {
return StringUtils.hasText(dataType)
&& ("Array".equalsIgnoreCase(dataType)
|| dataType.regionMatches(
true, 0, "Array<", 0, "Array<".length()));
}
/**
* 校验 index 和 loopItem 仅在所属循环体内引用。
*
* @param node 当前节点
* @param nodeMap 节点索引
* @param issues 问题列表
* @param issueKeys 问题去重键
*/
private void checkLoopVariableScope(
NodeView node,
Map<String, NodeView> nodeMap,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
if (node.data == null) {
return;
}
Set<String> references = new LinkedHashSet<>();
collectParameterReferences(node.data, references);
for (String reference : references) {
int separator = reference.indexOf('.');
if (separator <= 0) {
continue;
}
String loopNodeId = reference.substring(0, separator);
String variablePath = reference.substring(separator + 1);
if (!("index".equals(variablePath)
|| variablePath.startsWith("index.")
|| "loopItem".equals(variablePath)
|| variablePath.startsWith("loopItem."))) {
continue;
}
NodeView loopNode = nodeMap.get(loopNodeId);
if (loopNode == null || !TYPE_LOOP.equals(loopNode.type)) {
continue;
}
if (!isDescendantOfLoop(node, loopNodeId, nodeMap)) {
addIssue(
issues,
issueKeys,
"LOOP_SCOPE_REFERENCE_INVALID",
"循环变量只能在对应循环体内使用",
node.id,
null,
node.name);
}
}
}
/**
* 递归收集节点数据中的参数引用。
*
* @param value 待遍历值
* @param references 引用结果
*/
private void collectParameterReferences(
Object value, Set<String> references) {
if (value instanceof JSONObject object) {
String reference = trimToNull(object.getString("ref"));
if (StringUtils.hasText(reference)) {
references.add(reference);
}
for (Object child : object.values()) {
collectParameterReferences(child, references);
}
return;
}
if (value instanceof JSONArray array) {
for (Object child : array) {
collectParameterReferences(child, references);
}
}
}
/**
* 判断节点是否位于指定循环节点内部。
*
* @param node 当前节点
* @param loopNodeId 循环节点 ID
* @param nodeMap 节点索引
* @return 位于循环体内返回 {@code true}
*/
private boolean isDescendantOfLoop(
NodeView node,
String loopNodeId,
Map<String, NodeView> nodeMap) {
Set<String> visited = new HashSet<>();
NodeView current = node;
while (current != null
&& StringUtils.hasText(current.parentId)
&& visited.add(current.id)) {
if (loopNodeId.equals(current.parentId)) {
return true;
}
current = nodeMap.get(current.parentId);
}
return false;
}
/**

View File

@@ -0,0 +1,83 @@
package tech.easyflow.ai.easyagentsflow.knowledge;
import com.easyagents.core.document.Document;
import com.easyagents.flow.core.knowledge.Knowledge;
import com.easyagents.flow.core.node.KnowledgeNode;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentCollectionService;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link KnowledgeProviderImpl} 的工作流文档契约测试。
*/
public class KnowledgeProviderImplTest {
/**
* 验证对象数组同时提供稳定顶层字段和旧版字段。
*
* @throws Exception 注入测试依赖失败时抛出
*/
@Test
public void shouldExposeStableDocumentFieldsAndKeepLegacyFields()
throws Exception {
Document document = new Document();
document.setId(BigInteger.valueOf(42));
document.setTitle("文档标题");
document.setContent("文档内容");
document.addMetadata("documentId", BigInteger.valueOf(420));
document.addMetadata("legacyKey", "legacy-value");
DocumentCollectionService service =
mock(DocumentCollectionService.class);
when(service.search(any(KnowledgeRetrievalRequest.class)))
.thenReturn(Collections.singletonList(document));
KnowledgeProviderImpl provider = new KnowledgeProviderImpl();
setField(provider, "documentCollectionService", service);
KnowledgeNode knowledgeNode = new KnowledgeNode();
knowledgeNode.setId("knowledge-node");
Knowledge knowledge =
provider.getKnowledge(BigInteger.valueOf(88));
List<Map<String, Object>> result =
knowledge.search("问题", 10, knowledgeNode, null);
Assert.assertEquals(1, result.size());
Map<String, Object> item = result.get(0);
Assert.assertEquals("文档标题", item.get("title"));
Assert.assertEquals("文档内容", item.get("content"));
Assert.assertEquals(BigInteger.valueOf(420), item.get("documentId"));
Assert.assertEquals(BigInteger.valueOf(88), item.get("knowledgeId"));
Assert.assertEquals(42L, ((Number) item.get("id")).longValue());
Assert.assertTrue(item.containsKey("metadataMap"));
Assert.assertEquals(
"legacy-value",
((Map<?, ?>) item.get("metadataMap")).get("legacyKey"));
}
/**
* 注入测试依赖。
*
* @param target 目标对象
* @param fieldName 字段名称
* @param value 字段值
* @throws Exception 字段不存在或不可访问时抛出
*/
private static void setField(
Object target, String fieldName, Object value) throws Exception {
Field field = KnowledgeProviderImpl.class
.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -64,6 +64,134 @@ public class WorkflowCheckServiceTest {
assertHasCode(result, "EXPLICIT_LOOP_COUNT_INVALID");
}
/**
* 验证循环次数和输入数组可分别存在,也可同时存在。
*/
@Test
public void testSaveShouldPassForSupportedExplicitLoopInputs() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject countOnly = loopData(
fixedParameter("count", "3", "Number"), null);
JSONObject itemsOnly = loopData(
null, refParameter("items", "start.items", "Array<Object>"));
JSONObject both = loopData(
refParameter("count", "start.count", "Number"),
refParameter("items", "knowledge.documents", "Array"));
String content = workflowJson(
array(
node("loop-count", "loopNode", null, countOnly),
node("loop-items", "loopNode", null, itemsOnly),
node("loop-both", "loopNode", null, both)
),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertTrue(result.isPassed());
}
/**
* 验证显式循环至少需要一个输入。
*/
@Test
public void testSaveShouldBlockEmptyExplicitLoopInputs() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject loopData = data("循环");
loopData.put("loopInputs", new JSONObject());
String content = workflowJson(
array(node("loop-1", "loopNode", null, loopData)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "LOOP_INPUT_REQUIRED");
}
/**
* 验证输入数组只接受数组变量引用。
*/
@Test
public void testSaveShouldBlockFixedOrNonArrayLoopItems() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject fixedItems = loopData(
null, fixedParameter("items", "[]", "Array"));
JSONObject stringItems = loopData(
null, refParameter("items", "start.value", "String"));
String content = workflowJson(
array(
node("loop-fixed", "loopNode", null, fixedItems),
node("loop-string", "loopNode", null, stringItems)
),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "EXPLICIT_LOOP_ITEMS_TYPE_INVALID");
}
/**
* 验证新旧循环输入不能同时提交。
*/
@Test
public void testSaveShouldBlockConflictingLoopInputSchemas() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject loopData = loopData(
fixedParameter("count", "2", "Number"), null);
loopData.put("loopVars", array(
fixedParameter("loopVar", "2", "Number")));
String content = workflowJson(
array(node("loop-1", "loopNode", null, loopData)),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
assertHasCode(result, "LOOP_INPUT_SCHEMA_CONFLICT");
}
/**
* 验证循环变量只能由对应循环体中的节点引用。
*/
@Test
public void testSaveShouldBlockLoopVariableReferenceOutsideScope() throws Exception {
WorkflowCheckService service = newService(new HashMap<>());
JSONObject childData = data("循环内节点");
childData.put("inputDefs", array(
refParameter("item", "loop-1.loopItem.content", "String")));
JSONObject outsideData = data("循环外节点");
outsideData.put("inputDefs", array(
refParameter("index", "loop-1.index", "Number")));
String content = workflowJson(
array(
node(
"loop-1",
"loopNode",
null,
loopData(fixedParameter(
"count", "2", "Number"), null)),
node("inside", "codeNode", "loop-1", childData),
node("outside", "codeNode", null, outsideData)
),
new JSONArray());
WorkflowCheckResult result = service.checkContent(
content, WorkflowCheckStage.SAVE, null);
Assert.assertFalse(result.isPassed());
Assert.assertEquals(
1,
result.getIssues().stream()
.filter(issue -> "LOOP_SCOPE_REFERENCE_INVALID"
.equals(issue.getCode()))
.count());
}
/**
* 验证嵌套节点只能挂在显式循环节点下。
*/
@@ -552,6 +680,62 @@ public class WorkflowCheckServiceTest {
return node(id, "search-dataset-node", parentId, data);
}
/**
* 创建显式循环节点数据。
*
* @param count 次数参数
* @param items 数组参数
* @return 循环节点数据
*/
private static JSONObject loopData(JSONObject count, JSONObject items) {
JSONObject data = data("循环");
JSONObject inputs = new JSONObject();
if (count != null) {
inputs.put("count", count);
}
if (items != null) {
inputs.put("items", items);
}
data.put("loopInputs", inputs);
return data;
}
/**
* 创建固定值参数。
*
* @param name 参数名称
* @param value 参数值
* @param dataType 参数类型
* @return 参数对象
*/
private static JSONObject fixedParameter(
String name, String value, String dataType) {
JSONObject parameter = new JSONObject();
parameter.put("name", name);
parameter.put("refType", "fixed");
parameter.put("value", value);
parameter.put("dataType", dataType);
return parameter;
}
/**
* 创建变量引用参数。
*
* @param name 参数名称
* @param ref 引用路径
* @param dataType 参数类型
* @return 参数对象
*/
private static JSONObject refParameter(
String name, String ref, String dataType) {
JSONObject parameter = new JSONObject();
parameter.put("name", name);
parameter.put("refType", "ref");
parameter.put("ref", ref);
parameter.put("dataType", dataType);
return parameter;
}
private static JSONObject data(String title) {
JSONObject data = new JSONObject();
data.put("title", title);