通知公告(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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user