diff --git a/README.md b/README.md index 26d1036..8136969 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,18 @@ AgentService/ | 名单 | 工具 | 说明 | |---|---|---| -| 教职工(教师+管理员) | `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.buildings` `dorm.rooms` `dorm.roomStudents` `dorm.dormTree`、`activity.classTree`、`profession.*`(全部) | 审批、发布、统计、宿管、专业发展 | -| 学生 | `homework.*`(全部)、`activity.signup` `activity.cancelSignup`、`leave.reportBack` | 交作业、报名活动、销假 | -| 学生+教职工共用 | `leave.submit` `leave.withdraw` | 发起/撤销自己的事项 | +| 教职工(教师+管理员) | `leave.register` `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.buildings` `dorm.rooms` `dorm.roomStudents` `dorm.dormTree`、`activity.classTree`、`profession.*`(全部) | 请假登记/审批、发布、统计、宿管、专业发展 | +| 学生 | `leave.submit` `leave.reportBack`、`homework.*`(全部)、`activity.signup` `activity.cancelSignup` | 请假申请、交作业、报名活动、销假 | +| 学生+教职工共用 | `leave.withdraw` | 撤销请假(学生=申请撤销,教师=直接作废) | | 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 | +> ★ **请假为何分两个工具**:模块只有一个写接口 `POST /api/leave/Save`,靠 `type` 区分业务—— +> `type=2` 学生**申请**(待班主任审核)、`type=1` 教师**登记**(免审直接生效,且必须指明班级+学生)。 +> 合成一个工具时写死了 `type=2`,教师「帮学生请假」会被提交成待审核的学生申请; +> 而且「班级/学生」是可选参数、不满足"缺参才出表单"的条件,表单里根本没有这两项。 +> 拆成 `leave.submit`(学生)与 `leave.register`(教职工)后,角色过滤天然把错的工具挡在模型视野外, +> 表单字段由各自必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。 + ### 4.3 两道闸门 1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`, @@ -151,25 +158,46 @@ AgentService/ ### 5.1 表单化补参(两个请求形态) -**① 缺参 → 下发补全表单**:`POST /api/agent/chat {"message":"我要请假"}` +#### 5.1.0 裸意图直达(`service/IntentShortcut`) + +「只有意图、没有细节」的一句话(典型:**「我要请假」**)由后端**直接**按身份弹表单,不经过大模型。 +原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串类型列表、 +没有表单);② 调了写工具却自行**编造**起止时间直接提交;③ 用文字反问。用户的预期是立刻看到表单。 + +命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀 +与「吧/了/一下」后缀后,**正好等于**标准说法(`请假`/`请个假`/`请假申请`…)、且原句 ≤12 字。 +因此带信息的句子(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`)**不会**被劫持, +照常交给模型(模型能一次填全参数,或由下面的缺参拦截补表单)。 + +同一意图按身份落到不同工具: + +| 身份 | 工具 | 表单字段 | 提交后 | +|---|---|---|---| +| 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 | +| 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 | + +#### 5.1.1 缺参 → 下发补全表单 +`POST /api/agent/chat {"message":"我要请假"}`(学生身份) ```jsonc { "code": 0, "msg": "", "data": { "sessionId": "...", - "reply": "要完成「提交请假申请」,还差这几项:请假类型、开始时间、结束时间……", + "reply": "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,提交后由班主任审核。", "form": { "tool": "leave.submit", "title": "提交请假申请", "write": true, "landingPath": "/my", - "missing": ["请假类型", "开始时间", "结束时间"], - "preset": { "remarks": "发烧就医" }, - "recognized": [{ "name": "remarks", "label": "请假事由", "text": "发烧就医" }], + "missing": ["请假类型", "开始时间", "结束时间", "请假事由"], "fields": [ { "name": "leaveType", "label": "请假类型", "widget": "select", "type": "integer", "required": true, - "options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假" }] }, + "optionsFrom": "leave.types", "valueKey": "k", "labelKey": "v", + "optionsFallback": true, + "options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假及其他" }], + "optionCall": { "name": "leave.types", "method": "GET", + "pathAndQuery": "/homeleave/api/dict/LeaveTypeOptions" } }, { "name": "startDate", "label": "开始时间", "widget": "datetime", "required": true, "description": "请假开始时间,格式 yyyy-MM-dd HH:mm:ss", "example": "2026-10-12 08:00:00" } ] @@ -178,6 +206,25 @@ AgentService/ } ``` +**教师身份**下同一句「我要请假」得到的是登记表单,`classId` / `stuId` 用占位符地址做联动下拉: + +```jsonc +{ "name": "classId", "label": "班级", "widget": "select", "type": "integer", "required": true, + "optionsFrom": "leave.myClasses", "valueKey": "classId", "labelKey": "className", + "optionCall": { "name": "leave.myClasses", "method": "GET", + "pathAndQuery": "/homeleave/api/leave/MyClasses" } }, +{ "name": "stuId", "label": "学生", "widget": "select", "type": "integer", "required": true, + "dependsOn": ["classId"], "optionsFrom": "leave.classStudents", "valueKey": "stuId", "labelKey": "stuName", + "optionCall": { "name": "leave.classStudents", "method": "GET", + "pathAndQuery": "/homeleave/api/leave/ClassStudents?classId={{classId}}" } } +``` + +> **★ 为什么下拉选项要由前端代取**:模块的字典接口(`MyClasses` / `ClassStudents` / `LeaveTypeOptions`) +> 只认浏览器 CAS 会话,服务端直调必然 `code=302`。以前"服务端取一次、失败就退化成数字输入框", +> 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取** +> (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典, +> 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。 + **② 填完提交 → 直接执行**:`POST /api/agent/chat` ```jsonc diff --git a/src/main/java/com/yunhai/agent/auth/RolePolicy.java b/src/main/java/com/yunhai/agent/auth/RolePolicy.java index 2d597bb..15dccb9 100644 --- a/src/main/java/com/yunhai/agent/auth/RolePolicy.java +++ b/src/main/java/com/yunhai/agent/auth/RolePolicy.java @@ -16,10 +16,10 @@ import java.util.Set; * *

名单: *

* 未列入任何名单的工具 = 所有已识别角色都可用(如各类只读查询、宿舍通知回执)。 */ @@ -30,8 +30,8 @@ public final class RolePolicy { /** 教职工专属(教师 + 管理员) */ private static final Set STAFF_ONLY = new HashSet<>(Arrays.asList( - // 请假:审批与统计 - "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass", + // 请假:登记(教师给学生请假,type=1 免审)、审批与统计 + "leave.register", "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass", // 公告:发布与审批 "notice.sent", "notice.deptDics", "notice.publish", "notice.approvalList", "notice.approve", "notice.reject", @@ -44,7 +44,10 @@ public final class RolePolicy { /** 学生专属 */ private static final Set STUDENT_ONLY = new HashSet<>(Arrays.asList( "homework.myList", "homework.detail", "homework.result", "homework.submit", - "leave.reportBack" + "leave.reportBack", + // 请假申请(type=2 待审核)—— 教师代学生请假走 leave.register,两条链路必须分开, + // 否则教师提交会被写死成 type=2 的学生申请(类型错) + "leave.submit" )); /** 学生与家长共用(家校活动报名由家长或学生发起) */ @@ -52,9 +55,9 @@ public final class RolePolicy { "activity.signup", "activity.cancelSignup" )); - /** 学生与教职工共用(发起/撤销自己的事项) */ + /** 学生与教职工共用(撤销自己的事项;教师撤销 = 直接作废) */ private static final Set SHARED = new HashSet<>(Arrays.asList( - "leave.submit", "leave.withdraw" + "leave.withdraw" )); /** 模块级规则:整个模块仅教职工可用 */ diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index 4750de2..23e5bc2 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -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 messages = session.getMessages(); if (messages.isEmpty()) { messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles))); @@ -398,6 +407,54 @@ public class AgentOrchestrator { /* ==================== 表单:缺参拦截 与 直提交 ==================== */ + /** + * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」)时, + * 按当前身份直接构建表单返回 —— 学生拿「请假申请」,教职工拿「请假登记(帮学生请假)」。 + * + *

判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}), + * 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null}, + * 照常交给大模型处理。 + */ + private ChatResponse directForm(String message, CallerContext caller, Set authorizedModIds, + Set 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 empty = new LinkedHashMap<>(); + List 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 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> 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}。 + * + *

用于业务模块的字典接口(班级、学生名单、请假类型…)——这些接口只认浏览器 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> dictOptions(String toolName, CallerContext caller, Map>> 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(); } diff --git a/src/main/java/com/yunhai/agent/service/IntentShortcut.java b/src/main/java/com/yunhai/agent/service/IntentShortcut.java new file mode 100644 index 0000000..26ab579 --- /dev/null +++ b/src/main/java/com/yunhai/agent/service/IntentShortcut.java @@ -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; + +/** + * 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:「我要请假」)确定性地 + * 映射到对应表单,不经过大模型决策。 + * + *

为什么需要它

+ * 同一句「我要请假」,模型可能给出三种都不合预期的反应: + *
    + *
  1. 先去调只读字典工具({@code leave.types})→ 用户看到一串「病假 / 事假」列表,没有表单;
  2. + *
  3. 调了写工具却自行编造起止时间 → 直接提交出一条错误的请假;
  4. + *
  5. 用文字反问「请问您要请哪一天?」→ 用户还得再打字,不如直接给表单。
  6. + *
+ * 用户的预期是立刻看到表单。表单本来就能承载「补全 + 确认」,所以这里直接短路到表单, + * 既准(参数一定是用户填的)又快(省掉一轮模型往返)。 + * + *

为什么用「显式短语表」而不是宽松正则

+ * 宽松匹配(如"含『请假』二字就直达")会把「我请假那天作业怎么办」「10月12日病假发烧就医」 + * 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子, + * 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。 + * + *

身份差异

+ * 同一个意图在两个身份下落到不同工具:学生 = 请假申请({@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 LEAVE_BARE = new HashSet<>(Arrays.asList( + "请假", "请个假", "请一个假", "请假申请", "请假登记", "登记请假", + "学生请假", "老师请假", "教师请假", "帮学生请假", "给学生请假" + )); + + /** + * 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。 + * + *

⚠️ 这里**不能放「请」**:{@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; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java index eb9f2c4..c322596 100644 --- a/src/main/java/com/yunhai/agent/tool/FormField.java +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -49,4 +49,24 @@ public class FormField { /** 下拉选项 [{value,label}] */ private List> options = new ArrayList<>(); + + /* ==================== 动态选项(浏览器代取 + 联动) ==================== */ + + /** + * 「浏览器代取选项」的调用描述:业务模块的字典接口(班级、学生名单…)只认浏览器 CAS 会话, + * 服务端代取必然 302,所以把地址下发前端,由前端用**用户自己的登录态**取回原始字典再渲染下拉。 + * + *

地址串里可能含 {@code {{字段名}}} 占位符(见 {@link #dependsOn}), + * 前端在依赖字段有值时做替换(值需 URL 编码)后才发起请求。 + */ + private com.yunhai.agent.model.BrowserToolRequest.BrowserCall optionCall; + + /** 本字段选项依赖的先序字段名(如「学生」依赖「班级」);依赖未填时前端应清空并提示先选 */ + private List dependsOn = new ArrayList<>(); + + /** 从字典返回对象里取哪个字段作为选项「值」(如 classId / stuId) */ + private String valueKey; + + /** 从字典返回对象里取哪个字段作为选项「显示名」(如 className / stuName) */ + private String labelKey; } diff --git a/src/main/java/com/yunhai/agent/tool/ToolDef.java b/src/main/java/com/yunhai/agent/tool/ToolDef.java index 6d3b465..efb3cb6 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolDef.java +++ b/src/main/java/com/yunhai/agent/tool/ToolDef.java @@ -49,8 +49,14 @@ public class ToolDef { /** 参数定义 */ private List params = new ArrayList<>(); - /** 固定参数(接口要求但对话里无需用户提供,如学生申请固定 type=2) */ - private Map fixed = new LinkedHashMap<>(); + /** + * 固定参数(接口要求但对话里无需用户提供,如学生申请固定 {@code type=2})。 + * + *

★ 值类型用 {@code Object} 而不是 {@code String}:{@code type}/{@code continuousLeave} + * 这类字段在模块侧是**整型/枚举**,下发字符串 {@code "2"} 可能被 Jackson 判为非法枚举名, + * 必须按数字下发({@code {"type":2}})。需要字符串就直接传 String,不必再转换。 + */ + private Map fixed = new LinkedHashMap<>(); /** * 平台菜单模块号(与 GetUserMod 的 modId 对齐)。 @@ -103,7 +109,11 @@ public class ToolDef { return this; } - public ToolDef fix(String key, String value) { + /** + * 追加一个固定参数。值请用与模块字段一致的类型: + * 整型/枚举传 {@code Integer}({@code fix("type", 2)}),字符串传 {@code String}。 + */ + public ToolDef fix(String key, Object value) { this.fixed.put(key, value); return this; } diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java index 2cf9624..0d59447 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java +++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java @@ -257,6 +257,50 @@ public class ToolExecutor { return parseResponse(def, new ResponseEntity<>(body == null ? "" : body, hs), costMs); } + /** + * 生成「代取字典选项」的调用描述(供表单下拉使用)。 + * + *

与 {@link #prepareBrowserCall} 的差别:**不做必填校验**,而是把依赖字段写成 + * {@code {{字段名}}} 占位符(如 {@code /homeleave/api/leave/ClassStudents?classId={{classId}}}), + * 由前端在依赖字段有值后替换再发起请求 —— 这样「学生」这种依赖「班级」的下拉才拉得动。 + * + * @param dict 只读字典工具(如 leave.classStudents) + * @param placeholders 需要留占位符的依赖字段名(可为空 = 无参字典) + * @return 调用描述;字典工具非法或模块未配置地址时返回 {@code null} + */ + public com.yunhai.agent.model.BrowserToolRequest.BrowserCall prepareOptionCall( + ToolDef dict, java.util.Collection placeholders) { + if (dict == null || !dict.isReadOnly() || StringUtils.isBlank(dict.getPath())) { + return null; + } + String base = trimEnd(settings.getModules().get(dict.getModule())); + if (StringUtils.isBlank(base)) { + return null; + } + String method = dict.getHttpMethod() == null ? "GET" : dict.getHttpMethod().toUpperCase(); + + com.yunhai.agent.model.BrowserToolRequest.BrowserCall call = + new com.yunhai.agent.model.BrowserToolRequest.BrowserCall(); + call.setName(dict.getName()); + call.setTitle(dict.getTitle()); + call.setWrite(false); + call.setMethod(method); + + StringBuilder qs = new StringBuilder(); + if (placeholders != null) { + for (String ph : placeholders) { + if (StringUtils.isBlank(ph)) { + continue; + } + qs.append(qs.length() == 0 ? "?" : "&") + .append(urlEncode(ph)) + .append("={{").append(ph).append("}}"); + } + } + call.setPathAndQuery(stripOrigin(base + dict.getPath()) + qs); + return call; + } + /** 去掉 URL 里的协议与域名,只留 path+query(本服务的模块地址都由配置拼出,格式可控) */ private String stripOrigin(String fullUrl) { int idx = fullUrl.indexOf("://"); diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java index 28c5fcd..13e5f5c 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolParam.java +++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java @@ -75,6 +75,26 @@ public class ToolParam { */ private String optionsFrom; + /** + * 选项取自字典返回对象的哪个字段(如 {@code classId} / {@code stuId})。 + * + *

★ 为什么必须显式声明:模块字典返回的是业务 VO({@code TeachingClassVo{classId,className}}、 + * {@code ClassStudentVo{stuId,stuName}}),字段名五花八门,靠「猜常见键名」不可靠。 + * 显式声明后,前端在浏览器里取到原始数据也能**确定性地**映射成下拉项。 + */ + private String optionValueKey; + + /** 选项显示名取自字典返回对象的哪个字段(如 {@code className} / {@code stuName}) */ + private String optionLabelKey; + + /** + * 本字段的选项依赖哪些其它字段(如「学生」下拉依赖先选好「班级」)。 + * + *

作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项; + * ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。 + */ + private List dependsOn; + public ToolParam() { } @@ -149,6 +169,22 @@ public class ToolParam { return this; } + /** + * 字典返回对象里,取哪个字段做「值」、哪个字段做「显示名」。 + *

例:{@code optionsKeys("classId","className")} 对应 {@code {classId,className}} 这种业务 VO。 + */ + public ToolParam optionsKeys(String valueKey, String labelKey) { + this.optionValueKey = valueKey; + this.optionLabelKey = labelKey; + return this; + } + + /** 本字段的选项依赖的先序字段(用于联动下拉,如「学生」依赖「班级」) */ + public ToolParam dependsOn(String... fieldNames) { + this.dependsOn = Arrays.asList(fieldNames); + return this; + } + /** 表单里显示的名字 */ public String labelOf() { return StringUtils.isNotBlank(label) ? label : name; @@ -225,6 +261,9 @@ public class ToolParam { f.setOptions(optionList()); // 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」 f.setOptionsFrom(optionsFrom); + f.setDependsOn(dependsOn); + f.setValueKey(optionValueKey); + f.setLabelKey(optionLabelKey); // 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户 f.setOptionsFallback(isUsingFallbackOptions()); return f; diff --git a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java index 6e0f848..33f2941 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java @@ -8,9 +8,24 @@ import java.util.Arrays; import java.util.List; /** - * 学生请假模块工具(后端 HomeSchLeave,前缀 /api/leave、/api/dict、/api/leavestats)。 + * 学生请假模块工具(后端 HomeSchLeave / HomeLeave,前缀 /api/leave、/api/dict、/api/leavestats)。 * - *

平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 / 5 请假登记 / 6 请假统计 / 7 应用设置 + *

平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 / + * 5 请假登记 / 6 请假统计 / 7 应用设置 + * + *

★ 为什么「请假」要拆成两个工具

+ * 模块只有一个写接口 {@code POST /api/leave/Save},靠 {@code type} 区分两种业务: + *
    + *
  • {@code type=2} 学生申请 → 状态「待审核」,等班主任审批;
  • + *
  • {@code type=1} 教师登记 → 免审直接通过,且必须指明是给哪个班、哪个学生请的。
  • + *
+ * 早先合成一个工具并写死 {@code type=2},结果是:教师说「帮学生请假」会被提交成一条 + * 待审核的学生申请(类型错),而且表单里根本没有「班级 / 学生」可选(那两个参数是可选, + * 不满足"缺参才出表单"的条件)。拆开之后: + *
    + *
  • 角色过滤天然生效 —— 学生只看得见「请假申请」,教职工只看得见「请假登记」,模型不可能选错;
  • + *
  • 表单字段由各自工具的必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。
  • + *
*/ public final class LeaveTools { @@ -22,7 +37,8 @@ public final class LeaveTools { /** * 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。 *

权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义), - * 但该接口需登录会话、Agent 侧常取不到;没有兜底时表单会退化成让用户填「类型编号」的数字框。 + * 该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给浏览器用用户登录态取实时字典, + * 这份兜底保证「取不到时下拉里也还有标准项」,不会退化成让用户填「类型编号」的数字框。 */ private static final String[] LEAVE_TYPE_VALUES = {"1", "2"}; @@ -36,19 +52,25 @@ public final class LeaveTools { // ---------- 只读:辅助信息 ---------- list.add(ToolDef.of("leave.types", MODULE, "请假类型", - "查询本校本模块可选的请假类型(病假/事假等)及其编号。用户说“请假”但没给类型时,先调它拿到 leaveType 编号。", + "查询本校本模块可选的请假类型(病假/事假等)及其编号。" + + "仅在用户明确问「有哪些请假类型」时使用;" + + "用户说要请假时**不要**先调它 —— 请假表单里的「请假类型」下拉会自动带出可选值。", "GET", "/api/dict/LeaveTypeOptions") .readAt("/apply") .modId(2)); list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级", - "查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。登记请假、筛选前先调它拿 classId。", + "查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。" + + "仅在用户明确询问自己的班级时使用(如「我教哪几个班」);" + + "填表/登记请假时**不要**先调它 —— 表单的「班级」下拉会自动取到这个列表。", "GET", "/api/leave/MyClasses") .readAt("/apply") .modId(2)); list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单", - "查询某班级的学生名单(教师给学生登记请假时选人用)。", + "查询某班级的学生名单。" + + "仅在用户明确要看某个班的学生名单时使用;" + + "登记请假时**不要**先调它 —— 表单的「学生」下拉会在选好班级后自动带出名单。", "GET", "/api/leave/ClassStudents") .p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses")) .readAt("/register") @@ -57,7 +79,7 @@ public final class LeaveTools { // ---------- 只读:查询 ---------- list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录", "分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。" - + "用户问“我请了几天假”“上次请假通过没”时用它。", + + "用户问「我请了几天假」「上次请假通过没」时用它。", "GET", "/api/leave/List") .p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"), ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"), @@ -86,31 +108,61 @@ public final class LeaveTools { .readAt("/stats") .modId(6)); - // ---------- 写:提交 / 审批 / 销假 ---------- + // ---------- 写:学生请假申请(type=2,待审核) ---------- list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请", "学生提交一条请假申请(提交后状态为待审核,等班主任审批)。" + + "教师给学生请假请改用「帮学生登记请假」,不要用本工具。" + "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;" + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", "POST_JSON", "/api/leave/Save") .p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他") .label("请假类型") .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) - .optionsFrom("leave.types"), + .optionsFrom("leave.types").optionsKeys("k", "v"), ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) .label("开始时间").eg("2026-10-12 08:00:00"), ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) .label("结束时间").eg("2026-10-12 17:00:00"), ToolParam.required("remarks", "string", "请假事由") .label("请假事由").as("textarea").eg("发烧就医,需在家休息"), - ToolParam.optional("classId", "integer", "班级 id;用户只有唯一班级时可不传") - .label("班级"), - ToolParam.optional("stuId", "integer", "学生 id(教师代学生登记时用)") - .label("学生")) - .fix("type", "2") - .fix("continuousLeave", "0") + ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)") + .label("班级") + .optionsFrom("leave.myClasses").optionsKeys("classId", "className")) + .fix("type", 2) + .fix("continuousLeave", 0) .write("/my") .modId(2)); + // ---------- 写:教师代学生登记请假(type=1,免审通过) ---------- + list.add(ToolDef.of("leave.register", MODULE, "帮学生登记请假", + "教师/管理员给学生登记请假(教师登记免审,直接生效)。" + + "需要先确定班级与学生,再填请假类型、起止时间与事由。" + + "学生自己请假请改用「提交请假申请」,不要用本工具。" + + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", + "POST_JSON", "/api/leave/Save") + .p(ToolParam.required("classId", "integer", "学生所在班级 id(决定下面「学生」下拉的可选项)") + .label("班级") + .optionsFrom("leave.myClasses").optionsKeys("classId", "className"), + ToolParam.required("stuId", "integer", "学生 id(该班里要登记请假的那个学生)") + .label("学生") + .optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName") + .dependsOn("classId"), + ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他") + .label("请假类型") + .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) + .optionsFrom("leave.types").optionsKeys("k", "v"), + ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) + .label("开始时间").eg("2026-10-12 08:00:00"), + ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) + .label("结束时间").eg("2026-10-12 17:00:00"), + ToolParam.required("remarks", "string", "请假事由") + .label("请假事由").as("textarea").eg("家中有事,需请假一天")) + .fix("type", 1) + .fix("continuousLeave", 0) + .write("/register") + .modId(5)); + + // ---------- 写:撤销 / 销假 / 审批 ---------- list.add(ToolDef.of("leave.withdraw", MODULE, "撤销请假", "撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。", "POST_FORM", "/api/leave/Withdraw") @@ -128,7 +180,7 @@ public final class LeaveTools { .label("实际返校时间").eg("2026-10-12 08:00:00"), ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT) .label("实际结束时间").eg("2026-10-12 17:00:00")) - .fix("continuousLeave", "0") + .fix("continuousLeave", 0) .write("/my") .modId(3)); diff --git a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java new file mode 100644 index 0000000..e1d6f81 --- /dev/null +++ b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java @@ -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; + +/** + * 「我要请假」在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。 + * + *

这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单, + * 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。 + * 因此测试里的 {@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"); + 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 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()); + } +} diff --git a/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java b/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java new file mode 100644 index 0000000..34946bb --- /dev/null +++ b/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java @@ -0,0 +1,175 @@ +package com.yunhai.agent; + +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.model.BrowserToolRequest; +import com.yunhai.agent.service.IntentShortcut; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolExecutor; +import com.yunhai.agent.tool.ToolParam; +import com.yunhai.agent.tool.ToolRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.Collections; + +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; + +/** + * 「一句话请假」这条链路的断言:意图直达规则 + 学生/教师两条表单链路的字段推导。 + * + *

背景:同一句「我要请假」在两个身份下要落到不同工具、不同表单 —— + * 学生 = 请假申请(type=2 待审核),教职工 = 请假登记(type=1 免审,须先选班级与学生)。 + */ +class LeaveIntentFormTest { + + private IntentShortcut shortcut; + private ToolRegistry registry; + private AgentSettings settings; + private ToolExecutor executor; + + @BeforeEach + void setUp() { + shortcut = new IntentShortcut(); + settings = new AgentSettings(); + settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave"); + registry = new ToolRegistry(settings); + registry.init(); + executor = new ToolExecutor(settings, new RestTemplate()); + } + + /* ==================== 意图直达规则 ==================== */ + + @Test + void bareLeaveIntentIsRecognized() { + for (String text : Arrays.asList( + "我要请假", "请假", "我想请假", "请个假", "麻烦帮我请假吧", + "申请请假", "提交请假申请", "请假申请", "我要请假了", "帮学生请假", "登记请假")) { + assertNotNull(shortcut.match(text), "「" + text + "」应被识别为裸请假意图"); + } + } + + @Test + void bareIntentResolvesToDifferentToolsPerIdentity() { + IntentShortcut.Intent it = shortcut.match("我要请假"); + assertNotNull(it); + assertEquals("leave.submit", it.getStudentTool(), "学生应走「提交请假申请」"); + assertEquals("leave.register", it.getStaffTool(), "教职工应走「帮学生登记请假」"); + } + + @Test + void detailedSentencesAreNotHijacked() { + // 带时间/事由的句子交给模型(模型能一次填全参数),不能被直接劫持成空表单 + for (String text : Arrays.asList( + "我要请10月12日上午的病假,发烧就医", + "我请假那天作业怎么办", + "请假条怎么写", + "我要请假一天", + "请假流程是什么")) { + assertNull(shortcut.match(text), "「" + text + "」含细节,不应被直达规则命中"); + } + } + + @Test + void unrelatedOrEmptyInputIsIgnored() { + assertNull(shortcut.match(null)); + assertNull(shortcut.match("")); + assertNull(shortcut.match(" ")); + assertNull(shortcut.match("我要吃饭")); + assertNull(shortcut.match("我要请假我要请假我要请假我要请假")); + // 销假是另一个动作,不能当成请假 + assertNull(shortcut.match("我要销假")); + } + + /* ==================== 两条请假链路的工具定义 ==================== */ + + @Test + void studentFlowSubmitsApplicationWithType2() { + ToolDef submit = registry.get("leave.submit"); + assertNotNull(submit); + assertFalse(submit.isReadOnly(), "请假申请是写操作"); + assertEquals(2, ((Number) submit.getFixed().get("type")).intValue(), + "学生申请必须固定 type=2(待审核)"); + assertEquals(0, ((Number) submit.getFixed().get("continuousLeave")).intValue()); + + // 学生表单的必填项:类型 / 起止时间 / 事由 + assertTrue(requiredNames(submit).contains("leaveType")); + assertTrue(requiredNames(submit).contains("startDate")); + assertTrue(requiredNames(submit).contains("endDate")); + assertTrue(requiredNames(submit).contains("remarks")); + assertFalse(requiredNames(submit).contains("stuId"), "学生自查请假不该出现「学生」字段"); + } + + @Test + void teacherFlowRegistersLeaveWithType1AndStudentPicker() { + ToolDef register = registry.get("leave.register"); + assertNotNull(register); + assertFalse(register.isReadOnly(), "请假登记是写操作"); + assertEquals(1, ((Number) register.getFixed().get("type")).intValue(), + "教师登记必须固定 type=1(免审直接生效)"); + + // 教师必须选到人:班级 + 学生 都是必填,表单才会把这两项摆出来 + assertTrue(requiredNames(register).contains("classId"), "教师登记必须选班级"); + assertTrue(requiredNames(register).contains("stuId"), "教师登记必须选学生"); + + ToolParam stuId = param(register, "stuId"); + assertEquals(Collections.singletonList("classId"), stuId.getDependsOn(), + "「学生」下拉依赖先选好「班级」"); + assertEquals("stuId", stuId.getOptionValueKey()); + assertEquals("stuName", stuId.getOptionLabelKey()); + } + + /* ==================== 下拉选项的「代取地址」 ==================== */ + + @Test + void optionCallKeepsPlaceholderForDependentField() { + ToolDef dict = registry.get("leave.classStudents"); + BrowserToolRequest.BrowserCall call = executor.prepareOptionCall(dict, Collections.singletonList("classId")); + assertNotNull(call); + assertEquals("GET", call.getMethod()); + assertEquals("/homeleave/api/leave/ClassStudents?classId={{classId}}", call.getPathAndQuery(), + "依赖字段必须留成占位符,由前端填入用户选的班级后自取名单"); + } + + @Test + void optionCallWithoutDependencyIsPlainUrl() { + BrowserToolRequest.BrowserCall call = + executor.prepareOptionCall(registry.get("leave.types"), null); + assertNotNull(call); + assertEquals("/homeleave/api/dict/LeaveTypeOptions", call.getPathAndQuery()); + } + + @Test + void optionCallIsNullWhenModuleAddressMissing() { + AgentSettings bare = new AgentSettings(); + ToolExecutor noModule = new ToolExecutor(bare, new RestTemplate()); + assertNull(noModule.prepareOptionCall(registry.get("leave.types"), null), + "模块没配地址时不该下发一个必然打不通的代取地址"); + } + + /* ==================== 辅助 ==================== */ + + private java.util.List requiredNames(ToolDef def) { + java.util.List names = new java.util.ArrayList<>(); + for (ToolParam p : def.getParams()) { + if (p.isRequired()) { + names.add(p.getName()); + } + } + return names; + } + + private ToolParam param(ToolDef def, String name) { + for (ToolParam p : def.getParams()) { + if (name.equals(p.getName())) { + return p; + } + } + throw new AssertionError("工具 " + def.getName() + " 缺少参数 " + name); + } +} diff --git a/src/test/java/com/yunhai/agent/RolePermissionTest.java b/src/test/java/com/yunhai/agent/RolePermissionTest.java index fbe7f8b..0fa1e82 100644 --- a/src/test/java/com/yunhai/agent/RolePermissionTest.java +++ b/src/test/java/com/yunhai/agent/RolePermissionTest.java @@ -173,9 +173,10 @@ class RolePermissionTest { assertTrue(visible(visible, "homework.submit"), "学生应可用 homework.submit"); assertTrue(visible(visible, "activity.signup"), "学生应可用 activity.signup"); assertTrue(visible(visible, "leave.submit"), "学生应可用 leave.submit"); - // 学生不能做:审批、发布、专业发展、宿管管理 + // 学生不能做:审批、发布、专业发展、宿管管理、教师代登记 assertFalse(visible(visible, "leave.audit"), "学生不应看到 leave.audit"); assertFalse(visible(visible, "leave.batchPass"), "学生不应看到 leave.batchPass"); + assertFalse(visible(visible, "leave.register"), "学生不应看到「帮学生登记请假」(教师链路)"); assertFalse(visible(visible, "notice.publish"), "学生不应看到 notice.publish"); assertFalse(visible(visible, "profession.myPoints"), "学生不应看到 profession.*"); assertFalse(visible(visible, "dorm.buildings"), "学生不应看到宿舍管理侧"); @@ -209,12 +210,14 @@ class RolePermissionTest { assertTrue(roles.contains(AgentRole.TEACHER), "roleId=1 应识别为教师(老师)"); List visible = registry.visibleTools(null, roles); - // 教师能做:审批、发布、专业发展 + // 教师能做:登记请假、审批、发布、专业发展 + assertTrue(visible(visible, "leave.register"), "教师应可用「帮学生登记请假」"); assertTrue(visible(visible, "leave.audit"), "教师应可用 leave.audit"); assertTrue(visible(visible, "notice.publish"), "教师应可用 notice.publish"); assertTrue(visible(visible, "profession.myPoints"), "教师应可用 profession.*"); assertTrue(visible(visible, "leave.stats"), "教师应可用 leave.stats"); - // 教师不做:学生交作业、报名活动 + // 教师不做:学生交作业、报名活动、学生自己的请假申请 + assertFalse(visible(visible, "leave.submit"), "教师不应看到学生侧的「提交请假申请」"); assertFalse(visible(visible, "homework.submit"), "教师不应看到 homework.submit"); assertFalse(visible(visible, "activity.signup"), "教师不应看到 activity.signup"); }