feat(leave): 裸意图直达表单 + 按身份分离请假表单 + 下拉代取通道
build / build-push (push) Successful in 51s

- 新增 IntentShortcut:说「我要请假」这类只有意图的短句时,
  编排器在调模型前先做窄匹配(去空白标点 + 剥前后缀后正好等于标准说法、
  且原句 ≤12 字),命中即直接下发表单并跳过 LLM,消除「偶尔不准」。
- 请假拆两个工具:leave.submit(type=2 学生申请,待审核)与
  leave.register(type=1 教师帮助登记,免审,必填 classId+stuId)。
  RolePolicy 分别归入 STUDENT_ONLY / STAFF_ONLY,模型视野里只有一个。
- ToolDef.fixed 由 Map<String,String> 改为 Map<String,Object>:
  type/continuousLeave 必须按数字下发,字符串会被模块侧 Jackson 判非法。
- ToolParam/FormField 新增 optionsKeys(valueKey,labelKey) 与 dependsOn:
  下拉改为下发 BrowserCall optionCall(含 {{占位符}}),由前端带用户登录态
  代取后按 valueKey/labelKey 映射;stuId 依赖 classId,选完班级才解锁。
  ToolExecutor#prepareOptionCall 不做必填校验,只拼占位查询串。
- 修 leave.types/myClasses/classStudents 描述,明确「填表时不要先调它」。
- README:4.2 能力划分更新 + 新增「请假为何分两个工具」;5.1 增加
  5.1.0 裸意图直达(含身份对照表)与表单 JSON 示例。
- 测试:新增 IntentDirectFormTest(5)、LeaveIntentFormTest(9),
  以「被调用即失败」的假 LLM 断言「我要请假」不经过大模型;
  RolePermissionTest 补 leave.submit/register 的互斥断言。全量 62/62 通过。
This commit is contained in:
lijing
2026-10-12 17:49:55 +08:00
parent 61a3000bee
commit c11d331371
12 changed files with 896 additions and 46 deletions
@@ -0,0 +1,193 @@
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;
/**
* 「我要请假」在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。
*
* <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
* 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。
* 因此测试里的 {@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<ChatMessage> 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");
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<AgentRole> 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 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());
}
}