From d2c6c185b5fa72247e673da331f78c84e9ba1d2a Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 12 Oct 2026 18:54:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(notice):=20=E3=80=8C=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=A0=A1=E5=9B=AD=E5=85=AC=E5=91=8A=E3=80=8D=E8=A3=B8=E6=84=8F?= =?UTF-8?q?=E5=9B=BE=E7=9B=B4=E8=BE=BE=E8=A1=A8=E5=8D=95=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=8F=91=E6=96=87=E9=83=A8=E9=97=A8=E4=B8=8B=E6=8B=89=E4=B8=8E?= =?UTF-8?q?=E5=81=87=E7=9A=84=E5=8F=91=E5=B8=83=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntentShortcut 泛化成规则表(Rule{短语集 → Intent}):加新意图只需 ① 一条 Intent ② 一个短语集 ③ RULES.add(...),不用动 AgentOrchestrator。 新增 NOTICE_PUBLISH —— 仅教职工(notice.publish),学生无落点则落回模型如实说明。 短语表只收带「发/发布」动词的说法(发布公告/发个公告/发布校园公告/发通知…), 光说「公告」「通知」更像查询,不收,避免误判。 - ★ 修 prepareOptionCall 漏拼字典工具的 fixed 参数:公文部门字典是 /api/dict/List?type=1,丢了 type 前端取回的是「没指定类型」的结果 → 空下拉。 - deptId 补 optionsKeys("k","v"):平台通用字典返回 {k,v} 而非 id/name; 服务端兜底 fetchDictOptions 的候选键也补上 k/v。 - ★ 移除 notice.publish 的 publishType 与 deptName:后端 /api/notice/Create 就是 直接发布(无 publishType 字段,是否转审批由学校「应用设置」绑定的流程决定), 声明它会让用户以为选了「存草稿」却仍然直接发出;deptName 由后端按 deptId 反查字典。 - system prompt 加第 10 条说明。 - 测试:新增 NoticeIntentFormTest(7),IntentDirectFormTest 加教师/学生两条公告用例 (仍用「一调就炸」的假 LLM 证明不过大模型);全量 71/71。 --- README.md | 37 +++-- .../agent/service/AgentOrchestrator.java | 22 ++- .../yunhai/agent/service/IntentShortcut.java | 96 +++++++++-- .../com/yunhai/agent/tool/ToolExecutor.java | 17 ++ .../agent/tool/defs/CampusNoticeTools.java | 29 ++-- .../yunhai/agent/IntentDirectFormTest.java | 42 ++++- .../yunhai/agent/NoticeIntentFormTest.java | 156 ++++++++++++++++++ 7 files changed, 352 insertions(+), 47 deletions(-) create mode 100644 src/test/java/com/yunhai/agent/NoticeIntentFormTest.java diff --git a/README.md b/README.md index 8136969..c2d2a90 100644 --- a/README.md +++ b/README.md @@ -160,21 +160,34 @@ AgentService/ #### 5.1.0 裸意图直达(`service/IntentShortcut`) -「只有意图、没有细节」的一句话(典型:**「我要请假」**)由后端**直接**按身份弹表单,不经过大模型。 -原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串类型列表、 -没有表单);② 调了写工具却自行**编造**起止时间直接提交;③ 用文字反问。用户的预期是立刻看到表单。 +「只有意图、没有细节」的一句话(典型:**「我要请假」**、**「我要发布校园公告」**)由后端**直接**按身份弹表单, +不经过大模型。原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串 +类型列表、没有表单);② 调了写工具却自行**编造**参数直接提交;③ 用文字反问。用户的预期是立刻看到表单。 命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀 -与「吧/了/一下」后缀后,**正好等于**标准说法(`请假`/`请个假`/`请假申请`…)、且原句 ≤12 字。 -因此带信息的句子(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`)**不会**被劫持, -照常交给模型(模型能一次填全参数,或由下面的缺参拦截补表单)。 +与「吧/了/一下」后缀后,**正好等于**标准说法、且原句 ≤12 字。因此带信息的句子 +(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`、`发一条关于社团招新的通知`) +**不会**被劫持,照常交给模型。 + +**加新意图只需三步**(不用动 `AgentOrchestrator`):① 一条 `Intent`(工具 + 引导语); +② 一个「标准说法」短语集;③ 往 `RULES` 里追加一条 `Rule`。 同一意图按身份落到不同工具: -| 身份 | 工具 | 表单字段 | 提交后 | -|---|---|---|---| -| 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 | -| 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 | +| 意图 | 身份 | 工具 | 表单字段 | 提交后 | +|---|---|---|---|---| +| 请假 | 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 | +| 请假 | 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 | +| 发布校园公告 | 教职工 | `notice.publish` | 公告标题 / 公告正文 / **发文部门**(下拉) | **立即发布、全站可见** | +| 发布校园公告 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 | + +⚠️ **发布公告只认带「发/发布」动词的说法**(`发布公告`/`发个公告`/`我要发布校园公告`/`发通知`…): +光说「公告」「通知」更像**查询**,误判成"发布"会很突兀,故不进短语表。 + +⚠️ **不要再给 `notice.publish` 加「发布方式」参数**:后端 `/api/notice/Create` 就是**直接发布** +(是否转审批由学校「应用设置」里的流程绑定决定,请求侧绕不过),并不存在 `publishType` 字段 —— +声明它会让用户以为选了「存草稿」却仍然直接发出去。需要草稿/送审请走公告模块页面 +(`/api/notice/SaveDraft` 与 `/api/notice/Submit`,后者还需要先有草稿 id,属另一条链路)。 #### 5.1.1 缺参 → 下发补全表单 `POST /api/agent/chat {"message":"我要请假"}`(学生身份) @@ -224,6 +237,10 @@ AgentService/ > 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取** > (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典, > 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。 +> +> **字典工具的 `fixed` 参数会被一并拼上** —— 多个模块的字典接口靠它选字典种类, +> 例如公告的发文部门取自 `/api/dict/List?type=1`(`{k,v}` 形状,故 `deptId` 声明 +> `.optionsKeys("k","v")`)。早先漏拼固定参数,前端取回的是「没指定类型」的结果,下拉是空的。 **② 填完提交 → 直接执行**:`POST /api/agent/chat` diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index 23e5bc2..fb274a2 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -113,8 +113,8 @@ 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; @@ -408,8 +408,9 @@ public class AgentOrchestrator { /* ==================== 表单:缺参拦截 与 直提交 ==================== */ /** - * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」)时, - * 按当前身份直接构建表单返回 —— 学生拿「请假申请」,教职工拿「请假登记(帮学生请假)」。 + * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时, + * 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」; + * 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。 * *

判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}), * 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null}, @@ -449,8 +450,8 @@ public class AgentOrchestrator { resp.setForm(form); resp.setReply(reply); out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()))); - log.info("意图直达弹表单:session={} 身份={} 工具={}", session.getSessionId(), - staff ? "教职工" : "学生", def.getName()); + log.info("意图直达弹表单:session={} 意图={} 身份={} 工具={}", session.getSessionId(), + intent.getName(), staff ? "教职工" : "学生", def.getName()); finish(resp, session, 0, 0, out); return resp; } @@ -811,11 +812,12 @@ public class AgentOrchestrator { continue; } JSONObject o = (JSONObject) item; - String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key"); + // "k"/"v" 是平台通用字典(/api/dict/List)的形态,与业务 VO(classId/className)并存 + String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key", "k"); if (StringUtils.isBlank(value)) { continue; } - String label = firstOf(o, "name", "label", "text", "title", "dictName", "value"); + String label = firstOf(o, "name", "label", "text", "title", "dictName", "v", "value"); Map one = new LinkedHashMap<>(); one.put("value", value); one.put("label", StringUtils.isBlank(label) ? value : label); @@ -1007,6 +1009,10 @@ public class AgentOrchestrator { .append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。") .append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n"); + sb.append("10. ★ 「发布校园公告」是**立即发布、全站可见**的动作:必须拿到标题、正文、发文部门三项;") + .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 index 26ab579..bbaeace 100644 --- a/src/main/java/com/yunhai/agent/service/IntentShortcut.java +++ b/src/main/java/com/yunhai/agent/service/IntentShortcut.java @@ -3,13 +3,15 @@ package com.yunhai.agent.service; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Set; /** - * 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:「我要请假」)确定性地 - * 映射到对应表单,不经过大模型决策。 + * 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:「我要请假」、 + * 「发布校园公告」)确定性地映射到对应表单,不经过大模型决策。 * *

为什么需要它

* 同一句「我要请假」,模型可能给出三种都不合预期的反应: @@ -26,10 +28,15 @@ import java.util.Set; * 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子, * 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。 * + *

怎么加新意图

+ * 一次加三样:① 一条 {@link Intent}(工具 + 引导语);② 一个「标准说法」短语集; + * ③ 往 {@link #RULES} 里追加一条 {@link Rule}。不需要动 {@code AgentOrchestrator}。 + * *

身份差异

- * 同一个意图在两个身份下落到不同工具:学生 = 请假申请({@code type=2} 待审核), - * 教职工 = 请假登记({@code type=1} 免审通过,需先选班级与学生)。表单字段由工具定义推导, - * 因此「对应好表单」是自动成立的。 + * 同一个意图在不同身份下可能落到不同工具:请假 → 学生 {@code leave.submit}(type=2 待审核)/ + * 教职工 {@code leave.register}(type=1 免审,需先选班级与学生)。只在一个身份下成立时, + * 把另一个身份的工具置 {@code null},该身份就会照常交回模型(由模型的权限准则如实说明)。 + * 表单字段由工具定义推导,因此「对应好表单」是自动成立的。 */ @Component public class IntentShortcut { @@ -37,7 +44,10 @@ public class IntentShortcut { /** 一个意图在两种身份下的落点与引导话术 */ public static class Intent { - /** 学生身份用的工具(内部点分名) */ + /** 意图名(日志/排查用,如 {@code leave}) */ + private final String name; + + /** 学生身份用的工具(内部点分名;null = 该身份下不直达) */ private final String studentTool; /** 教职工身份用的工具(null = 该身份下不直达) */ @@ -49,13 +59,18 @@ public class IntentShortcut { /** 教职工身份的表单引导语 */ private final String staffReply; - Intent(String studentTool, String staffTool, String studentReply, String staffReply) { + Intent(String name, String studentTool, String staffTool, String studentReply, String staffReply) { + this.name = name; this.studentTool = studentTool; this.staffTool = staffTool; this.studentReply = studentReply; this.staffReply = staffReply; } + public String getName() { + return name; + } + public String getStudentTool() { return studentTool; } @@ -73,8 +88,21 @@ public class IntentShortcut { } } + /** 规则:一组「标准说法」→ 一个意图 */ + private static final class Rule { + + private final Set phrases; + + private final Intent intent; + + Rule(Set phrases, Intent intent) { + this.phrases = phrases; + this.intent = intent; + } + } + /** 请假意图:学生 → 请假申请;教职工 → 请假登记(帮学生请假) */ - private static final Intent LEAVE = new Intent( + private static final Intent LEAVE = new Intent("leave", "leave.submit", "leave.register", "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交," @@ -83,19 +111,50 @@ public class IntentShortcut { + "点「确认提交」我直接登记(教师登记免审,立即生效)。"); /** - * 标准说法全集(已剥掉语气词与标点后的形态)。 - * 新增意图时在这里加词 + 补一条 {@link #match(String)} 分支即可。 + * 发布校园公告意图:仅教职工(学生置 null → 交回模型如实说明身份不够)。 + * + *

工具只有一个,两个身份字段因此填同一个(保持结构统一,便于将来分叉)。 + */ + private static final Intent NOTICE_PUBLISH = new Intent("notice.publish", + null, + "notice.publish", + "发布校园公告需要教师或管理员身份;你可以先看看「我收到的公告」,或换个身份账号再来发布。", + "好的,下面是**发布校园公告**表单:填好公告标题和正文,选好发文部门," + + "点「确认提交」我就直接发布(立即生效,全站可见)。"); + + /** + * 请假的标准说法全集(已剥掉语气词与标点后的形态)。 */ private static final Set LEAVE_BARE = new HashSet<>(Arrays.asList( "请假", "请个假", "请一个假", "请假申请", "请假登记", "登记请假", "学生请假", "老师请假", "教师请假", "帮学生请假", "给学生请假" )); + /** + * 发布公告的标准说法全集。 + * + *

为什么不放光秃秃的「公告」「通知」二字:那更像查询意图 + * (「公告」「通知」单独一句,用户多半是想看看有什么),误判成"发布"会很突兀。 + * 只认带「发/发布」动词的说法,与「读公告」明确区分开。 + * + *

⚠️ 现在收「发通知」是安全的:本平台里 {@code campusnotice} 就是「校园公告(校园通知)」, + * 而宿舍通知目前**没有**发布工具({@code dormnotice} 只有家长侧回执)。等将来接上 + * 宿舍通知发布后,「发通知」会变成跨模块歧义(猜错模块比不直达更糟)—— + * 届时要么把本组里的「通知」说法摘掉只留「公告」,要么交给模型按上下文选。 + */ + private static final Set NOTICE_PUBLISH_BARE = new HashSet<>(Arrays.asList( + "发布公告", "发公告", "发个公告", "发布校园公告", "校园公告发布", + "发布一条公告", "发一条公告", "发布一篇公告", "发一篇公告", + "发布通知", "发通知", "发个通知", "发布校园通知", "校园通知发布", + "发布一条通知", "发一条通知" + )); + /** * 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。 * *

⚠️ 这里**不能放「请」**:{@code 请假申请} 本身以「请」开头,剥掉就变成「假申请」, * 反而会让标准说法匹配不上。只放意义明确的多字助动词。 + *

⚠️ 也不能放「发」「发布」:{@code 发布公告} 以「发布」开头,剥掉剩「公告」就落空了。 */ private static final String[] PREFIXES = { "我想要", "我需要", "我要", "我想", "帮我", "帮忙", "麻烦", @@ -107,8 +166,16 @@ public class IntentShortcut { "一下", "吧", "呢", "啊", "哦", "呀", "嘛", "了", "的" }; + /** 全部规则(顺序即匹配顺序;短语集之间不应有交集) */ + private static final List RULES = new ArrayList<>(); + + static { + RULES.add(new Rule(LEAVE_BARE, LEAVE)); + RULES.add(new Rule(NOTICE_PUBLISH_BARE, NOTICE_PUBLISH)); + } + /** - * 判断一句话是不是「裸请假意图」。 + * 判断一句话是不是「裸意图」。 * * @return 命中的意图;不是裸意图(含细节、或别的意图)返回 {@code null} */ @@ -118,8 +185,11 @@ public class IntentShortcut { if (normalized.length() < 2 || normalized.length() > 12) { return null; } - if (LEAVE_BARE.contains(stripDecorations(normalized))) { - return LEAVE; + String key = stripDecorations(normalized); + for (Rule r : RULES) { + if (r.phrases.contains(key)) { + return r.intent; + } } return null; } diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java index 0d59447..d89b243 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java +++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java @@ -264,6 +264,10 @@ public class ToolExecutor { * {@code {{字段名}}} 占位符(如 {@code /homeleave/api/leave/ClassStudents?classId={{classId}}}), * 由前端在依赖字段有值后替换再发起请求 —— 这样「学生」这种依赖「班级」的下拉才拉得动。 * + *

★ 字典工具自带的 {@code fixed} 参数必须带上:多个模块的字典接口靠它选字典种类 + * (如公告的发文部门取自 {@code /api/dict/List?type=1})。早先漏了这一步, + * 前端取回的是「没指定类型」的结果,下拉会是空的。 + * * @param dict 只读字典工具(如 leave.classStudents) * @param placeholders 需要留占位符的依赖字段名(可为空 = 无参字典) * @return 调用描述;字典工具非法或模块未配置地址时返回 {@code null} @@ -287,6 +291,19 @@ public class ToolExecutor { call.setMethod(method); StringBuilder qs = new StringBuilder(); + // 先拼固定参数(如 dict/List?type=1),再拼依赖占位符 + if (dict.getFixed() != null) { + for (Map.Entry e : dict.getFixed().entrySet()) { + Object v = e.getValue(); + if (v == null || StringUtils.isBlank(String.valueOf(v))) { + continue; + } + qs.append(qs.length() == 0 ? "?" : "&") + .append(urlEncode(e.getKey())) + .append("=") + .append(urlEncode(String.valueOf(v))); + } + } if (placeholders != null) { for (String ph : placeholders) { if (StringUtils.isBlank(ph)) { diff --git a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java index be9724b..60463f4 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java @@ -54,36 +54,35 @@ public final class CampusNoticeTools { .readAt("/")); list.add(ToolDef.of("notice.deptDics", MODULE, "发文部门字典", - "查询可选的发文部门(发布公告时填 deptId 用)。", + "查询本校本模块可选的发文部门(发布公告时填 deptId 用)。" + + "仅在用户明确问「有哪些发文部门」时使用;" + + "发布公告时**不要**先调它 —— 表单的「发文部门」下拉会自动带出可选值。", "GET", "/api/dict/List") .fix("type", "1") .readAt("/list/new")); list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告", - "发布一条校园公告。调用前必须确认:标题 title、正文 content、发文部门 deptId;" - + "缺失就问用户,不要编造部门。", + "发布一条校园公告,**立即生效、全站可见**(是否转审批由学校「应用设置」里的流程绑定决定," + + "请求侧无法选择)。调用前必须确认:标题 title、正文 content、发文部门 deptId;" + + "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;" + + "发文部门必须来自字典,不要编造。", "POST_JSON", "/api/notice/Create") .p(ToolParam.required("title", "string", "公告标题") .label("公告标题").eg("关于国庆假期安排的通知"), ToolParam.required("content", "string", "公告正文(纯文本)") .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"), - ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics") - .label("发文部门").optionsFrom("notice.deptDics"), - ToolParam.optional("deptName", "string", "发文部门名称") - .label("发文部门名称"), - ToolParam.optional("type", "integer", "重要程度") + ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段") + .label("发文部门") + .optionsFrom("notice.deptDics").optionsKeys("k", "v"), + ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要") .label("重要程度").dft("1") .options(new String[]{"1", "2"}, new String[]{"普通", "重要"}), - ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执") + ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执(要回执时收件人需点确认)") .label("需要阅读回执").dft("0") .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("smsFlag", "integer", "是否同时发短信") + ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒") .label("同时发短信").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("publishType", "string", "发布方式") - .label("发布方式").dft("direct") - .options(new String[]{"direct", "draft", "approve"}, - new String[]{"直接发布", "存草稿", "提交审批"})) + .options(new String[]{"0", "1"}, new String[]{"否", "是"})) .write("/list/new") .modId(null)); diff --git a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java index e1d6f81..bba10c5 100644 --- a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java +++ b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java @@ -38,7 +38,13 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; /** - * 「我要请假」在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。 + * 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。 + * + *

覆盖两条已落地的意图: + *

    + *
  • 「我要请假」→ 学生拿请假申请、教职工拿请假登记(帮学生请假);
  • + *
  • 「我要发布校园公告」→ 教职工拿发布表单,学生落回模型。
  • + *
* *

这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单, * 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。 @@ -70,6 +76,7 @@ class IntentDirectFormTest { void setUp() { settings = new AgentSettings(); settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave"); + settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice"); registry = new ToolRegistry(settings); registry.init(); @@ -166,6 +173,39 @@ class IntentDirectFormTest { 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()); + + // 公告表单:标题(单行)+ 正文(多行)+ 发文部门(下拉) + assertEquals("text", field(resp, "title").getWidget()); + assertEquals("textarea", field(resp, "content").getWidget()); + + 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()); + } + /* ==================== 不该被劫持的情况 ==================== */ @Test diff --git a/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java b/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java new file mode 100644 index 0000000..bc71ad1 --- /dev/null +++ b/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java @@ -0,0 +1,156 @@ +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.ArrayList; +import java.util.Arrays; +import java.util.List; + +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; + +/** + * 「发布校园公告」这条链路的断言:裸意图识别 + 发布工具的真实契约 + 发文部门下拉的代取地址。 + * + *

三个刻意的约束: + *

    + *
  1. 只认带「发/发布」动词的说法:光说「公告」「通知」更像查询, + * 误判成"发布"会很突兀;带细节的长句(「发一条关于社团招新的通知」)也交给模型。
  2. + *
  3. 仅教职工:{@code notice.publish} 在 {@code RolePolicy} 里归入 STAFF_ONLY, + * 学生身份没有落点 → 落回模型如实说明身份不够。
  4. + *
  5. 不声明「发布方式」:后端 {@code /api/notice/Create} 就是直接发布, + * 不存在 {@code publishType} 字段;声明它会让用户以为选了「存草稿」,实际仍直接发出。
  6. + *
+ */ +class NoticeIntentFormTest { + + private IntentShortcut shortcut; + private ToolRegistry registry; + private AgentSettings settings; + private ToolExecutor executor; + + @BeforeEach + void setUp() { + shortcut = new IntentShortcut(); + settings = new AgentSettings(); + settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice"); + registry = new ToolRegistry(settings); + registry.init(); + executor = new ToolExecutor(settings, new RestTemplate()); + } + + /* ==================== 裸意图规则 ==================== */ + + @Test + void bareNoticePublishIntentIsRecognized() { + for (String text : Arrays.asList( + "发布公告", "我要发布公告", "发布校园公告", "我要发布校园公告", + "发公告", "发个公告", "帮我发公告", "我要发一条公告", + "发布通知", "发通知", "发布校园通知", "麻烦发个通知吧")) { + IntentShortcut.Intent it = shortcut.match(text); + assertNotNull(it, "「" + text + "」应被识别为裸发布公告意图"); + assertEquals("notice.publish", it.getStaffTool(), + "「" + text + "」应落到发布校园公告工具"); + } + } + + @Test + void noticePublishHasNoStudentLanding() { + IntentShortcut.Intent it = shortcut.match("发布公告"); + assertNotNull(it); + assertNull(it.getStudentTool(), "学生没有发布公告能力,该身份下应落回模型"); + } + + @Test + void readingNoticesIsNotHijacked() { + // 光说「公告」「通知」更像查询;带细节的长句交模型 —— 都不能被"发布"规则抢走 + for (String text : Arrays.asList( + "公告", "通知", "有什么公告", "看看未读公告", "公告列表", + "这周有什么新的校园公告?", "发一条关于社团招新的通知")) { + assertNull(shortcut.match(text), "「" + text + "」不是发布意图,不应被直达规则命中"); + } + } + + /** 请假与公告两组规则不能互相串味 */ + @Test + void leaveAndNoticeRulesDoNotOverlap() { + IntentShortcut.Intent leave = shortcut.match("我要请假"); + assertNotNull(leave); + assertEquals("leave.register", leave.getStaffTool()); + + IntentShortcut.Intent notice = shortcut.match("我要发布公告"); + assertNotNull(notice); + assertEquals("notice.publish", notice.getStaffTool()); + } + + /* ==================== 发布工具的真实契约 ==================== */ + + @Test + void publishToolPublishesDirectlyWithoutPhantomPublishType() { + ToolDef publish = registry.get("notice.publish"); + assertNotNull(publish); + assertFalse(publish.isReadOnly(), "发布公告是写操作"); + assertEquals("/api/notice/Create", publish.getPath()); + + for (ToolParam p : publish.getParams()) { + assertFalse("publishType".equals(p.getName()), + "后端 /api/notice/Create 不支持发布方式,声明该参数会造成「选了存草稿却直接发出」"); + } + assertTrue(requiredNames(publish).contains("title")); + assertTrue(requiredNames(publish).contains("content")); + assertTrue(requiredNames(publish).contains("deptId"), "发文部门是后端强校验的必填项"); + } + + @Test + void deptIdDropdownUsesPlatformDictKeyShape() { + ToolParam deptId = param(registry.get("notice.publish"), "deptId"); + assertEquals("notice.deptDics", deptId.getOptionsFrom()); + assertEquals("k", deptId.getOptionValueKey(), "/api/dict/List 返回 {k,v},必须按键取值"); + assertEquals("v", deptId.getOptionLabelKey()); + } + + /* ==================== 下拉选项的代取地址 ==================== */ + + @Test + void deptDictOptionCallCarriesFixedTypeParam() { + BrowserToolRequest.BrowserCall call = + executor.prepareOptionCall(registry.get("notice.deptDics"), null); + assertNotNull(call); + assertEquals("GET", call.getMethod()); + assertEquals("/campusnotice/api/dict/List?type=1", call.getPathAndQuery(), + "发文部门字典靠 type=1 区分种类,丢了固定参数前端取回来是空的"); + } + + /* ==================== 辅助 ==================== */ + + private List requiredNames(ToolDef def) { + List names = new 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); + } +}