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());
}
}