From 8f0f8a04b323abbcc1581adcaadb7c49d841962e Mon Sep 17 00:00:00 2001 From: lijing Date: Wed, 14 Oct 2026 12:40:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(intent):=20=E3=80=8C=E6=88=91=E8=A6=81?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E9=80=9A=E7=9F=A5=E5=85=AC=E5=91=8A=E3=80=8D?= =?UTF-8?q?=E8=A3=B8=E6=84=8F=E5=9B=BE=E7=9B=B4=E8=BE=BE=EF=BC=88=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=85=AC=E5=91=8A=E6=A8=A1=E5=9D=97=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通知公告(key=notice、前缀 /notice)此前只有 mock 里有裸意图分支, 真实后端 IntentShortcut 缺这一条,前后端行为不一致。 - IntentShortcut: 新增 NOTIFY_PUBLISH 意图 + NOTIFY_PUBLISH_BARE 短语集 ★ 短语一律带「通知公告」全称限定 —— 它与「校园公告」(campusnotice) 是两个独立应用(按用户下发 vs 按班级下发),光说「发通知/发布通知」 两边都成立,猜错模块比不直达更糟;「发通知」继续保持不直达。 - 新增 NotifyIntentFormTest(7):规则识别 / 与校园公告的边界 / 「发通知」不劫持 / 发布工具为写操作且仅教职工 / 代取地址带 /notice 前缀。 - IntentDirectFormTest:补「发布通知公告」端到端两条(教职工直出表单、 学生落回模型),并断言表单里不出现校园公告独有字段。 - DormActivityPublishIntentTest:四个发布意图互不串味(补 notify)。 mvn -o test: 135/135 BUILD SUCCESS --- .../yunhai/agent/service/IntentShortcut.java | 35 +++- .../agent/DormActivityPublishIntentTest.java | 4 +- .../yunhai/agent/IntentDirectFormTest.java | 58 +++++- .../yunhai/agent/NotifyIntentFormTest.java | 170 ++++++++++++++++++ 4 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/yunhai/agent/NotifyIntentFormTest.java diff --git a/src/main/java/com/yunhai/agent/service/IntentShortcut.java b/src/main/java/com/yunhai/agent/service/IntentShortcut.java index 2b668f9..499be17 100644 --- a/src/main/java/com/yunhai/agent/service/IntentShortcut.java +++ b/src/main/java/com/yunhai/agent/service/IntentShortcut.java @@ -11,7 +11,7 @@ import java.util.Set; /** * 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:「我要请假」、 - * 「发布校园公告」、「发布宿舍通知」、「发布活动」) + * 「发布校园公告」、「发布通知公告」、「发布宿舍通知」、「发布活动」) * 确定性地映射到对应表单,不经过大模型决策。 * *

为什么需要它

@@ -123,6 +123,24 @@ public class IntentShortcut { "好的,下面是**发布校园公告**表单:填好公告标题和正文,选好发文部门," + "点「确认提交」我就直接发布(立即生效,全站可见)。"); + /** + * 发布通知意图(通知公告模块,key={@code notice}):仅教职工。 + * + *

★ 为什么短语必须带「通知公告」全称:本模块与「校园公告」({@code campusnotice})是 + * 两个独立的前端应用 —— 通知公告按「用户」下发、校园公告按「班级」下发, + * 光说「发通知 / 发布通知」两边都成立。同一句话猜错模块比不直达更糟, + * 所以只认带全称的说法,其余交给模型按上下文选。 + * + *

工具只有一个,两个身份字段因此填同一个(保持结构统一,便于将来分叉)。 + */ + private static final Intent NOTIFY_PUBLISH = new Intent("notify.publish", + null, + "notify.publish", + "发布通知需要教师或管理员身份;你可以先看看「我收到的通知」,或换个身份账号再来发布。", + "好的,下面是**发布通知**表单:填好通知标题和正文,选好来源部门与通知对象" + + "(默认「全部用户」,想要定向发送就取消它、再点具体人名)," + + "点「确认提交」我就直接发出去。"); + /** * 发布宿舍通知意图:仅教职工(宿管 / 管理员)。 * @@ -190,6 +208,20 @@ public class IntentShortcut { "发布校园通知", "校园通知发布" )); + /** + * 发布通知(通知公告模块)的标准说法全集。 + * + *

★ 必须带「通知公告」全称 —— 带模块限定是这个短语集存在的唯一理由。 + * 光说「发通知 / 发布通知」在校园公告与通知公告两个应用下都成立(跨模块歧义), + * 因此本组刻意不收「发布一条通知」「发一条通知」这类不带全称的说法; + * 同时收窄也保证了与 {@link #NOTICE_PUBLISH_BARE} 无交集。 + */ + private static final Set NOTIFY_PUBLISH_BARE = new HashSet<>(Arrays.asList( + "发布通知公告", "发通知公告", "发个通知公告", "通知公告发布", + "发布一条通知公告", "发一条通知公告", "发布一篇通知公告", "发一篇通知公告", + "发布一个通知公告", "发一个通知公告", "新增通知公告" + )); + /** * 发布宿舍通知的标准说法全集 —— 必须带「宿舍」限定(否则与校园公告混淆)。 */ @@ -244,6 +276,7 @@ public class IntentShortcut { static { RULES.add(new Rule(LEAVE_BARE, LEAVE)); RULES.add(new Rule(NOTICE_PUBLISH_BARE, NOTICE_PUBLISH)); + RULES.add(new Rule(NOTIFY_PUBLISH_BARE, NOTIFY_PUBLISH)); RULES.add(new Rule(DORM_PUBLISH_BARE, DORM_PUBLISH)); RULES.add(new Rule(ACTIVITY_PUBLISH_BARE, ACTIVITY_PUBLISH)); RULES.add(new Rule(PROFESSION_PUBLISH_BARE, PROFESSION_PUBLISH)); diff --git a/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java b/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java index 8815fff..d23f1e0 100644 --- a/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java +++ b/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java @@ -98,10 +98,12 @@ class DormActivityPublishIntentTest { } } - /** 三个发布意图不能互相串味 */ + /** 四个发布意图不能互相串味 */ @Test void publishRulesDoNotOverlap() { assertEquals("notice.publish", shortcut.match("我要发布校园公告").getStaffTool()); + // 「通知公告」是另一个应用(key=notice)—— 必须落到 notify.publish,不能与校园公告混淆 + assertEquals("notify.publish", shortcut.match("我要发布通知公告").getStaffTool()); assertEquals("dorm.publish", shortcut.match("我要发布宿舍通知").getStaffTool()); assertEquals("activity.publish", shortcut.match("我要发布活动").getStaffTool()); } diff --git a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java index 88e165b..2a8eaa7 100644 --- a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java +++ b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java @@ -40,10 +40,11 @@ import static org.mockito.Mockito.when; /** * 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。 * - *

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

覆盖已落地的意图: *

* *

这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单, @@ -77,6 +78,7 @@ class IntentDirectFormTest { settings = new AgentSettings(); settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave"); settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice"); + settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice"); settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice"); settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity"); settings.getModules().put("profession", "http://base.yunhaixingtong.com/profession"); @@ -209,6 +211,58 @@ class IntentDirectFormTest { assertEquals("(模型回复)", resp.getReply()); } + /* ==================== 教师:发布通知(通知公告模块) ==================== */ + + /** + * 「通知公告」(key={@code notice})与「校园公告」({@code campusnotice})是**两个独立应用**: + * 前者按「用户」下发、后者按「班级」下发。同一句「我要发布通知公告」必须落到前者的发布表单, + * 且表单上**不能**出现只有校园公告才有的字段(推送范围 / 回执 / 发布方式)。 + */ + @Test + void teacherSayingPublishNotifyGetsPublishFormWithoutCallingLlm() { + ChatResponse resp = chat(EXPLODING_LLM, "我要发布通知公告", caller(AgentRole.TEACHER)); + + assertNotNull(resp.getForm(), "应直接下发表单"); + assertEquals("notify.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("/notice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(), + "字典地址必须带模块前缀 /notice 与 type=1,否则取不回来源部门"); + assertEquals("k", deptId.getValueKey()); + assertEquals("v", deptId.getLabelKey()); + + // 通知对象:按用户多选(与校园公告的「推送范围」按班级完全不同) + FormField receiveList = field(resp, "receiveList"); + assertEquals("multiselect", receiveList.getWidget()); + assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址"); + assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery()); + + // 校园公告独有的字段绝不能串到通知公告的表单上 + for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) { + for (FormField f : resp.getForm().getFields()) { + assertFalse(foreign.equals(f.getName()), + "通知公告表单不该出现校园公告字段:" + foreign); + } + } + } + + @Test + void studentSayingPublishNotifyFallsThroughToModel() { + ChatResponse resp = chat(ECHO_LLM, "我要发布通知公告", caller(AgentRole.STUDENT)); + assertNull(resp.getForm(), "学生不该拿到发布通知表单"); + assertEquals("(模型回复)", resp.getReply()); + } + /* ==================== 教师:发布宿舍通知 ==================== */ @Test diff --git a/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java b/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java new file mode 100644 index 0000000..b0abb70 --- /dev/null +++ b/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java @@ -0,0 +1,170 @@ +package com.yunhai.agent; + +import com.yunhai.agent.auth.AgentRole; +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.HashSet; + +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; + +/** + * 「发布通知公告」这条链路的断言:裸意图识别 + 与「校园公告」的边界 + 发布工具的真实契约。 + * + *

本文件存在的唯一理由,是这两个「公告」是两个独立的前端应用: + * + * + * + * + * + * + *
两个公告的差异
校园公告 campusnotice通知公告 notice
下发对象按班级(推送范围 pushScopes)按用户(通知对象 receiveList)
审批 / 草稿有(publishType / 审批流)无(Create 即发布)
工具前缀{@code notice.*}{@code notify.*}
+ * + *

结论:光说「发通知」在两个应用下都成立(跨模块歧义),因此直达规则只认「通知公告」全称; + * 猜错模块比不直达更糟。这个边界在 {@link DormActivityPublishIntentTest#publishRulesDoNotOverlap()} + * 与 {@link IntentDirectFormTest} 里也有各自角度的断言。 + */ +class NotifyIntentFormTest { + + 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"); + settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice"); + registry = new ToolRegistry(settings); + registry.init(); + executor = new ToolExecutor(settings, new RestTemplate()); + } + + /* ==================== 裸意图规则 ==================== */ + + @Test + void bareNotifyPublishIntentIsRecognized() { + for (String text : Arrays.asList( + "发布通知公告", "我要发布通知公告", "发通知公告", "发个通知公告", + "帮我发布一条通知公告", "通知公告发布", "新增通知公告", + "麻烦发一篇通知公告", "我想发一个通知公告")) { + IntentShortcut.Intent it = shortcut.match(text); + assertNotNull(it, "「" + text + "」应被识别为发布通知公告意图"); + assertEquals("notify.publish", it.getStaffTool(), + "「" + text + "」应落到发布通知(通知公告模块)工具"); + assertNull(it.getStudentTool(), "学生没有发布通知能力,该身份下应落回模型"); + } + } + + /** + * 两个公告的边界:带「校园」限定的仍归校园公告,带「通知公告」全称的归通知公告。 + */ + @Test + void notifyAndCampusNoticeRulesAreDistinct() { + assertEquals("notify.publish", shortcut.match("我要发布通知公告").getStaffTool()); + assertEquals("notice.publish", shortcut.match("我要发布校园公告").getStaffTool()); + assertEquals("notice.publish", shortcut.match("我要发布公告").getStaffTool()); + assertEquals("notice.publish", shortcut.match("我要发布校园通知").getStaffTool(), + "「校园通知」是校园公告的说法,不能被通知公告抢走"); + } + + /** + * 不带模块限定的「发通知 / 发布通知」属跨模块歧义(校园公告 / 通知公告 / 宿舍通知都成立), + * 刻意不直达,交给模型按上下文选。这里把「不再劫持」固化下来 —— 谁把它加回短语集谁红。 + */ + @Test + void bareGenericSendNoticeIsNotHijacked() { + for (String text : Arrays.asList( + "发通知", "发布通知", "发个通知", "发一条通知", "发布一条通知", "发一篇通知")) { + assertNull(shortcut.match(text), + "「" + text + "」跨模块歧义,不应被任何发布直达规则命中"); + } + } + + @Test + void readingNoticesIsNotHijacked() { + // 光说「通知公告」「通知」更像查询;带细节的长句交模型 —— 都不能被"发布"规则抢走 + for (String text : Arrays.asList( + "通知公告", "通知", "有什么通知", "我有哪些未读的通知", "看看通知公告", + "这周有什么新的通知公告?", "发一条关于社团招新的通知公告,周五下午两点")) { + assertNull(shortcut.match(text), "「" + text + "」不是裸发布意图,不应被直达规则命中"); + } + } + + /* ==================== 发布工具的真实契约 ==================== */ + + @Test + void notifyPublishToolIsWriteStaffOnlyAndPostsToItsModuleUrl() { + ToolDef publish = registry.get("notify.publish"); + assertNotNull(publish, "注册表里应有 notify.publish"); + assertFalse(publish.isReadOnly(), "发布通知是写操作"); + assertEquals("POST_JSON", publish.getHttpMethod()); + assertEquals("/api/notice/Create", publish.getPath()); + assertEquals("/list/new", publish.getLandingPath()); + assertTrue(publish.isFormAll(), "发布表单应渲染全部字段(与模块「发通知」页一致)"); + + // 直达规则里写的工具名必须真存在,且确实是教职工专属(否则学生侧 null 就对不上了) + assertTrue(publish.allowsAny(new HashSet<>(Arrays.asList(AgentRole.TEACHER))), + "发布通知是教职工工具"); + assertTrue(publish.allowsAny(new HashSet<>(Arrays.asList(AgentRole.ADMIN))), + "管理员也能发布通知"); + assertFalse(publish.allowsAny(new HashSet<>(Arrays.asList(AgentRole.STUDENT))), + "学生不应看到发布通知 —— 与 IntentShortcut 里 studentTool=null 对应"); + } + + @Test + void notifyPublishCarriesCommaJoinedReceiveListWithAllSentinel() { + ToolDef publish = registry.get("notify.publish"); + ToolParam receiveList = param(publish, "receiveList"); + assertEquals("multiselect", receiveList.resolveWidget(), + "通知对象按用户多选(校园公告那边是按班级的 scopes 控件)"); + assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串"); + assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵是 all"); + assertEquals("notify.schoolAll", receiveList.getOptionsFrom()); + assertEquals("id", receiveList.getOptionValueKey()); + assertEquals("name", receiveList.getOptionLabelKey()); + } + + /* ==================== 下拉选项的代取地址(必须带模块前缀 /notice) ==================== */ + + @Test + void optionCallsCarryTheNoticeModulePrefix() { + BrowserToolRequest.BrowserCall dept = + executor.prepareOptionCall(registry.get("notify.deptDics"), null); + assertNotNull(dept); + assertEquals("/notice/api/dict/List?type=1", dept.getPathAndQuery(), + "来源部门字典靠 type=1 区分种类,且必须带模块前缀 /notice"); + + BrowserToolRequest.BrowserCall school = + executor.prepareOptionCall(registry.get("notify.schoolAll"), null); + assertNotNull(school); + assertEquals("/notice/api/base/GetSchoolAll", school.getPathAndQuery(), + "通知对象取自组织树,必须带模块前缀 /notice"); + } + + /* ==================== 辅助 ==================== */ + + 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); + } +}