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; /** * 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:「我要请假」、 - * 「发布校园公告」、「发布宿舍通知」、「发布活动」) + * 「发布校园公告」、「发布通知公告」、「发布宿舍通知」、「发布活动」) * 确定性地映射到对应表单,不经过大模型决策。 * *
★ 为什么短语必须带「通知公告」全称:本模块与「校园公告」({@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 覆盖两条已落地的意图:
+ * 覆盖已落地的意图:
* 这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
@@ -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;
+
+/**
+ * 「发布通知公告」这条链路的断言:裸意图识别 + 与「校园公告」的边界 + 发布工具的真实契约。
+ *
+ * 本文件存在的唯一理由,是这两个「公告」是两个独立的前端应用:
+ * 结论:光说「发通知」在两个应用下都成立(跨模块歧义),因此直达规则只认「通知公告」全称;
+ * 猜错模块比不直达更糟。这个边界在 {@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);
+ }
+}
*
*
*
+ *
+ *
+ *
+ * 校园公告 campusnotice 通知公告 notice
+ * 下发对象 按班级(推送范围 pushScopes) 按用户(通知对象 receiveList)
+ * 审批 / 草稿 有(publishType / 审批流) 无(Create 即发布)
+ * 工具前缀 {@code notice.*} {@code notify.*}