|
|
@@ -0,0 +1,362 @@
|
|
|
+package com.zsjz.ai.module.agent.service;
|
|
|
+
|
|
|
+import com.zsjz.ai.common.exception.ServerException;
|
|
|
+import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
|
|
|
+import com.zsjz.ai.module.agent.artifact.ArtifactService;
|
|
|
+import com.zsjz.ai.module.agent.config.ArtifactProperties;
|
|
|
+import com.zsjz.ai.module.agent.config.EtlProperties;
|
|
|
+import com.zsjz.ai.module.agent.dto.SaveExpertDTO;
|
|
|
+import com.zsjz.ai.module.agent.entity.AgentEntity;
|
|
|
+import com.zsjz.ai.module.agent.followup.FollowupService;
|
|
|
+import com.zsjz.ai.module.agent.intent.IntentService;
|
|
|
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
|
|
|
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
|
|
|
+import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
|
|
|
+import com.zsjz.ai.module.agent.python.PythonExecutor;
|
|
|
+import com.zsjz.ai.module.agent.rag.RagSchemaService;
|
|
|
+import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
|
|
|
+import com.zsjz.ai.module.agent.vo.ExpertVO;
|
|
|
+import com.zsjz.ai.module.call.service.CallContinuousService;
|
|
|
+import com.zsjz.ai.module.call.service.CallNightService;
|
|
|
+import com.zsjz.ai.module.call.service.CallRecordService;
|
|
|
+import com.zsjz.ai.module.call.service.CallSensitiveService;
|
|
|
+import com.zsjz.ai.module.dm.ai.FileAiProfileService;
|
|
|
+import com.zsjz.ai.module.graph.service.GraphService;
|
|
|
+import com.zsjz.ai.module.person.service.DataProfileService;
|
|
|
+import com.zsjz.ai.module.person.service.IntimacyService;
|
|
|
+import com.zsjz.ai.module.person.service.PersonBasicInfoService;
|
|
|
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
|
|
|
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
|
|
|
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
|
|
|
+import com.zsjz.ai.module.track.service.TrackCellTowerService;
|
|
|
+import com.zsjz.ai.module.track.service.TrackEnLocalService;
|
|
|
+import com.zsjz.ai.module.track.service.TrackExpressInfoService;
|
|
|
+import com.zsjz.ai.module.track.service.TrackMeetService;
|
|
|
+import com.zsjz.ai.module.track.service.TrackTogetherLiveService;
|
|
|
+import com.zsjz.ai.module.track.service.TrackTogetherTravelService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransBigService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransCardHoldService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransCashFlowService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransContinuousService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransFastFundFlowService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransFinancialService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransFixedDepositService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransFrequencyService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransFundFlowService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransRecordService;
|
|
|
+import com.zsjz.ai.module.trans.service.TransSensitiveService;
|
|
|
+import io.agentscope.harness.agent.HarnessAgent;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Optional;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.Mockito.doAnswer;
|
|
|
+import static org.mockito.Mockito.doReturn;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.spy;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 专家(智能体)管理:用户隔离 / 内置不可改删 / 配置校验 / 实例池驱逐。
|
|
|
+ *
|
|
|
+ * <p>DAO 层(save/updateById/removeById/getById)与查询缝(loadAllExperts)全部 stub,
|
|
|
+ * 本测试只验证业务规则本身,不依赖数据库。
|
|
|
+ */
|
|
|
+class AgentExpertTest {
|
|
|
+
|
|
|
+ private static final Long ME = 7L;
|
|
|
+
|
|
|
+ private AgentService service;
|
|
|
+
|
|
|
+ /** getById 的返回目标(模拟库里那行) */
|
|
|
+ private AgentEntity byId;
|
|
|
+
|
|
|
+ /** save/updateById 捕获的实体(断言配置是否落对) */
|
|
|
+ private AgentEntity captured;
|
|
|
+
|
|
|
+ /** 实例池(反射取真实池,验证驱逐) */
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, HarnessAgent> pool;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = spy(new AgentService(
|
|
|
+ mock(AgentModelFactory.class),
|
|
|
+ mock(AgentModelMapper.class),
|
|
|
+ Optional.empty(),
|
|
|
+ mock(RagSchemaService.class),
|
|
|
+ mock(EtlProperties.class),
|
|
|
+ mock(IntentService.class),
|
|
|
+ mock(FollowupService.class),
|
|
|
+ mock(SqlQueryMapper.class),
|
|
|
+ mock(TableInfoMapper.class),
|
|
|
+ mock(TableFieldMapper.class),
|
|
|
+ mock(PythonExecutor.class),
|
|
|
+ mock(AgentChatSessionMapper.class),
|
|
|
+ mock(CaseInfoMapper.class),
|
|
|
+ new WorkspaceManagerFactory(),
|
|
|
+ new AgentToolRegistry(
|
|
|
+ mock(CallRecordService.class), mock(CallNightService.class),
|
|
|
+ mock(CallContinuousService.class), mock(CallSensitiveService.class),
|
|
|
+ mock(TransBigService.class), mock(TransCardHoldService.class),
|
|
|
+ mock(TransCashFlowService.class), mock(TransContinuousService.class),
|
|
|
+ mock(TransFastFundFlowService.class), mock(TransFinancialService.class),
|
|
|
+ mock(TransFixedDepositService.class), mock(TransFrequencyService.class),
|
|
|
+ mock(TransFundFlowService.class), mock(TransRecordService.class),
|
|
|
+ mock(TransSensitiveService.class),
|
|
|
+ mock(TrackCellTowerService.class), mock(TrackEnLocalService.class),
|
|
|
+ mock(TrackExpressInfoService.class), mock(TrackMeetService.class),
|
|
|
+ mock(TrackTogetherLiveService.class), mock(TrackTogetherTravelService.class),
|
|
|
+ mock(GraphService.class),
|
|
|
+ mock(DataProfileService.class), mock(IntimacyService.class),
|
|
|
+ mock(PersonBasicInfoService.class),
|
|
|
+ mock(FileAiProfileService.class),
|
|
|
+ new ArtifactService(new ArtifactProperties())),
|
|
|
+ new ArtifactService(new ArtifactProperties())));
|
|
|
+ pool = (Map<String, HarnessAgent>) ReflectionTestUtils.getField(service, "agentPool");
|
|
|
+
|
|
|
+ // create 路径:库里那行还没建,回读刚 save 的实体(rowId 已被 save 桩回填)
|
|
|
+ doAnswer(inv -> byId != null ? byId : captured).when(service).getById(any());
|
|
|
+ doAnswer(inv -> {
|
|
|
+ captured = inv.getArgument(0);
|
|
|
+ captured.setRowId(99L); // 模拟 DB 回填自增主键
|
|
|
+ return true;
|
|
|
+ }).when(service).save(any(AgentEntity.class));
|
|
|
+ doAnswer(inv -> {
|
|
|
+ captured = inv.getArgument(0);
|
|
|
+ return true;
|
|
|
+ }).when(service).updateById(any(AgentEntity.class));
|
|
|
+ doReturn(true).when(service).removeById(any(java.io.Serializable.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void tearDown() throws Exception {
|
|
|
+ // 专家技能验证会临时建目录,清掉不留垃圾
|
|
|
+ Path skillsRoot = Path.of(System.getProperty("user.dir"), ".agentscope", "users",
|
|
|
+ String.valueOf(ME), "skills");
|
|
|
+ if (Files.isDirectory(skillsRoot)) {
|
|
|
+ try (var stream = Files.list(skillsRoot)) {
|
|
|
+ for (Path p : stream.toList()) {
|
|
|
+ try (var inner = Files.list(p)) {
|
|
|
+ inner.forEach(x -> deleteQuietly(x));
|
|
|
+ }
|
|
|
+ deleteQuietly(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 列表隔离 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("列表 = 内置(全局)+ 自己的;别人的不可见(后端过滤,不是前端隐藏)")
|
|
|
+ void listFiltersToBuiltinAndMine() {
|
|
|
+ AgentEntity builtin = agent(1L, "数刃", null, 1);
|
|
|
+ AgentEntity mine = agent(2L, "我的专家", ME, 0);
|
|
|
+ AgentEntity others = agent(3L, "别人的专家", 99L, 0);
|
|
|
+ doReturn(List.of(builtin, others, mine)).when(service).loadAllExperts();
|
|
|
+
|
|
|
+ List<ExpertVO> list = service.listExperts(ME);
|
|
|
+
|
|
|
+ assertEquals(2, list.size(), "内置 + 我的,别人的必须被过滤");
|
|
|
+ assertEquals("1", list.get(0).getId());
|
|
|
+ assertTrue(list.get(0).isBuiltin());
|
|
|
+ assertFalse(list.get(0).isMine());
|
|
|
+ assertEquals("2", list.get(1).getId());
|
|
|
+ assertFalse(list.get(1).isBuiltin());
|
|
|
+ assertTrue(list.get(1).isMine());
|
|
|
+ assertTrue(list.stream().noneMatch(v -> "别人的专家".equals(v.getName())));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 内置不可改删 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("内置专家:编辑与删除都被拒绝(400),且不触碰 DAO")
|
|
|
+ void builtinRejectsEditAndDelete() {
|
|
|
+ byId = agent(1L, "数刃", 1L, 1);
|
|
|
+ SaveExpertDTO dto = dto("改名", "新提示词");
|
|
|
+
|
|
|
+ ServerException edit = assertThrows(ServerException.class,
|
|
|
+ () -> service.updateExpert(ME, 1L, dto));
|
|
|
+ assertEquals(400, edit.getCode());
|
|
|
+ assertTrue(edit.getMsg().contains("内置专家不可编辑"), edit.getMsg());
|
|
|
+ verify(service, never()).updateById(any());
|
|
|
+
|
|
|
+ ServerException del = assertThrows(ServerException.class,
|
|
|
+ () -> service.deleteExpert(ME, 1L));
|
|
|
+ assertEquals(400, del.getCode());
|
|
|
+ assertTrue(del.getMsg().contains("内置专家不可删除"), del.getMsg());
|
|
|
+ verify(service, never()).removeById(any(java.io.Serializable.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("自建专家:不能改别人的(无权操作)")
|
|
|
+ void cannotTouchOthersExpert() {
|
|
|
+ byId = agent(3L, "别人的专家", 99L, 0);
|
|
|
+
|
|
|
+ ServerException ex = assertThrows(ServerException.class,
|
|
|
+ () -> service.updateExpert(ME, 3L, dto("x", "y")));
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ assertTrue(ex.getMsg().contains("无权操作该专家"), ex.getMsg());
|
|
|
+
|
|
|
+ assertThrows(ServerException.class, () -> service.deleteExpert(ME, 3L));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 编辑/删除自己的 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("编辑自己的专家:配置落库 + 驱逐该专家的池化实例(别的专家不受影响)")
|
|
|
+ void updateOwnedEvictsPool() {
|
|
|
+ byId = agent(2L, "我的专家", ME, 0);
|
|
|
+ pool.put("w5-a2-m9-u7", mock(HarnessAgent.class));
|
|
|
+ pool.put("w5-a9-m9-u7", mock(HarnessAgent.class));
|
|
|
+
|
|
|
+ SaveExpertDTO dto = dto("我的专家-改", "新的系统提示词");
|
|
|
+ dto.setToolGroups(List.of("call", "trans"));
|
|
|
+ dto.setSkillNames(List.of()); // 空 = 不限制,落库必须是 null(而不是 "[]")
|
|
|
+ ExpertVO vo = service.updateExpert(ME, 2L, dto);
|
|
|
+
|
|
|
+ assertNotNull(captured);
|
|
|
+ assertEquals("我的专家-改", captured.getName());
|
|
|
+ assertEquals("新的系统提示词", captured.getSysPrompt());
|
|
|
+ assertEquals("[\"call\",\"trans\"]", captured.getToolsAllowJson());
|
|
|
+ assertNull(captured.getSkillsAllowJson(), "空白名单要写 null,走默认口径");
|
|
|
+ assertEquals("2", vo.getId());
|
|
|
+ assertFalse(vo.isBuiltin());
|
|
|
+
|
|
|
+ assertFalse(pool.containsKey("w5-a2-m9-u7"), "配置变更后本专家的池实例必须被驱逐");
|
|
|
+ assertTrue(pool.containsKey("w5-a9-m9-u7"), "其它专家的实例不能被误删");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("删除自己的专家:移除记录并驱逐池实例")
|
|
|
+ void deleteOwnedRemovesAndEvicts() {
|
|
|
+ byId = agent(2L, "我的专家", ME, 0);
|
|
|
+ pool.put("w5-a2-m9-u7", mock(HarnessAgent.class));
|
|
|
+
|
|
|
+ service.deleteExpert(ME, 2L);
|
|
|
+
|
|
|
+ verify(service).removeById(2L);
|
|
|
+ assertFalse(pool.containsKey("w5-a2-m9-u7"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 新建与校验 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("新建:落 user_id=我、is_builtin=0,配置序列化正确")
|
|
|
+ void createSetsOwnershipAndConfig() {
|
|
|
+ SaveExpertDTO dto = dto("新专家", "你是XX领域的专家");
|
|
|
+ dto.setToolGroups(List.of("call"));
|
|
|
+ dto.setSkillNames(null);
|
|
|
+
|
|
|
+ ExpertVO vo = service.createExpert(ME, dto);
|
|
|
+
|
|
|
+ assertNotNull(captured);
|
|
|
+ assertEquals(ME, captured.getUserId());
|
|
|
+ assertEquals(0, captured.getIsBuiltin());
|
|
|
+ assertEquals("新专家", captured.getName());
|
|
|
+ assertEquals("你是XX领域的专家", captured.getSysPrompt());
|
|
|
+ assertEquals("[\"call\"]", captured.getToolsAllowJson());
|
|
|
+ assertNull(captured.getSkillsAllowJson());
|
|
|
+ assertEquals("99", vo.getId(), "create 后应回读带主键的实体");
|
|
|
+ assertTrue(vo.isMine());
|
|
|
+ assertFalse(vo.isBuiltin());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("校验:未知工具组拒绝;技能名非法/未安装拒绝")
|
|
|
+ void validatesGroupsAndSkills() {
|
|
|
+ SaveExpertDTO badGroup = dto("x", "y");
|
|
|
+ badGroup.setToolGroups(List.of("hack_group"));
|
|
|
+ ServerException groupEx = assertThrows(ServerException.class,
|
|
|
+ () -> service.createExpert(ME, badGroup));
|
|
|
+ assertEquals(400, groupEx.getCode());
|
|
|
+ assertTrue(groupEx.getMsg().contains("未知工具组"), groupEx.getMsg());
|
|
|
+
|
|
|
+ SaveExpertDTO badSkill = dto("x", "y");
|
|
|
+ badSkill.setSkillNames(List.of("../etc"));
|
|
|
+ ServerException nameEx = assertThrows(ServerException.class,
|
|
|
+ () -> service.createExpert(ME, badSkill));
|
|
|
+ assertTrue(nameEx.getMsg().contains("非法技能名"), nameEx.getMsg());
|
|
|
+
|
|
|
+ SaveExpertDTO missingSkill = dto("x", "y");
|
|
|
+ missingSkill.setSkillNames(List.of("不存在的技能abc"));
|
|
|
+ ServerException missEx = assertThrows(ServerException.class,
|
|
|
+ () -> service.createExpert(ME, missingSkill));
|
|
|
+ assertTrue(missEx.getMsg().contains("技能不存在"), missEx.getMsg());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("校验:已安装的技能可以通过白名单校验")
|
|
|
+ void acceptsInstalledSkill() throws Exception {
|
|
|
+ Path skillsRoot = Path.of(System.getProperty("user.dir"), ".agentscope", "users",
|
|
|
+ String.valueOf(ME), "skills");
|
|
|
+ Path skillDir = skillsRoot.resolve("测试技能");
|
|
|
+ Files.createDirectories(skillDir);
|
|
|
+ try {
|
|
|
+ SaveExpertDTO dto = dto("x", "y");
|
|
|
+ dto.setSkillNames(List.of("测试技能"));
|
|
|
+ ExpertVO vo = service.createExpert(ME, dto);
|
|
|
+ assertEquals("[\"测试技能\"]", vo.getSkillNames() == null ? null
|
|
|
+ : com.zsjz.ai.common.utils.Json.toStr(vo.getSkillNames()));
|
|
|
+ } finally {
|
|
|
+ Files.deleteIfExists(skillDir);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== JSON 工具 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("readStringList:null/空/坏 JSON 一律降级为 null(走默认口径)")
|
|
|
+ void readStringListDegrades() {
|
|
|
+ assertNull(AgentService.readStringList(null));
|
|
|
+ assertNull(AgentService.readStringList(""));
|
|
|
+ assertNull(AgentService.readStringList(" "));
|
|
|
+ assertNull(AgentService.readStringList("{不是数组}"));
|
|
|
+ assertEquals(List.of("a", "b"), AgentService.readStringList("[\"a\",\"b\"]"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助 ====================
|
|
|
+
|
|
|
+ private static AgentEntity agent(Long rowId, String name, Long userId, int builtin) {
|
|
|
+ AgentEntity e = new AgentEntity();
|
|
|
+ e.setRowId(rowId);
|
|
|
+ e.setName(name);
|
|
|
+ e.setUserId(userId);
|
|
|
+ e.setIsBuiltin(builtin);
|
|
|
+ e.setSysPrompt("原始提示词");
|
|
|
+ return e;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static SaveExpertDTO dto(String name, String sysPrompt) {
|
|
|
+ SaveExpertDTO d = new SaveExpertDTO();
|
|
|
+ d.setName(name);
|
|
|
+ d.setSysPrompt(sysPrompt);
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void deleteQuietly(Path path) {
|
|
|
+ try {
|
|
|
+ Files.deleteIfExists(path);
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ /* 测试清理尽力而为 */
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|