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
@@ -62,17 +62,19 @@ public class AgentOrchestrator {
private final ToolExecutor executor;
private final SessionStore sessionStore;
private final AgentAuditService auditService;
private final IntentShortcut intentShortcut;
@Autowired
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
ToolExecutor executor, SessionStore sessionStore,
AgentAuditService auditService) {
AgentAuditService auditService, IntentShortcut intentShortcut) {
this.settings = settings;
this.llmClient = llmClient;
this.registry = registry;
this.executor = executor;
this.sessionStore = sessionStore;
this.auditService = auditService;
this.intentShortcut = intentShortcut;
}
/**
@@ -111,6 +113,13 @@ public class AgentOrchestrator {
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
// ★ 裸意图直达:「我要请假」这类只有意图、没有细节的句子,直接按身份弹表单,
// 不交给大模型决策(模型可能先去查字典、或自行编造时间直接提交,都不合预期)。
ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out);
if (direct != null) {
return direct;
}
List<ChatMessage> messages = session.getMessages();
if (messages.isEmpty()) {
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
@@ -398,6 +407,54 @@ public class AgentOrchestrator {
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
/**
* 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」)时,
* 按当前身份直接构建表单返回 —— 学生拿「请假申请」,教职工拿「请假登记(帮学生请假)」。
*
* <p>判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}),
* 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null},
* 照常交给大模型处理。
*/
private ChatResponse directForm(String message, CallerContext caller, Set<Integer> authorizedModIds,
Set<AgentRole> roles, SessionStore.SessionState session, EventSink out) {
IntentShortcut.Intent intent = intentShortcut.match(message);
if (intent == null) {
return null;
}
boolean staff = roles != null
&& (roles.contains(AgentRole.TEACHER) || roles.contains(AgentRole.ADMIN));
String toolName = staff ? intent.getStaffTool() : intent.getStudentTool();
ToolDef def = toolName == null ? null : registry.get(toolName);
if (def == null) {
return null;
}
boolean visible = registry.visibleTools(authorizedModIds, roles).stream()
.anyMatch(t -> t.getName().equals(def.getName()));
if (!visible) {
return null;
}
Map<String, Object> empty = new LinkedHashMap<>();
List<ToolParam> missing = missingRequired(def, empty);
if (missing.isEmpty()) {
return null;
}
String reply = staff ? intent.getStaffReply() : intent.getStudentReply();
FormRequest form = buildForm(def, empty, missing, caller);
form.setDescription(reply);
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
resp.setForm(form);
resp.setReply(reply);
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing())));
log.info("意图直达弹表单:session={} 身份={} 工具={}", session.getSessionId(),
staff ? "教职工" : "学生", def.getName());
finish(resp, session, 0, 0, out);
return resp;
}
/**
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
*
@@ -592,10 +649,21 @@ public class AgentOrchestrator {
List<String> missingLabels = new ArrayList<>();
for (ToolParam p : missing) {
FormField f = p.toFormField();
// 字典类字段(如请假类型):先跑一次它的来源工具,把可选项直接摆到下拉框里
// 判据是「声明了来源」而不是「当前没有选项」—— 参数可能已带内置兜底,
// 实时字典取到就覆盖兜底,取不到则保留兜底(下拉框不会退化成数字框)。
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
//
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
// 服务端直调必然 code=302(README 第 11 节)。以前"服务端取一次、失败就退化成
// 数字输入框",结果教师登记请假时得手填 classId/stuId 编号——根本没法用。
// 现在把「去哪儿取」以 optionCall 下发(依赖字段留 {{占位符}}),
// 前端在学生选完班级后自己拉名单,下拉框才是真的下拉框。
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
if (optionCall != null) {
f.setOptionCall(optionCall);
f.setWidget("select");
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
f.setOptionsFallback(p.isUsingFallbackOptions());
} else if (StringUtils.isNotBlank(p.getOptionsFrom())) {
// 没有代取通道(模块未配地址 / 越权)→ 退回服务端取一次,取不到就保留原控件让用户手填编号
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
if (!opts.isEmpty()) {
f.setOptions(opts);
@@ -666,9 +734,43 @@ public class AgentOrchestrator {
}
}
}
// 服务端取不到实时字典时,至少把内置兜底的中文名翻出来(leaveType=1 → 病假),
// 免得「已识别」标签里显示一个裸编号让用户看不懂。
if (p.getFallbackValues() != null && !p.getFallbackValues().isEmpty()) {
int idx = p.getFallbackValues().indexOf(text);
if (idx >= 0 && p.getFallbackLabels() != null && idx < p.getFallbackLabels().size()) {
return p.getFallbackLabels().get(idx);
}
}
return text;
}
/**
* 生成某个字段「浏览器代取选项」的调用描述;不可用时返回 {@code null}。
*
* <p>用于业务模块的字典接口(班级、学生名单、请假类型…)——这些接口只认浏览器 CAS 会话,
* 服务端直调必然 302,所以只能把地址交给前端、由前端带用户登录态去取。
*/
private BrowserToolRequest.BrowserCall buildOptionCall(ToolParam p, CallerContext caller) {
if (StringUtils.isBlank(p.getOptionsFrom())) {
return null;
}
try {
ToolDef dict = registry.get(p.getOptionsFrom());
if (dict == null || !dict.isReadOnly()) {
return null;
}
// 与 fetchDictOptions 同源的角色约束:越权的字典不外泄地址
if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) {
return null;
}
return executor.prepareOptionCall(dict, p.getDependsOn());
} catch (Exception e) {
log.warn("生成代取选项描述失败 {}:{}", p.getOptionsFrom(), e.toString());
return null;
}
}
/** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */
private List<Map<String, String>> dictOptions(String toolName, CallerContext caller,
Map<String, List<Map<String, String>>> cache) {
@@ -898,8 +1000,12 @@ public class AgentOrchestrator {
sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n");
sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n");
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求“审批请假”“发布公告”,")
.append("或教师要求“提交学生的请假申请”),要如实说明“该操作需要 XX 身份”,不要试图越权,也不要承诺你会去办。\n");
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求「审批请假」「发布公告」),")
.append("要如实说明「该操作需要 XX 身份」,不要试图越权,也不要承诺你会去办。\n");
sb.append("9. ★ 请假要分清两条链路:「提交请假申请」= 学生给自己请假(提交后待班主任审核);")
.append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。")
.append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n");
return sb.toString();
}
@@ -0,0 +1,158 @@
package com.yunhai.agent.service;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:<b>「我要请假」</b>)确定性地
* 映射到对应表单,<b>不经过大模型决策</b>。
*
* <h3>为什么需要它</h3>
* 同一句「我要请假」,模型可能给出三种都不合预期的反应:
* <ol>
* <li>先去调只读字典工具({@code leave.types})→ 用户看到一串「病假 / 事假」列表,没有表单;</li>
* <li>调了写工具却<b>自行编造</b>起止时间 → 直接提交出一条错误的请假;</li>
* <li>用文字反问「请问您要请哪一天?」→ 用户还得再打字,不如直接给表单。</li>
* </ol>
* 用户的预期是<b>立刻看到表单</b>。表单本来就能承载「补全 + 确认」,所以这里直接短路到表单,
* 既准(参数一定是用户填的)又快(省掉一轮模型往返)。
*
* <h3>为什么用「显式短语表」而不是宽松正则</h3>
* 宽松匹配(如"含『请假』二字就直达")会把「我请假那天作业怎么办」「10月12日病假发烧就医」
* 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子,
* 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。
*
* <h3>身份差异</h3>
* 同一个意图在两个身份下落到<b>不同工具</b>:学生 = 请假申请({@code type=2} 待审核),
* 教职工 = 请假登记({@code type=1} 免审通过,需先选班级与学生)。表单字段由工具定义推导,
* 因此「对应好表单」是自动成立的。
*/
@Component
public class IntentShortcut {
/** 一个意图在两种身份下的落点与引导话术 */
public static class Intent {
/** 学生身份用的工具(内部点分名) */
private final String studentTool;
/** 教职工身份用的工具(null = 该身份下不直达) */
private final String staffTool;
/** 学生身份的表单引导语 */
private final String studentReply;
/** 教职工身份的表单引导语 */
private final String staffReply;
Intent(String studentTool, String staffTool, String studentReply, String staffReply) {
this.studentTool = studentTool;
this.staffTool = staffTool;
this.studentReply = studentReply;
this.staffReply = staffReply;
}
public String getStudentTool() {
return studentTool;
}
public String getStaffTool() {
return staffTool;
}
public String getStudentReply() {
return studentReply;
}
public String getStaffReply() {
return staffReply;
}
}
/** 请假意图:学生 → 请假申请;教职工 → 请假登记(帮学生请假) */
private static final Intent LEAVE = new Intent(
"leave.submit",
"leave.register",
"好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,"
+ "提交后由班主任审核。",
"好的,下面是**请假登记**表单:先选班级、再选学生,填好请假类型、起止时间和事由,"
+ "点「确认提交」我直接登记(教师登记免审,立即生效)。");
/**
* 标准说法全集(已剥掉语气词与标点后的形态)。
* 新增意图时在这里加词 + 补一条 {@link #match(String)} 分支即可。
*/
private static final Set<String> LEAVE_BARE = new HashSet<>(Arrays.asList(
"请假", "请个假", "请一个假", "请假申请", "请假登记", "登记请假",
"学生请假", "老师请假", "教师请假", "帮学生请假", "给学生请假"
));
/**
* 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。
*
* <p>⚠️ 这里**不能放「请」**:{@code 请假申请} 本身以「请」开头,剥掉就变成「假申请」,
* 反而会让标准说法匹配不上。只放意义明确的多字助动词。
*/
private static final String[] PREFIXES = {
"我想要", "我需要", "我要", "我想", "帮我", "帮忙", "麻烦",
"给我", "替我", "替", "申请", "提交", "办理", "办"
};
/** 后缀(语气词),同上 */
private static final String[] SUFFIXES = {
"一下", "吧", "呢", "啊", "哦", "呀", "嘛", "了", "的"
};
/**
* 判断一句话是不是「裸请假意图」。
*
* @return 命中的意图;不是裸意图(含细节、或别的意图)返回 {@code null}
*/
public Intent match(String rawMessage) {
String normalized = normalize(rawMessage);
// 长度门槛:真·裸意图都很短;长句几乎必然带时间/事由等细节,交给模型更合适
if (normalized.length() < 2 || normalized.length() > 12) {
return null;
}
if (LEAVE_BARE.contains(stripDecorations(normalized))) {
return LEAVE;
}
return null;
}
/** 去掉空白与标点(中英文都要),便于按「标准说法」比对 */
private String normalize(String s) {
if (StringUtils.isBlank(s)) {
return "";
}
return s.replaceAll("[\\s\\u3000]+", "")
.replaceAll("[,。!?、,.!?~~\"'“”‘’::;;()()《》\\[\\]【】]", "");
}
/** 剥掉「我要 / 帮我 / 麻烦…」这类前缀与「吧 / 了 / 一下」这类后缀,只留核心说法 */
private String stripDecorations(String n) {
String key = n;
// 前缀可能叠加(如「麻烦帮我」),循环剥到不能再剥
boolean changed = true;
while (changed) {
changed = false;
for (String p : PREFIXES) {
if (key.length() > p.length() && key.startsWith(p)) {
key = key.substring(p.length());
changed = true;
break;
}
}
}
for (String suf : SUFFIXES) {
if (key.length() > suf.length() && key.endsWith(suf)) {
return key.substring(0, key.length() - suf.length());
}
}
return key;
}
}