feat(intent): 「我要发布通知公告」裸意图直达(通知公告模块补齐)
build / build-push (push) Successful in 57s

通知公告(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
This commit is contained in:
lijing
2026-10-14 12:40:46 +08:00
parent 9410599008
commit 8f0f8a04b3
4 changed files with 263 additions and 4 deletions
@@ -11,7 +11,7 @@ import java.util.Set;
/**
* 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:<b>「我要请假」</b>、
* <b>「发布校园公告」</b>、<b>「发布宿舍通知」</b>、<b>「发布活动」</b>)
* <b>「发布校园公告」</b>、<b>「发布通知公告」</b>、<b>「发布宿舍通知」</b>、<b>「发布活动」</b>)
* 确定性地映射到对应表单,<b>不经过大模型决策</b>。
*
* <h3>为什么需要它</h3>
@@ -123,6 +123,24 @@ public class IntentShortcut {
"好的,下面是**发布校园公告**表单:填好公告标题和正文,选好发文部门,"
+ "点「确认提交」我就直接发布(立即生效,全站可见)。");
/**
* 发布通知意图(<b>通知公告</b>模块,key={@code notice}):<b>仅教职工</b>。
*
* <p>★ 为什么短语必须带「通知公告」全称:本模块与「校园公告」({@code campusnotice})是
* <b>两个独立的前端应用</b> —— 通知公告按「用户」下发、校园公告按「班级」下发,
* 光说「发通知 / 发布通知」两边都成立。同一句话猜错模块比不直达更糟,
* 所以只认带全称的说法,其余交给模型按上下文选。
*
* <p>工具只有一个,两个身份字段因此填同一个(保持结构统一,便于将来分叉)。
*/
private static final Intent NOTIFY_PUBLISH = new Intent("notify.publish",
null,
"notify.publish",
"发布通知需要教师或管理员身份;你可以先看看「我收到的通知」,或换个身份账号再来发布。",
"好的,下面是**发布通知**表单:填好通知标题和正文,选好来源部门与通知对象"
+ "(默认「全部用户」,想要定向发送就取消它、再点具体人名),"
+ "点「确认提交」我就直接发出去。");
/**
* 发布宿舍通知意图:<b>仅教职工</b>(宿管 / 管理员)。
*
@@ -190,6 +208,20 @@ public class IntentShortcut {
"发布校园通知", "校园通知发布"
));
/**
* 发布通知(<b>通知公告</b>模块)的标准说法全集。
*
* <p>★ 必须带「通知公告」全称 —— 带模块限定是这个短语集存在的唯一理由。
* 光说「发通知 / 发布通知」在校园公告与通知公告两个应用下都成立(跨模块歧义),
* 因此本组<b>刻意不收</b>「发布一条通知」「发一条通知」这类不带全称的说法;
* 同时收窄也保证了与 {@link #NOTICE_PUBLISH_BARE} 无交集。
*/
private static final Set<String> 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));
@@ -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());
}
@@ -40,10 +40,11 @@ import static org.mockito.Mockito.when;
/**
* 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。
*
* <p>覆盖两条已落地的意图:
* <p>覆盖已落地的意图:
* <ul>
* <li>「我要请假」→ 学生拿请假申请、教职工拿请假登记(帮学生请假);</li>
* <li>「我要发布校园公告」→ 教职工拿发布表单,学生落回模型。</li>
* <li>「我要发布校园公告」→ 教职工拿发布表单,学生落回模型;</li>
* <li>「我要发布通知公告」→ 同上,但落到**另一个应用**(通知公告,按用户下发)。</li>
* </ul>
*
* <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
@@ -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
@@ -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;
/**
* 「发布通知公告」这条链路的断言:裸意图识别 + 与「校园公告」的<b>边界</b> + 发布工具的真实契约。
*
* <p>本文件存在的唯一理由,是这两个「公告」是<b>两个独立的前端应用</b>:
* <table border="1">
* <caption>两个公告的差异</caption>
* <tr><th></th><th>校园公告 campusnotice</th><th>通知公告 notice</th></tr>
* <tr><td>下发对象</td><td>按<b>班级</b>(推送范围 pushScopes)</td><td>按<b>用户</b>(通知对象 receiveList)</td></tr>
* <tr><td>审批 / 草稿</td><td>有(publishType / 审批流)</td><td>无(Create 即发布)</td></tr>
* <tr><td>工具前缀</td><td>{@code notice.*}</td><td>{@code notify.*}</td></tr>
* </table>
*
* <p>结论:光说「发通知」在两个应用下都成立(跨模块歧义),因此直达规则<b>只认「通知公告」全称</b>;
* 猜错模块比不直达更糟。这个边界在 {@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(),
"「校园通知」是校园公告的说法,不能被通知公告抢走");
}
/**
* 不带模块限定的「发通知 / 发布通知」属跨模块歧义(校园公告 / 通知公告 / 宿舍通知都成立),
* <b>刻意不直达</b>,交给模型按上下文选。这里把「不再劫持」固化下来 —— 谁把它加回短语集谁红。
*/
@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);
}
}