package com.yunhai.agent; import com.alibaba.fastjson.JSONArray; import com.yunhai.agent.auth.AgentRole; import com.yunhai.agent.config.AgentSettings; import com.yunhai.agent.llm.LlmClient; import com.yunhai.agent.llm.dto.ChatMessage; import com.yunhai.agent.llm.dto.ChatResult; import com.yunhai.agent.model.ChatRequest; import com.yunhai.agent.model.ChatResponse; import com.yunhai.agent.service.AgentAuditService; import com.yunhai.agent.service.AgentOrchestrator; import com.yunhai.agent.service.EventSink; import com.yunhai.agent.service.IntentShortcut; import com.yunhai.agent.service.SessionStore; import com.yunhai.agent.tool.CallerContext; import com.yunhai.agent.tool.FormField; import com.yunhai.agent.tool.ToolExecutor; import com.yunhai.agent.tool.ToolRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.web.client.RestTemplate; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; 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.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; /** * 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。 * *

覆盖已落地的意图: *

* *

这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单, * 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。 * 因此测试里的 {@link LlmClient} 被设成「一旦被调用就失败」,用来证明这条链路真的没走模型。 */ class IntentDirectFormTest { private AgentSettings settings; private ToolRegistry registry; private SessionStore sessionStore; private AgentOrchestrator orchestrator; /** 被调用即失败的模型客户端:证明「意图直达」路径压根不碰大模型 */ private static final LlmClient EXPLODING_LLM = new LlmClient() { @Override public ChatResult chat(List messages, JSONArray tools) { throw new AssertionError("裸意图(我要请假)不应调用大模型"); } }; /** 正常应答的模型客户端:用于断言「非裸意图」确实回落到模型流程 */ private static final LlmClient ECHO_LLM = (messages, tools) -> { ChatResult r = new ChatResult(); r.setContent("(模型回复)"); return r; }; @BeforeEach void setUp() { settings = new AgentSettings(); settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave"); settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice"); settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice"); settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice"); settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity"); settings.getModules().put("profession", "http://base.yunhaixingtong.com/profession"); registry = new ToolRegistry(settings); registry.init(); sessionStore = Mockito.mock(SessionStore.class); SessionStore.SessionState state = new SessionStore.SessionState(); state.setSessionId("s-test"); state.setMessages(new ArrayList<>()); when(sessionStore.getOrCreate(any(), any(), any(), anyString())).thenReturn(state); } private AgentOrchestrator orchestrator(LlmClient llm) { return new AgentOrchestrator(settings, llm, registry, new ToolExecutor(settings, new RestTemplate()), sessionStore, Mockito.mock(AgentAuditService.class), new IntentShortcut()); } private CallerContext caller(AgentRole... roles) { CallerContext c = new CallerContext(); Set set = new LinkedHashSet<>(); Collections.addAll(set, roles); c.setRoles(set); c.setUserId("10008"); c.setUserName("测试用户"); return c; } private ChatResponse chat(LlmClient llm, String message, CallerContext caller) { ChatRequest req = new ChatRequest(); req.setMessage(message); return orchestrator(llm).chat(req, "姓名 测试用户", caller, null, EventSink.NOOP); } private FormField field(ChatResponse resp, String name) { for (FormField f : resp.getForm().getFields()) { if (name.equals(f.getName())) { return f; } } throw new AssertionError("表单里没有字段 " + name + ",实际:" + resp.getForm().getFields()); } /* ==================== 学生:请假申请 ==================== */ @Test void studentSayingWantLeaveGetsApplicationFormWithoutCallingLlm() { ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.STUDENT)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("leave.submit", resp.getForm().getTool(), "学生走「提交请假申请」"); assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertEquals("/my", resp.getForm().getLandingPath()); assertTrue(resp.getReply().contains("请假申请"), "引导语应说明这是请假申请,实际:" + resp.getReply()); // 学生表单:类型 / 起止时间 / 事由;不应出现「学生」这种教师侧字段 assertNotNull(field(resp, "leaveType")); assertNotNull(field(resp, "startDate")); assertNotNull(field(resp, "endDate")); assertNotNull(field(resp, "remarks")); for (FormField f : resp.getForm().getFields()) { assertFalse("stuId".equals(f.getName()), "学生自查请假不该出现「学生」字段"); } } /* ==================== 教师:帮学生登记 ==================== */ @Test void teacherSayingWantLeaveGetsRegisterFormWithClassAndStudentPickers() { ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.TEACHER)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("leave.register", resp.getForm().getTool(), "教师走「帮学生登记请假」"); assertEquals("/register", resp.getForm().getLandingPath()); assertTrue(resp.getReply().contains("登记"), "引导语应说明这是登记,实际:" + resp.getReply()); // 教师表单必须能选到人:班级 + 学生 都是下拉,且学生依赖班级 FormField classId = field(resp, "classId"); assertEquals("select", classId.getWidget()); assertNotNull(classId.getOptionCall(), "班级下拉应带上「代取地址」"); assertEquals("/homeleave/api/leave/MyClasses", classId.getOptionCall().getPathAndQuery()); FormField stuId = field(resp, "stuId"); assertEquals("select", stuId.getWidget()); assertEquals(Collections.singletonList("classId"), stuId.getDependsOn()); assertNotNull(stuId.getOptionCall(), "学生下拉应带上「代取地址」"); assertEquals("/homeleave/api/leave/ClassStudents?classId={{classId}}", stuId.getOptionCall().getPathAndQuery(), "学生名单的地址要留 {{classId}} 占位符,前端选完班级再替换"); assertEquals("stuId", stuId.getValueKey()); assertEquals("stuName", stuId.getLabelKey()); assertNotNull(field(resp, "leaveType")); assertNotNull(field(resp, "startDate")); assertNotNull(field(resp, "endDate")); assertNotNull(field(resp, "remarks")); } /* ==================== 教师:发布校园公告 ==================== */ @Test void teacherSayingPublishNoticeGetsPublishFormWithoutCallingLlm() { ChatResponse resp = chat(EXPLODING_LLM, "我要发布校园公告", caller(AgentRole.TEACHER)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("notice.publish", resp.getForm().getTool()); assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertEquals("/list/new", resp.getForm().getLandingPath()); assertTrue(resp.getReply().contains("发布"), "引导语应说明这是发布,实际:" + resp.getReply()); // 公告表单:标题(单行)+ 正文(富文本,与模块页 NewNotice.vue 的 wangEditor 一致)+ 发文部门(下拉) assertEquals("text", field(resp, "title").getWidget()); FormField content = field(resp, "content"); assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)"); assertEquals("/campusnotice/api/file/UploadImage", content.getRichTextUploadPath(), "正文插图要走模块真实前缀 /campusnotice(且不是附件的 FileUpload)"); assertEquals("/campusnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath(), "图片预览地址模板同样要带前缀,{id} 留给前端替换"); FormField deptId = field(resp, "deptId"); assertEquals("select", deptId.getWidget(), "发文部门应是下拉,不该让用户手填部门编号"); assertNotNull(deptId.getOptionCall(), "发文部门下拉应带上「代取地址」"); assertEquals("/campusnotice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(), "字典地址必须带 type=1,否则取不回发文部门"); assertEquals("k", deptId.getValueKey()); assertEquals("v", deptId.getLabelKey()); } @Test void studentSayingPublishNoticeFallsThroughToModel() { // 学生没有发布公告能力 → 不直达,交给模型如实说明需要教师/管理员身份 ChatResponse resp = chat(ECHO_LLM, "我要发布校园公告", caller(AgentRole.STUDENT)); assertNull(resp.getForm(), "学生不该拿到发布公告表单"); assertEquals("(模型回复)", resp.getReply()); } /* ==================== 教师:发布通知(通知公告模块) ==================== */ /** * 「通知公告」(key={@code notice})与「校园公告」({@code campusnotice})是**两个独立应用**: * 前者按「用户」下发、后者按「班级」下发。同一句「我要发布通知公告」必须落到前者的发布表单, * 且表单上**不能**出现只有校园公告才有的字段(推送范围 / 回执 / 发布方式)。 */ @Test void teacherSayingPublishNotifyGetsPublishFormWithoutCallingLlm() { ChatResponse resp = chat(EXPLODING_LLM, "我要发布通知公告", caller(AgentRole.TEACHER)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("notify.publish", resp.getForm().getTool()); assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertEquals("/list/new", resp.getForm().getLandingPath()); assertTrue(resp.getReply().contains("发布通知"), "引导语应说明这是发布通知,实际:" + resp.getReply()); // 标题(单行)+ 正文(富文本,与模块页的 wangEditor 一致) assertEquals("text", field(resp, "title").getWidget()); FormField content = field(resp, "content"); assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)"); assertEquals("/notice/api/file/UploadImage", content.getRichTextUploadPath(), "正文里插图片要打模块真实前缀 /notice(且不是附件的 FileUpload)"); assertEquals("/notice/api/file/Preview?id={id}", content.getRichTextPreviewPath(), "图片预览地址模板同样要带前缀,{id} 留给前端替换"); // 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类) FormField deptId = field(resp, "deptId"); assertEquals("select", deptId.getWidget(), "来源部门应是下拉,不该让用户手填部门编号"); assertNotNull(deptId.getOptionCall(), "来源部门下拉应带上「代取地址」"); assertEquals("/notice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(), "字典地址必须带模块前缀 /notice 与 type=1,否则取不回来源部门"); assertEquals("k", deptId.getValueKey()); assertEquals("v", deptId.getLabelKey()); // 通知对象:模块页同款自绘控件(radio 全部用户/指定用户 + 选人弹窗)。 // ★ 它也有 optionCall,必须确认**没有被降级成 select** —— // AgentOrchestrator 对「带 optionCall 的字段」会把控件改写成 select(防止取回字典后还是输入框), // userpicker 靠白名单豁免;漏了就变成普通下拉,正是这次要避免的。 FormField receiveList = field(resp, "receiveList"); assertEquals("userpicker", receiveList.getWidget(), "通知对象应是自绘的「全部用户 / 指定用户」+ 选人弹窗,不能被降级成 select"); assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址(弹窗里用它取原始树)"); assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery()); assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵值"); // ★ 选人弹窗有两个页签(模块页 UserCheckInput.vue 的 sources)——这里钉住**拼好前缀**的结果。 // 与附件上传同一个坑:只写相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。 // 注意「自定义组」的路径里带 query,拼前缀时不能被吃掉。 assertNotNull(receiveList.getPickerSources(), "选人弹窗的数据源页签要下发到前端"); assertEquals(2, receiveList.getPickerSources().size()); FormField.PickerSource tab0 = receiveList.getPickerSources().get(0); assertEquals("基础平台", tab0.getName()); assertNotNull(tab0.getCall()); assertEquals("GET", tab0.getCall().getMethod(), "取组织树是只读 GET"); assertEquals("/notice/api/base/GetSchoolAll", tab0.getCall().getPathAndQuery(), "代取地址必须带模块前缀 /notice"); FormField.PickerSource tab1 = receiveList.getPickerSources().get(1); assertEquals("自定义组", tab1.getName()); assertEquals("/notice/api/contacts/GetGroupTree?type=1", tab1.getCall().getPathAndQuery(), "自定义组树同样要带前缀,且 type=1 要保留"); // 校园公告独有的字段绝不能串到通知公告的表单上 for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) { for (FormField f : resp.getForm().getFields()) { assertFalse(foreign.equals(f.getName()), "通知公告表单不该出现校园公告字段:" + foreign); } } } @Test void studentSayingPublishNotifyFallsThroughToModel() { ChatResponse resp = chat(ECHO_LLM, "我要发布通知公告", caller(AgentRole.STUDENT)); assertNull(resp.getForm(), "学生不该拿到发布通知表单"); assertEquals("(模型回复)", resp.getReply()); } /* ==================== 教师:发布宿舍通知 ==================== */ @Test void teacherSayingPublishDormNoticeGetsPublishFormWithoutCallingLlm() { ChatResponse resp = chat(EXPLODING_LLM, "我要发布宿舍通知", caller(AgentRole.TEACHER)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("dorm.publish", resp.getForm().getTool()); assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertEquals("/list/new", resp.getForm().getLandingPath()); // 标题 + 正文(富文本,模块页 NewNotice.vue 用 wangEditor)+ 通知类型(下拉)+ 发文部门(下拉) assertEquals("text", field(resp, "title").getWidget()); FormField content = field(resp, "content"); assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)"); assertEquals("/dormnotice/api/file/UploadImage", content.getRichTextUploadPath(), "正文插图走模块真实前缀 /dormnotice"); assertEquals("/dormnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath()); FormField noticeType = field(resp, "noticeType"); assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号"); assertNotNull(noticeType.getOptionCall()); assertEquals("/dormnotice/api/dict/List?type=noticeType", noticeType.getOptionCall().getPathAndQuery(), "字典地址必须带 type=noticeType"); FormField deptId = field(resp, "deptId"); assertEquals("select", deptId.getWidget()); assertNotNull(deptId.getOptionCall()); assertEquals("/dormnotice/api/dict/List?type=dept", deptId.getOptionCall().getPathAndQuery()); assertEquals("k", deptId.getValueKey()); assertEquals("v", deptId.getLabelKey()); } @Test void studentSayingPublishDormNoticeFallsThroughToModel() { ChatResponse resp = chat(ECHO_LLM, "我要发布宿舍通知", caller(AgentRole.STUDENT)); assertNull(resp.getForm(), "学生不该拿到发布宿舍通知表单"); assertEquals("(模型回复)", resp.getReply()); } /* ==================== 教师:发布家校活动 ==================== */ @Test void teacherSayingPublishActivityGetsPublishFormWithoutCallingLlm() { ChatResponse resp = chat(EXPLODING_LLM, "我要发布活动", caller(AgentRole.TEACHER)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("activity.publish", resp.getForm().getTool()); assertTrue(resp.getForm().isWrite()); assertEquals("/list/new", resp.getForm().getLandingPath()); // 活动表单:标题 + 说明(**富文本**,模块页 PublishActivity.vue 用 wangEditor)+ // 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间 assertEquals("text", field(resp, "title").getWidget()); FormField content = field(resp, "content"); assertEquals("richtext", content.getWidget(), "活动说明用富文本编辑器(模块页 wangEditor)"); assertEquals("/familyactivity/api/file/UploadImage", content.getRichTextUploadPath(), "正文插图走模块真实前缀 /familyactivity(不是附件的 FileUpload)"); assertEquals("/familyactivity/api/file/Preview?id={id}", content.getRichTextPreviewPath()); FormField category = field(resp, "category"); assertEquals("select", category.getWidget()); assertNotNull(category.getOptionCall()); assertEquals("/familyactivity/api/dict/List?type=2", category.getOptionCall().getPathAndQuery()); FormField deptId = field(resp, "deptId"); assertEquals("select", deptId.getWidget()); assertNotNull(deptId.getOptionCall()); assertEquals("/familyactivity/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery()); assertNotNull(field(resp, "venue")); assertNotNull(field(resp, "startTime")); assertNotNull(field(resp, "endTime")); } @Test void studentSayingPublishActivityFallsThroughToModel() { ChatResponse resp = chat(ECHO_LLM, "我要发布活动", caller(AgentRole.STUDENT)); assertNull(resp.getForm(), "学生不该拿到发布活动表单"); assertEquals("(模型回复)", resp.getReply()); } /* ==================== 教师:发布专业发展记录 ==================== */ /** * 「我要发布专业发展」必须**直接**弹表单,而不是先被模型反问「你要发到哪个栏目」。 * *

用户反馈的原话:说「我要发布专业发展」被问了一轮栏目,答完才出表单, * 表单里却还留着空的「所属栏目」要再选一次 —— 同一件事问了两遍。 * 栏目是学校自建的(各校 id 不同),所以直达时不预置,直接交给表单里的下拉选。 */ @Test void teacherSayingPublishProfessionRecordGetsPublishFormWithoutCallingLlm() { ChatResponse resp = chat(EXPLODING_LLM, "我要发布专业发展", caller(AgentRole.TEACHER)); assertNotNull(resp.getForm(), "应直接下发表单"); assertEquals("profession.publish", resp.getForm().getTool()); assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertEquals("/record/new", resp.getForm().getLandingPath()); assertTrue(resp.getReply().contains("所属栏目"), "引导语要点明表单里就能选栏目,实际:" + resp.getReply()); // 「所属栏目」是必填下拉,且带着「用用户自己的登录态去取栏目树」的地址 FormField moduleId = field(resp, "moduleId"); assertEquals("select", moduleId.getWidget(), "栏目应是下拉,不该让用户手填栏目编号"); assertTrue(moduleId.isRequired(), "栏目必填"); assertNotNull(moduleId.getOptionCall(), "栏目下拉应带上「代取地址」"); assertEquals("/profession/api/moudule/ModuleTree", moduleId.getOptionCall().getPathAndQuery()); // 栏目**不能**被直达预置:各校栏目 id 不同,硬编码必然出错 assertFalse(resp.getForm().getPreset().containsKey("moduleId"), "直达不应预置栏目,实际 preset:" + resp.getForm().getPreset()); // 发布页是全字段表单(与模块页 RecordEdit.vue 对齐) assertTrue(resp.getForm().isAllFields(), "发布表单应渲染全部字段"); assertNotNull(field(resp, "title")); assertNotNull(field(resp, "remark")); assertNotNull(field(resp, "fileIds")); } @Test void studentSayingPublishProfessionRecordFallsThroughToModel() { ChatResponse resp = chat(ECHO_LLM, "我要发布专业发展", caller(AgentRole.STUDENT)); assertNull(resp.getForm(), "学生不该拿到发布专业发展表单"); assertEquals("(模型回复)", resp.getReply()); } /* ==================== 不该被劫持的情况 ==================== */ @Test void detailedSentenceStillGoesToTheModel() { // 带时间与事由的句子由模型处理(它能一次把参数填全),不能被直达规则截走 ChatResponse resp = chat(ECHO_LLM, "我要请10月12日上午的病假,发烧就医", caller(AgentRole.STUDENT)); assertNull(resp.getForm(), "含细节的句子不应被直达成空表单"); assertEquals("(模型回复)", resp.getReply()); } @Test void guestSayingWantLeaveFallsThroughToModel() { // 访客无权使用请假工具 → 不直达,交给模型如实说明需要什么身份 ChatResponse resp = chat(ECHO_LLM, "我要请假", caller(AgentRole.GUEST)); assertNull(resp.getForm(), "访客不该拿到请假表单"); assertEquals("(模型回复)", resp.getReply()); } @Test void unrelatedMessageStillGoesToTheModel() { ChatResponse resp = chat(ECHO_LLM, "这周有什么新的校园公告?", caller(AgentRole.STUDENT)); assertNull(resp.getForm()); assertEquals("(模型回复)", resp.getReply()); } }