diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java index 5b7674f9..21699194 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/ai/WorkflowDesignerOptionsView.java @@ -52,11 +52,17 @@ public record WorkflowDesignerOptionsView( * @param id 知识库 ID * @param title 知识库标题 * @param description 知识库描述 + * @param vectorEmbedModelId Embedding 模型 ID + * @param dimensionOfVectorModel 向量维度 + * @param vectorStoreEnabled 是否可用于向量检索 */ public record KnowledgeOption( @JsonSerialize(using = ToStringSerializer.class) BigInteger id, String title, - String description + String description, + @JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId, + Integer dimensionOfVectorModel, + Boolean vectorStoreEnabled ) { } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java index 9f4e39ab..23782cbd 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionService.java @@ -14,6 +14,7 @@ import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Service; import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView; import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.entity.ModelProvider; @@ -75,6 +76,7 @@ public class WorkflowDesignerOptionService { private final DatacenterSourceService datacenterSourceService; private final DatacenterDatasetRegistryService datacenterDatasetRegistryService; private final DatacenterDatasetQueryService datacenterDatasetQueryService; + private final WorkflowKnowledgeContractService workflowKnowledgeContractService; /** * 创建工作流设计器选项服务。 @@ -93,6 +95,7 @@ public class WorkflowDesignerOptionService { * @param datacenterSourceService 数据源服务 * @param datacenterDatasetRegistryService 数据集注册服务 * @param datacenterDatasetQueryService 数据集查询服务 + * @param workflowKnowledgeContractService 工作流知识库契约服务 */ public WorkflowDesignerOptionService( ModelService modelService, @@ -108,7 +111,8 @@ public class WorkflowDesignerOptionService { ResourceAccessService resourceAccessService, DatacenterSourceService datacenterSourceService, DatacenterDatasetRegistryService datacenterDatasetRegistryService, - DatacenterDatasetQueryService datacenterDatasetQueryService) { + DatacenterDatasetQueryService datacenterDatasetQueryService, + WorkflowKnowledgeContractService workflowKnowledgeContractService) { this.modelService = modelService; this.documentCollectionService = documentCollectionService; this.pluginService = pluginService; @@ -123,6 +127,7 @@ public class WorkflowDesignerOptionService { this.datacenterSourceService = datacenterSourceService; this.datacenterDatasetRegistryService = datacenterDatasetRegistryService; this.datacenterDatasetQueryService = datacenterDatasetQueryService; + this.workflowKnowledgeContractService = workflowKnowledgeContractService; } /** @@ -163,6 +168,7 @@ public class WorkflowDesignerOptionService { LoginAccount account = requireAccount(); Set modelIds = new HashSet<>(); Set knowledgeIds = new HashSet<>(); + List> knowledgeGroups = new ArrayList<>(); Set checkedPluginItemIds = new HashSet<>(); Set checkedWorkflowIds = new HashSet<>(); Set checkedSourceIds = new HashSet<>(); @@ -177,14 +183,22 @@ public class WorkflowDesignerOptionService { if (data == null) { continue; } - String nodeType = data.getString("type"); + String nodeType = node.getString("type"); + String dataType = data.getString("type"); + if (nodeType != null && !nodeType.isBlank() + && dataType != null && !dataType.isBlank() + && !Objects.equals(nodeType, dataType)) { + throw new BusinessException("工作流节点类型与节点数据类型不一致"); + } if (nodeType == null || nodeType.isBlank()) { - nodeType = node.getString("type"); + nodeType = dataType; } if ("llmNode".equals(nodeType)) { addReferenceId(modelIds, readReferenceId(data, "llmId", "模型")); } else if ("knowledgeNode".equals(nodeType)) { - addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库")); + List nodeKnowledgeIds = readKnowledgeReferenceIds(data); + knowledgeIds.addAll(nodeKnowledgeIds); + knowledgeGroups.add(nodeKnowledgeIds); } else if ("plugin-node".equals(nodeType)) { BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具"); if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) { @@ -198,6 +212,8 @@ public class WorkflowDesignerOptionService { } assertModelReferences(modelIds, account); assertKnowledgeReferences(knowledgeIds, account); + workflowKnowledgeContractService.assertMultiKnowledgeContracts( + knowledgeGroups, account.getTenantId()); } /** @@ -460,17 +476,55 @@ public class WorkflowDesignerOptionService { } private List listKnowledgeOptions(LoginAccount account) { - return documentCollectionService.list(QueryWrapper.create() + List collections = documentCollectionService.list(QueryWrapper.create() .eq(DocumentCollection::getTenantId, account.getTenantId()) - .orderBy(DocumentCollection::getModified, false)) - .stream() + .orderBy(DocumentCollection::getModified, false)); + Set vectorReadyIds = workflowKnowledgeContractService + .findVectorReadyKnowledgeIds(collections, account.getTenantId()); + return collections.stream() .filter(item -> resourceAccessService.canAccess( account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE)) .map(item -> new WorkflowDesignerOptionsView.KnowledgeOption( - item.getId(), item.getTitle(), item.getDescription())) + item.getId(), + item.getTitle(), + item.getDescription(), + item.getVectorEmbedModelId(), + item.getDimensionOfVectorModel(), + vectorReadyIds.contains(item.getId()))) .toList(); } + private List readKnowledgeReferenceIds(JSONObject data) { + if (data.containsKey("knowledgeIds")) { + Object rawIds = data.get("knowledgeIds"); + if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) { + throw new BusinessException("知识库节点至少需要选择一个知识库"); + } + List result = new ArrayList<>(); + for (Object id : ids) { + BigInteger parsed = parseReferenceId(id, "知识库"); + if (result.contains(parsed)) { + throw new BusinessException("知识库节点不能重复选择同一知识库"); + } + result.add(parsed); + } + return result; + } + BigInteger legacyId = readReferenceId(data, "knowledgeId", "知识库"); + return legacyId == null ? List.of() : List.of(legacyId); + } + + private BigInteger parseReferenceId(Object value, String resourceName) { + if (value == null || String.valueOf(value).isBlank()) { + throw new BusinessException(resourceName + "ID不能为空"); + } + try { + return new BigInteger(String.valueOf(value)); + } catch (NumberFormatException exception) { + throw new BusinessException(resourceName + "ID无效"); + } + } + private void addReferenceId(Set resourceIds, BigInteger resourceId) { if (resourceId != null) { resourceIds.add(resourceId); diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java index 003a9ed7..73e9f800 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowDesignerOptionServiceTest.java @@ -6,7 +6,10 @@ import org.mockito.MockedStatic; import org.testng.Assert; import org.testng.annotations.Test; import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService; import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.ModelProvider; +import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.DocumentCollectionService; @@ -129,6 +132,96 @@ public class WorkflowDesignerOptionServiceTest { } } + @Test + public void shouldAcceptCompatibleMultiKnowledgeReferences() { + ModelService modelService = mock(ModelService.class); + DocumentCollectionService knowledgeService = + mock(DocumentCollectionService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + when(accessService.canAccess(any(), any(), any(), any())) + .thenReturn(true); + when(knowledgeService.listByIds(any())) + .thenReturn(List.of( + knowledge(1, 7, 3), + knowledge(2, 7, 3))); + when(modelService.listModelInstances(any())) + .thenReturn(List.of(embeddingModel(7))); + WorkflowDesignerOptionService service = createService( + modelService, + knowledgeService, + mock(DatacenterSourceService.class), + mock(WorkflowService.class), + accessService); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount()); + + service.assertContentReferences(""" + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """); + } + } + + @Test + public void shouldRejectIncompatibleMultiKnowledgeReferences() { + ModelService modelService = mock(ModelService.class); + DocumentCollectionService knowledgeService = + mock(DocumentCollectionService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + when(accessService.canAccess(any(), any(), any(), any())) + .thenReturn(true); + when(knowledgeService.listByIds(any())) + .thenReturn(List.of( + knowledge(1, 7, 3), + knowledge(2, 8, 3))); + when(modelService.listModelInstances(any())) + .thenReturn(List.of(embeddingModel(7), embeddingModel(8))); + WorkflowDesignerOptionService service = createService( + modelService, + knowledgeService, + mock(DatacenterSourceService.class), + mock(WorkflowService.class), + accessService); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount()); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> service.assertContentReferences(""" + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """)); + + Assert.assertTrue(exception.getMessage().contains("Embedding")); + } + } + + @Test + public void shouldRejectConflictingRootAndDataNodeTypes() { + WorkflowDesignerOptionService service = createService( + mock(ModelService.class), + mock(DocumentCollectionService.class), + mock(DatacenterSourceService.class)); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount()); + + BusinessException exception = Assert.expectThrows( + BusinessException.class, + () -> service.assertContentReferences(""" + {"nodes":[{"type":"knowledgeNode","data":{ + "type":"llmNode","knowledgeId":"1" + }}]} + """)); + + Assert.assertTrue(exception.getMessage().contains("类型")); + } + } + private WorkflowDesignerOptionService createService( ModelService modelService, DocumentCollectionService knowledgeService, @@ -142,6 +235,20 @@ public class WorkflowDesignerOptionServiceTest { DatacenterSourceService sourceService, WorkflowService workflowService) { ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + return createService( + modelService, + knowledgeService, + sourceService, + workflowService, + resourceAccessService); + } + + private WorkflowDesignerOptionService createService( + ModelService modelService, + DocumentCollectionService knowledgeService, + DatacenterSourceService sourceService, + WorkflowService workflowService, + ResourceAccessService resourceAccessService) { return new WorkflowDesignerOptionService( modelService, knowledgeService, @@ -156,10 +263,40 @@ public class WorkflowDesignerOptionServiceTest { resourceAccessService, sourceService, mock(DatacenterDatasetRegistryService.class), - mock(DatacenterDatasetQueryService.class) + mock(DatacenterDatasetQueryService.class), + new WorkflowKnowledgeContractService( + knowledgeService, modelService) ); } + private DocumentCollection knowledge( + long id, long embeddingModelId, int dimension) { + DocumentCollection collection = new DocumentCollection(); + collection.setId(BigInteger.valueOf(id)); + collection.setTenantId(BigInteger.valueOf(100)); + collection.setVectorEmbedModelId(BigInteger.valueOf(embeddingModelId)); + collection.setDimensionOfVectorModel(dimension); + collection.setVectorStoreEnable(true); + collection.setVectorStoreCollection("collection_" + id); + return collection; + } + + private Model embeddingModel(long id) { + Model model = new Model(); + model.setId(BigInteger.valueOf(id)); + model.setTenantId(BigInteger.valueOf(100)); + model.setModelType(Model.MODEL_TYPES[1]); + model.setProviderId(BigInteger.ONE); + model.setModelName("embedding-" + id); + model.setEndpoint("https://embedding.example"); + model.setRequestPath("/v1/embeddings"); + ModelProvider provider = new ModelProvider(); + provider.setId(BigInteger.ONE); + provider.setProviderType("openai"); + model.setModelProvider(provider); + return model; + } + private LoginAccount loginAccount() { LoginAccount account = new LoginAccount(); account.setId(BigInteger.ONE); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubTools.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubTools.java index ef571d1f..4712eca3 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubTools.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubTools.java @@ -67,7 +67,9 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools { * @param arguments 调用参数 * @return 执行结果 */ - protected abstract AgentToolExecutionResult executeBusiness(Map arguments); + protected abstract AgentToolExecutionResult executeBusiness( + Map arguments, + AgentToolContext context); /** * {@inheritDoc} @@ -92,7 +94,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools { record.getMetadata().put("toolDisplayName", displayName()); appendEvent(record, "SUBMITTED", displayName() + "任务已提交"); taskStore.create(record); - dispatch(sessionId, record.getTaskId(), record.getArguments()); + dispatch(sessionId, record.getTaskId(), record.getArguments(), context); AsyncToolSubmitResult result = new AsyncToolSubmitResult(); result.setTaskId(taskId); @@ -157,16 +159,23 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools { return result; } - private void dispatch(String sessionId, String taskId, Map arguments) { + private void dispatch(String sessionId, + String taskId, + Map arguments, + AgentToolContext context) { try { - taskExecutor.execute(() -> executeTask(sessionId, taskId, arguments)); + taskExecutor.execute(() -> executeTask( + sessionId, taskId, arguments, context)); } catch (Exception e) { taskStore.update(sessionId, taskId, record -> fail(record, e)); throw new BusinessException("提交异步工具任务失败:" + safeMessage(e)); } } - private void executeTask(String sessionId, String taskId, Map arguments) { + private void executeTask(String sessionId, + String taskId, + Map arguments, + AgentToolContext context) { try { taskStore.update(sessionId, taskId, record -> { record.setStatus(AsyncToolTaskStatus.RUNNING); @@ -174,7 +183,8 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools { appendEvent(record, "RUNNING", displayName() + "任务执行中"); return record; }); - AgentToolExecutionResult executionResult = executeBusiness(arguments); + AgentToolExecutionResult executionResult = executeBusiness( + arguments, context); taskStore.update(sessionId, taskId, record -> { record.setStatus(AsyncToolTaskStatus.SUCCEEDED); record.setSummary(displayName() + "任务已完成"); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java index de72704f..f37fb9a9 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java @@ -1,5 +1,6 @@ package tech.easyflow.agent.runtime.asynctool; +import com.easyagents.agent.runtime.tool.AgentToolContext; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult; @@ -82,7 +83,9 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools { * {@inheritDoc} */ @Override - protected AgentToolExecutionResult executeBusiness(Map arguments) { + protected AgentToolExecutionResult executeBusiness( + Map arguments, + AgentToolContext context) { return pluginToolExecutor.execute(pluginItem, plugin, arguments); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/WorkflowAsyncSubTools.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/WorkflowAsyncSubTools.java index 19aa50c5..4fee2c6e 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/WorkflowAsyncSubTools.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/WorkflowAsyncSubTools.java @@ -1,5 +1,6 @@ package tech.easyflow.agent.runtime.asynctool; +import com.easyagents.agent.runtime.tool.AgentToolContext; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult; @@ -77,7 +78,9 @@ public class WorkflowAsyncSubTools extends AbstractAgentAsyncSubTools { * {@inheritDoc} */ @Override - protected AgentToolExecutionResult executeBusiness(Map arguments) { - return workflowToolExecutor.execute(workflow, arguments); + protected AgentToolExecutionResult executeBusiness( + Map arguments, + AgentToolContext context) { + return workflowToolExecutor.execute(workflow, arguments, context); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java index 56972c6a..73cb9dfc 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java @@ -161,7 +161,7 @@ public class AgentToolRuntimeCompiler { Tool tool = workflowToolExecutor.buildTool(workflow); AgentToolSpec spec = toToolSpec(tool, binding); AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context, - () -> workflowToolExecutor.execute(workflow, arguments).getResult()); + () -> workflowToolExecutor.execute(workflow, arguments, context).getResult()); return new CompiledSyncTool(spec, invoker); } if (type == AgentToolType.PLUGIN) { diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java index ee6df0e9..4168b60a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java @@ -2,13 +2,19 @@ package tech.easyflow.agent.runtime.tool; import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.core.model.chat.tool.Tool; +import com.easyagents.agent.runtime.AgentRuntimeContext; +import com.easyagents.agent.runtime.tool.AgentToolContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import tech.easyflow.ai.easyagents.tool.WorkflowTool; import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.entity.LoginAccount; +import java.math.BigInteger; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -59,11 +65,63 @@ public class WorkflowToolExecutor { * @return 执行结果 */ public AgentToolExecutionResult execute(Workflow workflow, Map arguments) { + return execute(workflow, arguments, null); + } + + /** + * 使用 Agent 调用身份执行 Workflow 工具。 + * + * @param workflow 工作流 + * @param arguments 执行参数 + * @param context Agent 工具上下文 + * @return 执行结果 + */ + public AgentToolExecutionResult execute(Workflow workflow, + Map arguments, + AgentToolContext context) { + Map variables = arguments == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(arguments); + variables.remove(Constants.LOGIN_USER_KEY); + LoginAccount account = toLoginAccount(context); + if (account != null) { + variables.put(Constants.LOGIN_USER_KEY, account); + } Object result = chainExecutor.executeWithoutSuspension( - definitionId(workflow), arguments == null ? Map.of() : arguments); + definitionId(workflow), variables); return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result)); } + private LoginAccount toLoginAccount(AgentToolContext context) { + AgentRuntimeContext runtimeContext = context == null + ? null + : context.getRuntimeContext(); + BigInteger userId = positiveId(runtimeContext == null + ? null + : runtimeContext.getUserId()); + BigInteger tenantId = positiveId(runtimeContext == null + ? null + : runtimeContext.getTenantId()); + if (userId == null || tenantId == null) { + return null; + } + LoginAccount account = new LoginAccount(); + account.setId(userId); + account.setTenantId(tenantId); + account.setLoginName(runtimeContext.getUserName()); + account.setNickname(runtimeContext.getUserName()); + return account; + } + + private BigInteger positiveId(String value) { + try { + BigInteger id = new BigInteger(value); + return id.signum() > 0 ? id : null; + } catch (RuntimeException exception) { + return null; + } + } + private String definitionId(Workflow workflow) { if (frozenDefinitionRegistry != null && workflow != null && workflow.getContent() != null && !workflow.getContent().isBlank()) { diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubToolsTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubToolsTest.java index 53bc9d50..0063ac88 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubToolsTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/AbstractAgentAsyncSubToolsTest.java @@ -146,7 +146,9 @@ public class AbstractAgentAsyncSubToolsTest { } @Override - protected AgentToolExecutionResult executeBusiness(Map arguments) { + protected AgentToolExecutionResult executeBusiness( + Map arguments, + AgentToolContext context) { return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1"); } } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java index 09d03909..1eccefa4 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java @@ -153,7 +153,10 @@ public class WorkflowPluginAsyncSubToolsTest { } @Override - public AgentToolExecutionResult execute(Workflow workflow, Map arguments) { + public AgentToolExecutionResult execute( + Workflow workflow, + Map arguments, + AgentToolContext context) { return new AgentToolExecutionResult(businessResult, "workflow-run-1"); } } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java index c3ae13dd..7880792d 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java @@ -145,7 +145,10 @@ public class AgentToolRuntimeCompilerTest { } @Override - public AgentToolExecutionResult execute(Workflow workflow, Map arguments) { + public AgentToolExecutionResult execute( + Workflow workflow, + Map arguments, + AgentToolContext context) { throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token"); } }); @@ -262,7 +265,10 @@ public class AgentToolRuntimeCompilerTest { } @Override - public AgentToolExecutionResult execute(Workflow workflow, Map arguments) { + public AgentToolExecutionResult execute( + Workflow workflow, + Map arguments, + AgentToolContext context) { return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1"); } } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutorTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutorTest.java new file mode 100644 index 00000000..4c3ca12a --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutorTest.java @@ -0,0 +1,100 @@ +package tech.easyflow.agent.runtime.tool; + +import com.easyagents.agent.runtime.AgentRuntimeContext; +import com.easyagents.agent.runtime.tool.AgentToolContext; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.entity.LoginAccount; + +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Agent Workflow 工具调用上下文测试。 + */ +public class WorkflowToolExecutorTest { + + @Test + @SuppressWarnings("unchecked") + public void shouldForwardAgentIdentityToFrozenWorkflowVariables() { + ChainExecutor chainExecutor = mock(ChainExecutor.class); + FrozenWorkflowDefinitionRegistry registry = + mock(FrozenWorkflowDefinitionRegistry.class); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(101)); + workflow.setContent("{\"nodes\":[]}"); + when(registry.register(workflow)).thenReturn("agent-frozen:101:hash"); + when(chainExecutor.executeWithoutSuspension(anyString(), anyMap())) + .thenReturn(Map.of("ok", true)); + WorkflowToolExecutor executor = new WorkflowToolExecutor( + chainExecutor, registry); + + AgentRuntimeContext runtimeContext = new AgentRuntimeContext(); + runtimeContext.setUserId("7"); + runtimeContext.setTenantId("9"); + runtimeContext.setUserName("测试用户"); + AgentToolContext context = new AgentToolContext(); + context.setRuntimeContext(runtimeContext); + Map arguments = new LinkedHashMap<>(); + arguments.put("question", "问题"); + LoginAccount forgedAccount = new LoginAccount(); + forgedAccount.setId(BigInteger.valueOf(999)); + forgedAccount.setTenantId(BigInteger.valueOf(999)); + arguments.put(Constants.LOGIN_USER_KEY, forgedAccount); + + executor.execute(workflow, arguments, context); + + ArgumentCaptor> variables = + ArgumentCaptor.forClass(Map.class); + verify(chainExecutor).executeWithoutSuspension( + org.mockito.ArgumentMatchers.eq("agent-frozen:101:hash"), + variables.capture()); + LoginAccount account = (LoginAccount) variables.getValue() + .get(Constants.LOGIN_USER_KEY); + Assert.assertEquals(BigInteger.valueOf(7), account.getId()); + Assert.assertEquals(BigInteger.valueOf(9), account.getTenantId()); + Assert.assertEquals("测试用户", account.getLoginName()); + Assert.assertSame(forgedAccount, + arguments.get(Constants.LOGIN_USER_KEY)); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldDropReservedIdentityWithoutAgentContext() { + ChainExecutor chainExecutor = mock(ChainExecutor.class); + FrozenWorkflowDefinitionRegistry registry = + mock(FrozenWorkflowDefinitionRegistry.class); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(101)); + workflow.setContent("{\"nodes\":[]}"); + when(registry.register(workflow)).thenReturn("agent-frozen:101:hash"); + when(chainExecutor.executeWithoutSuspension(anyString(), anyMap())) + .thenReturn(Map.of("ok", true)); + WorkflowToolExecutor executor = new WorkflowToolExecutor( + chainExecutor, registry); + Map arguments = new LinkedHashMap<>(); + arguments.put(Constants.LOGIN_USER_KEY, new LoginAccount()); + + executor.execute(workflow, arguments); + + ArgumentCaptor> variables = + ArgumentCaptor.forClass(Map.class); + verify(chainExecutor).executeWithoutSuspension( + anyString(), variables.capture()); + Assert.assertFalse(variables.getValue() + .containsKey(Constants.LOGIN_USER_KEY)); + Assert.assertTrue(arguments.containsKey(Constants.LOGIN_USER_KEY)); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiMilvusConfig.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiMilvusConfig.java index e17d77d5..86bd4282 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiMilvusConfig.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiMilvusConfig.java @@ -24,6 +24,7 @@ public class AiMilvusConfig extends MilvusVectorStoreConfig { config.setPoolMaxWaitMillis(getPoolMaxWaitMillis()); config.setPoolEvictionIntervalMillis(getPoolEvictionIntervalMillis()); config.setPoolMinEvictableIdleMillis(getPoolMinEvictableIdleMillis()); + config.setSearchTimeoutMillis(getSearchTimeoutMillis()); return config; } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiModuleConfig.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiModuleConfig.java index ba855d96..8508b261 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiModuleConfig.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/AiModuleConfig.java @@ -14,7 +14,8 @@ import tech.easyflow.ai.documentimport.task.DocumentImportStatusBroadcastPropert DocumentImportBulkProperties.class, DocumentImportParseMonitorProperties.class, DocumentImportStatusBroadcastProperties.class, - RagHealthProperties.class + RagHealthProperties.class, + MultiKnowledgeRetrievalProperties.class }) @AutoConfiguration public class AiModuleConfig { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/EasyFlowThreadPoolProperties.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/EasyFlowThreadPoolProperties.java index adb93840..00265e1f 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/EasyFlowThreadPoolProperties.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/EasyFlowThreadPoolProperties.java @@ -11,6 +11,7 @@ public class EasyFlowThreadPoolProperties { private Pool sse = new Pool(4, 16, 2000, 30, true); private Pool documentImport = new Pool(2, 4, 200, 60, true); private Pool agentAsyncTool = new Pool(2, 8, 200, 60, true); + private Pool knowledgeRetrieval = new Pool(4, 8, 64, 30, true); /** * 获取 SSE 线程池配置。 @@ -66,6 +67,14 @@ public class EasyFlowThreadPoolProperties { this.agentAsyncTool = agentAsyncTool; } + public Pool getKnowledgeRetrieval() { + return knowledgeRetrieval; + } + + public void setKnowledgeRetrieval(Pool knowledgeRetrieval) { + this.knowledgeRetrieval = knowledgeRetrieval; + } + /** * 线程池配置项。 */ diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/MultiKnowledgeRetrievalProperties.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/MultiKnowledgeRetrievalProperties.java new file mode 100644 index 00000000..1a9b1857 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/MultiKnowledgeRetrievalProperties.java @@ -0,0 +1,108 @@ +package tech.easyflow.ai.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * 工作流多知识库向量检索的资源与时限配置。 + */ +@ConfigurationProperties(prefix = "easyflow.ai.knowledge.multi-retrieval") +public class MultiKnowledgeRetrievalProperties { + + private int maxSources = 8; + private int candidateMultiplier = 5; + private int perSourceCandidateLimit = 50; + private int totalCandidateLimit = 400; + private double minVectorScore = 0.6D; + private Duration perSourceTimeout = Duration.ofSeconds(10); + private Duration totalTimeout = Duration.ofSeconds(20); + + public int getMaxSources() { + return maxSources; + } + + public void setMaxSources(int maxSources) { + this.maxSources = maxSources; + } + + public int getCandidateMultiplier() { + return candidateMultiplier; + } + + public void setCandidateMultiplier(int candidateMultiplier) { + this.candidateMultiplier = candidateMultiplier; + } + + public int getPerSourceCandidateLimit() { + return perSourceCandidateLimit; + } + + public void setPerSourceCandidateLimit(int perSourceCandidateLimit) { + this.perSourceCandidateLimit = perSourceCandidateLimit; + } + + public int getTotalCandidateLimit() { + return totalCandidateLimit; + } + + public void setTotalCandidateLimit(int totalCandidateLimit) { + this.totalCandidateLimit = totalCandidateLimit; + } + + public double getMinVectorScore() { + return minVectorScore; + } + + public void setMinVectorScore(double minVectorScore) { + this.minVectorScore = minVectorScore; + } + + public Duration getPerSourceTimeout() { + return perSourceTimeout; + } + + public void setPerSourceTimeout(Duration perSourceTimeout) { + this.perSourceTimeout = perSourceTimeout; + } + + public Duration getTotalTimeout() { + return totalTimeout; + } + + public void setTotalTimeout(Duration totalTimeout) { + this.totalTimeout = totalTimeout; + } + + /** + * 启动期校验全部有界配置。 + */ + public void validate() { + if (maxSources < 2 || maxSources > 64) { + throw new IllegalArgumentException("多知识库最大来源数必须在 2 到 64 之间"); + } + if (candidateMultiplier < 1 || candidateMultiplier > 20) { + throw new IllegalArgumentException("多知识库候选倍率必须在 1 到 20 之间"); + } + if (perSourceCandidateLimit < 1 || perSourceCandidateLimit > 1000) { + throw new IllegalArgumentException("单知识库候选上限必须在 1 到 1000 之间"); + } + long derivedCandidateLimit = (long) maxSources + * perSourceCandidateLimit; + if (totalCandidateLimit < derivedCandidateLimit + || totalCandidateLimit > 10000) { + throw new IllegalArgumentException( + "多知识库总候选上限不能小于最大来源数与单库候选上限的乘积"); + } + if (!Double.isFinite(minVectorScore) || minVectorScore < 0D || minVectorScore > 1D) { + throw new IllegalArgumentException("多知识库向量阈值必须在 0 到 1 之间"); + } + if (perSourceTimeout == null || perSourceTimeout.isZero() || perSourceTimeout.isNegative()) { + throw new IllegalArgumentException("单知识库超时时间必须大于 0"); + } + if (totalTimeout == null || totalTimeout.isZero() || totalTimeout.isNegative() + || totalTimeout.compareTo(perSourceTimeout) < 0) { + throw new IllegalArgumentException("节点总超时时间不能小于单知识库超时时间"); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/ThreadPoolConfig.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/ThreadPoolConfig.java index 8896b4e0..3875a0a2 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/ThreadPoolConfig.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/config/ThreadPoolConfig.java @@ -104,4 +104,28 @@ public class ThreadPoolConfig { executor.initialize(); return executor; } + + /** + * 创建工作流多知识库检索线程池。 + * + * @return 多知识库检索线程池 + */ + @Bean(name = "knowledgeRetrievalExecutor") + public ThreadPoolTaskExecutor knowledgeRetrievalExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + EasyFlowThreadPoolProperties.Pool pool = properties.getKnowledgeRetrieval(); + executor.setCorePoolSize(pool.getCoreSize()); + executor.setMaxPoolSize(pool.getMaxSize()); + executor.setQueueCapacity(pool.getQueueCapacity()); + executor.setKeepAliveSeconds(pool.getKeepAliveSeconds()); + executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout()); + executor.setThreadNamePrefix("knowledge-retrieval-"); + executor.setRejectedExecutionHandler((runnable, executorService) -> { + log.error("多知识库检索线程池过载,active={}, queue={}", + executorService.getActiveCount(), executorService.getQueue().size()); + throw new BusinessException("知识库检索繁忙,请稍后重试"); + }); + executor.initialize(); + return executor; + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImpl.java index a0e19e8f..02264b47 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImpl.java @@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONObject; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.knowledge.Knowledge; import com.easyagents.flow.core.knowledge.KnowledgeProvider; +import com.easyagents.flow.core.knowledge.KnowledgeSearchRequest; import com.easyagents.flow.core.node.KnowledgeNode; import org.springframework.stereotype.Component; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; @@ -14,6 +15,7 @@ import tech.easyflow.ai.service.DocumentCollectionService; import javax.annotation.Resource; import java.math.BigInteger; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -26,6 +28,9 @@ public class KnowledgeProviderImpl implements KnowledgeProvider { @Resource private DocumentCollectionService documentCollectionService; + @Resource + private WorkflowMultiKnowledgeRetrievalService multiKnowledgeRetrievalService; + /** * 获取知识库检索器。 * @@ -44,26 +49,88 @@ public class KnowledgeProviderImpl implements KnowledgeProvider { int limit, KnowledgeNode knowledgeNode, Chain chain) { - KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest(); - request.setKnowledgeId(new BigInteger(id.toString())); - request.setQuery(keyword); - request.setLimit(limit); - request.setRetrievalMode(KnowledgeRetrievalModes.parse(knowledgeNode.getRetrievalMode())); - request.setCallerType("WORKFLOW"); - request.setCallerId(knowledgeNode.getId()); - List documents = documentCollectionService.search(request); - if (limit > 0 && documents.size() > limit) { - documents = new ArrayList<>(documents.subList(0, limit)); - } - List> res = new ArrayList<>(); - for (Document document : documents) { - res.add(toWorkflowDocument(document, id)); - } - return res; + return searchSingle( + new BigInteger(id.toString()), + keyword, + limit, + knowledgeNode); } }; } + @Override + public Map search(KnowledgeSearchRequest request) { + if (request == null || request.getKnowledgeIds().isEmpty()) { + return null; + } + List knowledgeIds = new ArrayList<>(); + for (Object id : request.getKnowledgeIds()) { + try { + knowledgeIds.add(new BigInteger(String.valueOf(id))); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("知识库 ID 无效: " + id, exception); + } + } + if (knowledgeIds.size() == 1) { + List> documents = searchSingle( + knowledgeIds.get(0), + request.getKeyword(), + request.getLimit(), + request.getKnowledgeNode()); + return buildOutputs(documents); + } + if (!"VECTOR".equalsIgnoreCase(request.getRetrievalMode())) { + throw new IllegalArgumentException("多知识库检索仅支持 VECTOR 模式"); + } + MultiKnowledgeRetrievalResult result = multiKnowledgeRetrievalService.search( + knowledgeIds, + request.getKeyword(), + request.getLimit(), + request.getKnowledgeNode() == null + ? null + : request.getKnowledgeNode().getId(), + request.getChain()); + List> documents = new ArrayList<>(); + for (Document document : result.getDocuments()) { + documents.add(toWorkflowDocument( + document, + document.getMetadata("knowledgeId", null))); + } + return buildOutputs(documents); + } + + private List> searchSingle( + BigInteger knowledgeId, + String keyword, + int limit, + KnowledgeNode knowledgeNode) { + KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest(); + request.setKnowledgeId(knowledgeId); + request.setQuery(keyword); + request.setLimit(limit); + request.setRetrievalMode(KnowledgeRetrievalModes.parse( + knowledgeNode == null + ? null + : knowledgeNode.getRetrievalMode())); + request.setCallerType("WORKFLOW"); + request.setCallerId(knowledgeNode == null ? null : knowledgeNode.getId()); + List documents = documentCollectionService.search(request); + if (limit > 0 && documents.size() > limit) { + documents = new ArrayList<>(documents.subList(0, limit)); + } + List> result = new ArrayList<>(); + for (Document document : documents) { + result.add(toWorkflowDocument(document, knowledgeId)); + } + return result; + } + + private Map buildOutputs(List> documents) { + Map outputs = new LinkedHashMap<>(); + outputs.put("documents", documents); + return outputs; + } + /** * 将检索文档转换为工作流稳定对象,并保留旧序列化字段。 * diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/MultiKnowledgeRetrievalResult.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/MultiKnowledgeRetrievalResult.java new file mode 100644 index 00000000..5303cd44 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/MultiKnowledgeRetrievalResult.java @@ -0,0 +1,46 @@ +package tech.easyflow.ai.easyagentsflow.knowledge; + +import com.easyagents.core.document.Document; + +import java.util.Collections; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 多知识库检索结果及可观察状态。 + */ +public class MultiKnowledgeRetrievalResult { + + private final List documents; + private final Map summary; + private final List> sourceStatuses; + + public MultiKnowledgeRetrievalResult( + List documents, + Map summary, + List> sourceStatuses) { + this.documents = documents == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(documents)); + this.summary = summary == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(summary)); + this.sourceStatuses = sourceStatuses == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(sourceStatuses)); + } + + public List getDocuments() { + return documents; + } + + public Map getSummary() { + return summary; + } + + public List> getSourceStatuses() { + return sourceStatuses; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowKnowledgeContractService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowKnowledgeContractService.java new file mode 100644 index 00000000..253e028a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowKnowledgeContractService.java @@ -0,0 +1,579 @@ +package tech.easyflow.ai.easyagentsflow.knowledge; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.springframework.stereotype.Service; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 工作流多知识库 Embedding 契约校验与发布快照服务。 + */ +@Service +public class WorkflowKnowledgeContractService { + + public static final String SNAPSHOT_KEY = "knowledgeContracts"; + + private final DocumentCollectionService documentCollectionService; + private final ModelService modelService; + + public WorkflowKnowledgeContractService( + DocumentCollectionService documentCollectionService, + ModelService modelService) { + this.documentCollectionService = documentCollectionService; + this.modelService = modelService; + } + + /** + * 批量计算设计器中真正具备向量检索条件的知识库。 + * + * @param collections 候选知识库 + * @param tenantId 当前租户 + * @return 可用知识库 ID + */ + public Set findVectorReadyKnowledgeIds( + List collections, + BigInteger tenantId) { + if (collections == null || collections.isEmpty()) { + return Collections.emptySet(); + } + Map models = loadModels(collections); + Set result = new LinkedHashSet<>(); + for (DocumentCollection collection : collections) { + if (isVectorReady(collection, models, tenantId)) { + result.add(collection.getId()); + } + } + return result; + } + + /** + * 校验保存阶段全部多知识库节点的 Embedding 契约。 + * + * @param knowledgeGroups 各知识库节点的有序引用 + * @param tenantId 当前租户 + */ + public void assertMultiKnowledgeContracts( + List> knowledgeGroups, + BigInteger tenantId) { + resolveContext(onlyMultiGroups(knowledgeGroups), tenantId); + } + + /** + * 解析工作流引用的全部知识库,并校验存在性与租户归属。 + * + * @param content 工作流内容 + * @param tenantId 工作流租户 + * @return 按工作流首次引用顺序排列的知识库 + */ + public List resolveReferencedCollections( + String content, + BigInteger tenantId) { + List> groups = readKnowledgeGroups(content); + if (groups.isEmpty()) { + return List.of(); + } + Set ids = new LinkedHashSet<>(); + groups.forEach(ids::addAll); + Map collections = loadCollections( + ids, tenantId); + return ids.stream().map(collections::get).toList(); + } + + /** + * 校验工作流内容并生成不包含凭据的多知识库发布契约。 + * + * @param content 工作流内容 + * @param tenantId 工作流租户 + * @return 稳定的发布契约列表 + */ + public List> buildSnapshotContracts( + String content, + BigInteger tenantId) { + List> groups = readKnowledgeGroups(content).stream() + .filter(group -> group.size() > 1) + .toList(); + ContractContext context = resolveContext(groups, tenantId); + if (groups.isEmpty()) { + return List.of(); + } + Set orderedIds = new LinkedHashSet<>(); + groups.forEach(orderedIds::addAll); + List> result = new ArrayList<>(); + for (BigInteger id : orderedIds) { + result.add(toContract( + context.collections.get(id), + context.models, + tenantId)); + } + return List.copyOf(result); + } + + /** + * 审批真正发布前重新校验提交快照与当前知识库契约是否一致。 + * + * @param resourceSnapshot 待发布工作流快照 + */ + public void assertSnapshotCurrent(Map resourceSnapshot) { + if (resourceSnapshot == null) { + throw new BusinessException("工作流发布快照不能为空"); + } + String content = text(resourceSnapshot.get("content")); + BigInteger tenantId = bigInteger(resourceSnapshot.get("tenantId")); + List> current = buildSnapshotContracts( + content, tenantId); + List> frozen = readSnapshotContracts( + resourceSnapshot.get(SNAPSHOT_KEY)); + if (!current.equals(frozen)) { + throw new BusinessException("工作流引用的知识库 Embedding 配置已变化,请重新提交发布"); + } + } + + /** + * 已发布工作流运行时校验本节点引用仍与发布契约一致。 + * + * @param publishedSnapshot 已发布工作流快照 + * @param collections 当前节点知识库 + */ + public Model assertPublishedContracts( + Map publishedSnapshot, + List collections) { + if (collections == null || collections.size() < 2) { + throw new BusinessException("多知识库检索至少需要两个知识库"); + } + if (publishedSnapshot == null || publishedSnapshot.isEmpty()) { + throw new BusinessException("已发布工作流缺少知识库契约"); + } + BigInteger tenantId = bigInteger(publishedSnapshot.get("tenantId")); + ContractContext context = resolveLoadedContext(collections, tenantId); + Model embeddingModel = requireCompatibleEmbeddingModel( + collections, context, tenantId); + Map> frozenById = new LinkedHashMap<>(); + for (Map contract : readSnapshotContracts( + publishedSnapshot.get(SNAPSHOT_KEY))) { + frozenById.put(text(contract.get("knowledgeId")), contract); + } + for (DocumentCollection collection : collections) { + Map frozen = frozenById.get( + String.valueOf(collection.getId())); + Map current = toContract( + collection, context.models, tenantId); + if (!current.equals(frozen)) { + throw new BusinessException("已发布工作流的知识库 Embedding 配置已变化,请重新发布"); + } + } + return embeddingModel; + } + + /** + * 校验 Agent 冻结定义中的契约指纹与当前有效配置一致。 + * + * @param expectedFingerprint 冻结定义中的契约指纹 + * @param collections 定义引用的全部多知识库 + * @param tenantId 冻结定义租户 + */ + public Model assertFrozenContractFingerprint( + String expectedFingerprint, + List allCollections, + List currentCollections, + BigInteger tenantId) { + ContractContext context = resolveLoadedContext( + allCollections, tenantId); + Model embeddingModel = requireCompatibleEmbeddingModel( + currentCollections, context, tenantId); + List> current = new ArrayList<>(); + for (DocumentCollection collection : allCollections) { + current.add(toContract(collection, context.models, tenantId)); + } + if (!Objects.equals(expectedFingerprint, fingerprint(current))) { + throw new BusinessException("Agent 冻结工作流的知识库 Embedding 配置已变化,请重新发布 Agent"); + } + return embeddingModel; + } + + /** + * 使用已加载的知识库快照校验当前多库契约,并返回同一次批量读取的模型快照。 + * + * @param collections 当前节点知识库快照 + * @param tenantId 执行租户 + * @return 已校验的 Embedding 模型快照 + */ + public Model requireCompatibleEmbeddingModel( + List collections, + BigInteger tenantId) { + ContractContext context = resolveLoadedContext(collections, tenantId); + return requireCompatibleEmbeddingModel( + collections, context, tenantId); + } + + /** + * 计算发布快照中规范知识库契约的稳定指纹。 + * + * @param contracts 发布快照契约 + * @return SHA-256 十六进制指纹 + */ + public String fingerprintSnapshotContracts(Object contracts) { + return fingerprint(readSnapshotContracts(contracts)); + } + + private ContractContext resolveContext( + List> groups, + BigInteger tenantId) { + if (groups == null || groups.isEmpty()) { + return ContractContext.empty(); + } + if (tenantId == null) { + throw new BusinessException("工作流租户不能为空"); + } + Set ids = new LinkedHashSet<>(); + groups.forEach(ids::addAll); + Map collections = loadCollections( + ids, tenantId); + Map models = loadModels(collections.values()); + for (List group : groups) { + assertCompatibleGroup(group, collections, models, tenantId); + } + return new ContractContext(collections, models); + } + + private ContractContext resolveLoadedContext( + List collections, + BigInteger tenantId) { + if (collections == null || collections.isEmpty()) { + throw new BusinessException("工作流引用的知识库不存在或已失效"); + } + if (tenantId == null) { + throw new BusinessException("工作流租户不能为空"); + } + Map byId = new LinkedHashMap<>(); + for (DocumentCollection collection : collections) { + if (collection == null || collection.getId() == null) { + throw new BusinessException("工作流引用的知识库不存在或已失效"); + } + if (!Objects.equals(tenantId, collection.getTenantId())) { + throw new BusinessException("工作流引用了其他租户的知识库"); + } + if (byId.put(collection.getId(), collection) != null) { + throw new BusinessException("知识库节点不能重复选择同一知识库"); + } + } + return new ContractContext(byId, loadModels(collections)); + } + + private Model requireCompatibleEmbeddingModel( + List collections, + ContractContext context, + BigInteger tenantId) { + if (collections == null || collections.size() < 2) { + throw new BusinessException("多知识库检索至少需要两个知识库"); + } + List ids = collections.stream() + .map(DocumentCollection::getId) + .toList(); + assertCompatibleGroup( + ids, context.collections, context.models, tenantId); + Model embeddingModel = context.models.get( + collections.get(0).getVectorEmbedModelId()); + if (embeddingModel == null) { + throw new BusinessException("知识库 Embedding 模型不存在或已失效"); + } + return embeddingModel; + } + + private void assertCompatibleGroup( + List group, + Map collections, + Map models, + BigInteger tenantId) { + DocumentCollection first = collections.get(group.get(0)); + if (!isVectorReady(first, models, tenantId)) { + throw new BusinessException("知识库未完成有效的向量检索配置: " + first.getTitle()); + } + for (BigInteger id : group) { + DocumentCollection current = collections.get(id); + if (!isVectorReady(current, models, tenantId)) { + throw new BusinessException("知识库未完成有效的向量检索配置: " + current.getTitle()); + } + if (!Objects.equals( + first.getVectorEmbedModelId(), + current.getVectorEmbedModelId()) + || !Objects.equals( + first.getDimensionOfVectorModel(), + current.getDimensionOfVectorModel()) + || !Objects.equals( + first.getTenantId(), current.getTenantId())) { + throw new BusinessException("多知识库检索要求使用相同的 Embedding 模型和向量维度"); + } + } + } + + private boolean isVectorReady( + DocumentCollection collection, + Map models, + BigInteger tenantId) { + if (collection == null + || collection.getId() == null + || !Objects.equals(collection.getTenantId(), tenantId) + || !Boolean.TRUE.equals(collection.getVectorStoreEnable()) + || collection.getVectorEmbedModelId() == null + || collection.getDimensionOfVectorModel() == null + || collection.getDimensionOfVectorModel() <= 0 + || collection.getVectorStoreCollection() == null + || collection.getVectorStoreCollection().isBlank()) { + return false; + } + Model model = models.get(collection.getVectorEmbedModelId()); + return model != null + && Model.MODEL_TYPES[1].equals(model.getModelType()) + && model.getModelProvider() != null + && text(model.getModelProvider().getProviderType()) != null + && Objects.equals(model.getTenantId(), tenantId); + } + + private Map loadModels( + Collection collections) { + Set modelIds = new LinkedHashSet<>(); + for (DocumentCollection collection : collections) { + if (collection != null && collection.getVectorEmbedModelId() != null) { + modelIds.add(collection.getVectorEmbedModelId()); + } + } + if (modelIds.isEmpty()) { + return Collections.emptyMap(); + } + List loaded = modelService.listModelInstances(modelIds); + Map result = new LinkedHashMap<>(); + if (loaded != null) { + for (Model model : loaded) { + if (model != null && model.getId() != null) { + result.put(model.getId(), model); + } + } + } + return result; + } + + private Map toContract( + DocumentCollection collection, + Map models, + BigInteger tenantId) { + if (!isVectorReady(collection, models, tenantId)) { + throw new BusinessException("知识库未完成有效的向量检索配置: " + + (collection == null ? "" : collection.getTitle())); + } + Model model = models.get(collection.getVectorEmbedModelId()); + Map contract = new LinkedHashMap<>(); + contract.put("knowledgeId", String.valueOf(collection.getId())); + contract.put("tenantId", String.valueOf(collection.getTenantId())); + contract.put("embeddingModelId", String.valueOf(model.getId())); + contract.put("embeddingDimension", collection.getDimensionOfVectorModel()); + contract.put("vectorStoreCollection", collection.getVectorStoreCollection()); + contract.put("vectorStoreType", nullableText(collection.getVectorStoreType())); + contract.put("modelProviderId", nullableString(model.getProviderId())); + contract.put( + "modelProviderType", + nullableText(model.getModelProvider().getProviderType())); + contract.put("modelType", model.getModelType()); + contract.put("modelName", nullableText(model.getModelName())); + contract.put("modelEndpoint", nullableText(model.getEndpoint())); + contract.put("modelRequestPath", nullableText(model.getRequestPath())); + return contract; + } + + private Map loadCollections( + Set ids, + BigInteger tenantId) { + if (tenantId == null) { + throw new BusinessException("工作流租户不能为空"); + } + List loaded = documentCollectionService.listByIds(ids); + Map collections = new LinkedHashMap<>(); + if (loaded != null) { + for (DocumentCollection collection : loaded) { + if (collection != null && collection.getId() != null) { + collections.put(collection.getId(), collection); + } + } + } + if (collections.size() != ids.size()) { + throw new BusinessException("工作流引用的知识库不存在或已失效"); + } + for (DocumentCollection collection : collections.values()) { + if (!Objects.equals(tenantId, collection.getTenantId())) { + throw new BusinessException("工作流引用了其他租户的知识库"); + } + } + return collections; + } + + private List> readKnowledgeGroups(String content) { + if (content == null || content.isBlank()) { + return List.of(); + } + JSONObject root; + try { + root = JSON.parseObject(content); + } catch (RuntimeException exception) { + throw new BusinessException("工作流内容不是合法JSON"); + } + JSONArray nodes = root.getJSONArray("nodes"); + if (nodes == null || nodes.isEmpty()) { + return List.of(); + } + List> groups = new ArrayList<>(); + for (int index = 0; index < nodes.size(); index++) { + JSONObject node = nodes.getJSONObject(index); + JSONObject data = node == null ? null : node.getJSONObject("data"); + if (data == null || !"knowledgeNode".equals(nodeType(node, data))) { + continue; + } + List group = new ArrayList<>(); + Set unique = new LinkedHashSet<>(); + Object rawIds = data.get("knowledgeIds"); + if (rawIds instanceof JSONArray ids && !ids.isEmpty()) { + for (Object rawId : ids) { + BigInteger id = bigInteger(rawId); + if (!unique.add(id)) { + throw new BusinessException("知识库节点不能重复选择同一知识库"); + } + group.add(id); + } + } else { + group.add(bigInteger(data.get("knowledgeId"))); + } + groups.add(List.copyOf(group)); + } + return List.copyOf(groups); + } + + private List> onlyMultiGroups( + List> knowledgeGroups) { + if (knowledgeGroups == null || knowledgeGroups.isEmpty()) { + return List.of(); + } + return knowledgeGroups.stream() + .filter(Objects::nonNull) + .filter(group -> group.size() > 1) + .map(List::copyOf) + .toList(); + } + + private List> readSnapshotContracts(Object value) { + if (value == null) { + return List.of(); + } + JSONArray array; + try { + array = value instanceof JSONArray jsonArray + ? jsonArray + : JSON.parseArray(JSON.toJSONString(value)); + } catch (RuntimeException exception) { + throw new BusinessException("工作流知识库发布契约无效"); + } + List> result = new ArrayList<>(); + for (int index = 0; index < array.size(); index++) { + JSONObject object = array.getJSONObject(index); + if (object == null) { + throw new BusinessException("工作流知识库发布契约无效"); + } + Map contract = new LinkedHashMap<>(); + contract.put("knowledgeId", text(object.get("knowledgeId"))); + contract.put("tenantId", text(object.get("tenantId"))); + contract.put("embeddingModelId", text(object.get("embeddingModelId"))); + contract.put("embeddingDimension", object.getInteger("embeddingDimension")); + contract.put("vectorStoreCollection", nullableText(object.getString("vectorStoreCollection"))); + contract.put("vectorStoreType", nullableText(object.getString("vectorStoreType"))); + contract.put("modelProviderId", nullableText(object.getString("modelProviderId"))); + contract.put("modelProviderType", nullableText(object.getString("modelProviderType"))); + contract.put("modelType", nullableText(object.getString("modelType"))); + contract.put("modelName", nullableText(object.getString("modelName"))); + contract.put("modelEndpoint", nullableText(object.getString("modelEndpoint"))); + contract.put("modelRequestPath", nullableText(object.getString("modelRequestPath"))); + result.add(contract); + } + return List.copyOf(result); + } + + private String nodeType(JSONObject node, JSONObject data) { + String rootType = text(node.getString("type")); + String dataType = text(data.getString("type")); + if (rootType != null && dataType != null + && !Objects.equals(rootType, dataType)) { + throw new BusinessException("工作流节点类型与节点数据类型不一致"); + } + return rootType == null ? dataType : rootType; + } + + private BigInteger bigInteger(Object value) { + String normalized = text(value); + if (normalized == null) { + throw new BusinessException("知识库或租户 ID 无效"); + } + try { + BigInteger result = new BigInteger(normalized); + if (result.signum() <= 0) { + throw new NumberFormatException("non-positive"); + } + return result; + } catch (NumberFormatException exception) { + throw new BusinessException("知识库或租户 ID 无效"); + } + } + + private String text(Object value) { + if (value == null) { + return null; + } + String result = String.valueOf(value).trim(); + return result.isEmpty() ? null : result; + } + + private String nullableText(String value) { + return text(value); + } + + private String nullableString(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String fingerprint(Object value) { + try { + byte[] bytes = JSON.toJSONString(value) + .getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private record ContractContext( + Map collections, + Map models) { + + private static ContractContext empty() { + return new ContractContext( + Collections.emptyMap(), + Collections.emptyMap()); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowMultiKnowledgeRetrievalService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowMultiKnowledgeRetrievalService.java new file mode 100644 index 00000000..74b1f498 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowMultiKnowledgeRetrievalService.java @@ -0,0 +1,1097 @@ +package tech.easyflow.ai.easyagentsflow.knowledge; + +import com.easyagents.core.document.Document; +import com.easyagents.core.model.embedding.EmbeddingModel; +import com.easyagents.core.store.StoreTimeoutException; +import com.easyagents.core.store.VectorData; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.ChainStatus; +import com.easyagents.flow.core.chain.Node; +import com.easyagents.flow.core.node.KnowledgeNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import tech.easyflow.ai.config.MultiKnowledgeRetrievalProperties; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry; +import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; +import tech.easyflow.ai.rag.KnowledgeVectorCandidateRequest; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.util.StringUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletionService; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * 工作流多知识库纯向量召回与全局排序服务。 + */ +@Service +public class WorkflowMultiKnowledgeRetrievalService { + + private static final Logger LOG = LoggerFactory.getLogger( + WorkflowMultiKnowledgeRetrievalService.class); + private static final String CALLER_TYPE = "WORKFLOW"; + + private final DocumentCollectionService documentCollectionService; + private final MultiKnowledgeRetrievalProperties properties; + private final Executor executor; + private final WorkflowService workflowService; + private final ResourceAccessService resourceAccessService; + private final WorkflowKnowledgeContractService workflowKnowledgeContractService; + + public WorkflowMultiKnowledgeRetrievalService( + DocumentCollectionService documentCollectionService, + WorkflowService workflowService, + ResourceAccessService resourceAccessService, + WorkflowKnowledgeContractService workflowKnowledgeContractService, + MultiKnowledgeRetrievalProperties properties, + @Qualifier("knowledgeRetrievalExecutor") Executor executor) { + this.documentCollectionService = documentCollectionService; + this.workflowService = workflowService; + this.resourceAccessService = resourceAccessService; + this.workflowKnowledgeContractService = workflowKnowledgeContractService; + this.properties = properties; + this.executor = executor; + this.properties.validate(); + } + + /** + * 并发召回多个知识库并按向量存储返回分数的原始精度生成全局 TopK。 + * + * @param knowledgeIds 知识库 ID + * @param query 查询词 + * @param globalLimit 全局最终条数 + * @param callerId 工作流节点 ID + * @param chain 当前工作流执行链 + * @return 检索结果 + */ + public MultiKnowledgeRetrievalResult search( + List knowledgeIds, + String query, + int globalLimit, + String callerId, + Chain chain) { + long startedAt = System.nanoTime(); + long totalDeadline = deadlineAfter( + startedAt, properties.getTotalTimeout().toNanos()); + List normalizedIds = normalizeIds(knowledgeIds); + if (normalizedIds.size() < 2) { + throw new BusinessException("多知识库检索至少需要两个知识库"); + } + if (normalizedIds.size() > properties.getMaxSources()) { + throw new BusinessException("选择的知识库数量超过平台上限"); + } + if (globalLimit <= 0 || globalLimit > properties.getTotalCandidateLimit()) { + throw new BusinessException("最终返回条数超出允许范围"); + } + + RuntimeContext runtimeContext = validateRuntimeContext( + normalizedIds, chain); + List collections = runtimeContext.collections(); + boolean monitorCancellation = prepareCancellationMonitoring(chain); + ensureBeforeDeadline(totalDeadline); + if (StringUtil.noText(query)) { + return emptyResult(collections, startedAt); + } + float[] queryVector = embedQueryWithDeadline( + collections.get(0), + runtimeContext.embeddingModel(), + query, + totalDeadline, + chain, + monitorCancellation); + int candidateLimit = Math.min( + properties.getPerSourceCandidateLimit(), + Math.max(globalLimit, + Math.multiplyExact(globalLimit, + properties.getCandidateMultiplier()))); + + CompletionService completion = + new ExecutorCompletionService<>(executor); + Map, SourceExecution> futures = + new LinkedHashMap<>(); + Map results = new LinkedHashMap<>(); + long perSourceTimeoutNanos = + properties.getPerSourceTimeout().toNanos(); + for (DocumentCollection collection : collections) { + try { + SourceExecution execution = new SourceExecution(collection); + Future future = completion.submit(() -> { + long sourceStartedAt = execution.markStarted(); + long sourceDeadline = Math.min( + totalDeadline, + deadlineAfter( + sourceStartedAt, + perSourceTimeoutNanos)); + try { + return searchSource( + collection, + query, + queryVector, + candidateLimit, + callerId, + sourceDeadline); + } finally { + execution.markCompleted(); + } + }); + futures.put(future, execution); + } catch (RuntimeException exception) { + cancelAll(futures.keySet()); + throw exception; + } + } + Set> pending = + new LinkedHashSet<>(futures.keySet()); + try { + while (!pending.isEmpty()) { + ensureNotCancelled( + chain, monitorCancellation, futures.keySet()); + long now = System.nanoTime(); + expireTimedOutSources( + pending, + futures, + results, + futures.keySet(), + now, + perSourceTimeoutNanos, + totalDeadline, + startedAt); + if (pending.isEmpty()) { + break; + } + if (now >= totalDeadline) { + expireRemainingSources( + pending, + futures, + results, + futures.keySet(), + perSourceTimeoutNanos, + totalDeadline, + startedAt); + break; + } + long remaining = nanosUntilNextDeadline( + pending, + futures, + now, + perSourceTimeoutNanos, + totalDeadline); + Future completedFuture = completion.poll( + remaining, TimeUnit.NANOSECONDS); + if (completedFuture == null) { + continue; + } + if (!pending.remove(completedFuture)) { + continue; + } + collectCompletedSource( + completedFuture, + futures.get(completedFuture), + results, + futures.keySet(), + perSourceTimeoutNanos, + totalDeadline, + startedAt); + } + } catch (InterruptedException exception) { + cancelAll(futures.keySet()); + Thread.currentThread().interrupt(); + throw new BusinessException("多知识库检索已中断"); + } + + List orderedResults = new ArrayList<>(); + for (DocumentCollection collection : collections) { + orderedResults.add(results.get(collection.getId())); + } + long successfulSources = orderedResults.stream() + .filter(SourceResult::isSuccessful) + .count(); + if (successfulSources == 0L) { + throw new BusinessException("全部知识库检索失败或超时"); + } + + List documents = rankGlobally(orderedResults, globalLimit); + int failedSources = orderedResults.size() - (int) successfulSources; + int candidateCount = orderedResults.stream() + .mapToInt(result -> result.documents.size()) + .sum(); + Map summary = new LinkedHashMap<>(); + summary.put("requestedSourceCount", orderedResults.size()); + summary.put("successfulSourceCount", (int) successfulSources); + summary.put("failedSourceCount", failedSources); + summary.put("candidateCount", candidateCount); + summary.put("resultCount", documents.size()); + summary.put("partialFailure", failedSources > 0); + summary.put("elapsedMillis", elapsedMillis(startedAt)); + + List> statuses = orderedResults.stream() + .map(SourceResult::toStatusMap) + .toList(); + LOG.info( + "Workflow multi-knowledge retrieval completed, callerId={}, requestedSources={}, successfulSources={}, failedSources={}, candidates={}, results={}, elapsedMillis={}, sourceStatuses={}", + callerId, + orderedResults.size(), + successfulSources, + failedSources, + candidateCount, + documents.size(), + summary.get("elapsedMillis"), + statuses); + return new MultiKnowledgeRetrievalResult(documents, summary, statuses); + } + + private SourceResult searchSource( + DocumentCollection collection, + String query, + float[] queryVector, + int candidateLimit, + String callerId, + long sourceDeadline) { + long startedAt = System.nanoTime(); + try { + KnowledgeVectorCandidateRequest request = + new KnowledgeVectorCandidateRequest(); + request.setKnowledgeId(collection.getId()); + request.setCollection(collection); + request.setQuery(query); + request.setLimit(candidateLimit); + request.setMinVectorScore(properties.getMinVectorScore()); + request.setQueryVector(queryVector); + request.setTimeoutMillis(remainingMillis(sourceDeadline)); + request.setCallerType(CALLER_TYPE); + request.setCallerId(callerId); + List documents = + documentCollectionService.searchVectorCandidates(request); + return SourceResult.succeeded( + collection, + documents, + elapsedMillis(startedAt)); + } catch (BusinessException exception) { + throw exception; + } catch (StoreTimeoutException exception) { + LOG.warn( + "Workflow knowledge source retrieval timed out, callerId={}, knowledgeId={}, elapsedMillis={}", + callerId, + collection.getId(), + elapsedMillis(startedAt)); + return SourceResult.timedOut( + collection, elapsedMillis(startedAt)); + } catch (RuntimeException exception) { + LOG.warn( + "Workflow knowledge source retrieval failed, callerId={}, knowledgeId={}, errorType={}, message={}", + callerId, + collection.getId(), + exception.getClass().getSimpleName(), + sanitizeError(exception), + sanitizedLogException(exception)); + return SourceResult.failed( + collection, exception, elapsedMillis(startedAt)); + } + } + + private List rankGlobally( + List sourceResults, + int globalLimit) { + Map byResource = new LinkedHashMap<>(); + for (SourceResult sourceResult : sourceResults) { + if (!sourceResult.isSuccessful()) { + continue; + } + for (Document document : sourceResult.documents) { + RankedCandidate candidate = RankedCandidate.from( + document, sourceResult.collection); + if (!candidate.hasValidScore(properties.getMinVectorScore())) { + continue; + } + String resourceKey = candidate.resourceKey(); + RankedCandidate existing = byResource.get(resourceKey); + if (existing == null || candidate.score > existing.score) { + if (existing != null) { + candidate.references.addAll(existing.references); + } + byResource.put(resourceKey, candidate); + } else { + existing.references.addAll(candidate.references); + } + } + } + + List sorted = new ArrayList<>(byResource.values()); + sorted.sort(RankedCandidate.ORDER); + if (sorted.size() > properties.getTotalCandidateLimit()) { + sorted = new ArrayList<>(sorted.subList( + 0, properties.getTotalCandidateLimit())); + } + + Map byContent = new LinkedHashMap<>(); + for (RankedCandidate candidate : sorted) { + String contentKey = candidate.normalizedContent(); + RankedCandidate existing = byContent.get(contentKey); + if (existing == null) { + byContent.put(contentKey, candidate); + } else { + existing.references.addAll(candidate.references); + } + } + + List result = new ArrayList<>(); + for (RankedCandidate candidate : byContent.values()) { + if (result.size() >= globalLimit) { + break; + } + int globalRank = result.size() + 1; + candidate.document.addMetadata("vectorScore", candidate.score); + candidate.document.addMetadata("globalRank", globalRank); + candidate.document.addMetadata( + "sourceReferences", + candidate.distinctReferences()); + result.add(candidate.document); + } + return result; + } + + private List loadCollections(List ids) { + List loaded = documentCollectionService.listByIds(ids); + Map byId = new LinkedHashMap<>(); + if (loaded != null) { + for (DocumentCollection collection : loaded) { + if (collection != null && collection.getId() != null) { + byId.put(collection.getId(), collection); + } + } + } + List ordered = new ArrayList<>(); + for (BigInteger id : ids) { + DocumentCollection collection = byId.get(id); + if (collection == null) { + throw new BusinessException("知识库不存在或已失效: " + id); + } + ordered.add(collection); + } + return ordered; + } + + private RuntimeContext validateRuntimeContext( + List knowledgeIds, + Chain chain) { + if (chain == null || chain.getDefinition() == null + || !StringUtil.hasText(chain.getDefinition().getId())) { + throw new BusinessException("缺少工作流执行上下文"); + } + String definitionId = chain.getDefinition().getId(); + boolean frozen = FrozenWorkflowDefinitionRegistry + .isFrozenDefinitionId(definitionId); + boolean published = PublishedWorkflowDefinitionIds.isPublished( + definitionId); + FrozenWorkflowDefinitionRegistry.FrozenDefinitionIdentity frozenIdentity = + frozen ? parseFrozenIdentity(definitionId) : null; + Workflow workflow = frozen ? null : loadWorkflow(definitionId); + if (!frozen && workflow == null) { + throw new BusinessException("工作流不存在或已失效"); + } + if (!frozen && published && (!PublishStatus.PUBLISHED.getCode().equals( + workflow.getPublishStatus()) + || workflow.getPublishedSnapshotJson() == null + || workflow.getPublishedSnapshotJson().isEmpty())) { + throw new BusinessException("已发布工作流不存在或已失效"); + } + LoginAccount account = resolveAccount(chain); + if (account == null || account.getId() == null + || account.getTenantId() == null) { + throw new BusinessException(403, 403, "无权限使用工作流知识库"); + } + BigInteger executionTenantId = frozen + ? frozenIdentity.tenantId() + : workflow.getTenantId(); + if (!Objects.equals(executionTenantId, account.getTenantId())) { + throw new BusinessException(403, 403, "无权限使用工作流知识库"); + } + List allCollections = frozen + ? loadCollections(readFrozenKnowledgeIds(chain)) + : loadCollections(knowledgeIds); + List collections = frozen + ? selectCollections(knowledgeIds, allCollections) + : allCollections; + for (DocumentCollection collection : allCollections) { + if (!Objects.equals( + executionTenantId, collection.getTenantId())) { + throw new BusinessException(403, 403, "无权限使用工作流知识库"); + } + } + for (DocumentCollection collection : collections) { + if (!published && !frozen && !resourceAccessService.canAccess( + account, + CategoryResourceType.KNOWLEDGE, + collection, + ResourceAction.USE)) { + throw new BusinessException(403, 403, "无权限使用工作流知识库"); + } + } + Model embeddingModel; + if (frozen) { + embeddingModel = workflowKnowledgeContractService + .assertFrozenContractFingerprint( + frozenIdentity.knowledgeContractFingerprint(), + allCollections, + collections, + executionTenantId); + } else if (published) { + embeddingModel = workflowKnowledgeContractService + .assertPublishedContracts( + workflow.getPublishedSnapshotJson(), collections); + } else { + embeddingModel = workflowKnowledgeContractService + .requireCompatibleEmbeddingModel( + collections, executionTenantId); + } + return new RuntimeContext(collections, embeddingModel); + } + + private List selectCollections( + List ids, + List candidates) { + Map byId = new LinkedHashMap<>(); + candidates.forEach(collection -> byId.put( + collection.getId(), collection)); + List result = new ArrayList<>(); + for (BigInteger id : ids) { + DocumentCollection collection = byId.get(id); + if (collection == null) { + throw new BusinessException("Agent 冻结工作流知识库引用无效"); + } + result.add(collection); + } + return List.copyOf(result); + } + + private FrozenWorkflowDefinitionRegistry.FrozenDefinitionIdentity + parseFrozenIdentity(String definitionId) { + try { + return FrozenWorkflowDefinitionRegistry.parseIdentity(definitionId); + } catch (IllegalArgumentException exception) { + throw new BusinessException("Agent 冻结工作流执行上下文无效"); + } + } + + private List readFrozenKnowledgeIds(Chain chain) { + if (chain.getDefinition().getNodes() == null) { + throw new BusinessException("Agent 冻结工作流缺少知识库契约上下文"); + } + Set ids = new LinkedHashSet<>(); + for (Node node : chain.getDefinition().getNodes()) { + if (!(node instanceof KnowledgeNode knowledgeNode)) { + continue; + } + List nodeIds = knowledgeNode.getKnowledgeIds(); + if (nodeIds.size() < 2) { + continue; + } + for (Object value : nodeIds) { + try { + BigInteger id = new BigInteger(String.valueOf(value)); + if (id.signum() <= 0) { + throw new NumberFormatException("non-positive"); + } + ids.add(id); + } catch (RuntimeException exception) { + throw new BusinessException("Agent 冻结工作流知识库引用无效"); + } + } + } + if (ids.isEmpty()) { + throw new BusinessException("Agent 冻结工作流缺少知识库契约上下文"); + } + return List.copyOf(ids); + } + + private Workflow loadWorkflow(String definitionId) { + try { + BigInteger workflowId = new BigInteger( + PublishedWorkflowDefinitionIds.unwrap(definitionId)); + return workflowService.getById(workflowId); + } catch (RuntimeException exception) { + throw new BusinessException("工作流执行上下文无效"); + } + } + + private LoginAccount resolveAccount(Chain chain) { + if (chain.getExecutionState() == null + || chain.getExecutionState().getMemory() == null) { + return null; + } + Object value = chain.getExecutionState() + .getMemory() + .get(Constants.LOGIN_USER_KEY); + return value instanceof LoginAccount account ? account : null; + } + + private float[] embedQuery( + DocumentCollection collection, + Model model, + String query) { + EmbeddingModel embeddingModel = model.toEmbeddingModel(); + if (embeddingModel == null) { + throw new BusinessException("知识库 Embedding 模型不可用"); + } + VectorData vectorData = embeddingModel.embed(query); + float[] vector = vectorData == null ? null : vectorData.getVector(); + if (vector == null || vector.length == 0) { + throw new BusinessException("Embedding 模型未返回查询向量"); + } + if (!Objects.equals(vector.length, collection.getDimensionOfVectorModel())) { + throw new BusinessException("Embedding 查询向量维度与知识库不一致"); + } + return vector; + } + + private float[] embedQueryWithDeadline( + DocumentCollection collection, + Model model, + String query, + long totalDeadline, + Chain chain, + boolean monitorCancellation) { + FutureTask task = new FutureTask<>( + () -> embedQuery(collection, model, query)); + try { + executor.execute(task); + } catch (RuntimeException exception) { + task.cancel(true); + throw exception; + } + try { + while (true) { + ensureNotCancelled( + chain, + monitorCancellation, + Collections.singleton(task)); + long remainingNanos = totalDeadline - System.nanoTime(); + if (remainingNanos <= 0L) { + task.cancel(true); + throw new BusinessException("多知识库检索总耗时超时"); + } + try { + return task.get( + Math.min( + TimeUnit.MILLISECONDS.toNanos(100), + remainingNanos), + TimeUnit.NANOSECONDS); + } catch (TimeoutException ignored) { + // 短轮询用于及时响应工作流取消和总超时。 + } + } + } catch (InterruptedException exception) { + task.cancel(true); + Thread.currentThread().interrupt(); + throw new BusinessException("多知识库检索已中断"); + } catch (ExecutionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof BusinessException businessException) { + throw businessException; + } + throw new BusinessException( + "生成知识库查询向量失败"); + } + } + + private MultiKnowledgeRetrievalResult emptyResult( + List collections, + long startedAt) { + List> statuses = new ArrayList<>(); + for (DocumentCollection collection : collections) { + Map status = new LinkedHashMap<>(); + status.put("knowledgeId", collection.getId()); + status.put("knowledgeName", collection.getTitle()); + status.put("status", "EMPTY"); + status.put("candidateCount", 0); + status.put("elapsedMillis", elapsedMillis(startedAt)); + statuses.add(status); + } + Map summary = new LinkedHashMap<>(); + summary.put("requestedSourceCount", collections.size()); + summary.put("successfulSourceCount", collections.size()); + summary.put("failedSourceCount", 0); + summary.put("candidateCount", 0); + summary.put("resultCount", 0); + summary.put("partialFailure", false); + summary.put("elapsedMillis", elapsedMillis(startedAt)); + return new MultiKnowledgeRetrievalResult( + Collections.emptyList(), summary, statuses); + } + + private List normalizeIds(List ids) { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (ids != null) { + for (BigInteger id : ids) { + if (id != null) { + normalized.add(id); + } + } + } + return new ArrayList<>(normalized); + } + + private void cancelAll(Set> futures) { + for (Future future : futures) { + future.cancel(true); + } + } + + private void ensureNotCancelled( + Chain chain, + boolean monitorCancellation, + Set> futures) { + if (monitorCancellation && !chain.isExecutionActiveNow()) { + for (Future future : futures) { + future.cancel(true); + } + throw new BusinessException("工作流执行已取消"); + } + } + + private boolean prepareCancellationMonitoring(Chain chain) { + ChainState executionState = chain.getExecutionState(); + // executeNode 只初始化 READY 临时链,不进入完整执行生命周期。 + if (executionState != null + && executionState.getStatus() == ChainStatus.READY) { + return false; + } + ensureNotCancelled(chain, true, Collections.emptySet()); + return true; + } + + private void ensureBeforeDeadline(long deadline) { + if (System.nanoTime() >= deadline) { + throw new BusinessException("多知识库检索总耗时超时"); + } + } + + private void expireTimedOutSources( + Set> pending, + Map, SourceExecution> executions, + Map results, + Set> allFutures, + long now, + long perSourceTimeoutNanos, + long totalDeadline, + long requestStartedAt) throws InterruptedException { + List> expired = new ArrayList<>(); + for (Future future : pending) { + SourceExecution execution = executions.get(future); + if (execution.startedAt > 0L + && now - execution.startedAt >= perSourceTimeoutNanos) { + if (!execution.cancelIfIncomplete(future)) { + collectCompletedSource( + future, + execution, + results, + allFutures, + perSourceTimeoutNanos, + totalDeadline, + requestStartedAt); + } else { + results.put( + execution.collection.getId(), + SourceResult.timedOut( + execution.collection, + execution.elapsedMillis(requestStartedAt))); + } + expired.add(future); + } + } + pending.removeAll(expired); + } + + private void expireRemainingSources( + Set> pending, + Map, SourceExecution> executions, + Map results, + Set> allFutures, + long perSourceTimeoutNanos, + long totalDeadline, + long requestStartedAt) throws InterruptedException { + for (Future future : pending) { + SourceExecution execution = executions.get(future); + if (!execution.cancelIfIncomplete(future)) { + collectCompletedSource( + future, + execution, + results, + allFutures, + perSourceTimeoutNanos, + totalDeadline, + requestStartedAt); + } else { + results.put( + execution.collection.getId(), + SourceResult.timedOut( + execution.collection, + execution.elapsedMillis(requestStartedAt))); + } + } + pending.clear(); + } + + private void collectCompletedSource( + Future future, + SourceExecution execution, + Map results, + Set> allFutures, + long perSourceTimeoutNanos, + long totalDeadline, + long requestStartedAt) throws InterruptedException { + if (!execution.completedWithin(perSourceTimeoutNanos, totalDeadline)) { + results.put( + execution.collection.getId(), + SourceResult.timedOut( + execution.collection, + execution.elapsedMillis(requestStartedAt))); + return; + } + try { + results.put(execution.collection.getId(), future.get()); + } catch (CancellationException exception) { + results.put( + execution.collection.getId(), + SourceResult.timedOut( + execution.collection, + execution.elapsedMillis(requestStartedAt))); + } catch (ExecutionException exception) { + if (exception.getCause() instanceof BusinessException + businessException) { + cancelAll(allFutures); + throw businessException; + } + results.put( + execution.collection.getId(), + SourceResult.failed( + execution.collection, + exception.getCause(), + execution.elapsedMillis(requestStartedAt))); + } + } + + private long nanosUntilNextDeadline( + Set> pending, + Map, SourceExecution> executions, + long now, + long perSourceTimeoutNanos, + long totalDeadline) { + long deadline = totalDeadline; + for (Future future : pending) { + SourceExecution execution = executions.get(future); + if (execution.startedAt > 0L) { + deadline = Math.min( + deadline, + deadlineAfter( + execution.startedAt, + perSourceTimeoutNanos)); + } + } + return Math.max(1L, Math.min( + TimeUnit.MILLISECONDS.toNanos(100), deadline - now)); + } + + private static long elapsedMillis(long startedAt) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + } + + private static long deadlineAfter(long startedAt, long timeoutNanos) { + if (timeoutNanos > 0L + && startedAt > Long.MAX_VALUE - timeoutNanos) { + return Long.MAX_VALUE; + } + return startedAt + timeoutNanos; + } + + private static long remainingMillis(long deadlineNanos) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0L) { + throw new StoreTimeoutException("知识库检索已超时"); + } + long millis = TimeUnit.NANOSECONDS.toMillis(remainingNanos); + return remainingNanos % TimeUnit.MILLISECONDS.toNanos(1L) == 0L + ? Math.max(1L, millis) + : Math.max(1L, millis + 1L); + } + + private static String sanitizeError(Throwable throwable) { + if (throwable == null) { + return "检索失败"; + } + return "知识库检索失败(" + + throwable.getClass().getSimpleName() + + ")"; + } + + private static RuntimeException sanitizedLogException( + RuntimeException exception) { + RuntimeException sanitized = new RuntimeException( + "Knowledge source retrieval failed"); + sanitized.setStackTrace(exception.getStackTrace()); + return sanitized; + } + + private static final class SourceExecution { + + private final DocumentCollection collection; + private volatile long startedAt; + private volatile long completedAt; + + private SourceExecution(DocumentCollection collection) { + this.collection = collection; + } + + private long markStarted() { + startedAt = System.nanoTime(); + return startedAt; + } + + private synchronized void markCompleted() { + completedAt = System.nanoTime(); + } + + private synchronized boolean cancelIfIncomplete(Future future) { + if (completedAt > 0L || future.isDone()) { + return false; + } + return future.cancel(true); + } + + private boolean completedWithin( + long perSourceTimeoutNanos, long totalDeadline) { + if (startedAt <= 0L || completedAt <= 0L) { + return false; + } + long sourceDeadline = deadlineAfter( + startedAt, perSourceTimeoutNanos); + return completedAt <= sourceDeadline + && completedAt <= totalDeadline; + } + + private long elapsedMillis(long requestStartedAt) { + return WorkflowMultiKnowledgeRetrievalService.elapsedMillis( + startedAt > 0L ? startedAt : requestStartedAt); + } + } + + private static final class SourceResult { + + private final DocumentCollection collection; + private final String status; + private final List documents; + private final String error; + private final long elapsedMillis; + + private SourceResult( + DocumentCollection collection, + String status, + List documents, + String error, + long elapsedMillis) { + this.collection = collection; + this.status = status; + this.documents = documents == null + ? Collections.emptyList() + : documents; + this.error = error; + this.elapsedMillis = elapsedMillis; + } + + private static SourceResult succeeded( + DocumentCollection collection, + List documents, + long elapsedMillis) { + List result = documents == null + ? Collections.emptyList() + : documents; + return new SourceResult( + collection, + result.isEmpty() ? "EMPTY" : "SUCCEEDED", + result, + null, + elapsedMillis); + } + + private static SourceResult failed( + DocumentCollection collection, + Throwable throwable, + long elapsedMillis) { + return new SourceResult( + collection, + "FAILED", + Collections.emptyList(), + sanitizeError(throwable), + elapsedMillis); + } + + private static SourceResult timedOut( + DocumentCollection collection, + long elapsedMillis) { + return new SourceResult( + collection, + "TIMED_OUT", + Collections.emptyList(), + "知识库检索超时", + elapsedMillis); + } + + private boolean isSuccessful() { + return "SUCCEEDED".equals(status) || "EMPTY".equals(status); + } + + private Map toStatusMap() { + Map result = new LinkedHashMap<>(); + result.put("knowledgeId", collection.getId()); + result.put("knowledgeName", collection.getTitle()); + result.put("status", status); + result.put("candidateCount", documents.size()); + result.put("elapsedMillis", elapsedMillis); + if (error != null) { + result.put("error", error); + } + return result; + } + } + + private record RuntimeContext( + List collections, + Model embeddingModel) { + } + + private static final class RankedCandidate { + + private static final Comparator ORDER = + Comparator.comparingDouble((RankedCandidate item) -> item.score) + .reversed() + .thenComparing(item -> item.knowledgeId.toString()) + .thenComparing(item -> stableValue(item.documentId)) + .thenComparing(item -> stableValue(item.chunkId)); + + private final Document document; + private final BigInteger knowledgeId; + private final Object documentId; + private final Object chunkId; + private final double score; + private final List> references = new ArrayList<>(); + + private RankedCandidate( + Document document, + BigInteger knowledgeId, + Object documentId, + Object chunkId, + double score) { + this.document = document; + this.knowledgeId = knowledgeId; + this.documentId = documentId; + this.chunkId = chunkId; + this.score = score; + } + + private static RankedCandidate from( + Document document, + DocumentCollection collection) { + Object documentId = metadata(document, "documentId", document.getId()); + Object chunkId = metadata(document, "chunkId", null); + Double score = document.getScore(); + RankedCandidate candidate = new RankedCandidate( + document, + collection.getId(), + documentId, + chunkId, + score == null ? Double.NaN : score); + Map reference = new LinkedHashMap<>(); + reference.put("knowledgeId", collection.getId()); + reference.put("knowledgeName", collection.getTitle()); + reference.put("documentId", documentId); + reference.put("chunkId", chunkId); + candidate.references.add(reference); + return candidate; + } + + private boolean hasValidScore(double minScore) { + return Double.isFinite(score) && score >= minScore; + } + + private String resourceKey() { + if (hasStableValue(chunkId)) { + return knowledgeId + ":chunk:" + stableValue(chunkId); + } + if (hasStableValue(documentId)) { + return knowledgeId + ":document:" + stableValue(documentId); + } + return knowledgeId + ":result:" + stableValue(document.getId()); + } + + private String normalizedContent() { + String content = document.getContent(); + if (content == null) { + return "__resource__" + resourceKey(); + } + String normalized = content + .replace("\r\n", "\n") + .replace('\r', '\n') + .trim(); + return normalized.isEmpty() + ? "__resource__" + resourceKey() + : normalized; + } + + private List> distinctReferences() { + Map> unique = new LinkedHashMap<>(); + for (Map reference : references) { + String key = stableValue(reference.get("knowledgeId")) + ":" + + stableValue(reference.get("documentId")) + ":" + + stableValue(reference.get("chunkId")); + unique.putIfAbsent(key, reference); + } + return new ArrayList<>(unique.values()); + } + + private static Object metadata( + Document document, String key, Object defaultValue) { + if (document == null || document.getMetadataMap() == null) { + return defaultValue; + } + return document.getMetadataMap().getOrDefault(key, defaultValue); + } + + private static String stableValue(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static boolean hasStableValue(Object value) { + return value != null && !String.valueOf(value).isBlank(); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java index 5effcdc8..85f78756 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java @@ -1,5 +1,6 @@ package tech.easyflow.ai.easyagentsflow.repository; +import com.alibaba.fastjson2.JSON; import com.easyagents.flow.core.chain.ChainDefinition; import com.easyagents.flow.core.node.ConfirmNode; import com.easyagents.flow.core.parser.ChainParser; @@ -84,9 +85,35 @@ public class AgentWorkflowSnapshotFactory { snapshot.put("englishName", workflow.getEnglishName()); snapshot.put("revision", workflow.getRevision()); snapshot.put("content", prepared.content()); + snapshot.put("tenantId", workflow.getTenantId()); + snapshot.put( + "publishedSnapshotJson", + knowledgeRuntimeSnapshot(workflow)); return snapshot; } + /** + * 提取冻结执行所需的知识库契约,不携带工作流快照中的其他字段。 + * + * @param workflow 已发布工作流 + * @return 租户与知识库契约白名单 + */ + Map knowledgeRuntimeSnapshot(Workflow workflow) { + Map runtimeSnapshot = new LinkedHashMap<>(); + runtimeSnapshot.put("tenantId", workflow.getTenantId()); + Map publishedSnapshot = + workflow.getPublishedSnapshotJson(); + Object contracts = publishedSnapshot == null + ? null + : publishedSnapshot.get("knowledgeContracts"); + runtimeSnapshot.put( + "knowledgeContracts", + contracts == null + ? java.util.List.of() + : JSON.parse(JSON.toJSONString(contracts))); + return runtimeSnapshot; + } + /** * Agent 可执行 Workflow 的准备结果。 * diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java index 95fd7a2e..6549e947 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java @@ -2,8 +2,11 @@ package tech.easyflow.ai.easyagentsflow.repository; import com.easyagents.flow.core.chain.ChainDefinition; import org.springframework.stereotype.Component; +import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.common.web.exceptions.BusinessException; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -24,6 +27,7 @@ public class FrozenWorkflowDefinitionRegistry { private static final int MAX_ENTRIES = 512; private final AgentWorkflowSnapshotFactory snapshotFactory; + private final WorkflowKnowledgeContractService workflowKnowledgeContractService; private final Map definitions = new LinkedHashMap<>(32, 0.75F, true); private final Map workflows = @@ -33,9 +37,13 @@ public class FrozenWorkflowDefinitionRegistry { * 创建冻结定义注册表。 * * @param snapshotFactory Agent Workflow 冻结快照工厂 + * @param workflowKnowledgeContractService 知识库契约服务 */ - public FrozenWorkflowDefinitionRegistry(AgentWorkflowSnapshotFactory snapshotFactory) { + public FrozenWorkflowDefinitionRegistry( + AgentWorkflowSnapshotFactory snapshotFactory, + WorkflowKnowledgeContractService workflowKnowledgeContractService) { this.snapshotFactory = snapshotFactory; + this.workflowKnowledgeContractService = workflowKnowledgeContractService; } /** @@ -48,7 +56,19 @@ public class FrozenWorkflowDefinitionRegistry { public String register(Workflow workflow) { AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow); String preparedContent = prepared.content(); - String id = PREFIX + workflow.getId() + ":" + sha256(preparedContent); + Map runtimeSnapshot = + snapshotFactory.knowledgeRuntimeSnapshot(workflow); + if (workflow.getTenantId() == null + || workflow.getTenantId().signum() <= 0) { + throw new BusinessException("绑定工作流租户快照不完整,请重新发布工作流"); + } + String contractFingerprint = workflowKnowledgeContractService + .fingerprintSnapshotContracts( + runtimeSnapshot.get("knowledgeContracts")); + String id = PREFIX + workflow.getId() + + ":" + workflow.getTenantId() + + ":" + contractFingerprint + + ":" + sha256(preparedContent); synchronized (definitions) { if (definitions.containsKey(id)) { definitions.get(id); @@ -59,7 +79,7 @@ public class FrozenWorkflowDefinitionRegistry { definition.setName(workflow.getEnglishName()); definition.setDescription(workflow.getDescription()); definitions.put(id, definition); - workflows.put(id, workflow); + workflows.put(id, frozenWorkflow(workflow, runtimeSnapshot)); while (definitions.size() > MAX_ENTRIES) { String eldest = definitions.keySet().iterator().next(); definitions.remove(eldest); @@ -100,9 +120,66 @@ public class FrozenWorkflowDefinitionRegistry { * @return 是否冻结定义 */ public boolean isFrozen(String definitionId) { + return isFrozenDefinitionId(definitionId); + } + + /** + * 判断定义 ID 是否属于冻结 Agent 工作流,不触发注册表实例创建。 + * + * @param definitionId 定义 ID + * @return 是否冻结定义 + */ + public static boolean isFrozenDefinitionId(String definitionId) { return definitionId != null && definitionId.startsWith(PREFIX); } + /** + * 解析冻结定义中可独立校验的租户和知识库契约指纹。 + * + * @param definitionId 冻结定义 ID + * @return 冻结定义身份 + */ + public static FrozenDefinitionIdentity parseIdentity(String definitionId) { + if (!isFrozenDefinitionId(definitionId)) { + throw new IllegalArgumentException("Not a frozen workflow definition"); + } + String[] parts = definitionId.substring(PREFIX.length()) + .split(":", 4); + if (parts.length != 4 + || !parts[2].matches("[0-9a-f]{64}") + || !parts[3].matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Invalid frozen workflow definition"); + } + try { + BigInteger workflowId = new BigInteger(parts[0]); + BigInteger tenantId = new BigInteger(parts[1]); + if (workflowId.signum() <= 0 || tenantId.signum() <= 0) { + throw new NumberFormatException("non-positive"); + } + return new FrozenDefinitionIdentity( + workflowId, tenantId, parts[2]); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException( + "Invalid frozen workflow definition", exception); + } + } + + private Workflow frozenWorkflow( + Workflow source, + Map runtimeSnapshot) { + Workflow frozen = new Workflow(); + frozen.setId(source.getId()); + frozen.setTenantId(source.getTenantId()); + frozen.setTitle(source.getTitle()); + frozen.setDescription(source.getDescription()); + frozen.setEnglishName(source.getEnglishName()); + frozen.setRevision(source.getRevision()); + frozen.setContent(source.getContent()); + frozen.setPublishedSnapshotJson( + new LinkedHashMap<>(runtimeSnapshot)); + return frozen; + } + private String sha256(String content) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") @@ -111,4 +188,17 @@ public class FrozenWorkflowDefinitionRegistry { throw new IllegalStateException("SHA-256 is unavailable", exception); } } + + /** + * 冻结定义中不依赖进程内 LRU 状态的执行身份。 + * + * @param workflowId 工作流 ID + * @param tenantId 工作流租户 + * @param knowledgeContractFingerprint 知识库契约指纹 + */ + public record FrozenDefinitionIdentity( + BigInteger workflowId, + BigInteger tenantId, + String knowledgeContractFingerprint) { + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java index 0be15574..7b14fd31 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckService.java @@ -6,8 +6,11 @@ import com.alibaba.fastjson2.JSONObject; import com.easyagents.flow.core.chain.DataType; import com.easyagents.flow.core.node.ConfirmNode; import com.easyagents.flow.core.parser.ChainParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import tech.easyflow.ai.config.MultiKnowledgeRetrievalProperties; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult; import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; @@ -42,6 +45,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -49,6 +53,9 @@ import java.util.stream.Collectors; */ @Service public class WorkflowCheckService { + private static final Logger LOGGER = LoggerFactory.getLogger( + WorkflowCheckService.class); + private static final long SLOW_CHECK_THRESHOLD_MS = 500L; private static final String LEVEL_ERROR = "ERROR"; private static final String LEVEL_WARNING = "WARNING"; private static final String TYPE_START = "startNode"; @@ -56,6 +63,7 @@ public class WorkflowCheckService { private static final String TYPE_LOOP = "loopNode"; private static final String TYPE_CONDITION = "conditionNode"; private static final String TYPE_CONFIRM = "confirmNode"; + private static final String TYPE_KNOWLEDGE = "knowledgeNode"; private static final Set CONFIRM_ARRAY_LEFT_OPERATORS = Set.of( "contains", "notContains", "isEmpty", "isNotEmpty"); private static final String TYPE_WORKFLOW = "workflow-node"; @@ -64,6 +72,10 @@ public class WorkflowCheckService { private static final String SYSTEM_START_PARAM_NAME = "user_input"; private static final int MIN_LOOP_COUNT = 1; private static final int MAX_LOOP_COUNT = 300; + private static final int DEFAULT_MAX_KNOWLEDGE_SOURCES = 8; + private static final int DEFAULT_MAX_MULTI_KNOWLEDGE_LIMIT = 200; + private static final Pattern COMPLETE_VARIABLE_REFERENCE = + Pattern.compile("^\\{\\{\\s*[^\\s{}][^{}]*?\\s*}}$"); private static final String JOIN_MODE_ANY = "any"; private static final String JOIN_MODE_ALL = "all"; @@ -79,6 +91,8 @@ public class WorkflowCheckService { private PluginItemService pluginItemService; @Resource private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; + @Resource + private MultiKnowledgeRetrievalProperties multiKnowledgeRetrievalProperties; public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) { if (workflowId == null) { @@ -95,9 +109,11 @@ public class WorkflowCheckService { if (stage == null) { throw new BusinessException("校验阶段不能为空"); } + long checkStartedAt = System.nanoTime(); List issues = new ArrayList<>(); Set issueKeys = new LinkedHashSet<>(); ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys); + long baseFinishedAt = System.nanoTime(); if (parsedWorkflow != null) { List startNodes = parsedWorkflow.nodes.stream() .filter(node -> TYPE_START.equals(node.type)) @@ -105,11 +121,22 @@ public class WorkflowCheckService { checkStartFormSchema(startNodes, issues, issueKeys); checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys); } + long schemaFinishedAt = System.nanoTime(); if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) { runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys); } - return buildResult(stage, issues); + WorkflowCheckResult result = buildResult(stage, issues); + logSlowCheck( + stage, + currentWorkflowId, + parsedWorkflow, + issues.size(), + checkStartedAt, + baseFinishedAt, + schemaFinishedAt, + System.nanoTime()); + return result; } public void checkOrThrow(String content, WorkflowCheckStage stage, BigInteger currentWorkflowId) { @@ -174,6 +201,21 @@ public class WorkflowCheckService { if (!StringUtils.hasText(node.type) || !parserMap.containsKey(node.type)) { addIssue(issues, issueKeys, "NODE_TYPE_UNKNOWN", "节点类型无法识别: " + safe(node.type), node.id, null, node.name); } + String dataType = node.data == null + ? null + : trimToNull(node.data.getString("type")); + if (StringUtils.hasText(node.type) + && StringUtils.hasText(dataType) + && !Objects.equals(node.type, dataType)) { + addIssue( + issues, + issueKeys, + "NODE_TYPE_MISMATCH", + "节点类型与节点数据类型不一致", + node.id, + null, + node.name); + } if (StringUtils.hasText(node.parentId) && node.parentId.equals(node.id)) { addIssue(issues, issueKeys, "NODE_PARENT_SELF", "节点不能引用自己作为父节点", node.id, null, node.name); } @@ -189,6 +231,7 @@ public class WorkflowCheckService { } checkLoopConfigurations(nodes, nodeMap, issues, issueKeys); checkConditionConfigurations(nodes, issues, issueKeys); + checkKnowledgeConfigurations(nodes, issues, issueKeys); checkConfirmConfigurations(nodes, issues, issueKeys); checkConfirmOutputReferences(nodes, issues, issueKeys); @@ -246,6 +289,195 @@ public class WorkflowCheckService { return parsedWorkflow; } + /** + * 校验知识库节点的新旧引用字段和多库向量模式约束。 + */ + private void checkKnowledgeConfigurations( + List nodes, + List issues, + Set issueKeys) { + for (NodeView node : nodes) { + if (!TYPE_KNOWLEDGE.equals(node.type) || node.data == null) { + continue; + } + checkKnowledgeOutputContract(node, issues, issueKeys); + List knowledgeIds = new ArrayList<>(); + if (node.data.containsKey("knowledgeIds")) { + Object rawIds = node.data.get("knowledgeIds"); + if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) { + addIssue( + issues, + issueKeys, + "KNOWLEDGE_IDS_INVALID", + "知识库节点至少需要选择一个知识库", + node.id, + null, + node.name); + continue; + } + Set unique = new LinkedHashSet<>(); + for (Object rawId : ids) { + String id = trimToNull(rawId == null + ? null + : String.valueOf(rawId)); + if (id == null || !id.matches("[0-9]+")) { + addIssue( + issues, + issueKeys, + "KNOWLEDGE_IDS_INVALID", + "知识库节点包含无效的知识库ID", + node.id, + null, + node.name); + continue; + } + if (!unique.add(id)) { + addIssue( + issues, + issueKeys, + "KNOWLEDGE_IDS_DUPLICATE", + "知识库节点不能重复选择同一知识库", + node.id, + null, + node.name); + } + } + knowledgeIds.addAll(unique); + if (knowledgeIds.size() > maxKnowledgeSources()) { + addIssue( + issues, + issueKeys, + "KNOWLEDGE_SOURCE_LIMIT_EXCEEDED", + "知识库节点选择数量超过平台上限", + node.id, + null, + node.name); + } + } else { + String legacyId = trimToNull(node.data.getString("knowledgeId")); + if (legacyId == null || !legacyId.matches("[0-9]+")) { + addIssue( + issues, + issueKeys, + "KNOWLEDGE_ID_INVALID", + "知识库节点需要选择知识库", + node.id, + null, + node.name); + continue; + } + knowledgeIds.add(legacyId); + } + + String retrievalMode = trimToNull( + node.data.getString("retrievalMode")); + if (knowledgeIds.size() > 1 + && !"VECTOR".equalsIgnoreCase(retrievalMode)) { + addIssue( + issues, + issueKeys, + "MULTI_KNOWLEDGE_MODE_INVALID", + "多知识库检索仅支持向量检索", + node.id, + null, + node.name); + } + String limit = trimToNull(node.data.getString("limit")); + if (limit != null + && !COMPLETE_VARIABLE_REFERENCE.matcher(limit).matches()) { + try { + int parsedLimit = Integer.parseInt(limit); + if (parsedLimit <= 0 + || (knowledgeIds.size() > 1 + && parsedLimit > maxMultiKnowledgeLimit())) { + throw new NumberFormatException("non-positive"); + } + } catch (NumberFormatException exception) { + addIssue( + issues, + issueKeys, + "KNOWLEDGE_LIMIT_INVALID", + "知识库节点最终返回条数必须为正整数或有效变量引用", + node.id, + null, + node.name); + } + } + } + } + + /** + * 知识库节点只允许暴露稳定的 documents 输出及四个历史子字段。 + */ + private void checkKnowledgeOutputContract( + NodeView node, + List issues, + Set issueKeys) { + if (!node.data.containsKey("outputDefs")) { + return; + } + Object rawOutputDefs = node.data.get("outputDefs"); + if (rawOutputDefs instanceof JSONArray outputDefs + && outputDefs.size() == 1 + && isCanonicalKnowledgeDocumentsOutput( + outputDefs.getJSONObject(0))) { + return; + } + addIssue( + issues, + issueKeys, + "KNOWLEDGE_OUTPUT_SCHEMA_INVALID", + "知识库节点输出参数必须为 documents,并仅包含 title、content、documentId、knowledgeId", + node.id, + null, + node.name); + } + + private boolean isCanonicalKnowledgeDocumentsOutput(JSONObject output) { + if (output == null + || !"documents".equals(output.getString("name")) + || !"Array".equalsIgnoreCase(output.getString("dataType"))) { + return false; + } + JSONArray children = output.getJSONArray("children"); + if (children == null || children.size() != 4) { + return false; + } + Map expectedTypes = Map.of( + "title", "String", + "content", "String", + "documentId", "Number", + "knowledgeId", "Number"); + Set names = new LinkedHashSet<>(); + for (int index = 0; index < children.size(); index++) { + JSONObject child = children.getJSONObject(index); + if (child == null) { + return false; + } + String name = trimToNull(child.getString("name")); + String expectedType = expectedTypes.get(name); + if (expectedType == null + || !names.add(name) + || !expectedType.equalsIgnoreCase( + child.getString("dataType"))) { + return false; + } + } + return names.equals(expectedTypes.keySet()); + } + + private int maxKnowledgeSources() { + return multiKnowledgeRetrievalProperties == null + ? DEFAULT_MAX_KNOWLEDGE_SOURCES + : multiKnowledgeRetrievalProperties.getMaxSources(); + } + + private int maxMultiKnowledgeLimit() { + return multiKnowledgeRetrievalProperties == null + ? DEFAULT_MAX_MULTI_KNOWLEDGE_LIMIT + : multiKnowledgeRetrievalProperties.getTotalCandidateLimit(); + } + /** * 校验节点汇聚模式及其静态可证明的到达安全性。 * @@ -1963,6 +2195,36 @@ public class WorkflowCheckService { return result; } + private void logSlowCheck( + WorkflowCheckStage stage, + BigInteger workflowId, + ParsedWorkflow parsedWorkflow, + int issueCount, + long checkStartedAt, + long baseFinishedAt, + long schemaFinishedAt, + long checkFinishedAt) { + long totalMs = elapsedMillis(checkStartedAt, checkFinishedAt); + if (totalMs < SLOW_CHECK_THRESHOLD_MS) { + return; + } + LOGGER.warn( + "Workflow check is slow: stage={}, workflowId={}, nodes={}, " + + "issues={}, baseMs={}, schemaMs={}, strictMs={}, totalMs={}", + stage, + workflowId, + parsedWorkflow == null ? 0 : parsedWorkflow.nodes.size(), + issueCount, + elapsedMillis(checkStartedAt, baseFinishedAt), + elapsedMillis(baseFinishedAt, schemaFinishedAt), + elapsedMillis(schemaFinishedAt, checkFinishedAt), + totalMs); + } + + private long elapsedMillis(long startedAt, long finishedAt) { + return Math.max(0L, (finishedAt - startedAt) / 1_000_000L); + } + private static class ConfirmOutputIndex { private final Map outputTypes = new HashMap<>(); private final Set nodeIds = new HashSet<>(); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java index a37cda33..55df7e2f 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java @@ -157,6 +157,15 @@ public abstract class AbstractAiResourceLifecycleHandler implements ApprovalS protected void enrichOfflineSnapshot(T resource, Map snapshot) { } + /** + * 在发布快照参与重复发布比较前补充资源专属契约。 + * + * @param resource 资源 + * @param snapshot 当前发布快照 + */ + protected void enrichPublishSnapshot(T resource, Map snapshot) { + } + /** * 删除前额外校验。 * @@ -287,6 +296,7 @@ public abstract class AbstractAiResourceLifecycleHandler implements ApprovalS throw new BusinessException("当前" + resourceLabel() + "状态不允许发布"); } Map snapshot = buildResourceSnapshot(resource); + enrichPublishSnapshot(resource, snapshot); if (currentStatus == PublishStatus.PUBLISHED && isSameSnapshot(snapshot, getPublishedSnapshot(resource))) { throw new BusinessException("当前内容与已发布版本一致,无需重新发布"); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java index 9fbe635f..1ebfa72d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleHandler.java @@ -64,6 +64,30 @@ public interface AiResourceLifecycleHandler { applyApprovedAction(actionType, resourceId, resourceSnapshot, operatorId); } + /** + * 执行带审批申请人身份的通过回调。 + * + * @param actionType 动作类型 + * @param resourceId 资源 ID + * @param resourceSnapshot 审批冻结快照 + * @param operatorId 审批操作人 ID + * @param approvalInstanceId 审批实例 ID + * @param applicantId 审批申请人 ID + */ + default void applyApprovedAction(String actionType, + BigInteger resourceId, + Map resourceSnapshot, + BigInteger operatorId, + BigInteger approvalInstanceId, + BigInteger applicantId) { + applyApprovedAction( + actionType, + resourceId, + resourceSnapshot, + operatorId, + approvalInstanceId); + } + /** * 在实际提交或直接执行前持有冻结快照所需资源。 * diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java index 9a233d54..a19fd6a6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java @@ -110,7 +110,8 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic instance.getResourceId(), readResourceSnapshot(instance.getSnapshotJson()), operatorId, - instance.getId() + instance.getId(), + instance.getApplicantId() ); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java index 385d70a7..a6fd6371 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java @@ -3,6 +3,9 @@ package tech.easyflow.ai.publish; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; +import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; @@ -13,16 +16,22 @@ import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider; import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.ai.vo.OfflineImpactBindingVo; import tech.easyflow.approval.service.ApprovalInstanceService; +import tech.easyflow.approval.enums.ApprovalActionType; import tech.easyflow.approval.enums.ApprovalResourceType; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.system.service.SysAccountService; import java.math.BigInteger; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * 工作流生命周期处理器。 @@ -37,6 +46,9 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; private final AgentResourceReferenceService agentResourceReferenceService; private final List workflowScheduleReferenceProviders; + private final WorkflowCheckService workflowCheckService; + private final WorkflowKnowledgeContractService workflowKnowledgeContractService; + private final SysAccountService sysAccountService; public WorkflowApprovalSubjectHandler(WorkflowService workflowService, ResourceAccessService resourceAccessService, @@ -46,7 +58,10 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, AgentResourceReferenceService agentResourceReferenceService, ObjectMapper objectMapper, - List workflowScheduleReferenceProviders) { + List workflowScheduleReferenceProviders, + WorkflowCheckService workflowCheckService, + WorkflowKnowledgeContractService workflowKnowledgeContractService, + SysAccountService sysAccountService) { super(approvalInstanceService, objectMapper); this.workflowService = workflowService; this.resourceAccessService = resourceAccessService; @@ -57,6 +72,9 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH this.workflowScheduleReferenceProviders = workflowScheduleReferenceProviders == null ? List.of() : List.copyOf(workflowScheduleReferenceProviders); + this.workflowCheckService = workflowCheckService; + this.workflowKnowledgeContractService = workflowKnowledgeContractService; + this.sysAccountService = sysAccountService; } @Override @@ -132,6 +150,12 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH @Override protected Map buildPublishSnapshot(Workflow resource, PublishStatus currentStatus) { + workflowCheckService.checkOrThrow( + resource.getContent(), + WorkflowCheckStage.SAVE, + resource.getId()); + assertKnowledgeUseAccess( + resource.getContent(), resource.getTenantId(), null); Map snapshot = super.buildPublishSnapshot(resource, currentStatus); OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId()); if (impact.isHasPluginBindings()) { @@ -140,6 +164,53 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH return snapshot; } + @Override + protected void enrichPublishSnapshot( + Workflow resource, + Map snapshot) { + snapshot.put( + WorkflowKnowledgeContractService.SNAPSHOT_KEY, + workflowKnowledgeContractService.buildSnapshotContracts( + resource.getContent(), resource.getTenantId())); + } + + @Override + public void applyApprovedAction( + String actionType, + BigInteger resourceId, + Map resourceSnapshot, + BigInteger operatorId) { + if (ApprovalActionType.PUBLISH == ApprovalActionType.from(actionType)) { + assertKnowledgeUseAccess( + String.valueOf(resourceSnapshot.get("content")), + snapshotTenantId(resourceSnapshot), + null); + } + super.applyApprovedAction( + actionType, resourceId, resourceSnapshot, operatorId); + } + + @Override + public void applyApprovedAction( + String actionType, + BigInteger resourceId, + Map resourceSnapshot, + BigInteger operatorId, + BigInteger approvalInstanceId, + BigInteger applicantId) { + if (ApprovalActionType.PUBLISH == ApprovalActionType.from(actionType)) { + BigInteger tenantId = snapshotTenantId(resourceSnapshot); + LoginAccount applicant = requireCurrentApplicant( + applicantId, tenantId); + assertKnowledgeUseAccess( + String.valueOf(resourceSnapshot.get("content")), + tenantId, + applicant); + } + super.applyApprovedAction( + actionType, resourceId, resourceSnapshot, operatorId); + } + @Override protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { Workflow update = new Workflow(); @@ -151,6 +222,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH @Override protected void publishResource(BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId) { + workflowKnowledgeContractService.assertSnapshotCurrent(resourceSnapshot); Workflow update = new Workflow(); update.setId(resourceId); update.setPublishStatus(PublishStatus.PUBLISHED.getCode()); @@ -162,6 +234,62 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH workflowPluginBindingService.syncByWorkflowId(resourceId); } + private void assertKnowledgeUseAccess( + String content, + BigInteger tenantId, + LoginAccount account) { + workflowKnowledgeContractService.resolveReferencedCollections( + content, tenantId) + .forEach(collection -> { + if (account == null) { + resourceAccessService.assertAccess( + CategoryResourceType.KNOWLEDGE, + collection, + ResourceAction.USE, + "无权限使用工作流知识库"); + return; + } + if (!resourceAccessService.canAccess( + account, + CategoryResourceType.KNOWLEDGE, + collection, + ResourceAction.USE)) { + throw new BusinessException( + 403, 403, "无权限使用工作流知识库"); + } + }); + } + + private BigInteger snapshotTenantId(Map resourceSnapshot) { + Object value = resourceSnapshot == null + ? null + : resourceSnapshot.get("tenantId"); + try { + BigInteger tenantId = new BigInteger(String.valueOf(value)); + if (tenantId.signum() <= 0) { + throw new NumberFormatException("non-positive"); + } + return tenantId; + } catch (RuntimeException exception) { + throw new BusinessException("工作流发布快照租户无效"); + } + } + + private LoginAccount requireCurrentApplicant( + BigInteger applicantId, + BigInteger tenantId) { + SysAccount account = applicantId == null + ? null + : sysAccountService.getById(applicantId); + if (account == null + || !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus()) + || !Objects.equals(tenantId, account.getTenantId())) { + throw new BusinessException( + 403, 403, "审批申请人账号已失效或不属于当前租户"); + } + return account.toLoginAccount(); + } + @Override protected void markResourceOffline(BigInteger resourceId) { Workflow update = new Workflow(); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/rag/KnowledgeVectorCandidateRequest.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/rag/KnowledgeVectorCandidateRequest.java new file mode 100644 index 00000000..bd64a00c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/rag/KnowledgeVectorCandidateRequest.java @@ -0,0 +1,93 @@ +package tech.easyflow.ai.rag; + +import tech.easyflow.ai.entity.DocumentCollection; + +import java.math.BigInteger; + +/** + * 单知识库原始向量候选请求,仅供内部跨库编排使用。 + */ +public class KnowledgeVectorCandidateRequest { + + private BigInteger knowledgeId; + private DocumentCollection collection; + private String query; + private int limit; + private double minVectorScore; + private float[] queryVector; + private Long timeoutMillis; + private String callerType; + private String callerId; + + public BigInteger getKnowledgeId() { + return knowledgeId; + } + + public void setKnowledgeId(BigInteger knowledgeId) { + this.knowledgeId = knowledgeId; + } + + public DocumentCollection getCollection() { + return collection; + } + + public void setCollection(DocumentCollection collection) { + this.collection = collection; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public int getLimit() { + return limit; + } + + public void setLimit(int limit) { + this.limit = limit; + } + + public double getMinVectorScore() { + return minVectorScore; + } + + public void setMinVectorScore(double minVectorScore) { + this.minVectorScore = minVectorScore; + } + + public float[] getQueryVector() { + return queryVector; + } + + public void setQueryVector(float[] queryVector) { + this.queryVector = queryVector; + } + + public Long getTimeoutMillis() { + return timeoutMillis; + } + + public void setTimeoutMillis(Long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + } + + public String getCallerType() { + return callerType; + } + + public void setCallerType(String callerType) { + this.callerType = callerType; + } + + public String getCallerId() { + return callerId; + } + + public void setCallerId(String callerId) { + this.callerId = callerId; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/DocumentCollectionService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/DocumentCollectionService.java index b29bc8c8..adc8c9f6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/DocumentCollectionService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/DocumentCollectionService.java @@ -3,6 +3,7 @@ package tech.easyflow.ai.service; import com.easyagents.core.document.Document; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; +import tech.easyflow.ai.rag.KnowledgeVectorCandidateRequest; import com.mybatisflex.core.service.IService; import java.math.BigInteger; @@ -20,6 +21,14 @@ public interface DocumentCollectionService extends IService List search(KnowledgeRetrievalRequest request); + /** + * 查询未归一化、未取整、未重排的原始向量候选。 + * + * @param request 原始向量候选请求 + * @return 保留向量存储分数原始精度的候选 + */ + List searchVectorCandidates(KnowledgeVectorCandidateRequest request); + DocumentCollection getDetail(String idOrAlias); DocumentCollection getByAlias(String idOrAlias); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ModelService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ModelService.java index 60cfa99c..5024443f 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ModelService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/ModelService.java @@ -5,6 +5,7 @@ import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.service.capability.ModelCapabilityResolution; import java.math.BigInteger; +import java.util.Collection; import java.util.List; import java.util.Map; @@ -35,6 +36,14 @@ public interface ModelService extends IService { Model getModelInstance(BigInteger modelId); + /** + * 批量读取已关联供应商并补齐供应商默认配置的模型。 + * + * @param modelIds 模型 ID + * @return 实际运行配置模型 + */ + List listModelInstances(Collection modelIds); + Model getModelInstanceByInvokeCode(String invokeCode); void validateForSaveOrUpdate(Model entity, boolean isSave); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java index 73351559..19da98c1 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java @@ -29,6 +29,7 @@ import tech.easyflow.ai.mapper.DocumentCollectionMapper; import tech.easyflow.ai.mapper.DocumentMapper; import tech.easyflow.ai.mapper.FaqItemMapper; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; +import tech.easyflow.ai.rag.KnowledgeVectorCandidateRequest; import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.ModelService; import tech.easyflow.ai.support.DocumentStoreLifecycleSupport; @@ -60,7 +61,6 @@ public class DocumentCollectionServiceImpl extends ServiceImpl searchVectorCandidates( + KnowledgeVectorCandidateRequest request) { + if (request == null || request.getKnowledgeId() == null) { + throw new BusinessException("知识库ID不能为空"); + } + if (StringUtil.noText(request.getQuery())) { + return Collections.emptyList(); + } + if (request.getLimit() <= 0) { + throw new BusinessException("向量候选数量必须大于0"); + } + if (!Double.isFinite(request.getMinVectorScore()) + || request.getMinVectorScore() < 0D + || request.getMinVectorScore() > 1D) { + throw new BusinessException("向量相似度阈值无效"); + } + DocumentCollection collection = request.getCollection(); + if (collection == null + || !Objects.equals( + request.getKnowledgeId(), collection.getId())) { + throw new BusinessException("知识库检索快照无效"); + } + List documents = prepareSearchDocuments( + collection, + searchVectorDocuments( + collection, + request.getQuery(), + request.getLimit(), + request.getMinVectorScore(), + request.getQueryVector(), + request.getTimeoutMillis())); + for (Document document : documents) { + document.addMetadata("knowledgeId", collection.getId()); + document.addMetadata("knowledgeName", collection.getTitle()); + document.addMetadata("vectorScore", document.getScore()); + } + LOG.info( + "Knowledge raw vector candidates completed, callerType={}, callerId={}, knowledgeId={}, limit={}, minVectorScore={}, hitCount={}", + request.getCallerType(), + request.getCallerId(), + request.getKnowledgeId(), + request.getLimit(), + request.getMinVectorScore(), + documents.size()); + return documents; + } + /** * {@inheritDoc} */ @@ -279,38 +327,60 @@ public class DocumentCollectionServiceImpl extends ServiceImpl searchVectorDocuments(DocumentCollection documentCollection, + String keyword, + int docRecallMaxNum, + Double minSimilarity, + float[] queryVector, + Long timeoutMillis) { DocumentStore documentStore = documentCollection.toDocumentStore(); if (documentStore == null) { throw new BusinessException("知识库没有配置向量库"); } try { - Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId()); - if (model == null) { - throw new BusinessException("知识库没有配置向量模型"); + if (queryVector == null || queryVector.length == 0) { + Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId()); + if (model == null) { + throw new BusinessException("知识库没有配置向量模型"); + } + documentStore.setEmbeddingModel(model.toEmbeddingModel()); } - - documentStore.setEmbeddingModel(model.toEmbeddingModel()); SearchWrapper wrapper = new SearchWrapper(); wrapper.setMaxResults(docRecallMaxNum); + if (queryVector != null && queryVector.length > 0) { + wrapper.setVector(queryVector); + } if (minSimilarity != null) { - wrapper.setMinScore((double) minSimilarity); + wrapper.setMinScore(minSimilarity); } wrapper.setText(keyword); StoreOptions options = StoreOptions.ofCollectionName(documentCollection.getVectorStoreCollection()); options.setIndexName(documentCollection.getVectorStoreCollection()); + if (timeoutMillis != null) { + options.setTimeoutMillis(timeoutMillis); + } List documents = documentStore.search(wrapper, options); List result = documents == null ? Collections.emptyList() : documents; LOG.info( - "Knowledge vector search completed, knowledgeId={}, collectionName={}, query={}, limit={}, minSimilarity={}, hitCount={}, hits={}", + "Knowledge vector search completed, knowledgeId={}, collectionName={}, limit={}, minSimilarity={}, hitCount={}", documentCollection.getId(), documentCollection.getVectorStoreCollection(), - keyword, docRecallMaxNum, minSimilarity, - result.size(), - summarizeDocuments(result) + result.size() ); return result; } finally { @@ -854,7 +924,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl { Map summary = new LinkedHashMap<>(); summary.put("id", hit.getDocumentId()); - summary.put("title", hit.getTitle()); summary.put("source", hit.getHitSource()); summary.put("score", hit.getScore()); summary.put("vectorScore", hit.getVectorScore()); summary.put("keywordScore", hit.getKeywordScore()); summary.put("rank", hit.getRank()); - summary.put("content", truncate(hit.getContent())); - summary.put("metadata", hit.getMetadata()); return summary; }) .collect(Collectors.toList()); } /** - * 构建文档命中摘要,避免完整知识库内容撑爆日志。 + * 构建不包含知识正文、标题和元数据的文档命中摘要。 * * @param documents 文档命中列表 * @return 文档摘要 @@ -896,25 +963,10 @@ public class DocumentCollectionServiceImpl extends ServiceImpl { Map summary = new LinkedHashMap<>(); summary.put("id", document.getId()); - summary.put("title", document.getTitle()); summary.put("score", document.getScore()); - summary.put("content", truncate(document.getContent())); - summary.put("metadata", document.getMetadataMap()); return summary; }) .collect(Collectors.toList()); } - /** - * 截断日志文本,保留足够排查上下文。 - * - * @param text 原始文本 - * @return 截断文本 - */ - private String truncate(String text) { - if (text == null || text.length() <= LOG_TEXT_MAX_LENGTH) { - return text; - } - return text.substring(0, LOG_TEXT_MAX_LENGTH) + "..."; - } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java index 37c738e1..3dbab38d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ModelServiceImpl.java @@ -216,6 +216,20 @@ public class ModelServiceImpl extends ServiceImpl implements return fillProviderDefaults(model); } + @Override + public List listModelInstances(Collection modelIds) { + if (modelIds == null || modelIds.isEmpty()) { + return List.of(); + } + return modelMapper.selectListWithRelationsByQuery( + QueryWrapper.create().in(Model::getId, modelIds)) + .stream() + .map(model -> model.getModelProvider() == null + ? model + : fillProviderDefaults(model)) + .toList(); + } + @Override public Model getModelInstanceByInvokeCode(String invokeCode) { if (StrUtil.isBlank(invokeCode)) { diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImplTest.java index 84a22923..4fb055a5 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImplTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/KnowledgeProviderImplTest.java @@ -2,6 +2,7 @@ package tech.easyflow.ai.easyagentsflow.knowledge; import com.easyagents.core.document.Document; import com.easyagents.flow.core.knowledge.Knowledge; +import com.easyagents.flow.core.knowledge.KnowledgeSearchRequest; import com.easyagents.flow.core.node.KnowledgeNode; import org.junit.Assert; import org.junit.Test; @@ -13,6 +14,7 @@ import java.math.BigInteger; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -35,6 +37,7 @@ public class KnowledgeProviderImplTest { document.setId(BigInteger.valueOf(42)); document.setTitle("文档标题"); document.setContent("文档内容"); + document.setScore(0.9D); document.addMetadata("documentId", BigInteger.valueOf(420)); document.addMetadata("sourceFileName", "元数据标题"); document.addMetadata("legacyKey", "legacy-value"); @@ -59,6 +62,7 @@ public class KnowledgeProviderImplTest { Assert.assertEquals("文档内容", item.get("content")); Assert.assertEquals(BigInteger.valueOf(420), item.get("documentId")); Assert.assertEquals(BigInteger.valueOf(88), item.get("knowledgeId")); + Assert.assertNull(item.get("vectorScore")); Assert.assertEquals(42L, ((Number) item.get("id")).longValue()); Assert.assertTrue(item.containsKey("metadataMap")); Assert.assertEquals( @@ -112,6 +116,66 @@ public class KnowledgeProviderImplTest { .get("question")); } + @Test + public void shouldKeepLegacyOutputContractForMultiKnowledge() + throws Exception { + Document document = new Document(); + document.setId(BigInteger.valueOf(45)); + document.setTitle("来源文档"); + document.setContent("跨库内容"); + document.setScore(0.923456D); + document.addMetadata("knowledgeId", BigInteger.valueOf(88)); + document.addMetadata("knowledgeName", "业务知识库"); + document.addMetadata("documentId", BigInteger.valueOf(450)); + document.addMetadata("chunkId", BigInteger.valueOf(45)); + document.addMetadata("vectorScore", 0.923456D); + document.addMetadata("globalRank", 1); + document.addMetadata("sourceReferences", List.of(Map.of( + "knowledgeId", BigInteger.valueOf(88), + "documentId", BigInteger.valueOf(450), + "chunkId", BigInteger.valueOf(45)))); + + MultiKnowledgeRetrievalResult retrievalResult = + new MultiKnowledgeRetrievalResult( + List.of(document), + Map.of("requestedSourceCount", 2, "resultCount", 1), + List.of(Map.of("status", "SUCCEEDED"))); + WorkflowMultiKnowledgeRetrievalService multiService = + mock(WorkflowMultiKnowledgeRetrievalService.class); + when(multiService.search( + any(), any(), any(Integer.class), any(), any())) + .thenReturn(retrievalResult); + + KnowledgeProviderImpl provider = new KnowledgeProviderImpl(); + setField(provider, "documentCollectionService", + mock(DocumentCollectionService.class)); + setField(provider, "multiKnowledgeRetrievalService", multiService); + KnowledgeNode node = new KnowledgeNode(); + node.setId("knowledge-node"); + node.setKnowledgeIds(List.of("88", "99")); + node.setRetrievalMode("VECTOR"); + + Map output = provider.search( + new KnowledgeSearchRequest( + node.getKnowledgeIds(), + "问题", + 3, + "VECTOR", + node, + null)); + + Assert.assertEquals(Set.of("documents"), output.keySet()); + List documents = (List) output.get("documents"); + Map item = (Map) documents.get(0); + Assert.assertEquals("来源文档", item.get("title")); + Assert.assertEquals("跨库内容", item.get("content")); + Assert.assertEquals(BigInteger.valueOf(450), item.get("documentId")); + Assert.assertEquals(BigInteger.valueOf(88), item.get("knowledgeId")); + Assert.assertFalse(item.containsKey("knowledgeName")); + Assert.assertFalse(item.containsKey("vectorScore")); + Assert.assertFalse(item.containsKey("globalRank")); + } + /** * 注入测试依赖。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowKnowledgeContractServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowKnowledgeContractServiceTest.java new file mode 100644 index 00000000..10186caa --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowKnowledgeContractServiceTest.java @@ -0,0 +1,209 @@ +package tech.easyflow.ai.easyagentsflow.knowledge; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.ModelProvider; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link WorkflowKnowledgeContractService} 契约回归测试。 + */ +public class WorkflowKnowledgeContractServiceTest { + + @Test + public void shouldRequireExistingEmbeddingModelForVectorReadiness() { + DocumentCollectionService collectionService = + mock(DocumentCollectionService.class); + ModelService modelService = mock(ModelService.class); + DocumentCollection ready = knowledge(1, 7, 3); + DocumentCollection orphan = knowledge(2, 8, 3); + when(modelService.listModelInstances(any())) + .thenReturn(List.of(embeddingModel(7))); + WorkflowKnowledgeContractService service = + new WorkflowKnowledgeContractService( + collectionService, modelService); + + Set result = service.findVectorReadyKnowledgeIds( + List.of(ready, orphan), BigInteger.TEN); + + Assert.assertEquals(Set.of(BigInteger.ONE), result); + } + + @Test + public void shouldRejectEmbeddingContractChangedAfterSubmission() { + DocumentCollectionService collectionService = + mock(DocumentCollectionService.class); + ModelService modelService = mock(ModelService.class); + DocumentCollection first = knowledge(1, 7, 3); + DocumentCollection second = knowledge(2, 7, 3); + when(collectionService.listByIds(any())) + .thenReturn(List.of(first, second)); + when(modelService.listModelInstances(any())) + .thenReturn(List.of(embeddingModel(7))); + WorkflowKnowledgeContractService service = + new WorkflowKnowledgeContractService( + collectionService, modelService); + String content = """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """; + List> contracts = + service.buildSnapshotContracts(content, BigInteger.TEN); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("tenantId", BigInteger.TEN); + snapshot.put("content", content); + snapshot.put(WorkflowKnowledgeContractService.SNAPSHOT_KEY, contracts); + second.setDimensionOfVectorModel(4); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.assertSnapshotCurrent(snapshot)); + + Assert.assertTrue(exception.getMessage().contains("Embedding")); + } + + @Test + public void shouldRejectMultiKnowledgeWhenEmbeddingModelIsMissing() { + DocumentCollectionService collectionService = + mock(DocumentCollectionService.class); + ModelService modelService = mock(ModelService.class); + when(collectionService.listByIds(any())) + .thenReturn(List.of( + knowledge(1, 7, 3), + knowledge(2, 7, 3))); + when(modelService.listModelInstances(any())).thenReturn(List.of()); + WorkflowKnowledgeContractService service = + new WorkflowKnowledgeContractService( + collectionService, modelService); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.assertMultiKnowledgeContracts( + List.of(List.of(BigInteger.ONE, BigInteger.TWO)), + BigInteger.TEN)); + + Assert.assertTrue(exception.getMessage().contains("向量检索配置")); + } + + @Test + public void shouldRejectEffectiveProviderEndpointDrift() { + DocumentCollectionService collectionService = + mock(DocumentCollectionService.class); + ModelService modelService = mock(ModelService.class); + DocumentCollection first = knowledge(1, 7, 3); + DocumentCollection second = knowledge(2, 7, 3); + Model effectiveModel = embeddingModel(7); + when(collectionService.listByIds(any())) + .thenReturn(List.of(first, second)); + when(modelService.listModelInstances(any())) + .thenReturn(List.of(effectiveModel)); + WorkflowKnowledgeContractService service = + new WorkflowKnowledgeContractService( + collectionService, modelService); + String content = """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """; + Map snapshot = new LinkedHashMap<>(); + snapshot.put("tenantId", BigInteger.TEN); + snapshot.put("content", content); + snapshot.put( + WorkflowKnowledgeContractService.SNAPSHOT_KEY, + service.buildSnapshotContracts(content, BigInteger.TEN)); + effectiveModel.setEndpoint("https://embedding.changed.example"); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.assertSnapshotCurrent(snapshot)); + + Assert.assertTrue(exception.getMessage().contains("Embedding")); + } + + @Test + public void shouldResolveSingleKnowledgeReferenceForPublishAccessCheck() { + DocumentCollectionService collectionService = + mock(DocumentCollectionService.class); + DocumentCollection collection = knowledge(1, 7, 3); + when(collectionService.listByIds(any())) + .thenReturn(List.of(collection)); + WorkflowKnowledgeContractService service = + new WorkflowKnowledgeContractService( + collectionService, mock(ModelService.class)); + + List result = service.resolveReferencedCollections( + """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeId":"1" + }}]} + """, + BigInteger.TEN); + + Assert.assertEquals(List.of(collection), result); + } + + @Test + public void shouldRejectConflictingRootAndDataNodeTypes() { + WorkflowKnowledgeContractService service = + new WorkflowKnowledgeContractService( + mock(DocumentCollectionService.class), + mock(ModelService.class)); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> service.buildSnapshotContracts( + """ + {"nodes":[{"type":"knowledgeNode","data":{ + "type":"llmNode","knowledgeIds":["1","2"] + }}]} + """, + BigInteger.TEN)); + + Assert.assertTrue(exception.getMessage().contains("类型")); + } + + private DocumentCollection knowledge( + long id, long embeddingModelId, int dimension) { + DocumentCollection collection = new DocumentCollection(); + collection.setId(BigInteger.valueOf(id)); + collection.setTitle("知识库" + id); + collection.setTenantId(BigInteger.TEN); + collection.setVectorStoreEnable(true); + collection.setVectorStoreCollection("collection_" + id); + collection.setVectorStoreType("MILVUS"); + collection.setVectorEmbedModelId(BigInteger.valueOf(embeddingModelId)); + collection.setDimensionOfVectorModel(dimension); + return collection; + } + + private Model embeddingModel(long id) { + Model model = new Model(); + model.setId(BigInteger.valueOf(id)); + model.setTenantId(BigInteger.TEN); + model.setProviderId(BigInteger.ONE); + model.setModelType(Model.MODEL_TYPES[1]); + model.setModelName("embedding-" + id); + model.setEndpoint("https://embedding.example"); + model.setRequestPath("/v1/embeddings"); + ModelProvider provider = new ModelProvider(); + provider.setId(BigInteger.ONE); + provider.setProviderType("openai"); + model.setModelProvider(provider); + return model; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowMultiKnowledgeRetrievalServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowMultiKnowledgeRetrievalServiceTest.java new file mode 100644 index 00000000..0a7ef05d --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/knowledge/WorkflowMultiKnowledgeRetrievalServiceTest.java @@ -0,0 +1,911 @@ +package tech.easyflow.ai.easyagentsflow.knowledge; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.easyagents.core.document.Document; +import com.easyagents.core.model.embedding.EmbeddingModel; +import com.easyagents.core.store.StoreTimeoutException; +import com.easyagents.core.store.VectorData; +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.ChainStatus; +import com.easyagents.flow.core.node.KnowledgeNode; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; +import tech.easyflow.ai.config.MultiKnowledgeRetrievalProperties; +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.ModelProvider; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.rag.KnowledgeVectorCandidateRequest; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.constant.Constants; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +/** + * 多知识库向量汇总核心语义测试。 + */ +public class WorkflowMultiKnowledgeRetrievalServiceTest { + + @Test + public void shouldEmbedOnceAndSortByFullPrecision() { + Fixture fixture = new Fixture(); + Model driftedModel = mock(Model.class); + EmbeddingModel driftedEmbeddingModel = mock(EmbeddingModel.class); + when(driftedModel.toEmbeddingModel()).thenReturn(driftedEmbeddingModel); + when(fixture.modelService.listModelInstances(any())) + .thenReturn(List.of(fixture.model), List.of(driftedModel)); + Document lower = document(11, "第一条", 0.8123451D); + Document higher = document(22, "第二条", 0.8123452D); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = invocation.getArgument(0); + return request.getKnowledgeId().equals(BigInteger.ONE) + ? List.of(lower) + : List.of(higher); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(2, result.getDocuments().size()); + Assert.assertEquals(BigInteger.valueOf(22), result.getDocuments().get(0).getId()); + Assert.assertEquals(1, result.getDocuments().get(0).getMetadataMap().get("globalRank")); + Assert.assertEquals(0.8123452D, + (Double) result.getDocuments().get(0).getMetadataMap().get("vectorScore"), + 0D); + verify(fixture.embeddingModel, times(1)).embed("问题"); + verify(driftedEmbeddingModel, never()).embed(any(String.class)); + verify(fixture.modelService, times(1)).listModelInstances(any()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(KnowledgeVectorCandidateRequest.class); + verify(fixture.collectionService, times(2)) + .searchVectorCandidates(captor.capture()); + Assert.assertSame( + captor.getAllValues().get(0).getQueryVector(), + captor.getAllValues().get(1).getQueryVector()); + for (KnowledgeVectorCandidateRequest request : captor.getAllValues()) { + DocumentCollection expected = request.getKnowledgeId().equals(BigInteger.ONE) + ? fixture.first + : fixture.second; + Assert.assertSame(expected, request.getCollection()); + Assert.assertNotNull(request.getTimeoutMillis()); + Assert.assertTrue(request.getTimeoutMillis() > 0L); + Assert.assertTrue(request.getTimeoutMillis() + <= fixture.properties.getPerSourceTimeout().toMillis()); + } + } + + @Test + public void shouldDeduplicateExactContentAndKeepAllReferences() { + Fixture fixture = new Fixture(); + Document first = document(11, "相同内容\r\n第二行", 0.91D); + Document repeatedResource = document(11, "相同内容\r\n第二行", 0.89D); + Document second = document(22, "相同内容\n第二行", 0.87D); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = invocation.getArgument(0); + return request.getKnowledgeId().equals(BigInteger.ONE) + ? List.of(first, repeatedResource) + : List.of(second); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 5, + "node-1", + fixture.chain); + + Assert.assertEquals(1, result.getDocuments().size()); + List references = (List) result.getDocuments().get(0) + .getMetadataMap().get("sourceReferences"); + Assert.assertEquals(2, references.size()); + Assert.assertEquals(3, result.getSummary().get("candidateCount")); + } + + @Test + public void shouldPreferChunkIdAndFallbackToDocumentIdForResourceDedup() { + Fixture fixture = new Fixture(); + Document first = document(11, "分片内容", 0.91D); + Document sameChunk = document(12, "分片内容的旧副本", 0.89D); + sameChunk.addMetadata("chunkId", BigInteger.valueOf(11)); + Document firstDocument = document(13, "文档内容", 0.88D); + firstDocument.getMetadataMap().remove("chunkId"); + firstDocument.addMetadata("documentId", BigInteger.valueOf(130)); + Document sameDocument = document(14, "文档内容的旧副本", 0.87D); + sameDocument.getMetadataMap().remove("chunkId"); + sameDocument.addMetadata("documentId", BigInteger.valueOf(130)); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = invocation.getArgument(0); + return request.getKnowledgeId().equals(BigInteger.ONE) + ? List.of(first, sameChunk, firstDocument, sameDocument) + : List.of(); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 5, + "node-1", + fixture.chain); + + Assert.assertEquals(2, result.getDocuments().size()); + Assert.assertEquals(BigInteger.valueOf(11), result.getDocuments().get(0).getId()); + Assert.assertEquals(BigInteger.valueOf(13), result.getDocuments().get(1).getId()); + } + + @Test + public void shouldFilterInvalidScoresApplyThresholdAndUseStableTieOrder() { + Fixture fixture = new Fixture(); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = invocation.getArgument(0); + if (request.getKnowledgeId().equals(BigInteger.ONE)) { + return List.of( + document(11, "低分", 0.59D), + document(12, "非数字", Double.NaN), + document(13, "第一库同分", 0.8D)); + } + return List.of( + document(22, "无穷值", Double.POSITIVE_INFINITY), + document(23, "第二库同分", 0.8D), + document(24, "阈值边界", 0.6D)); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(2, result.getDocuments().size()); + Assert.assertEquals(BigInteger.valueOf(13), result.getDocuments().get(0).getId()); + Assert.assertEquals(BigInteger.valueOf(23), result.getDocuments().get(1).getId()); + } + + @Test + public void shouldKeepIndependentCandidateBudgetForEachSource() { + Fixture fixture = new Fixture(); + fixture.properties.setPerSourceCandidateLimit(10); + fixture.properties.setMaxSources(2); + fixture.properties.setTotalCandidateLimit(20); + fixture.properties.setCandidateMultiplier(3); + WorkflowMultiKnowledgeRetrievalService service = fixture.createService(); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenReturn(List.of()); + + service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 3, + "node-1", + fixture.chain); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(KnowledgeVectorCandidateRequest.class); + verify(fixture.collectionService, times(2)) + .searchVectorCandidates(captor.capture()); + Assert.assertEquals(9, captor.getAllValues().get(0).getLimit()); + Assert.assertEquals(9, captor.getAllValues().get(1).getLimit()); + } + + @Test + public void shouldAllowGlobalTopKToComeFromOneKnowledgeSource() { + Fixture fixture = new Fixture(); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = invocation.getArgument(0); + if (BigInteger.ONE.equals(request.getKnowledgeId())) { + return List.of( + document(11, "第一库第一条", 0.99D), + document(12, "第一库第二条", 0.98D), + document(13, "第一库第三条", 0.97D)); + } + return List.of(document(21, "第二库", 0.8D)); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 3, + "node-1", + fixture.chain); + + Assert.assertEquals( + List.of( + BigInteger.valueOf(11), + BigInteger.valueOf(12), + BigInteger.valueOf(13)), + result.getDocuments().stream().map(Document::getId).toList()); + } + + @Test + public void shouldValidateContextAndExposeNamedStatusesForEmptyQuery() { + Fixture fixture = new Fixture(); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + " ", + 2, + "node-1", + fixture.chain); + + Assert.assertTrue(result.getDocuments().isEmpty()); + Assert.assertEquals("知识库一", + result.getSourceStatuses().get(0).get("knowledgeName")); + Assert.assertEquals("EMPTY", + result.getSourceStatuses().get(0).get("status")); + Assert.assertTrue(result.getSourceStatuses().get(0) + .containsKey("elapsedMillis")); + verify(fixture.embeddingModel, never()).embed(any(String.class)); + verify(fixture.resourceAccessService, times(2)).canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE)); + } + + @Test + public void shouldTimeoutOneSourceFromItsActualStartAndKeepOtherResults() + throws Exception { + Fixture fixture = new Fixture(); + fixture.properties.setPerSourceTimeout(Duration.ofMillis(30)); + fixture.properties.setTotalTimeout(Duration.ofMillis(300)); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + WorkflowMultiKnowledgeRetrievalService service = + fixture.createService(executor); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = + invocation.getArgument(0); + if (request.getKnowledgeId().equals(BigInteger.ONE)) { + Thread.sleep(200L); + } + return List.of(document(22, "可用内容", 0.9D)); + }); + + MultiKnowledgeRetrievalResult result = service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(Boolean.TRUE, + result.getSummary().get("partialFailure")); + Assert.assertEquals("TIMED_OUT", + result.getSourceStatuses().get(0).get("status")); + Assert.assertEquals("SUCCEEDED", + result.getSourceStatuses().get(1).get("status")); + } finally { + executor.shutdownNow(); + executor.awaitTermination(2, TimeUnit.SECONDS); + } + } + + @Test + public void shouldKeepSourcesThatCompletedAtTheTimeoutBoundary() { + Fixture fixture = new Fixture(); + fixture.properties.setPerSourceTimeout(Duration.ofMillis(80)); + fixture.properties.setTotalTimeout(Duration.ofMillis(80)); + AtomicInteger submissions = new AtomicInteger(); + Executor boundaryExecutor = command -> { + int submission = submissions.incrementAndGet(); + command.run(); + if (submission > 1) { + try { + Thread.sleep(60L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exception); + } + } + }; + when(fixture.collectionService.searchVectorCandidates(any())) + .thenReturn(List.of(document(11, "临界点结果", 0.9D))); + + MultiKnowledgeRetrievalResult result = fixture + .createService(boundaryExecutor) + .search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(2, result.getSummary().get("successfulSourceCount")); + Assert.assertEquals(Boolean.FALSE, result.getSummary().get("partialFailure")); + } + + @Test + public void shouldRejectSourceCompletedAfterItsDeadlineBeforeCollection() + throws Exception { + Fixture fixture = new Fixture(); + fixture.properties.setPerSourceTimeout(Duration.ofMillis(30)); + fixture.properties.setTotalTimeout(Duration.ofMillis(300)); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = + invocation.getArgument(0); + if (request.getKnowledgeId().equals(BigInteger.ONE)) { + Thread.sleep(45L); + } + return List.of(document(11, "截止时间结果", 0.9D)); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals("TIMED_OUT", + result.getSourceStatuses().get(0).get("status")); + Assert.assertEquals("SUCCEEDED", + result.getSourceStatuses().get(1).get("status")); + } + + @Test + public void shouldClassifyStoreDeadlineAsTimedOut() { + Fixture fixture = new Fixture(); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = + invocation.getArgument(0); + if (request.getKnowledgeId().equals(BigInteger.ONE)) { + throw new StoreTimeoutException( + "synthetic Milvus deadline"); + } + return List.of(document(22, "可用内容", 0.9D)); + }); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals("TIMED_OUT", + result.getSourceStatuses().get(0).get("status")); + Assert.assertEquals("SUCCEEDED", + result.getSourceStatuses().get(1).get("status")); + Assert.assertEquals(Boolean.TRUE, + result.getSummary().get("partialFailure")); + } + + @Test + public void shouldReturnPartialSuccessAndRejectAllFailure() { + Fixture fixture = new Fixture(); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + KnowledgeVectorCandidateRequest request = invocation.getArgument(0); + if (request.getKnowledgeId().equals(BigInteger.ONE)) { + throw new IllegalStateException("private host detail"); + } + return List.of(document(22, "可用内容", 0.9D)); + }); + + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger( + WorkflowMultiKnowledgeRetrievalService.class); + ListAppender logAppender = new ListAppender<>(); + logAppender.start(); + logger.addAppender(logAppender); + MultiKnowledgeRetrievalResult result; + try { + result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + } finally { + logger.detachAppender(logAppender); + logAppender.stop(); + } + + Assert.assertEquals(1, result.getDocuments().size()); + Assert.assertEquals(Boolean.TRUE, result.getSummary().get("partialFailure")); + Assert.assertEquals("FAILED", result.getSourceStatuses().get(0).get("status")); + Assert.assertFalse(String.valueOf(result.getSourceStatuses().get(0).get("error")) + .contains("private host detail")); + Assert.assertFalse(logAppender.list.stream().anyMatch(event -> { + String message = event.getFormattedMessage(); + String throwableMessage = event.getThrowableProxy() == null + ? "" + : event.getThrowableProxy().getMessage(); + return message.contains("private host detail") + || throwableMessage.contains("private host detail"); + })); + + doThrow(new IllegalStateException("unavailable")) + .when(fixture.collectionService) + .searchVectorCandidates(any()); + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("all source failure must fail the node"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("全部知识库")); + } + } + + @Test + public void shouldFailFastWhenSourceConfigurationBecomesInvalid() { + Fixture fixture = new Fixture(); + doThrow(new BusinessException("知识库配置已失效")) + .when(fixture.collectionService) + .searchVectorCandidates(any()); + + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("configuration failures must fail the node"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("配置已失效")); + } + } + + @Test + public void shouldApplyTotalTimeoutToEmbedding() throws Exception { + Fixture fixture = new Fixture(); + fixture.properties.setPerSourceTimeout(Duration.ofMillis(50)); + fixture.properties.setTotalTimeout(Duration.ofMillis(80)); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + when(fixture.embeddingModel.embed("问题")) + .thenAnswer(invocation -> { + Thread.sleep(500L); + VectorData vectorData = new VectorData(); + vectorData.setVector(new float[]{0.1F, 0.2F, 0.3F}); + return vectorData; + }); + WorkflowMultiKnowledgeRetrievalService service = + fixture.createService(executor); + + long startedAt = System.nanoTime(); + try { + service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("embedding must respect the node total timeout"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("总耗时超时")); + Assert.assertTrue(TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startedAt) < 400L); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(2, TimeUnit.SECONDS); + } + } + + @Test + public void shouldStopWhenWorkflowExecutionIsCancelled() throws Exception { + Fixture fixture = new Fixture(); + AtomicInteger activeChecks = new AtomicInteger(); + when(fixture.chain.isExecutionActiveNow()) + .thenAnswer(invocation -> activeChecks.incrementAndGet() < 3); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenAnswer(invocation -> { + Thread.sleep(5_000L); + return List.of(); + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + WorkflowMultiKnowledgeRetrievalService service = + fixture.createService(executor); + try { + service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("cancelled workflow must stop knowledge retrieval"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("已取消")); + } + } finally { + executor.shutdownNow(); + Assert.assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test + public void shouldAllowDirectNodeRunWithoutRunningChainState() { + Fixture fixture = new Fixture(); + when(fixture.state.getStatus()).thenReturn(ChainStatus.READY); + when(fixture.chain.isExecutionActiveNow()).thenReturn(false); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenReturn(List.of(document(11, "单节点运行结果", 0.91D))); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(1, result.getDocuments().size()); + Assert.assertEquals(2, result.getSummary().get("successfulSourceCount")); + } + + @Test + public void shouldRejectInconsistentEmbeddingContract() { + Fixture fixture = new Fixture(); + fixture.second.setVectorEmbedModelId(BigInteger.valueOf(99)); + Model secondModel = mock(Model.class); + when(secondModel.getModelType()).thenReturn(Model.MODEL_TYPES[1]); + when(secondModel.getId()).thenReturn(BigInteger.valueOf(99)); + when(secondModel.getTenantId()).thenReturn(BigInteger.TEN); + when(secondModel.getProviderId()).thenReturn(BigInteger.ONE); + when(secondModel.getModelProvider()).thenReturn(fixture.modelProvider); + when(secondModel.getModelName()).thenReturn("embedding-other"); + when(fixture.modelService.listModelInstances(any())) + .thenReturn(List.of(fixture.model, secondModel)); + + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("inconsistent embedding contract must be rejected"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("Embedding")); + } + } + + @Test + public void shouldRejectUnauthorizedDraftKnowledgeSource() { + Fixture fixture = new Fixture(); + when(fixture.resourceAccessService.canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE))) + .thenReturn(false); + + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("draft execution must recheck knowledge permission"); + } catch (BusinessException expected) { + Assert.assertEquals(403, expected.getHttpStatus()); + } + } + + @Test + public void shouldTrustPublishedSnapshotBindingWithinSameTenant() { + Fixture fixture = new Fixture(); + when(fixture.definition.getId()).thenReturn("published:100"); + fixture.workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + String content = """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """; + fixture.workflow.setPublishedSnapshotJson(Map.of( + "tenantId", BigInteger.TEN, + "content", content, + WorkflowKnowledgeContractService.SNAPSHOT_KEY, + fixture.knowledgeContractService.buildSnapshotContracts( + content, BigInteger.TEN))); + when(fixture.resourceAccessService.canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE))) + .thenReturn(false); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenReturn(List.of()); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(2, result.getSummary().get("successfulSourceCount")); + verify(fixture.resourceAccessService, never()).canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE)); + } + + @Test + public void shouldRejectPublishedRunAfterEffectiveModelConfigDrifts() { + Fixture fixture = new Fixture(); + when(fixture.definition.getId()).thenReturn("published:100"); + fixture.workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + String content = """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """; + fixture.workflow.setPublishedSnapshotJson(Map.of( + "tenantId", BigInteger.TEN, + WorkflowKnowledgeContractService.SNAPSHOT_KEY, + fixture.knowledgeContractService.buildSnapshotContracts( + content, BigInteger.TEN))); + when(fixture.model.getEndpoint()).thenReturn("https://changed.example"); + + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("published execution must reject embedding drift"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("配置已变化")); + } + } + + @Test + public void shouldTrustFrozenAgentWorkflowBindingWithinSameTenant() { + Fixture fixture = new Fixture(); + String content = """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """; + List> contracts = + fixture.knowledgeContractService.buildSnapshotContracts( + content, BigInteger.TEN); + fixture.workflow.setPublishedSnapshotJson(Map.of( + "tenantId", BigInteger.TEN, + WorkflowKnowledgeContractService.SNAPSHOT_KEY, + contracts)); + String definitionId = "agent-frozen:100:10:" + + fixture.knowledgeContractService + .fingerprintSnapshotContracts(contracts) + + ":" + "0".repeat(64); + when(fixture.definition.getId()).thenReturn(definitionId); + KnowledgeNode knowledgeNode = new KnowledgeNode(); + knowledgeNode.setKnowledgeIds(List.of("1", "2")); + when(fixture.definition.getNodes()) + .thenReturn(List.of(knowledgeNode)); + when(fixture.resourceAccessService.canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE))) + .thenReturn(false); + when(fixture.collectionService.searchVectorCandidates(any())) + .thenReturn(List.of()); + + MultiKnowledgeRetrievalResult result = fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + + Assert.assertEquals(2, result.getSummary().get("successfulSourceCount")); + verify(fixture.workflowService, never()).getById(any()); + verify(fixture.resourceAccessService, never()).canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE)); + } + + @Test + public void shouldRejectFrozenAgentRunAfterEmbeddingContractDrifts() { + Fixture fixture = new Fixture(); + String content = """ + {"nodes":[{"type":"knowledgeNode","data":{ + "knowledgeIds":["1","2"],"retrievalMode":"VECTOR" + }}]} + """; + List> contracts = + fixture.knowledgeContractService.buildSnapshotContracts( + content, BigInteger.TEN); + String definitionId = "agent-frozen:100:10:" + + fixture.knowledgeContractService + .fingerprintSnapshotContracts(contracts) + + ":" + "0".repeat(64); + when(fixture.definition.getId()).thenReturn(definitionId); + KnowledgeNode knowledgeNode = new KnowledgeNode(); + knowledgeNode.setKnowledgeIds(List.of("1", "2")); + when(fixture.definition.getNodes()) + .thenReturn(List.of(knowledgeNode)); + when(fixture.model.getEndpoint()) + .thenReturn("https://changed.example"); + + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("frozen Agent execution must reject embedding drift"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("重新发布 Agent")); + } + } + + @Test + public void shouldRejectCrossTenantKnowledgeSources() { + Fixture fixture = new Fixture(); + fixture.second.setTenantId(BigInteger.valueOf(11)); + + try { + fixture.service.search( + List.of(BigInteger.ONE, BigInteger.TWO), + "问题", + 2, + "node-1", + fixture.chain); + Assert.fail("cross-tenant knowledge sources must be rejected"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("无权限")); + } + } + + private static Document document(long id, String content, double score) { + Document document = new Document(); + document.setId(BigInteger.valueOf(id)); + document.setContent(content); + document.setScore(score); + document.addMetadata("chunkId", BigInteger.valueOf(id)); + document.addMetadata("documentId", BigInteger.valueOf(id * 10)); + return document; + } + + private static DocumentCollection collection(long id, String title) { + DocumentCollection collection = new DocumentCollection(); + collection.setId(BigInteger.valueOf(id)); + collection.setTitle(title); + collection.setTenantId(BigInteger.TEN); + collection.setVectorStoreEnable(true); + collection.setVectorStoreCollection("collection_" + id); + collection.setVectorEmbedModelId(BigInteger.valueOf(7)); + collection.setDimensionOfVectorModel(3); + return collection; + } + + private static final class Fixture { + + private final DocumentCollectionService collectionService = + mock(DocumentCollectionService.class); + private final ModelService modelService = mock(ModelService.class); + private final WorkflowService workflowService = mock(WorkflowService.class); + private final ResourceAccessService resourceAccessService = + mock(ResourceAccessService.class); + private final EmbeddingModel embeddingModel = mock(EmbeddingModel.class); + private final Model model = mock(Model.class); + private final ModelProvider modelProvider = mock(ModelProvider.class); + private final Chain chain = mock(Chain.class); + private final ChainDefinition definition = mock(ChainDefinition.class); + private final ChainState state = mock(ChainState.class); + private final Workflow workflow = new Workflow(); + private final DocumentCollection first = collection(1, "知识库一"); + private final DocumentCollection second = collection(2, "知识库二"); + private final MultiKnowledgeRetrievalProperties properties = + new MultiKnowledgeRetrievalProperties(); + private final WorkflowMultiKnowledgeRetrievalService service; + private final WorkflowKnowledgeContractService knowledgeContractService; + + private Fixture() { + when(collectionService.listByIds(any())) + .thenReturn(List.of(first, second)); + when(model.getModelType()).thenReturn(Model.MODEL_TYPES[1]); + when(model.getId()).thenReturn(BigInteger.valueOf(7)); + when(model.getTenantId()).thenReturn(BigInteger.TEN); + when(model.getProviderId()).thenReturn(BigInteger.ONE); + when(model.getModelProvider()).thenReturn(modelProvider); + when(modelProvider.getProviderType()).thenReturn("openai"); + when(model.getModelName()).thenReturn("embedding-test"); + when(model.toEmbeddingModel()).thenReturn(embeddingModel); + when(modelService.listModelInstances(any())).thenReturn(List.of(model)); + VectorData vectorData = new VectorData(); + vectorData.setVector(new float[]{0.1F, 0.2F, 0.3F}); + when(embeddingModel.embed("问题")).thenReturn(vectorData); + when(definition.getId()).thenReturn("100"); + when(chain.getDefinition()).thenReturn(definition); + when(chain.isExecutionActiveNow()).thenReturn(true); + when(state.getStatus()).thenReturn(ChainStatus.RUNNING); + ConcurrentHashMap memory = new ConcurrentHashMap<>(); + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.ONE); + account.setTenantId(BigInteger.TEN); + memory.put(Constants.LOGIN_USER_KEY, account); + when(state.getMemory()).thenReturn(memory); + when(chain.getExecutionState()).thenReturn(state); + workflow.setId(BigInteger.valueOf(100)); + workflow.setTenantId(BigInteger.TEN); + when(workflowService.getById(BigInteger.valueOf(100))) + .thenReturn(workflow); + when(resourceAccessService.canAccess( + any(LoginAccount.class), + eq(CategoryResourceType.KNOWLEDGE), + any(DocumentCollection.class), + eq(ResourceAction.USE))) + .thenReturn(true); + properties.setMinVectorScore(0.6D); + knowledgeContractService = new WorkflowKnowledgeContractService( + collectionService, modelService); + service = createService(); + } + + private WorkflowMultiKnowledgeRetrievalService createService() { + Executor directExecutor = Runnable::run; + return createService(directExecutor); + } + + private WorkflowMultiKnowledgeRetrievalService createService( + Executor executor) { + return new WorkflowMultiKnowledgeRetrievalService( + collectionService, + workflowService, + resourceAccessService, + knowledgeContractService, + properties, + executor); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java index 01e077b5..225d696d 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java @@ -11,6 +11,7 @@ import tech.easyflow.ai.node.WorkflowNode; import tech.easyflow.common.web.exceptions.BusinessException; import java.math.BigInteger; +import java.util.List; import java.util.Map; import static org.mockito.Mockito.mock; @@ -34,9 +35,16 @@ public class AgentWorkflowSnapshotFactoryTest { Assert.assertEquals(workflow.getId(), snapshot.get("id")); Assert.assertEquals("prepared-content", snapshot.get("content")); - Assert.assertEquals(6, snapshot.size()); - Assert.assertFalse(snapshot.containsKey("tenantId")); - Assert.assertFalse(snapshot.containsKey("publishedSnapshotJson")); + Assert.assertEquals(8, snapshot.size()); + Assert.assertEquals(BigInteger.TEN, snapshot.get("tenantId")); + @SuppressWarnings("unchecked") + Map publishedSnapshot = + (Map) snapshot.get("publishedSnapshotJson"); + Assert.assertEquals(BigInteger.TEN, publishedSnapshot.get("tenantId")); + Assert.assertEquals( + List.of(Map.of("knowledgeId", "1")), + publishedSnapshot.get("knowledgeContracts")); + Assert.assertFalse(publishedSnapshot.containsKey("secret")); } /** @@ -78,7 +86,9 @@ public class AgentWorkflowSnapshotFactoryTest { workflow.setRevision(3); workflow.setContent("raw-content"); workflow.setTenantId(BigInteger.TEN); - workflow.setPublishedSnapshotJson(Map.of("secret", "hidden")); + workflow.setPublishedSnapshotJson(Map.of( + "secret", "hidden", + "knowledgeContracts", List.of(Map.of("knowledgeId", "1")))); return workflow; } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistryTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistryTest.java new file mode 100644 index 00000000..693e44c0 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistryTest.java @@ -0,0 +1,67 @@ +package tech.easyflow.ai.easyagentsflow.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.parser.ChainParser; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Agent 工作流冻结定义内容寻址测试。 + */ +public class FrozenWorkflowDefinitionRegistryTest { + + @Test + public void shouldIncludeKnowledgeContractInDefinitionIdentity() { + ChainParser parser = mock(ChainParser.class); + WorkflowDatacenterContentService contentService = + mock(WorkflowDatacenterContentService.class); + when(contentService.prepareContent("raw-content")) + .thenReturn("prepared-content"); + when(parser.parse("prepared-content")) + .thenAnswer(ignored -> new ChainDefinition()); + AgentWorkflowSnapshotFactory factory = + new AgentWorkflowSnapshotFactory(parser, contentService); + FrozenWorkflowDefinitionRegistry registry = + new FrozenWorkflowDefinitionRegistry( + factory, + new WorkflowKnowledgeContractService( + mock(DocumentCollectionService.class), + mock(ModelService.class))); + Workflow first = workflow("https://one.example"); + Workflow second = workflow("https://two.example"); + + String firstId = registry.register(first); + String secondId = registry.register(second); + + Assert.assertNotEquals(firstId, secondId); + Map frozenSnapshot = registry.getWorkflow(firstId) + .getPublishedSnapshotJson(); + Assert.assertEquals(BigInteger.TEN, frozenSnapshot.get("tenantId")); + Assert.assertFalse(frozenSnapshot.containsKey("secret")); + } + + private Workflow workflow(String endpoint) { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.ONE); + workflow.setTenantId(BigInteger.TEN); + workflow.setContent("raw-content"); + workflow.setPublishedSnapshotJson(Map.of( + "secret", "hidden", + "knowledgeContracts", List.of(Map.of( + "knowledgeId", "1", + "modelEndpoint", endpoint)))); + return workflow; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java index 63af966b..4af86049 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowCheckServiceTest.java @@ -1295,6 +1295,182 @@ public class WorkflowCheckServiceTest { Assert.assertTrue(result.isPassed()); } + @Test + public void testKnowledgeNodeShouldAcceptLegacyAndMultiVectorReferences() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject legacy = data("历史知识库"); + legacy.put("knowledgeId", "101"); + legacy.put("retrievalMode", "HYBRID"); + legacy.put("limit", "5"); + JSONObject multi = data("多知识库"); + multi.put("knowledgeIds", stringArray("201", "202")); + multi.put("retrievalMode", "VECTOR"); + multi.put("limit", "{{start.limit}}"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array( + node("k1", "knowledgeNode", null, legacy), + node("k2", "knowledgeNode", null, multi)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + Assert.assertTrue(result.isPassed()); + } + + @Test + public void testMultiKnowledgeNodeShouldRequireVectorMode() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("knowledgeIds", stringArray("201", "202")); + data.put("retrievalMode", "HYBRID"); + data.put("limit", "5"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "MULTI_KNOWLEDGE_MODE_INVALID"); + } + + @Test + public void testKnowledgeNodeShouldRejectDuplicateIdsAndInvalidLimit() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("knowledgeIds", stringArray("201", "201")); + data.put("retrievalMode", "VECTOR"); + data.put("limit", "0"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "KNOWLEDGE_IDS_DUPLICATE"); + assertHasCode(result, "KNOWLEDGE_LIMIT_INVALID"); + } + + @Test + public void testKnowledgeNodeShouldRejectMalformedVariableLimit() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("knowledgeIds", stringArray("201", "202")); + data.put("retrievalMode", "VECTOR"); + data.put("limit", "abc{{"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "KNOWLEDGE_LIMIT_INVALID"); + } + + @Test + public void testKnowledgeNodeShouldRejectBlankVariableLimit() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("knowledgeIds", stringArray("201", "202")); + data.put("retrievalMode", "VECTOR"); + data.put("limit", "{{ }}"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "KNOWLEDGE_LIMIT_INVALID"); + } + + @Test + public void testKnowledgeNodeShouldRejectMismatchedDataType() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("type", "llmNode"); + data.put("knowledgeIds", stringArray("201", "202")); + data.put("retrievalMode", "VECTOR"); + data.put("limit", "5"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "NODE_TYPE_MISMATCH"); + } + + @Test + public void testMultiKnowledgeNodeShouldRejectConfiguredBounds() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("knowledgeIds", stringArray( + "201", "202", "203", "204", "205", + "206", "207", "208", "209")); + data.put("retrievalMode", "VECTOR"); + data.put("limit", "201"); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "KNOWLEDGE_SOURCE_LIMIT_EXCEEDED"); + assertHasCode(result, "KNOWLEDGE_LIMIT_INVALID"); + } + + @Test + public void testKnowledgeNodeShouldRejectRemovedDiagnosticOutputs() + throws Exception { + WorkflowCheckService service = newService(new HashMap<>()); + JSONObject data = data("多知识库"); + data.put("knowledgeIds", stringArray("201", "202")); + data.put("retrievalMode", "VECTOR"); + data.put("limit", "5"); + JSONArray outputDefs = new JSONArray(); + JSONObject documents = new JSONObject(); + documents.put("name", "documents"); + documents.put("dataType", "Array"); + JSONArray children = new JSONArray(); + children.add(outputDef("title", "String")); + children.add(outputDef("content", "String")); + children.add(outputDef("documentId", "Number")); + children.add(outputDef("knowledgeId", "Number")); + documents.put("children", children); + outputDefs.add(documents); + outputDefs.add(outputDef("sourceStatuses", "Array")); + data.put("outputDefs", outputDefs); + + WorkflowCheckResult result = service.checkContent( + workflowJson( + array(node("k1", "knowledgeNode", null, data)), + new JSONArray()), + WorkflowCheckStage.SAVE, + null); + + assertHasCode(result, "KNOWLEDGE_OUTPUT_SCHEMA_INVALID"); + } + private static WorkflowCheckService newService(Map workflowStore) throws Exception { WorkflowCheckService service = new WorkflowCheckService(); ChainParser parser = ChainParser.builder() @@ -1387,6 +1563,13 @@ public class WorkflowCheckServiceTest { return array; } + private static JSONObject outputDef(String name, String dataType) { + JSONObject output = new JSONObject(); + output.put("name", name); + output.put("dataType", dataType); + return output; + } + private static JSONObject node(String id, String type, String parentId, JSONObject data) { JSONObject node = new JSONObject(); node.put("id", id); diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java index af40ae22..43c3ab9c 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java @@ -3,7 +3,11 @@ package tech.easyflow.ai.publish; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Assert; import org.junit.Test; +import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage; +import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; @@ -15,8 +19,13 @@ import tech.easyflow.ai.vo.OfflineImpactBindingVo; import tech.easyflow.ai.vo.OfflineImpactCheckVo; import tech.easyflow.approval.enums.ApprovalActionType; import tech.easyflow.approval.service.ApprovalInstanceService; +import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.system.service.SysAccountService; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; import java.math.BigInteger; import java.util.List; @@ -24,8 +33,13 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -53,7 +67,10 @@ public class WorkflowApprovalSubjectHandlerTest { mock(WorkflowPluginSnapshotResolver.class), mock(AgentResourceReferenceService.class), new ObjectMapper(), - List.of(scheduleReferenceProvider) + List.of(scheduleReferenceProvider), + mock(WorkflowCheckService.class), + mock(WorkflowKnowledgeContractService.class), + mock(SysAccountService.class) ); Workflow workflow = new Workflow(); workflow.setId(workflowId); @@ -91,7 +108,10 @@ public class WorkflowApprovalSubjectHandlerTest { mock(WorkflowPluginSnapshotResolver.class), mock(AgentResourceReferenceService.class), new ObjectMapper(), - List.of(scheduleReferenceProvider) + List.of(scheduleReferenceProvider), + mock(WorkflowCheckService.class), + mock(WorkflowKnowledgeContractService.class), + mock(SysAccountService.class) ); Workflow workflow = new Workflow(); workflow.setId(workflowId); @@ -116,6 +136,285 @@ public class WorkflowApprovalSubjectHandlerTest { Assert.assertEquals(2, referenceChecks.get()); } + @Test + public void shouldValidateAndFreezeKnowledgeContractOnPublish() { + BigInteger workflowId = BigInteger.valueOf(103); + ResourceOfflineImpactService offlineImpactService = + mock(ResourceOfflineImpactService.class); + when(offlineImpactService.checkWorkflowImpact(workflowId)) + .thenReturn(new OfflineImpactCheckVo()); + WorkflowCheckService workflowCheckService = + mock(WorkflowCheckService.class); + WorkflowKnowledgeContractService contractService = + mock(WorkflowKnowledgeContractService.class); + List> contracts = List.of( + Map.of("knowledgeId", "1")); + when(contractService.buildSnapshotContracts( + "{\"nodes\":[]}", BigInteger.TEN)) + .thenReturn(contracts); + WorkflowApprovalSubjectHandler handler = + new WorkflowApprovalSubjectHandler( + mock(WorkflowService.class), + mock(ResourceAccessService.class), + mock(ApprovalInstanceService.class), + offlineImpactService, + mock(WorkflowPluginBindingService.class), + mock(WorkflowPluginSnapshotResolver.class), + mock(AgentResourceReferenceService.class), + new ObjectMapper(), + List.of(), + workflowCheckService, + contractService, + mock(SysAccountService.class)); + Workflow workflow = new Workflow(); + workflow.setId(workflowId); + workflow.setTenantId(BigInteger.TEN); + workflow.setContent("{\"nodes\":[]}"); + workflow.setPublishStatus(PublishStatus.DRAFT.getCode()); + + Map snapshot = handler.buildPublishSnapshot( + workflow, PublishStatus.DRAFT); + + Assert.assertEquals( + contracts, + snapshot.get(WorkflowKnowledgeContractService.SNAPSHOT_KEY)); + verify(workflowCheckService).checkOrThrow( + workflow.getContent(), WorkflowCheckStage.SAVE, workflowId); + } + + @Test + public void shouldNotRequireKnowledgeContractWhenDeletingDraft() { + WorkflowKnowledgeContractService contractService = + mock(WorkflowKnowledgeContractService.class); + WorkflowApprovalSubjectHandler handler = + new WorkflowApprovalSubjectHandler( + mock(WorkflowService.class), + mock(ResourceAccessService.class), + mock(ApprovalInstanceService.class), + mock(ResourceOfflineImpactService.class), + mock(WorkflowPluginBindingService.class), + mock(WorkflowPluginSnapshotResolver.class), + mock(AgentResourceReferenceService.class), + new ObjectMapper(), + List.of(), + mock(WorkflowCheckService.class), + contractService, + mock(SysAccountService.class)); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(104)); + workflow.setContent("{\"nodes\":[]}"); + + Map snapshot = handler.buildDeleteSnapshot( + workflow, PublishStatus.DRAFT); + + Assert.assertFalse(snapshot.containsKey( + WorkflowKnowledgeContractService.SNAPSHOT_KEY)); + verifyNoInteractions(contractService); + } + + @Test + public void shouldRejectPublishWhenSingleKnowledgeAccessWasRevoked() { + ResourceAccessService accessService = mock(ResourceAccessService.class); + WorkflowKnowledgeContractService contractService = + mock(WorkflowKnowledgeContractService.class); + DocumentCollection collection = new DocumentCollection(); + collection.setId(BigInteger.ONE); + collection.setTenantId(BigInteger.TEN); + when(contractService.resolveReferencedCollections(any(), eq(BigInteger.TEN))) + .thenReturn(List.of(collection)); + doThrow(new BusinessException(403, 403, "无权限使用工作流知识库")) + .when(accessService) + .assertAccess( + eq(CategoryResourceType.KNOWLEDGE), + eq(collection), + eq(ResourceAction.USE), + any()); + WorkflowApprovalSubjectHandler handler = handler( + mock(WorkflowService.class), accessService, contractService); + Workflow workflow = workflow(105); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> handler.buildPublishSnapshot( + workflow, PublishStatus.DRAFT)); + + Assert.assertEquals(403, exception.getHttpStatus()); + } + + @Test + public void shouldRecheckApplicantKnowledgeAccessBeforeApprovalTakesEffect() { + WorkflowService workflowService = mock(WorkflowService.class); + ResourceAccessService accessService = mock(ResourceAccessService.class); + SysAccountService accountService = mock(SysAccountService.class); + WorkflowKnowledgeContractService contractService = + mock(WorkflowKnowledgeContractService.class); + DocumentCollection collection = new DocumentCollection(); + collection.setId(BigInteger.ONE); + collection.setTenantId(BigInteger.TEN); + when(contractService.resolveReferencedCollections(any(), eq(BigInteger.TEN))) + .thenReturn(List.of(collection)); + SysAccount applicant = new SysAccount(); + applicant.setId(BigInteger.ONE); + applicant.setDeptId(BigInteger.valueOf(3)); + applicant.setTenantId(BigInteger.TEN); + applicant.setStatus(EnumDataStatus.AVAILABLE.getCode()); + when(accountService.getById(BigInteger.ONE)).thenReturn(applicant); + WorkflowApprovalSubjectHandler handler = handler( + workflowService, + accessService, + contractService, + accountService); + Map snapshot = Map.of( + "content", "{\"nodes\":[]}", + "tenantId", BigInteger.TEN); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> handler.applyApprovedAction( + ApprovalActionType.PUBLISH.getCode(), + BigInteger.valueOf(106), + snapshot, + BigInteger.valueOf(9), + BigInteger.valueOf(99), + BigInteger.ONE)); + + Assert.assertEquals(403, exception.getHttpStatus()); + verify(accessService).canAccess( + argThat(account -> BigInteger.valueOf(3).equals( + account.getDeptId())), + eq(CategoryResourceType.KNOWLEDGE), + eq(collection), + eq(ResourceAction.USE)); + verify(workflowService, never()).updateById(any(Workflow.class)); + } + + @Test + public void shouldRejectApprovedPublishWhenApplicantIsDisabled() { + WorkflowService workflowService = mock(WorkflowService.class); + SysAccountService accountService = mock(SysAccountService.class); + SysAccount applicant = new SysAccount(); + applicant.setId(BigInteger.ONE); + applicant.setTenantId(BigInteger.TEN); + applicant.setStatus(EnumDataStatus.UNAVAILABLE.getCode()); + when(accountService.getById(BigInteger.ONE)).thenReturn(applicant); + WorkflowApprovalSubjectHandler handler = handler( + workflowService, + mock(ResourceAccessService.class), + mock(WorkflowKnowledgeContractService.class), + accountService); + Map snapshot = Map.of( + "content", "{\"nodes\":[]}", + "tenantId", BigInteger.TEN); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> handler.applyApprovedAction( + ApprovalActionType.PUBLISH.getCode(), + BigInteger.valueOf(109), + snapshot, + BigInteger.valueOf(9), + BigInteger.valueOf(99), + BigInteger.ONE)); + + Assert.assertEquals(403, exception.getHttpStatus()); + verify(workflowService, never()).updateById(any(Workflow.class)); + } + + @Test + public void shouldRejectApprovedPublishWhenEmbeddingContractDrifts() { + WorkflowService workflowService = mock(WorkflowService.class); + WorkflowKnowledgeContractService contractService = + mock(WorkflowKnowledgeContractService.class); + Map snapshot = Map.of( + "content", "{\"nodes\":[]}", + "tenantId", BigInteger.TEN); + doThrow(new BusinessException("工作流引用的知识库 Embedding 配置已变化,请重新提交发布")) + .when(contractService) + .assertSnapshotCurrent(snapshot); + WorkflowApprovalSubjectHandler handler = handler( + workflowService, + mock(ResourceAccessService.class), + contractService); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> handler.applyApprovedAction( + ApprovalActionType.PUBLISH.getCode(), + BigInteger.valueOf(107), + snapshot, + BigInteger.ONE)); + + Assert.assertTrue(exception.getMessage().contains("配置已变化")); + verify(workflowService, never()).updateById(any(Workflow.class)); + } + + @Test + public void shouldRejectRepeatedPublishWithSameFullSnapshot() { + WorkflowKnowledgeContractService contractService = + mock(WorkflowKnowledgeContractService.class); + when(contractService.buildSnapshotContracts(any(), eq(BigInteger.TEN))) + .thenReturn(List.of(Map.of("knowledgeId", "1"))); + WorkflowApprovalSubjectHandler handler = handler( + mock(WorkflowService.class), + mock(ResourceAccessService.class), + contractService); + Workflow workflow = workflow(108); + Map first = handler.buildPublishSnapshot( + workflow, PublishStatus.DRAFT); + workflow.setPublishedSnapshotJson(first); + + BusinessException exception = Assert.assertThrows( + BusinessException.class, + () -> handler.buildPublishSnapshot( + workflow, PublishStatus.PUBLISHED)); + + Assert.assertTrue(exception.getMessage().contains("无需重新发布")); + } + + private WorkflowApprovalSubjectHandler handler( + WorkflowService workflowService, + ResourceAccessService accessService, + WorkflowKnowledgeContractService contractService) { + return handler( + workflowService, + accessService, + contractService, + mock(SysAccountService.class)); + } + + private WorkflowApprovalSubjectHandler handler( + WorkflowService workflowService, + ResourceAccessService accessService, + WorkflowKnowledgeContractService contractService, + SysAccountService accountService) { + ResourceOfflineImpactService impactService = + mock(ResourceOfflineImpactService.class); + when(impactService.checkWorkflowImpact(any())) + .thenReturn(new OfflineImpactCheckVo()); + return new WorkflowApprovalSubjectHandler( + workflowService, + accessService, + mock(ApprovalInstanceService.class), + impactService, + mock(WorkflowPluginBindingService.class), + mock(WorkflowPluginSnapshotResolver.class), + mock(AgentResourceReferenceService.class), + new ObjectMapper(), + List.of(), + mock(WorkflowCheckService.class), + contractService, + accountService); + } + + private Workflow workflow(long id) { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(id)); + workflow.setTenantId(BigInteger.TEN); + workflow.setContent("{\"nodes\":[]}"); + workflow.setPublishStatus(PublishStatus.DRAFT.getCode()); + return workflow; + } + /** * 创建定时任务引用摘要。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImplTest.java index eded1214..c65bf40d 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImplTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImplTest.java @@ -1,17 +1,22 @@ package tech.easyflow.ai.service.impl; import com.easyagents.core.document.Document; +import com.easyagents.core.store.DocumentStore; +import com.easyagents.core.store.SearchWrapper; +import com.easyagents.core.store.StoreOptions; import com.easyagents.search.engine.service.DocumentSearcher; import com.easyagents.search.engine.service.KeywordSearchRequest; import com.mybatisflex.core.query.QueryWrapper; import org.junit.Assert; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.ObjectProvider; import tech.easyflow.ai.config.SearcherFactory; import tech.easyflow.ai.enums.DocumentProcessStatus; import tech.easyflow.ai.mapper.DocumentChunkMapper; import tech.easyflow.ai.mapper.DocumentMapper; import tech.easyflow.ai.mapper.FaqItemMapper; +import tech.easyflow.ai.rag.KnowledgeVectorCandidateRequest; import java.io.Serializable; import java.lang.reflect.Field; @@ -23,6 +28,12 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import static tech.easyflow.ai.entity.DocumentCollection.KEY_DOC_RECALL_MAX_NUM; import static tech.easyflow.ai.entity.DocumentCollection.KEY_SIMILARITY_THRESHOLD; @@ -34,6 +45,80 @@ import static tech.easyflow.ai.entity.DocumentCollection.KEY_SIMILARITY_THRESHOL */ public class DocumentCollectionServiceImplTest { + /** + * 验证多知识库统一阈值不会在向量查询前降为 float。 + */ + @Test + public void searchVectorCandidatesShouldKeepDoubleThresholdPrecision() { + BigInteger knowledgeId = BigInteger.ONE; + DocumentStore documentStore = mock(DocumentStore.class); + tech.easyflow.ai.entity.DocumentCollection collection = + mock(tech.easyflow.ai.entity.DocumentCollection.class); + when(collection.getId()).thenReturn(knowledgeId); + when(collection.getTitle()).thenReturn("知识库"); + when(collection.getVectorStoreCollection()).thenReturn("knowledge_1"); + when(collection.toDocumentStore()).thenReturn(documentStore); + when(documentStore.search(any(), any())).thenReturn(List.of()); + DocumentCollectionServiceImpl service = + new TestDocumentCollectionService(collection); + + KnowledgeVectorCandidateRequest request = + new KnowledgeVectorCandidateRequest(); + request.setKnowledgeId(knowledgeId); + request.setCollection(collection); + request.setQuery("问题"); + request.setLimit(5); + request.setMinVectorScore(0.6D); + request.setQueryVector(new float[]{0.1F, 0.2F}); + request.setTimeoutMillis(1_234L); + + service.searchVectorCandidates(request); + + ArgumentCaptor wrapper = + ArgumentCaptor.forClass(SearchWrapper.class); + ArgumentCaptor options = + ArgumentCaptor.forClass(StoreOptions.class); + verify(documentStore).search(wrapper.capture(), options.capture()); + Assert.assertEquals(0.6D, wrapper.getValue().getMinScore(), 0D); + Assert.assertEquals(Long.valueOf(1_234L), + options.getValue().getTimeoutMillis()); + } + + /** + * 验证多库检索沿用契约校验时加载的知识库快照,不在执行前重新读取可变配置。 + */ + @Test + public void searchVectorCandidatesShouldUseValidatedCollectionSnapshot() { + BigInteger knowledgeId = BigInteger.ONE; + DocumentStore snapshotStore = mock(DocumentStore.class); + DocumentStore changedStore = mock(DocumentStore.class); + tech.easyflow.ai.entity.DocumentCollection snapshot = + mock(tech.easyflow.ai.entity.DocumentCollection.class); + tech.easyflow.ai.entity.DocumentCollection changed = + mock(tech.easyflow.ai.entity.DocumentCollection.class); + when(snapshot.getId()).thenReturn(knowledgeId); + when(snapshot.getVectorStoreCollection()).thenReturn("validated_collection"); + when(snapshot.toDocumentStore()).thenReturn(snapshotStore); + when(changed.getId()).thenReturn(knowledgeId); + when(changed.toDocumentStore()).thenReturn(changedStore); + when(snapshotStore.search(any(), any())).thenReturn(List.of()); + DocumentCollectionServiceImpl service = + new TestDocumentCollectionService(changed); + KnowledgeVectorCandidateRequest request = + new KnowledgeVectorCandidateRequest(); + request.setKnowledgeId(knowledgeId); + request.setCollection(snapshot); + request.setQuery("问题"); + request.setLimit(5); + request.setMinVectorScore(0.6D); + request.setQueryVector(new float[]{0.1F, 0.2F}); + + service.searchVectorCandidates(request); + + verify(snapshotStore).search(any(), any()); + verify(changedStore, never()).search(any(), any()); + } + /** * 验证最终相关度阈值会过滤所有已统一到零到一范围的检索结果。 */ diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ModelServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ModelServiceImplTest.java new file mode 100644 index 00000000..3e8964d5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ModelServiceImplTest.java @@ -0,0 +1,52 @@ +package tech.easyflow.ai.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.ModelProvider; +import tech.easyflow.ai.mapper.ModelMapper; + +import java.math.BigInteger; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link ModelServiceImpl} 实际运行配置装配测试。 + */ +public class ModelServiceImplTest { + + @Test + public void shouldFillProviderDefaultsWhenLoadingModelInstances() { + ModelProvider provider = new ModelProvider(); + provider.setId(BigInteger.ONE); + provider.setProviderType("openai"); + provider.setEndpoint("https://provider.example"); + provider.setEmbedPath("/v1/provider-embeddings"); + Model model = new Model(); + model.setId(BigInteger.TEN); + model.setProviderId(BigInteger.ONE); + model.setModelProvider(provider); + model.setModelType(Model.MODEL_TYPES[1]); + model.setModelName("embedding-model"); + ModelMapper mapper = mock(ModelMapper.class); + when(mapper.selectListWithRelationsByQuery(any(QueryWrapper.class))) + .thenReturn(List.of(model)); + ModelServiceImpl service = new ModelServiceImpl(); + service.modelMapper = mapper; + + List result = service.listModelInstances( + List.of(BigInteger.TEN)); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals( + "https://provider.example", + result.get(0).getEndpoint()); + Assert.assertEquals( + "/v1/provider-embeddings", + result.get(0).getRequestPath()); + } +} diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml index 93a76e6b..61f2e422 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml @@ -218,6 +218,15 @@ easyflow: rag: health: cache-ttl: 5s + knowledge: + multi-retrieval: + max-sources: 8 + candidate-multiplier: 5 + per-source-candidate-limit: 50 + total-candidate-limit: 400 + min-vector-score: 0.6 + per-source-timeout: 10s + total-timeout: 20s document-import: bulk: max-file-count: 2000 @@ -253,6 +262,12 @@ easyflow: queue-capacity: 200 keep-alive-seconds: 60 allow-core-thread-timeout: true + knowledge-retrieval: + core-size: 4 + max-size: 8 + queue-capacity: 64 + keep-alive-seconds: 30 + allow-core-thread-timeout: true scheduler: pool-size: 4 @@ -360,6 +375,7 @@ rag: poolMaxWaitMillis: 3000 poolEvictionIntervalMillis: 60000 poolMinEvictableIdleMillis: 300000 + searchTimeoutMillis: 10000 # 搜索引擎配置 searcher: lucene: diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue index cc9120ec..ba4f48f8 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue @@ -12,7 +12,7 @@ import { import { useRoute } from 'vue-router'; import {usePreferences} from '@easyflow/preferences'; -import {getOptions, sortNodes} from '@easyflow/utils'; +import {sortNodes} from '@easyflow/utils'; import {Tinyflow} from '@tinyflow-ai/vue'; import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue'; @@ -220,7 +220,14 @@ const provider = computed(() => ({ description: item.description, }; }), - knowledge: () => getOptions('title', 'id', knowledgeList.value), + knowledge: () => knowledgeList.value.map((item: any) => ({ + label: item.title, + value: item.id, + description: item.description, + embeddingModelId: item.vectorEmbedModelId, + embeddingDimension: item.dimensionOfVectorModel, + vectorStoreEnabled: item.vectorStoreEnabled, + })), searchEngine: (): any => [ { value: 'bocha-search', diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.test.ts new file mode 100644 index 00000000..dd4f1c5f --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/base/select.test.ts @@ -0,0 +1,63 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import Select from './select.svelte'; + +describe('Select multiple interaction', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('keeps the list open across selections and restores focus on Escape', async () => { + const onSelect = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const app = mount(Select, { + target: host, + props: { + items: [ + { label: '知识库一', value: 1 }, + { label: '知识库二', value: 2 }, + { + disabledReason: '向量配置不可用', + label: '知识库三', + selectable: false, + value: 3, + }, + ], + multiple: true, + onSelect, + value: [2, 1], + }, + }); + flushSync(); + + const trigger = host.querySelector('.tf-select-input')!; + expect(trigger.textContent).toContain('知识库二'); + expect(trigger.textContent).toContain('+1'); + trigger.click(); + flushSync(); + + const listbox = host.querySelector('[role="listbox"]')!; + const floating = listbox.parentElement!; + const options = listbox.querySelectorAll('[role="option"]'); + expect(floating.style.display).toBe('block'); + options[0].click(); + options[1].click(); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(floating.style.display).toBe('block'); + expect(options[2].getAttribute('aria-label')).toContain('向量配置不可用'); + options[2].click(); + expect(onSelect).toHaveBeenCalledTimes(2); + expect(floating.style.display).toBe('block'); + + listbox.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), + ); + flushSync(); + expect(floating.style.display).toBe('none'); + expect(document.activeElement).toBe(trigger); + + await unmount(app); + }); +}); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterItem.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterItem.svelte index 062ec971..40fc6312 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterItem.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterItem.svelte @@ -12,6 +12,7 @@ isArrayDataType, resolveLoopOutputDataType, } from '../../utils/loopScope'; + import {resolveReferencedParameterDataType} from '../utils/referenceDataType'; onMount(() => { if (!param.refType) { @@ -122,7 +123,13 @@ const updateRef = (item: any) => { const newValue = item.value; if (!loopOutputAggregation) { - updateParam('ref', newValue); + updateParams({ + ref: newValue, + dataType: resolveReferencedParameterDataType( + param, + item.dataType + ) + }); return; } const dataType = item.dataType || 'String'; @@ -182,6 +189,22 @@ () => acceptedContentTypes, () => param.ref || '' ); + $effect(() => { + const referencedDataType = selectItems.selected?.dataType; + const nextDataType = resolveReferencedParameterDataType( + param, + referencedDataType + ); + if ( + !loopOutputAggregation && + param.refType === 'ref' && + param.ref && + referencedDataType && + param.dataType !== nextDataType + ) { + updateParam('dataType', nextDataType); + } + }); let sourceDataType = $derived.by(() => { return selectItems.selected?.dataType || param.dataType || 'String'; }); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte index 779af53d..03fa8608 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte @@ -5,7 +5,6 @@ import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte'; import {Heading, Select} from '../base'; import {getCurrentNodeId} from '#components/utils/NodeUtils'; - import {useAddParameter} from '../utils/useAddParameter.svelte'; import {getOptions} from '../utils/NodeUtils'; import {onMount} from 'svelte'; import OutputDefList from '../core/OutputDefList.svelte'; @@ -17,6 +16,12 @@ syncManagedParametersForFields, updateFieldBindingMeta, } from '../../utils/workflowNodeFields'; + import { + buildKnowledgeSelectItems, + ensureKnowledgeOutputDefs, + normalizeKnowledgeIds, + toggleKnowledgeId, + } from '../utils/knowledgeNode'; const { data, ...rest }: { data: TinyflowNodeData, @@ -25,7 +30,6 @@ const currentNodeId = getCurrentNodeId(); let currentNode = useNodesData(currentNodeId); - const { addParameter } = useAddParameter(); const { nodes, edges } = $derived(useStore()); const editorParameters = $derived.by(() => { return buildEditorReferenceParameters( @@ -37,6 +41,7 @@ }); const options = getOptions(); + const reloadKnowledgeValue = '__reload_knowledge_options__'; const retrievalModeOptions: SelectItem[] = [ { value: 'HYBRID', label: '混合检索' }, { value: 'VECTOR', label: '向量检索' }, @@ -44,10 +49,33 @@ ]; let knowledgeArray = $state([]); - onMount(async () => { - const newLLMs = await options.provider?.knowledge?.(); - knowledgeArray.push(...(newLLMs || [])); - }); + let knowledgeLoading = $state(false); + let knowledgeLoadError = $state(''); + const selectedKnowledgeIds = $derived(normalizeKnowledgeIds(data)); + const isMultiKnowledge = $derived(selectedKnowledgeIds.length > 1); + const selectableKnowledgeItems = $derived( + buildKnowledgeSelectItems( + knowledgeArray, + selectedKnowledgeIds, + knowledgeLoadError + ? { value: reloadKnowledgeValue, label: '重新加载知识库' } + : undefined + ) + ); + + async function loadKnowledges() { + knowledgeLoading = true; + knowledgeLoadError = ''; + try { + knowledgeArray = [...((await options.provider?.knowledge?.()) || [])]; + } catch (error) { + knowledgeLoadError = error instanceof Error ? error.message : '知识库加载失败'; + } finally { + knowledgeLoading = false; + } + } + + onMount(loadKnowledges); const { updateNodeData } = useSvelteFlow(); const syncFieldValue = (fieldName: 'keyword' | 'limit', nextValue: string) => { @@ -67,59 +95,40 @@ }); }; + const toggleKnowledge = (item: SelectItem) => { + if (item.value === reloadKnowledgeValue) { + loadKnowledges(); + return; + } + const nextIds = toggleKnowledgeId(selectedKnowledgeIds, item.value); + updateNodeData(currentNodeId, { + knowledgeIds: nextIds, + knowledgeId: undefined, + retrievalMode: nextIds.length > 1 + ? 'VECTOR' + : (data.retrievalMode || 'HYBRID'), + }); + }; + $effect(() => { - if (!data.outputDefs || data.outputDefs.length === 0) { - addParameter(currentNodeId, 'outputDefs', - { - name: 'documents', - dataType: 'Array', - nameDisabled: true, - dataTypeDisabled: true, - addChildDisabled: true, - deleteDisabled: true, - children: [ - { - name: 'title', - dataType: 'String', - nameDisabled: true, - dataTypeDisabled: true, - deleteDisabled: true - }, - { - name: 'content', - dataType: 'String', - nameDisabled: true, - dataTypeDisabled: true, - deleteDisabled: true - }, - { - name: 'documentId', - dataType: 'Number', - nameDisabled: true, - dataTypeDisabled: true, - deleteDisabled: true - }, - { - name: 'knowledgeId', - dataType: 'Number', - nameDisabled: true, - dataTypeDisabled: true, - deleteDisabled: true - } - ] - } - ); + const nextOutputDefs = ensureKnowledgeOutputDefs(data.outputDefs); + if (JSON.stringify(nextOutputDefs) !== JSON.stringify(data.outputDefs || [])) { + updateNodeData(currentNodeId, { outputDefs: nextOutputDefs }); } }); $effect(() => { - if (!data.retrievalMode) { - updateNodeData(currentNodeId, () => { - return { - retrievalMode: 'HYBRID' - }; + if (!Array.isArray(data.knowledgeIds) && data.knowledgeId !== undefined) { + updateNodeData(currentNodeId, { + knowledgeIds: normalizeKnowledgeIds(data), + knowledgeId: undefined, }); } + if (isMultiKnowledge && data.retrievalMode !== 'VECTOR') { + updateNodeData(currentNodeId, { retrievalMode: 'VECTOR' }); + } else if (!data.retrievalMode) { + updateNodeData(currentNodeId, { retrievalMode: 'HYBRID' }); + } }); @@ -136,14 +145,17 @@ 知识库设置
知识库
-
关键字
@@ -169,7 +181,8 @@ retrievalMode: newValue } }) - }} value={data.retrievalMode ? [data.retrievalMode] : ['HYBRID']} /> + }} value={data.retrievalMode ? [data.retrievalMode] : ['HYBRID']} + disabled={isMultiKnowledge} /> diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/knowledgeNode.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/knowledgeNode.test.ts new file mode 100644 index 00000000..1f28f5ea --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/knowledgeNode.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyEmbeddingCompatibility, + buildKnowledgeSelectItems, + ensureKnowledgeOutputDefs, + normalizeKnowledgeIds, + toggleKnowledgeId, +} from './knowledgeNode'; + +describe('knowledge node helpers', () => { + it('prefers canonical knowledgeIds over legacy knowledgeId', () => { + expect(normalizeKnowledgeIds({ knowledgeId: '1', knowledgeIds: ['2', '3', '2'] })) + .toEqual(['2', '3']); + expect(normalizeKnowledgeIds({ knowledgeId: '1' })).toEqual(['1']); + expect(normalizeKnowledgeIds({ knowledgeIds: [1, '1', ' 2 '] })).toEqual(['1', '2']); + }); + + it('toggles one selected knowledge without changing order', () => { + expect(toggleKnowledgeId(['1'], '2')).toEqual(['1', '2']); + expect(toggleKnowledgeId(['1', '2'], '1')).toEqual(['2']); + expect(toggleKnowledgeId([1], '1')).toEqual([]); + }); + + it('disables incompatible unselected knowledge bases', () => { + const items = applyEmbeddingCompatibility([ + { value: '1', label: '一', embeddingModelId: 'm1', embeddingDimension: 3, vectorStoreEnabled: true }, + { value: '2', label: '二', embeddingModelId: 'm1', embeddingDimension: 3, vectorStoreEnabled: true }, + { value: '3', label: '三', embeddingModelId: 'm2', embeddingDimension: 3, vectorStoreEnabled: true }, + ], ['1']); + + expect(items[1]?.selectable).toBe(true); + expect(items[2]?.selectable).toBe(false); + expect(items[2]?.disabledReason).toContain('Embedding'); + }); + + it('keeps knowledge bases selectable when an older options API omits vector metadata', () => { + const items = applyEmbeddingCompatibility([ + { value: '1', label: '一' }, + { value: '2', label: '二' }, + { value: '3', label: '三' }, + ], ['1']); + + expect(items.every((item) => item.selectable !== false)).toBe(true); + expect(items.every((item) => item.disabledReason === undefined)).toBe(true); + }); + + it('does not let unknown metadata override an explicit unavailable state', () => { + const unknownFirst = applyEmbeddingCompatibility([ + { value: '1', label: '旧接口知识库' }, + { value: '2', label: '明确不可用', vectorStoreEnabled: false }, + ], ['1']); + expect(unknownFirst[1]?.selectable).toBe(false); + + const unavailableFirst = applyEmbeddingCompatibility([ + { value: '1', label: '明确不可用', vectorStoreEnabled: false }, + { value: '2', label: '旧接口知识库' }, + ], ['1']); + expect(unavailableFirst[1]?.selectable).toBe(false); + }); + + it('requires a missing selected knowledge base to be removed before adding another', () => { + const items = applyEmbeddingCompatibility([ + { value: '2', label: '二' }, + ], ['missing']); + + expect(items.find((item) => item.value === '2')?.selectable).toBe(false); + expect(items.find((item) => item.value === 'missing')?.selectable).toBe(true); + }); + + it('keeps the reload action selectable when loading fails with an existing selection', () => { + const items = buildKnowledgeSelectItems([], ['1'], { + value: '__reload__', + label: '重新加载知识库', + }); + + expect(items.find((item) => item.value === '1')?.selectable).toBe(true); + expect(items.find((item) => item.value === '__reload__')?.selectable).toBe(true); + expect(items.find((item) => item.value === '__reload__')?.disabledReason).toBeUndefined(); + }); + + it('keeps invalid selected knowledge bases visible and removable', () => { + const items = applyEmbeddingCompatibility([ + { value: 1, label: '一', embeddingModelId: 'm1', embeddingDimension: 3, vectorStoreEnabled: true }, + { value: 2, label: '二', embeddingModelId: 'm2', embeddingDimension: 3, vectorStoreEnabled: true }, + ], ['1', '2', '9']); + + expect(items.map((item) => item.value)).toEqual(['1', '2', '9']); + expect(items[1]?.selectable).toBe(true); + expect(items[1]?.disabledReason).toContain('仅可移除'); + expect(items[2]?.label).toContain('已失效'); + expect(items[2]?.disabledReason).toContain('仅可移除'); + }); + + it('restores legacy output definitions without exposing retrieval diagnostics', () => { + const outputDefs = ensureKnowledgeOutputDefs([ + { id: 'documents-id', name: 'documents', dataType: 'Array', children: [ + { name: 'title', dataType: 'String' }, + ] }, + { name: 'retrievalSummary', dataType: 'Object' }, + ]); + + expect(outputDefs.map((item) => item.name)).toEqual(['documents']); + expect(outputDefs[0]?.id).toBe('documents-id'); + expect(outputDefs[0]?.children?.find((item) => item.name === 'title')?.id) + .toBe('knowledge_documents_title'); + expect(outputDefs[0]?.children?.map((item) => item.name)).toEqual([ + 'title', + 'content', + 'documentId', + 'knowledgeId', + ]); + const ids = outputDefs.flatMap((item) => [item.id, ...(item.children || []).map((child) => child.id)]); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/knowledgeNode.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/knowledgeNode.ts new file mode 100644 index 00000000..3e41d5a1 --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/knowledgeNode.ts @@ -0,0 +1,186 @@ +import type { Parameter, SelectItem } from '#types'; + +export function normalizeKnowledgeIds(data: Record): string[] { + if (Array.isArray(data.knowledgeIds)) { + const seen = new Set(); + return data.knowledgeIds.flatMap((id: unknown) => { + if (id === null || id === undefined) return []; + const value = String(id).trim(); + if (!value || seen.has(value)) return []; + seen.add(value); + return [value]; + }); + } + return data.knowledgeId === null || data.knowledgeId === undefined || String(data.knowledgeId).trim() === '' + ? [] + : [String(data.knowledgeId).trim()]; +} + +export function toggleKnowledgeId( + selectedIds: Array, + value: number | string, +): string[] { + const key = String(value); + return selectedIds.some((id) => String(id) === key) + ? selectedIds.filter((id) => String(id) !== key).map(String) + : [...selectedIds.map(String), key]; +} + +export function applyEmbeddingCompatibility( + items: SelectItem[], + selectedIds: Array, +): SelectItem[] { + const selectedKeys = new Set(selectedIds.map(String)); + const normalizedItems = items.map((item) => ({ ...item, value: String(item.value) })); + for (const selectedId of selectedIds.map(String)) { + if (!normalizedItems.some((item) => String(item.value) === selectedId)) { + normalizedItems.push({ + value: selectedId, + label: `已失效知识库(${selectedId})`, + selectable: true, + disabledReason: '知识库不存在或已无权限,仅可移除', + }); + } + } + const firstSelected = normalizedItems.find( + (item) => String(item.value) === String(selectedIds[0]), + ); + if (!firstSelected) return normalizedItems; + + const vectorState = (item: SelectItem): 'available' | 'unavailable' | 'unknown' => { + if (item.vectorStoreEnabled === false) return 'unavailable'; + if ( + item.vectorStoreEnabled === true + && item.embeddingModelId !== null + && item.embeddingModelId !== undefined + && item.embeddingDimension !== null + && item.embeddingDimension !== undefined + ) { + return 'available'; + } + return 'unknown'; + }; + + const firstState = vectorState(firstSelected); + const isCompatible = (item: SelectItem) => firstState === 'available' + && vectorState(item) === 'available' + && firstSelected.embeddingModelId === item.embeddingModelId + && firstSelected.embeddingDimension === item.embeddingDimension; + + return normalizedItems.map((item) => { + if (selectedKeys.has(String(item.value))) { + if (item.disabledReason) return item; + const itemState = vectorState(item); + if (itemState === 'unavailable') { + return { + ...item, + selectable: true, + disabledReason: '未启用向量检索,仅可移除', + }; + } + if ( + firstState === 'available' + && itemState === 'available' + && !isCompatible(item) + ) { + return { + ...item, + selectable: true, + disabledReason: '与首个知识库的 Embedding 配置不一致,仅可移除', + }; + } + return { ...item, selectable: true, disabledReason: undefined }; + } + const itemState = vectorState(item); + if (firstSelected.disabledReason || firstState === 'unavailable') { + return { + ...item, + selectable: false, + disabledReason: '请先移除未启用向量检索的知识库', + }; + } + if (itemState === 'unavailable') { + return { + ...item, + selectable: false, + disabledReason: '未启用向量检索', + }; + } + if (firstState === 'unknown' || itemState === 'unknown') { + return { ...item, selectable: true, disabledReason: undefined }; + } + return isCompatible(item) + ? { ...item, selectable: true, disabledReason: undefined } + : { + ...item, + selectable: false, + disabledReason: '与首个知识库的 Embedding 模型或向量维度不一致', + }; + }); +} + +export function buildKnowledgeSelectItems( + items: SelectItem[], + selectedIds: Array, + reloadItem?: SelectItem, +): SelectItem[] { + const compatibleItems = applyEmbeddingCompatibility(items, selectedIds); + return reloadItem + ? [ + ...compatibleItems, + { ...reloadItem, selectable: true, disabledReason: undefined }, + ] + : compatibleItems; +} + +const locked = (parameter: Parameter): Parameter => ({ + ...parameter, + nameDisabled: true, + dataTypeDisabled: true, + deleteDisabled: true, +}); + +const expectedOutputDefs: Parameter[] = [ + locked({ + id: 'knowledge_documents', + name: 'documents', + dataType: 'Array', + addChildDisabled: true, + children: [ + locked({ id: 'knowledge_documents_title', name: 'title', dataType: 'String' }), + locked({ id: 'knowledge_documents_content', name: 'content', dataType: 'String' }), + locked({ id: 'knowledge_documents_document_id', name: 'documentId', dataType: 'Number' }), + locked({ id: 'knowledge_documents_knowledge_id', name: 'knowledgeId', dataType: 'Number' }), + ], + }), +]; + +function mergeParameter(current: Parameter | undefined, expected: Parameter): Parameter { + if (!current) { + return { + ...expected, + children: expected.children?.map((child) => ({ ...child })), + }; + } + const result = { ...current, ...expected, id: current.id || expected.id }; + if (expected.children) { + const currentChildren = current.children || []; + result.children = expected.children.map((child) => + mergeParameter( + currentChildren.find((item) => item.name === child.name), + child, + ), + ); + } + return result; +} + +export function ensureKnowledgeOutputDefs(outputDefs?: Parameter[]): Parameter[] { + const current = outputDefs || []; + return expectedOutputDefs.map((expected) => + mergeParameter( + current.find((item) => item.name === expected.name), + expected, + ), + ); +} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/referenceDataType.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/referenceDataType.test.ts new file mode 100644 index 00000000..2d732599 --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/referenceDataType.test.ts @@ -0,0 +1,19 @@ +import {describe, expect, it} from 'vitest'; + +import {resolveReferencedParameterDataType} from './referenceDataType'; + +describe('resolveReferencedParameterDataType', () => { + it('普通引用跟随来源输出类型变化', () => { + expect(resolveReferencedParameterDataType( + {dataType: 'String'}, + 'Array', + )).toBe('Array'); + }); + + it('锁定参数保持业务契约类型', () => { + expect(resolveReferencedParameterDataType( + {dataType: 'Array', dataTypeDisabled: true}, + 'Array', + )).toBe('Array'); + }); +}); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/referenceDataType.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/referenceDataType.ts new file mode 100644 index 00000000..50a14509 --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/referenceDataType.ts @@ -0,0 +1,13 @@ +import type {Parameter} from '#types'; + +type ReferenceParameter = Pick; + +export function resolveReferencedParameterDataType( + parameter: ReferenceParameter, + referencedDataType?: string, +) { + if (parameter.dataTypeDisabled === true) { + return parameter.dataType; + } + return referencedDataType || parameter.dataType || 'String'; +} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts index a4d3bec1..f22b4518 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts @@ -26,6 +26,9 @@ export type SelectItem = { isCollection?: boolean; tags?: string[]; disabledReason?: string; + embeddingModelId?: string; + embeddingDimension?: number; + vectorStoreEnabled?: boolean; children?: SelectItem[]; }; diff --git a/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts b/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts index d23f1039..8126b448 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ conditions: ['browser'] }, test: { - environment: 'happy-dom' + environment: 'happy-dom', + include: ['src/**/*.test.ts'], } }); diff --git a/easyflow-ui-admin/vitest.config.ts b/easyflow-ui-admin/vitest.config.ts index a10b5fa3..ec21912f 100644 --- a/easyflow-ui-admin/vitest.config.ts +++ b/easyflow-ui-admin/vitest.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ plugins: [Vue(), VueJsx()], test: { environment: 'happy-dom', - exclude: [...configDefaults.exclude, '**/e2e/**'], + exclude: [ + ...configDefaults.exclude, + '**/e2e/**', + 'packages/tinyflow-ui/**', + ], }, }); diff --git a/easyflow-ui-admin/vitest.workspace.ts b/easyflow-ui-admin/vitest.workspace.ts index f00d6f68..88b52b58 100644 --- a/easyflow-ui-admin/vitest.workspace.ts +++ b/easyflow-ui-admin/vitest.workspace.ts @@ -1,3 +1,6 @@ import { defineWorkspace } from 'vitest/config'; -export default defineWorkspace(['vitest.config.ts']); +export default defineWorkspace([ + 'vitest.config.ts', + 'packages/tinyflow-ui/vitest.config.ts', +]); diff --git a/easyflow-ui-usercenter/app/src/views/bots/bot/index.vue b/easyflow-ui-usercenter/app/src/views/bots/bot/index.vue index 80c6a4cf..ef8d1684 100644 --- a/easyflow-ui-usercenter/app/src/views/bots/bot/index.vue +++ b/easyflow-ui-usercenter/app/src/views/bots/bot/index.vue @@ -60,8 +60,13 @@ async function getRunningParams() { function onSubmit() { initState.value = !initState.value; } -function resumeChain(data: any) { - workflowForm.value?.resume(data); +async function resumeChain(data: any, onSettled: (accepted: boolean) => void) { + try { + const accepted = await workflowForm.value?.resume(data); + onSettled(accepted === true); + } catch { + onSettled(false); + } } const chainInfo = ref(null); function onAsyncExecute(info: any) {