feat(dorm,activity): 补齐宿舍通知与家校活动的发布/回执/台账能力 + 两个裸意图直达表单
build / build-push (push) Successful in 51s

两个模块与「校园公告」同构(发布 → 阅读情况 → 回执闭环),原先只接了「读列表 + 我提交」
一层,价值最高的 发布/盯回执/催办/统计 全空。本轮补齐。

宿舍通知(9 → 35 个工具)
- 发布闭环:dorm.publish(立即发布、默认发给全部宿舍)/ dorm.editNotice /
  dorm.sendList(改指 /api/notice/SendList,我发布的)/ dorm.readDetail / dorm.statistics;
  字典:dorm.noticeTypeDic(type=noticeType)/ dorm.deptDic(type=dept)/ dorm.formTemplates。
- 回执闭环:dorm.receipts / dorm.receiptDetail / dorm.reviewReceipt / dorm.remind /
  dorm.expedite / dorm.remindStudent / dorm.markRead / dorm.pull / dorm.top;
  家长侧补 dorm.initializeRed(一键已读)。
- 宿舍特有的床位台账(模块自带、此前完全没接):dorm.pendingStudents → dorm.assign
  → dorm.removeStudent / dorm.transfer / dorm.setMonitor,以及楼栋房间增改
  (dorm.addBuilding / dorm.editBuilding / dorm.addRoom / dorm.editRoom)。

家校活动(7 → 22 个工具)
- 发布闭环:activity.publish / activity.edit / activity.sent / activity.readDetail /
  activity.statistics;字典:activity.deptDic(type=1)/ activity.categoryDic(type=2)。
- 报名闭环:activity.signups / activity.signupDetail / activity.review /
  activity.remind / activity.expedite / activity.pull / activity.top;
  家长侧补 activity.initializeRed。

裸意图直达(IntentShortcut)
- 新增 DORM_PUBLISH(仅教职工)与 ACTIVITY_PUBLISH(仅教职工),学生落回模型如实说明。
- ★ 修跨模块歧义:「发通知 / 发布通知」不带限定时校园公告与宿舍通知都成立,
  已从公告短语集移除,改由模型按上下文选;只认带「公告/校园」或「宿舍」「活动」限定的说法。

角色(RolePolicy)
- 发布/编辑/阅读情况/回执处理/催办/统计/字典/床位台账 → STAFF_ONLY;
  家长侧(通知箱、回执提交、活动广场、报名、一键已读)保持不限。

表单下拉复用请假的 optionCall + valueKeys + dependsOn 通道(模块 /api/* 只认浏览器会话,
服务端必然 302)。发布表单的接收对象固定为「全部」(receiveType/receiveList 用 fix 写死),
避免表单里出现难用的房间/班级 id 输入框。

测试:新增 DormActivityPublishIntentTest(10);IntentDirectFormTest +4(教师得表单且不过大模型、
学生落回模型);NoticeIntentFormTest 增加「发通知不再被公告劫持」;
RolePermissionTest 补新工具的可见性断言。全量 86/86 通过。

README:4.2 能力划分更新 + 新增「宿舍通知/家校活动补全了哪些能力」;5.1.0 意图对照表补三行。
This commit is contained in:
lijing
2026-10-12 19:49:52 +08:00
parent d2c6c185b5
commit b6a8a63b0e
10 changed files with 918 additions and 31 deletions
+25 -3
View File
@@ -122,7 +122,7 @@ AgentService/
| 名单 | 工具 | 说明 |
|---|---|---|
| 教职工(教师+管理员) | `leave.register` `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.buildings` `dorm.rooms` `dorm.roomStudents` `dorm.dormTree`、`activity.classTree`、`profession.*`(全部) | 请假登记/审批、发布、统计、宿管、专业发展 |
| 教职工(教师+管理员) | `leave.register` `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.*`(发布/编辑/我发布的/阅读情况/回执名单与处理/催办/催回执/代标记/撤回/置顶/统计/字典模板 **+ 楼栋房间床位台账**;家长侧 5 个除外)、`activity.*`(发布/编辑/我发布的/阅读情况/报名名单与处理/催办/上报/撤回/置顶/统计/字典 + 选班级;家长侧除外)、`profession.*`(全部) | 请假登记/审批、发布公告、**宿舍通知发布与回执管理 + 床位台账**、**活动发布与报名处理**、专业发展 |
| 学生 | `leave.submit` `leave.reportBack`、`homework.*`(全部)、`activity.signup` `activity.cancelSignup` | 请假申请、交作业、报名活动、销假 |
| 学生+教职工共用 | `leave.withdraw` | 撤销请假(学生=申请撤销,教师=直接作废) |
| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 |
@@ -134,6 +134,22 @@ AgentService/
> 拆成 `leave.submit`(学生)与 `leave.register`(教职工)后,角色过滤天然把错的工具挡在模型视野外,
> 表单字段由各自必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。
> ★ **宿舍通知 / 家校活动补全了哪些能力**:这两个模块与「校园公告」是**同一套骨架**
> —— *发布 → 阅读情况 → 回执闭环*,原先只接了「读列表 + 家长提交」这一层,
> 价值最高的 *发布 / 盯回执 / 催办 / 统计* 全空。本轮补齐:
> - **发布闭环**:`dorm.publish` / `activity.publish`(+ `edit` / 我发布的 / 阅读情况 / 统计)、
> 发布表单下拉所需的字典工具(`dorm.noticeTypeDic` `dorm.deptDic` `activity.deptDic` `activity.categoryDic`);
> - **回执闭环**:`dorm.receipts` / `dorm.reviewReceipt` / `dorm.remind` / `activity.signups` / `activity.review` / `activity.remind`
> —— 回答「还有谁没交 / 没回执」,并支持确认、取消、催办;
> - **宿舍特有(模块自带但一直没接)的床位台账**:`dorm.pendingStudents` → `dorm.assign`(安排入住)
> → `dorm.removeStudent`(退宿)/ `dorm.transfer`(调宿)/ `dorm.setMonitor`(设宿舍长),
> 以及楼栋房间的增改(`dorm.addBuilding` `dorm.editBuilding` `dorm.addRoom` `dorm.editRoom`)。
>
> 写操作**不需要新传输层**:统一走已跑通的浏览器代执行(前端带用户登录态调 `/api/*`),
> 表单下拉复用请假的 `optionCall + valueKeys + dependsOn` 通道。
> ⚠️ **删除类(`/api/notice/Delete`、`/api/activity/Delete`)暂未接入**:它们的请求体是
> **JSON 数组**(`@RequestBody List<Integer>`),而工具执行器只构造对象体;且删除破坏性强,留待需要时单列。
### 4.3 两道闸门
1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`,
@@ -180,9 +196,15 @@ AgentService/
| 请假 | 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 |
| 发布校园公告 | 教职工 | `notice.publish` | 公告标题 / 公告正文 / **发文部门**(下拉) | **立即发布、全站可见** |
| 发布校园公告 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
| 发布宿舍通知 | 教职工 | `dorm.publish` | 通知标题 / 通知正文 / **通知类型 + 发文部门**(下拉)/ 回执方式 | 发给**全部宿舍**的家长(房间级定向到模块页面勾选) |
| 发布宿舍通知 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员(宿管)身份 |
| 发布家校活动 | 教职工 | `activity.publish` | 活动标题 / 说明 / **活动分类 + 主办部门**(下拉)/ 地点 / 起止时间 / 报名方式 | 发给**全部班级**的家长 |
| 发布家校活动 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
⚠️ **发布公告只认带「发/发布」动词的说法**(`发布公告`/`发个公告`/`我要发布校园公告`/`发通知`…):
光说「公告」「通知」更像**查询**,误判成"发布"会很突兀,故不进短语表。
⚠️ **发布类意图必须带模块限定**。2026-10 起宿舍通知也接上了发布工具,因此
`发通知`/`发布通知` 成了**跨模块歧义**(校园公告 / 宿舍通知都可能),已**移出**公告短语集;
现在只认 `发布公告`/`发个公告`/`我要发布校园公告`/`发布校园通知`(带「公告」或「校园」限定)
与 `发布宿舍通知`/`发宿舍通知`(带「宿舍」限定)。光说「公告」「通知」更像**查询**,本就不进短语表。
⚠️ **不要再给 `notice.publish` 加「发布方式」参数**:后端 `/api/notice/Create` 就是**直接发布**
(是否转审批由学校「应用设置」里的流程绑定决定,请求侧绕不过),并不存在 `publishType` 字段 ——
@@ -16,7 +16,8 @@ import java.util.Set;
*
* <p>名单:
* <ul>
* <li>{@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):请假登记/审批/统计、公告发布审批、宿管、专业发展;</li>
* <li>{@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):请假登记/审批/统计、公告发布审批、
* 宿舍通知发布/回执处理/床位台账、家校活动发布/报名处理、专业发展;</li>
* <li>{@link #STUDENT_ONLY} —— 学生侧:请假申请、交作业、销假;</li>
* <li>{@link #STUDENT_OR_PARENT} —— 家校活动报名:家长与学生都可发起;</li>
* <li>{@link #SHARED} —— 学生与教职工都可能发起的:撤销请假。</li>
@@ -35,10 +36,23 @@ public final class RolePolicy {
// 公告:发布与审批
"notice.sent", "notice.deptDics", "notice.publish",
"notice.approvalList", "notice.approve", "notice.reject",
// 宿舍通知:楼栋/房间/住宿生管理侧
// 宿舍通知:发布 / 编辑 / 阅读情况 / 回执处理 / 催办 / 统计
"dorm.publish", "dorm.editNotice", "dorm.sendList",
"dorm.readDetail", "dorm.receipts", "dorm.receiptDetail", "dorm.reviewReceipt",
"dorm.remind", "dorm.expedite", "dorm.remindStudent", "dorm.markRead",
"dorm.pull", "dorm.top", "dorm.statistics",
// 宿舍通知:发布表单的字典与单据模板
"dorm.deptDic", "dorm.noticeTypeDic", "dorm.formTemplates",
// 宿舍通知:楼栋 / 房间 / 床位台账(宿管日常)
"dorm.buildings", "dorm.rooms", "dorm.roomStudents", "dorm.dormTree",
// 家校活动:发布时选班级
"activity.classTree"
"dorm.pendingStudents", "dorm.assign", "dorm.removeStudent", "dorm.transfer",
"dorm.setMonitor", "dorm.addBuilding", "dorm.editBuilding", "dorm.addRoom", "dorm.editRoom",
// 家校活动:发布 / 编辑 / 阅读情况 / 报名处理 / 催办 / 统计
"activity.publish", "activity.edit", "activity.sent",
"activity.readDetail", "activity.signups", "activity.signupDetail", "activity.review",
"activity.remind", "activity.expedite", "activity.pull", "activity.top", "activity.statistics",
// 家校活动:发布表单的字典与选班级
"activity.deptDic", "activity.categoryDic", "activity.classTree"
));
/** 学生专属 */
@@ -1013,6 +1013,13 @@ public class AgentOrchestrator {
.append("缺哪项就照常调用工具让系统弹表单,不要自己编造发文部门,也不要用文字反复追问。")
.append("该工具只对教职工下发(学生身份看不到),学生要求发布公告时如实说明身份不够。\n");
sb.append("11. ★ 「发布宿舍通知」与「发布校园公告」是**两个不同模块**,不要混用:")
.append("宿舍通知发给**家长**、可要求家长回执(回执方式:无需/确认/填报),发布时要填通知类型与发文部门;")
.append("校园公告是全校范围的公告。用户说「发通知」而没指明模块时,先问清楚是校园公告还是宿舍通知。\n");
sb.append("12. ★ 「发布家校活动」要拿到标题、说明、活动分类、主办部门、活动地点与起止时间;")
.append("活动默认要求家长报名。缺项就照常调用工具让系统弹表单,分类与主办部门必须来自字典,不要编造。\n");
return sb.toString();
}
@@ -11,7 +11,8 @@ import java.util.Set;
/**
* 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:<b>「我要请假」</b>、
* <b>「发布校园公告」</b>)确定性地映射到对应表单,<b>不经过大模型决策</b>。
* <b>「发布校园公告」</b>、<b>「发布宿舍通知」</b>、<b>「发布活动」</b>)
* 确定性地映射到对应表单,<b>不经过大模型决策</b>。
*
* <h3>为什么需要它</h3>
* 同一句「我要请假」,模型可能给出三种都不合预期的反应:
@@ -122,6 +123,29 @@ public class IntentShortcut {
"好的,下面是**发布校园公告**表单:填好公告标题和正文,选好发文部门,"
+ "点「确认提交」我就直接发布(立即生效,全站可见)。");
/**
* 发布宿舍通知意图:<b>仅教职工</b>(宿管 / 管理员)。
*
* <p>与公告不同,宿舍通知强调「给家长发、要回执」,所以表单里带「回执方式」;
* 接收对象默认全部宿舍(房间级定向请到模块页面里勾选)。
*/
private static final Intent DORM_PUBLISH = new Intent("dorm.publish",
null,
"dorm.publish",
"发布宿舍通知需要教师或管理员(宿管)身份;你可以先看看「我的宿舍通知箱」,或换个身份账号再来发布。",
"好的,下面是**发布宿舍通知**表单:填好标题和正文,选好通知类型与发文部门,"
+ "点「确认提交」我就发给全部宿舍的家长(要家长回执的话,在「回执方式」里选一种)。");
/**
* 发布家校活动意图:<b>仅教职工</b>。活动需要地点与起止时间,故表单字段比公告多。
*/
private static final Intent ACTIVITY_PUBLISH = new Intent("activity.publish",
null,
"activity.publish",
"发布家校活动需要教师或管理员身份;你可以先看看「活动广场」,或换个身份账号再来发布。",
"好的,下面是**发布家校活动**表单:填好活动标题、说明、分类、地点和起止时间,"
+ "点「确认提交」我就发给全部班级的家长(需要家长报名的话,报名方式保持默认即可)。");
/**
* 请假的标准说法全集(已剥掉语气词与标点后的形态)。
*/
@@ -137,16 +161,33 @@ public class IntentShortcut {
* (「公告」「通知」单独一句,用户多半是想看看有什么),误判成"发布"会很突兀。
* 只认带「发/发布」动词的说法,与「读公告」明确区分开。
*
* <p>⚠️ 现在收「发通知」是安全的:本平台里 {@code campusnotice} 就是「校园公告(校园通知)」,
* 而宿舍通知目前**没有**发布工具({@code dormnotice} 只有家长侧回执)。等将来接上
* 宿舍通知发布后,「发通知」会变成跨模块歧义(猜错模块比不直达更糟)——
* 届时要么把本组里的「通知」说法摘掉只留「公告」,要么交给模型按上下文选。
* <p>★ 2026-10 起「发通知 / 发布通知」这类**不带模块限定**的说法已从本组移除:
* 宿舍通知也接上了发布工具({@code dorm.publish}),同一句「发通知」会变成跨模块歧义
* (猜错模块比不直达更糟)。现在只认带「公告」或「校园」限定的说法,其余交给模型按上下文选。
*/
private static final Set<String> NOTICE_PUBLISH_BARE = new HashSet<>(Arrays.asList(
"发布公告", "发公告", "发个公告", "发布校园公告", "校园公告发布",
"发布一条公告", "发一条公告", "发布一篇公告", "发一篇公告",
"发布通知", "发通知", "发个通知", "发布校园通知", "校园通知发布",
"发布一条通知", "发一条通知"
"发布校园通知", "校园通知发布"
));
/**
* 发布宿舍通知的标准说法全集 —— 必须带「宿舍」限定(否则与校园公告混淆)。
*/
private static final Set<String> DORM_PUBLISH_BARE = new HashSet<>(Arrays.asList(
"发布宿舍通知", "发宿舍通知", "发个宿舍通知", "发一条宿舍通知", "发布一条宿舍通知",
"宿舍通知发布", "发布宿舍公告", "发宿舍公告", "宿舍公告发布", "给宿舍发通知"
));
/**
* 发布家校活动的标准说法全集。
*
* <p>⚠️ 不收「报名活动」—— 那是家长侧的另一件事({@code activity.signup}),不属发布意图。
*/
private static final Set<String> ACTIVITY_PUBLISH_BARE = new HashSet<>(Arrays.asList(
"发布活动", "发活动", "发个活动", "发起活动", "活动发布",
"发布一个活动", "发布一条活动", "发一条活动",
"发布家校活动", "发家校活动", "发布校园活动", "发校园活动", "发布亲子活动"
));
/**
@@ -172,6 +213,8 @@ public class IntentShortcut {
static {
RULES.add(new Rule(LEAVE_BARE, LEAVE));
RULES.add(new Rule(NOTICE_PUBLISH_BARE, NOTICE_PUBLISH));
RULES.add(new Rule(DORM_PUBLISH_BARE, DORM_PUBLISH));
RULES.add(new Rule(ACTIVITY_PUBLISH_BARE, ACTIVITY_PUBLISH));
}
/**
@@ -7,20 +7,36 @@ import java.util.ArrayList;
import java.util.List;
/**
* 家校活动模块工具 —— 后端 FamilyActivity,前缀 /api/activity、/api/base。
* 家校活动模块工具 —— 后端 FamilyActivity,前缀 /api/activity、/api/dict、/api/base。
*
* <p>核心闭环:活动广场 → 家长报名 → 学校确认。
* <p>核心闭环:<b>学校发活动 → 家长报名 → 学校确认报名 / 盯报名情况</b>。
* 与「宿舍通知」同构(发布 → 阅读情况 → 回执闭环),只是回执换成了「报名」。
*/
public final class ActivityTools {
public static final String MODULE = "familyactivity";
/** 主办部门字典的 type 值(/api/dict/List?type=1) */
private static final Object DICT_TYPE_DEPT = 1;
/** 活动分类字典的 type 值(/api/dict/List?type=2) */
private static final Object DICT_TYPE_CATEGORY = 2;
private ActivityTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
addParentSide(list);
addPublishLoop(list);
addSignupLoop(list);
addDictionaries(list);
return list;
}
/* ==================== 家长 / 学生侧 ==================== */
private static void addParentSide(List<ToolDef> list) {
list.add(ToolDef.of("activity.plaza", MODULE, "活动广场",
"当前家长/学生视角的活动广场(含我的报名状态与分桶计数)。用户问“最近有什么活动”“我能报什么”时用它。",
"GET", "/api/activity/MyActivities")
@@ -72,11 +88,185 @@ public final class ActivityTools {
.label("活动"))
.write("/par/mine"));
list.add(ToolDef.of("activity.initializeRed", MODULE, "一键已读全部活动",
"把家长端收到的活动一次性全部标记为已读。",
"POST_FORM", "/api/activity/InitializeRed")
.write("/par/activities"));
}
/* ==================== 发布闭环(教职工) ==================== */
private static void addPublishLoop(List<ToolDef> list) {
list.add(ToolDef.of("activity.publish", MODULE, "发布家校活动",
"发布一条家校活动(可要求家长报名),默认发给**全部班级**的家长。"
+ "调用前必须确认:标题 title、正文 content、活动分类 category、主办部门 deptId、"
+ "活动地点 venue、开始/结束时间 startTime/endTime;"
+ "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;"
+ "活动分类与主办部门必须来自字典,不要编造。默认立即发布;如需先存草稿,把 draft 置 true。",
"POST_JSON", "/api/activity/Create")
.p(ToolParam.required("title", "string", "活动标题")
.label("活动标题").eg("秋季亲子运动会"),
ToolParam.required("content", "string", "活动说明(纯文本)")
.label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"),
ToolParam.required("category", "string", "活动分类,来自 activity.categoryDic 的 k 字段")
.label("活动分类")
.optionsFrom("activity.categoryDic").optionsKeys("k", "v"),
ToolParam.required("deptId", "string", "主办部门 id,来自 activity.deptDic 的 k 字段")
.label("主办部门")
.optionsFrom("activity.deptDic").optionsKeys("k", "v"),
ToolParam.required("venue", "string", "活动地点")
.label("活动地点").eg("学校操场"),
ToolParam.required("startTime", "string", "活动开始时间 yyyy-MM-dd HH:mm:ss")
.label("开始时间").eg("2026-10-20 09:00:00"),
ToolParam.required("endTime", "string", "活动结束时间 yyyy-MM-dd HH:mm:ss")
.label("结束时间").eg("2026-10-20 11:30:00"),
ToolParam.required("receiptMode", "string", "报名方式:signup 需要家长报名 / none 无需报名")
.label("报名方式").dft("signup")
.options(new String[]{"signup", "none"}, new String[]{"需要家长报名", "无需报名"}),
ToolParam.optional("signupDeadline", "string", "报名截止时间 yyyy-MM-dd HH:mm:ss(留空=不限)")
.label("报名截止时间").eg("2026-10-18 18:00:00"),
ToolParam.optional("quota", "integer", "名额上限(0 或留空 = 不限)")
.label("名额上限").dft("0"),
ToolParam.optional("allowAccompany", "integer", "是否允许家长随行")
.label("家长随行").dft("1")
.options(new String[]{"1", "0"}, new String[]{"允许", "不允许"}),
ToolParam.optional("cost", "number", "活动费用(元,留空=免费)")
.label("活动费用"),
ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒家长")
.label("同时发短信").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("topmost", "integer", "是否置顶")
.label("置顶").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("draft", "string", "是否存草稿:true 只保存不发布")
.label("存草稿").dft("false")
.options(new String[]{"false", "true"}, new String[]{"立即发布", "存草稿"}))
// 接收对象:默认全部班级(班级级定向请到模块页面里勾选)
.fix("receiveType", "class")
.fix("receiveList", "ALL")
.write("/list/new"));
list.add(ToolDef.of("activity.edit", MODULE, "编辑家校活动",
"修改一条已发布(或草稿)的活动。调用前须确认 sourceId(要改的那条活动 id)。",
"POST_JSON", "/api/activity/Edit")
.p(ToolParam.required("sourceId", "integer", "要编辑的活动 id")
.label("活动"),
ToolParam.required("title", "string", "活动标题")
.label("活动标题"),
ToolParam.required("content", "string", "活动说明")
.label("活动说明").as("textarea"))
.fix("receiveType", "class")
.fix("receiveList", "ALL")
.write("/list"));
list.add(ToolDef.of("activity.sent", MODULE, "我发布的活动",
"查询我发布的家校活动列表(可按关键词筛选)。用户问“我发过哪些活动”“那个活动发了没”时用它。",
"GET", "/api/activity/SendList")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("scope", "string", "传 user 只看自己发布的"),
ToolParam.optional("keyword", "string", "标题关键词"))
.readAt("/list"));
list.add(ToolDef.of("activity.readDetail", MODULE, "活动阅读情况",
"查询某个活动的阅读情况(已读 / 未读分组,按年级·班级)。",
"GET", "/api/activity/ReadDetail")
.p(ToolParam.required("serid", "integer", "活动 id"))
.readAt("/signups"));
list.add(ToolDef.of("activity.pull", MODULE, "撤回活动",
"撤回一条已发布的活动(撤回后家长端不可见)。",
"POST_FORM", "/api/activity/UserPull")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"))
.write("/list"));
list.add(ToolDef.of("activity.top", MODULE, "置顶 / 取消置顶活动",
"把一个活动置顶或取消置顶。",
"POST_FORM", "/api/activity/SetTopmost")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"),
ToolParam.optional("topmost", "integer", "1 置顶(默认) / 0 取消置顶")
.label("操作").dft("1")
.options(new String[]{"1", "0"}, new String[]{"置顶", "取消置顶"}))
.write("/list"));
list.add(ToolDef.of("activity.expedite", MODULE, "催办未读(活动)",
"对一个活动的未读人员统一催办(短信 / 站内提醒)。",
"POST_FORM", "/api/activity/Expedite")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"))
.write("/signups"));
list.add(ToolDef.of("activity.statistics", MODULE, "活动统计",
"查询活动的发布 / 阅读 / 报名统计(可按时间段)。",
"GET", "/api/activity/Statis")
.p(ToolParam.optional("startTime", "string", "开始时间 yyyy-MM-dd").eg("2026-10-01"),
ToolParam.optional("endTime", "string", "结束时间 yyyy-MM-dd").eg("2026-10-31"))
.readAt("/stats"));
}
/* ==================== 报名闭环(教职工) ==================== */
private static void addSignupLoop(List<ToolDef> list) {
list.add(ToolDef.of("activity.signups", MODULE, "活动报名名单",
"查询某个活动的报名 / 回执名单(按班级分组 + 汇总)。"
+ "用户问“还有谁没报名”“报名情况怎么样”时用它。",
"GET", "/api/activity/Signups")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"),
ToolParam.optional("signupStatus", "string",
"筛选:all 全部 / signed 已报名 / confirmed 已确认 / canceled 已取消 / none 未报名")
.dft("all"))
.readAt("/signups"));
list.add(ToolDef.of("activity.signupDetail", MODULE, "单条报名详情",
"查看某条报名的明细(家长填写的参加人数 / 联系人 / 备注)。",
"GET", "/api/activity/SignupDetail")
.p(ToolParam.required("signupId", "integer", "报名 id"))
.readAt("/signups"));
list.add(ToolDef.of("activity.review", MODULE, "处理活动报名",
"对一条报名执行确认 / 取消 / 候补操作。",
"POST_JSON", "/api/activity/Review")
.p(ToolParam.required("signupId", "integer", "报名 id")
.label("报名"),
ToolParam.required("action", "string", "处理动作:confirm 通过 / cancel 取消 / waitlist 转候补")
.label("处理动作")
.options(new String[]{"confirm", "cancel", "waitlist"},
new String[]{"通过", "取消", "转候补"}))
.write("/signups"));
list.add(ToolDef.of("activity.remind", MODULE, "催报名(活动)",
"向未报名的家长发送报名提醒。",
"POST_FORM", "/api/activity/Remind")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"))
.write("/signups"));
}
/* ==================== 字典(发布表单下拉的来源) ==================== */
private static void addDictionaries(List<ToolDef> list) {
list.add(ToolDef.of("activity.deptDic", MODULE, "主办部门字典",
"查询本校可选的主办部门(发布活动时填 deptId 用)。"
+ "仅在用户明确问「有哪些主办部门」时使用;"
+ "发布活动时**不要**先调它 —— 表单的「主办部门」下拉会自动带出可选值。",
"GET", "/api/dict/List")
.fix("type", DICT_TYPE_DEPT)
.readAt("/list/new"));
list.add(ToolDef.of("activity.categoryDic", MODULE, "活动分类字典",
"查询本校可选的活动分类(发布活动时填 category 用)。"
+ "仅在用户明确问「有哪些活动分类」时使用;"
+ "发布活动时**不要**先调它 —— 表单的「活动分类」下拉会自动带出可选值。",
"GET", "/api/dict/List")
.fix("type", DICT_TYPE_CATEGORY)
.readAt("/list/new"));
list.add(ToolDef.of("activity.classTree", MODULE, "年级班级树",
"年级 → 班级 树(发布活动时选接收班级用)。",
"GET", "/api/base/GetClassTree")
.readAt("/list/new"));
return list;
}
}
@@ -7,20 +7,44 @@ import java.util.ArrayList;
import java.util.List;
/**
* 宿舍通知模块工具 —— 后端 DormNotice,前缀 /api/notice、/api/dorm、/api/base。
* 宿舍通知模块工具 —— 后端 DormNotice,前缀 /api/notice、/api/dorm、/api/dict、/api/base。
*
* <p>家校闭环:宿管发通知 → 家长查看 → 家长回执/填报。
* <p>两条主线:
* <ul>
* <li><b>家校闭环</b>:宿管发通知 → 家长查看 → 家长回执/填报 → 宿管盯回执、催办。</li>
* <li><b>床位台账</b>:待安排池 → 安排入住 → 退宿 → 调宿 → 设宿舍长(宿管每天的活)。</li>
* </ul>
*
* <p>注意:本模块与「校园公告」一样,服务端直调用户态接口会拿到 {@code code=302}(只认浏览器 CAS 会话),
* 凡是需要「取字典给下拉」的字段都用 {@code optionsFrom + optionsKeys} 声明,由前端带用户登录态代取
* (见 AgentOrchestrator#buildForm)。
*/
public final class DormNoticeTools {
public static final String MODULE = "dormnotice";
/** 通知类型字典的 type 值(/api/dict/List?type=noticeType) */
private static final String DICT_TYPE_NOTICE = "noticeType";
/** 发文部门字典的 type 值(/api/dict/List?type=dept) */
private static final String DICT_TYPE_DEPT = "dept";
private DormNoticeTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
addParentSide(list);
addPublishLoop(list);
addReceiptLoop(list);
addDictionaries(list);
addDormLedger(list);
return list;
}
/* ==================== 家长 / 学生侧 ==================== */
private static void addParentSide(List<ToolDef> list) {
list.add(ToolDef.of("dorm.myNotices", MODULE, "我的宿舍通知箱",
"当前家长/学生视角的宿舍通知箱(含我的回执状态与各分桶计数)。用户问“宿舍有什么通知”“我要回执”时用它。",
"GET", "/api/notice/MyNotices")
@@ -35,14 +59,6 @@ public final class DormNoticeTools {
.p(ToolParam.required("serid", "integer", "通知 id"))
.readAt("/par/notices"));
list.add(ToolDef.of("dorm.sendList", MODULE, "宿舍通知发布列表",
"查询已发布的宿舍通知(家长端收件箱 / 管理端全部)。",
"GET", "/api/notice/List")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "标题关键词"))
.readAt("/list"));
list.add(ToolDef.of("dorm.submitReceipt", MODULE, "提交宿舍通知回执",
"家长提交某条宿舍通知的回执 / 填报。调用前须确认 serid;如该通知需填报,还需填 fillData 内容。",
"POST_JSON", "/api/notice/SubmitReceipt")
@@ -67,6 +83,198 @@ public final class DormNoticeTools {
.label("宿舍通知"))
.write("/par/mine"));
list.add(ToolDef.of("dorm.initializeRed", MODULE, "一键已读全部宿舍通知",
"把我收到的宿舍通知一次性全部标记为已读。",
"POST_FORM", "/api/notice/InitializeRed")
.write("/par/notices"));
}
/* ==================== 发布闭环(教职工) ==================== */
private static void addPublishLoop(List<ToolDef> list) {
list.add(ToolDef.of("dorm.publish", MODULE, "发布宿舍通知",
"发布一条宿舍通知(可要求家长回执 / 填报),默认发给**全部宿舍**的家长。"
+ "调用前必须确认:标题 title、正文 content、通知类型 noticeType、发文部门 deptId;"
+ "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;"
+ "通知类型与发文部门必须来自字典,不要编造。默认立即发布;如需先存草稿,把 draft 置 true。",
"POST_JSON", "/api/notice/Create")
.p(ToolParam.required("title", "string", "通知标题")
.label("通知标题").eg("关于宿舍熄灯时间调整的通知"),
ToolParam.required("content", "string", "通知正文(纯文本)")
.label("通知正文").as("textarea").eg("各位家长:自本周起,宿舍熄灯时间调整为 22:30……"),
ToolParam.required("noticeType", "string", "通知类型,来自 dorm.noticeTypeDic 的 k 字段")
.label("通知类型")
.optionsFrom("dorm.noticeTypeDic").optionsKeys("k", "v"),
ToolParam.required("deptId", "string", "发文部门 id,来自 dorm.deptDic 的 k 字段")
.label("发文部门")
.optionsFrom("dorm.deptDic").optionsKeys("k", "v"),
ToolParam.required("receiptMode", "string", "回执方式:none 无需回执 / confirm 确认回执 / fill 填报回执")
.label("回执方式").dft("none")
.options(new String[]{"none", "confirm", "fill"},
new String[]{"无需回执", "需要确认回执", "需要填报回执"}),
ToolParam.optional("deadlineTime", "string", "回执截止时间 yyyy-MM-dd HH:mm:ss(留空=不限)")
.label("回执截止时间").eg("2026-10-20 18:00:00"),
ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行")
.label("宿管到场配合").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒家长")
.label("同时发短信").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("topmost", "integer", "是否置顶")
.label("置顶").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("draft", "string", "是否存草稿:true 只保存不发布")
.label("存草稿").dft("false")
.options(new String[]{"false", "true"}, new String[]{"立即发布", "存草稿"}))
// 接收对象:默认全部宿舍(房间级定向请到模块页面里勾选)
.fix("receiveType", "room")
.fix("receiveList", "ALL")
.write("/list/new"));
list.add(ToolDef.of("dorm.editNotice", MODULE, "编辑宿舍通知",
"修改一条已发布(或草稿)的宿舍通知。调用前须确认 sourceId(要改的那条通知 id)。",
"POST_JSON", "/api/notice/Edit")
.p(ToolParam.required("sourceId", "integer", "要编辑的通知 id")
.label("通知"),
ToolParam.required("title", "string", "通知标题")
.label("通知标题"),
ToolParam.required("content", "string", "通知正文")
.label("通知正文").as("textarea"))
.fix("receiveType", "room")
.fix("receiveList", "ALL")
.write("/list"));
list.add(ToolDef.of("dorm.sendList", MODULE, "我发布的宿舍通知",
"查询我发布的宿舍通知列表(可按关键词 / 状态筛选)。用户问“我发过哪些宿舍通知”“那条通知发了没”时用它。",
"GET", "/api/notice/SendList")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("scope", "string", "传 user 只看自己发布的"),
ToolParam.optional("keyword", "string", "标题关键词"))
.readAt("/list"));
list.add(ToolDef.of("dorm.readDetail", MODULE, "宿舍通知阅读情况",
"查询某条宿舍通知的阅读情况(已读 / 未读分组,按楼栋·房间)。",
"GET", "/api/notice/ReadDetail")
.p(ToolParam.required("serid", "integer", "通知 id"))
.readAt("/receipts"));
list.add(ToolDef.of("dorm.pull", MODULE, "撤回宿舍通知",
"撤回一条已发布的宿舍通知(撤回后家长端不可见)。",
"POST_FORM", "/api/notice/UserPull")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"))
.write("/list"));
list.add(ToolDef.of("dorm.top", MODULE, "置顶 / 取消置顶宿舍通知",
"把一条宿舍通知置顶或取消置顶。",
"POST_FORM", "/api/notice/SetTopmost")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"),
ToolParam.optional("topmost", "integer", "1 置顶(默认) / 0 取消置顶")
.label("操作").dft("1")
.options(new String[]{"1", "0"}, new String[]{"置顶", "取消置顶"}))
.write("/list"));
list.add(ToolDef.of("dorm.expedite", MODULE, "催办未读(宿舍通知)",
"对一条宿舍通知的未读人员统一催办(短信 / 站内提醒)。",
"POST_FORM", "/api/notice/Expedite")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"))
.write("/receipts"));
list.add(ToolDef.of("dorm.remindStudent", MODULE, "向某学生家长催办(宿舍通知)",
"针对某条宿舍通知,单独向某个学生的家长发送提醒。",
"POST_FORM", "/api/notice/RemindStudent")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"),
ToolParam.required("studentId", "integer", "学生 id")
.label("学生"))
.write("/receipts"));
list.add(ToolDef.of("dorm.markRead", MODULE, "代标记某学生已读(宿舍通知)",
"管理员代为把某条宿舍通知标记为某学生已读。",
"POST_FORM", "/api/notice/MarkRead")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"),
ToolParam.required("studentId", "integer", "学生 id")
.label("学生"))
.write("/receipts"));
list.add(ToolDef.of("dorm.statistics", MODULE, "宿舍通知统计",
"查询宿舍通知的发送 / 阅读 / 回执统计(可按时间段)。",
"GET", "/api/notice/Statis")
.p(ToolParam.optional("startTime", "string", "开始时间 yyyy-MM-dd").eg("2026-10-01"),
ToolParam.optional("endTime", "string", "结束时间 yyyy-MM-dd").eg("2026-10-31"))
.readAt("/stats"));
}
/* ==================== 回执闭环(教职工) ==================== */
private static void addReceiptLoop(List<ToolDef> list) {
list.add(ToolDef.of("dorm.receipts", MODULE, "宿舍通知回执名单",
"查询某条宿舍通知的回执 / 填报名单(按楼栋·楼层分组 + 汇总)。"
+ "用户问“还有谁没回执”“回执情况怎么样”时用它。",
"GET", "/api/notice/Receipts")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"),
ToolParam.optional("status", "string", "筛选:all 全部 / confirmed 已确认 / filled 已填报 / canceled 已取消 / none 未回执")
.dft("all"))
.readAt("/receipts"));
list.add(ToolDef.of("dorm.receiptDetail", MODULE, "单条回执详情",
"查看某条回执的明细(家长填写的确认 / 填报内容)。",
"GET", "/api/notice/ReceiptDetail")
.p(ToolParam.required("receiptId", "integer", "回执 id"))
.readAt("/receipts"));
list.add(ToolDef.of("dorm.reviewReceipt", MODULE, "处理宿舍通知回执",
"对一条回执执行确认 / 取消 / 重置操作。",
"POST_JSON", "/api/notice/Review")
.p(ToolParam.required("receiptId", "integer", "回执 id")
.label("回执"),
ToolParam.required("action", "string", "处理动作:confirm 通过确认 / cancel 取消 / reset 重置为待处理")
.label("处理动作")
.options(new String[]{"confirm", "cancel", "reset"},
new String[]{"通过确认", "取消", "重置为待处理"}))
.write("/receipts"));
list.add(ToolDef.of("dorm.remind", MODULE, "催回执(宿舍通知)",
"向未回执的家长发送回执提醒。",
"POST_FORM", "/api/notice/Remind")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"))
.write("/receipts"));
}
/* ==================== 字典 / 模板(发布表单下拉的来源) ==================== */
private static void addDictionaries(List<ToolDef> list) {
list.add(ToolDef.of("dorm.noticeTypeDic", MODULE, "宿舍通知类型字典",
"查询本校可选的宿舍通知类型(发布宿舍通知时填 noticeType 用)。"
+ "仅在用户明确问「有哪些通知类型」时使用;"
+ "发布宿舍通知时**不要**先调它 —— 表单的「通知类型」下拉会自动带出可选值。",
"GET", "/api/dict/List")
.fix("type", DICT_TYPE_NOTICE)
.readAt("/list/new"));
list.add(ToolDef.of("dorm.deptDic", MODULE, "宿舍通知发文部门字典",
"查询本校可选的发文部门(发布宿舍通知时填 deptId 用)。"
+ "仅在用户明确问「有哪些发文部门」时使用;"
+ "发布宿舍通知时**不要**先调它 —— 表单的「发文部门」下拉会自动带出可选值。",
"GET", "/api/dict/List")
.fix("type", DICT_TYPE_DEPT)
.readAt("/list/new"));
list.add(ToolDef.of("dorm.formTemplates", MODULE, "回执单据模板",
"查询「需填报」类宿舍通知可用的单据模板(模板字段由后端预置,发布人只选类型)。",
"GET", "/api/notice/FormTemplates")
.readAt("/list/new"));
}
/* ==================== 宿舍床位台账(宿管日常) ==================== */
private static void addDormLedger(List<ToolDef> list) {
list.add(ToolDef.of("dorm.buildings", MODULE, "宿舍楼栋列表",
"查询宿舍楼栋列表(含房间数/床位数/入住数)。",
"GET", "/api/dorm/BuildingList")
@@ -86,10 +294,104 @@ public final class DormNoticeTools {
.readAt("/setting"));
list.add(ToolDef.of("dorm.dormTree", MODULE, "宿舍楼栋房间树",
"楼栋 → 房间 的完整树(发布宿舍通知时选接收房间用)。",
"楼栋 → 房间 的完整树(发布宿舍通知、选择房间时用)。",
"GET", "/api/base/GetDormTree")
.readAt("/list/new"));
return list;
list.add(ToolDef.of("dorm.pendingStudents", MODULE, "待安排床位学生池",
"查询尚未安排床位的学生名单(「安排入住」的数据源)。",
"GET", "/api/dorm/PendingStudents")
.p(ToolParam.optional("keyword", "string", "姓名关键词"),
ToolParam.optional("gender", "string", "性别筛选"))
.readAt("/setting"));
list.add(ToolDef.of("dorm.assign", MODULE, "安排入住(分配床位)",
"把待安排的学生调入某个房间(按空床位顺序自动落位)。"
+ "调用前须确认 roomId 与学生 id(可多个,逗号分隔)。",
"POST_FORM", "/api/dorm/AssignStudent")
.p(ToolParam.required("roomId", "integer", "目标房间 id")
.label("房间"),
ToolParam.required("studentIds", "string", "学生 id,多个用英文逗号分隔")
.label("学生"))
.write("/setting"));
list.add(ToolDef.of("dorm.removeStudent", MODULE, "退宿(移出房间)",
"把某学生移出所在房间,退回待安排池。",
"POST_FORM", "/api/dorm/RemoveStudent")
.p(ToolParam.required("roomId", "integer", "房间 id")
.label("房间"),
ToolParam.required("studentId", "integer", "学生 id")
.label("学生"))
.write("/setting"));
list.add(ToolDef.of("dorm.transfer", MODULE, "调宿(换房间)",
"把房间内某学生转到另一间房。",
"POST_FORM", "/api/dorm/TransferStudent")
.p(ToolParam.required("studentId", "integer", "学生 id")
.label("学生"),
ToolParam.required("fromRoomId", "integer", "原房间 id")
.label("原房间"),
ToolParam.required("toRoomId", "integer", "目标房间 id")
.label("目标房间"))
.write("/setting"));
list.add(ToolDef.of("dorm.setMonitor", MODULE, "设置 / 取消宿舍长",
"把某房间内的一名学生设为宿舍长;studentId 传 0 表示取消宿舍长。",
"POST_FORM", "/api/dorm/SetMonitor")
.p(ToolParam.required("roomId", "integer", "房间 id")
.label("房间"),
ToolParam.required("studentId", "integer", "学生 id(传 0 取消宿舍长)")
.label("学生"))
.write("/setting"));
list.add(ToolDef.of("dorm.addBuilding", MODULE, "新增宿舍楼栋",
"新增一栋宿舍楼。",
"POST_FORM", "/api/dorm/AddBuilding")
.p(ToolParam.required("name", "string", "楼栋名称")
.label("楼栋名称").eg("1 号楼"),
ToolParam.optional("floors", "integer", "楼层数")
.label("楼层数"),
ToolParam.optional("remark", "string", "备注")
.label("备注").as("textarea"))
.write("/setting"));
list.add(ToolDef.of("dorm.editBuilding", MODULE, "编辑宿舍楼栋",
"修改宿舍楼栋信息(名称 / 楼层数 / 备注)。",
"POST_FORM", "/api/dorm/EditBuilding")
.p(ToolParam.required("buildingId", "integer", "楼栋 id")
.label("楼栋"),
ToolParam.required("name", "string", "楼栋名称")
.label("楼栋名称"),
ToolParam.optional("floors", "integer", "楼层数")
.label("楼层数"),
ToolParam.optional("remark", "string", "备注")
.label("备注").as("textarea"))
.write("/setting"));
list.add(ToolDef.of("dorm.addRoom", MODULE, "新增宿舍房间",
"在某楼栋下新增房间(可按 roomCount 批量生成连续房间号)。",
"POST_FORM", "/api/dorm/AddRoom")
.p(ToolParam.required("buildingId", "integer", "所属楼栋 id")
.label("楼栋"),
ToolParam.required("roomNo", "string", "房间号")
.label("房间号").eg("301"),
ToolParam.optional("bedCount", "integer", "床位数")
.label("床位数").dft("6"),
ToolParam.optional("roomCount", "integer", "批量生成房间数量(留空=只建一间)")
.label("批量数量"))
.write("/setting"));
list.add(ToolDef.of("dorm.editRoom", MODULE, "编辑宿舍房间",
"修改房间信息(房间号 / 床位数 / 宿管)。",
"POST_FORM", "/api/dorm/EditRoom")
.p(ToolParam.required("roomId", "integer", "房间 id")
.label("房间"),
ToolParam.optional("roomNo", "string", "房间号")
.label("房间号"),
ToolParam.optional("bedCount", "integer", "床位数")
.label("床位数"),
ToolParam.optional("supervisor", "string", "宿管姓名")
.label("宿管"))
.write("/setting"));
}
}
@@ -0,0 +1,208 @@
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;
/**
* 「发布宿舍通知」「发布家校活动」两条链路的断言:裸意图识别 + 发布工具的真实契约 + 字典下拉的代取地址。
*
* <p>刻意的约束:
* <ol>
* <li><b>必须带模块限定</b>:宿舍通知只认「宿舍」字样、活动只认「活动」字样。
* 本轮起「发通知 / 发布通知」这类不带限定的说法<b>不再直达</b> —— 校园公告与宿舍通知
* 都有发布工具,同一句「发通知」是跨模块歧义(猜错模块比不直达更糟),交给模型按上下文选。</li>
* <li><b>仅教职工</b>:两个发布工具都归 STAFF_ONLY,学生身份没有落点 → 落回模型如实说明。</li>
* <li><b>接收对象固定为「全部」</b>:{@code receiveType}/{@code receiveList} 由 {@code fix} 写死,
* 房间/班级级定向请到模块页面里勾选,避免表单里出现难用的 id 输入框。</li>
* </ol>
*/
class DormActivityPublishIntentTest {
private IntentShortcut shortcut;
private ToolRegistry registry;
private AgentSettings settings;
private ToolExecutor executor;
@BeforeEach
void setUp() {
shortcut = new IntentShortcut();
settings = new AgentSettings();
settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
registry = new ToolRegistry(settings);
registry.init();
executor = new ToolExecutor(settings, new RestTemplate());
}
/* ==================== 裸意图规则 ==================== */
@Test
void bareDormPublishIntentIsRecognized() {
for (String text : Arrays.asList(
"发布宿舍通知", "我要发布宿舍通知", "发宿舍通知", "发个宿舍通知",
"帮我发一条宿舍通知", "宿舍通知发布", "发布宿舍公告")) {
IntentShortcut.Intent it = shortcut.match(text);
assertNotNull(it, "「" + text + "」应被识别为发布宿舍通知意图");
assertEquals("dorm.publish", it.getStaffTool(), "「" + text + "」应落到发布宿舍通知工具");
assertNull(it.getStudentTool(), "学生没有发布宿舍通知能力");
}
}
@Test
void bareActivityPublishIntentIsRecognized() {
for (String text : Arrays.asList(
"发布活动", "我要发布活动", "发活动", "发个活动",
"发布家校活动", "帮我发布一条活动", "活动发布", "发布亲子活动")) {
IntentShortcut.Intent it = shortcut.match(text);
assertNotNull(it, "「" + text + "」应被识别为发布活动意图");
assertEquals("activity.publish", it.getStaffTool(), "「" + text + "」应落到发布活动工具");
assertNull(it.getStudentTool(), "学生没有发布活动能力");
}
}
@Test
void genericSendNoticeIsNoLongerHijacked() {
// 「发通知」不带模块限定 → 校园公告与宿舍通知都成立,属跨模块歧义,交给模型按上下文选
for (String text : Arrays.asList(
"发通知", "发布通知", "发个通知", "发一条通知")) {
assertNull(shortcut.match(text),
"「" + text + "」跨模块歧义(公告 / 宿舍通知),不应被直达规则命中");
}
}
@Test
void readingAndSignupAreNotHijacked() {
for (String text : Arrays.asList(
"宿舍有什么通知", "我有哪些未读的宿舍通知", "这周有什么活动",
"看看活动", "报名活动", "我要报名活动", "活动报名情况",
"发一条关于宿舍卫生检查的通知,本周五下午查")) {
assertNull(shortcut.match(text), "「" + text + "」不是发布意图,不应被直达规则命中");
}
}
/** 三个发布意图不能互相串味 */
@Test
void publishRulesDoNotOverlap() {
assertEquals("notice.publish", shortcut.match("我要发布校园公告").getStaffTool());
assertEquals("dorm.publish", shortcut.match("我要发布宿舍通知").getStaffTool());
assertEquals("activity.publish", shortcut.match("我要发布活动").getStaffTool());
}
/* ==================== 宿舍通知发布工具契约 ==================== */
@Test
void dormPublishToolContract() {
ToolDef publish = registry.get("dorm.publish");
assertNotNull(publish);
assertFalse(publish.isReadOnly(), "发布宿舍通知是写操作");
assertEquals("/api/notice/Create", publish.getPath());
List<String> required = requiredNames(publish);
assertTrue(required.contains("title"));
assertTrue(required.contains("content"));
assertTrue(required.contains("noticeType"), "通知类型是发布表单必填项");
assertTrue(required.contains("deptId"), "发文部门是发布表单必填项");
// 接收对象由 fix 写死(避免表单里出现房间 id 输入框)
assertEquals("room", publish.getFixed().get("receiveType"));
assertEquals("ALL", publish.getFixed().get("receiveList"));
}
@Test
void dormDictsUsePlatformKeyShape() {
ToolParam noticeType = param(registry.get("dorm.publish"), "noticeType");
assertEquals("dorm.noticeTypeDic", noticeType.getOptionsFrom());
assertEquals("k", noticeType.getOptionValueKey(), "/api/dict/List 返回 {k,v},必须按键取值");
assertEquals("v", noticeType.getOptionLabelKey());
ToolParam deptId = param(registry.get("dorm.publish"), "deptId");
assertEquals("dorm.deptDic", deptId.getOptionsFrom());
assertEquals("k", deptId.getOptionValueKey());
assertEquals("v", deptId.getOptionLabelKey());
}
@Test
void dormDictOptionCallCarriesFixedTypeParam() {
BrowserToolRequest.BrowserCall noticeTypeCall =
executor.prepareOptionCall(registry.get("dorm.noticeTypeDic"), null);
assertNotNull(noticeTypeCall);
assertEquals("/dormnotice/api/dict/List?type=noticeType", noticeTypeCall.getPathAndQuery());
BrowserToolRequest.BrowserCall deptCall =
executor.prepareOptionCall(registry.get("dorm.deptDic"), null);
assertNotNull(deptCall);
assertEquals("/dormnotice/api/dict/List?type=dept", deptCall.getPathAndQuery());
}
/* ==================== 家校活动发布工具契约 ==================== */
@Test
void activityPublishToolContract() {
ToolDef publish = registry.get("activity.publish");
assertNotNull(publish);
assertFalse(publish.isReadOnly(), "发布活动是写操作");
assertEquals("/api/activity/Create", publish.getPath());
List<String> required = requiredNames(publish);
for (String k : Arrays.asList("title", "content", "category", "deptId", "venue", "startTime", "endTime")) {
assertTrue(required.contains(k), "发布活动表单缺少必填项:" + k);
}
assertEquals("class", publish.getFixed().get("receiveType"));
assertEquals("ALL", publish.getFixed().get("receiveList"));
}
@Test
void activityDictOptionCallsUseExpectedTypeIds() {
BrowserToolRequest.BrowserCall dept =
executor.prepareOptionCall(registry.get("activity.deptDic"), null);
assertNotNull(dept);
assertEquals("/familyactivity/api/dict/List?type=1", dept.getPathAndQuery(),
"主办部门字典是 type=1");
BrowserToolRequest.BrowserCall category =
executor.prepareOptionCall(registry.get("activity.categoryDic"), null);
assertNotNull(category);
assertEquals("/familyactivity/api/dict/List?type=2", category.getPathAndQuery(),
"活动分类字典是 type=2");
}
/* ==================== 辅助 ==================== */
private List<String> requiredNames(ToolDef def) {
List<String> 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);
}
}
@@ -77,6 +77,8 @@ 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("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
registry = new ToolRegistry(settings);
registry.init();
@@ -206,6 +208,79 @@ class IntentDirectFormTest {
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布宿舍通知 ==================== */
@Test
void teacherSayingPublishDormNoticeGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布宿舍通知", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("dorm.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/list/new", resp.getForm().getLandingPath());
// 标题 + 正文 + 通知类型(下拉)+ 发文部门(下拉)
assertEquals("text", field(resp, "title").getWidget());
assertEquals("textarea", field(resp, "content").getWidget());
FormField noticeType = field(resp, "noticeType");
assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号");
assertNotNull(noticeType.getOptionCall());
assertEquals("/dormnotice/api/dict/List?type=noticeType",
noticeType.getOptionCall().getPathAndQuery(), "字典地址必须带 type=noticeType");
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget());
assertNotNull(deptId.getOptionCall());
assertEquals("/dormnotice/api/dict/List?type=dept", deptId.getOptionCall().getPathAndQuery());
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
}
@Test
void studentSayingPublishDormNoticeFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布宿舍通知", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布宿舍通知表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布家校活动 ==================== */
@Test
void teacherSayingPublishActivityGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布活动", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("activity.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite());
assertEquals("/list/new", resp.getForm().getLandingPath());
// 活动表单:标题 + 说明(多行)+ 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间
assertEquals("text", field(resp, "title").getWidget());
assertEquals("textarea", field(resp, "content").getWidget());
FormField category = field(resp, "category");
assertEquals("select", category.getWidget());
assertNotNull(category.getOptionCall());
assertEquals("/familyactivity/api/dict/List?type=2", category.getOptionCall().getPathAndQuery());
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget());
assertNotNull(deptId.getOptionCall());
assertEquals("/familyactivity/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery());
assertNotNull(field(resp, "venue"));
assertNotNull(field(resp, "startTime"));
assertNotNull(field(resp, "endTime"));
}
@Test
void studentSayingPublishActivityFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布活动", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布活动表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 不该被劫持的情况 ==================== */
@Test
@@ -58,7 +58,7 @@ class NoticeIntentFormTest {
for (String text : Arrays.asList(
"发布公告", "我要发布公告", "发布校园公告", "我要发布校园公告",
"发公告", "发个公告", "帮我发公告", "我要发一条公告",
"发布通知", "发通知", "发布校园通知", "麻烦发个通知吧")) {
"发布校园通知", "麻烦帮我发布校园通知")) {
IntentShortcut.Intent it = shortcut.match(text);
assertNotNull(it, "「" + text + "」应被识别为裸发布公告意图");
assertEquals("notice.publish", it.getStaffTool(),
@@ -66,6 +66,19 @@ class NoticeIntentFormTest {
}
}
/**
* 2026-10 起宿舍通知也接上了发布工具 → 「发通知 / 发布通知」变成跨模块歧义,
* 已从公告短语集移除(改由模型按上下文选),这里把「不再劫持」固化下来。
*/
@Test
void genericSendNoticeIsNotHijackedByCampusNotice() {
for (String text : Arrays.asList(
"发通知", "发布通知", "发个通知", "发一条通知")) {
assertNull(shortcut.match(text),
"「" + text + "」跨模块歧义(校园公告 / 宿舍通知),不应被公告直达规则命中");
}
}
@Test
void noticePublishHasNoStudentLanding() {
IntentShortcut.Intent it = shortcut.match("发布公告");
@@ -180,6 +180,10 @@ class RolePermissionTest {
assertFalse(visible(visible, "notice.publish"), "学生不应看到 notice.publish");
assertFalse(visible(visible, "profession.myPoints"), "学生不应看到 profession.*");
assertFalse(visible(visible, "dorm.buildings"), "学生不应看到宿舍管理侧");
assertFalse(visible(visible, "dorm.publish"), "学生不应看到发布宿舍通知");
assertFalse(visible(visible, "dorm.assign"), "学生不应看到床位安排(宿管侧)");
assertFalse(visible(visible, "activity.publish"), "学生不应看到发布活动");
assertFalse(visible(visible, "activity.signups"), "学生不应看到活动报名名单(教师侧)");
}
@Test
@@ -202,6 +206,9 @@ class RolePermissionTest {
assertFalse(visible(visible, "leave.submit"), "家长不应看到请假申请");
assertFalse(visible(visible, "leave.audit"), "家长不应看到审批");
assertFalse(visible(visible, "notice.publish"), "家长不应看到发布公告");
assertFalse(visible(visible, "dorm.publish"), "家长不应看到发布宿舍通知");
assertFalse(visible(visible, "activity.publish"), "家长不应看到发布活动");
assertFalse(visible(visible, "activity.signups"), "家长不应看到活动报名名单(教师侧)");
}
@Test
@@ -216,6 +223,12 @@ class RolePermissionTest {
assertTrue(visible(visible, "notice.publish"), "教师应可用 notice.publish");
assertTrue(visible(visible, "profession.myPoints"), "教师应可用 profession.*");
assertTrue(visible(visible, "leave.stats"), "教师应可用 leave.stats");
// 教师能做:发布宿舍通知 / 盯回执 / 床位台账 / 发布活动 / 处理报名
assertTrue(visible(visible, "dorm.publish"), "教师应可用发布宿舍通知");
assertTrue(visible(visible, "dorm.receipts"), "教师应可用宿舍通知回执名单");
assertTrue(visible(visible, "dorm.assign"), "教师(宿管)应可用床位安排");
assertTrue(visible(visible, "activity.publish"), "教师应可用发布活动");
assertTrue(visible(visible, "activity.review"), "教师应可用处理活动报名");
// 教师不做:学生交作业、报名活动、学生自己的请假申请
assertFalse(visible(visible, "leave.submit"), "教师不应看到学生侧的「提交请假申请」");
assertFalse(visible(visible, "homework.submit"), "教师不应看到 homework.submit");