- 新增 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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user