feat(notice): 「发布校园公告」裸意图直达表单 + 修发文部门下拉与假的发布方式
build / build-push (push) Successful in 1m9s

- IntentShortcut 泛化成规则表(Rule{短语集 → Intent}):加新意图只需
  ① 一条 Intent ② 一个短语集 ③ RULES.add(...),不用动 AgentOrchestrator。
  新增 NOTICE_PUBLISH —— 仅教职工(notice.publish),学生无落点则落回模型如实说明。
  短语表只收带「发/发布」动词的说法(发布公告/发个公告/发布校园公告/发通知…),
  光说「公告」「通知」更像查询,不收,避免误判。
- ★ 修 prepareOptionCall 漏拼字典工具的 fixed 参数:公文部门字典是
  /api/dict/List?type=1,丢了 type 前端取回的是「没指定类型」的结果 → 空下拉。
- deptId 补 optionsKeys("k","v"):平台通用字典返回 {k,v} 而非 id/name;
  服务端兜底 fetchDictOptions 的候选键也补上 k/v。
- ★ 移除 notice.publish 的 publishType 与 deptName:后端 /api/notice/Create 就是
  直接发布(无 publishType 字段,是否转审批由学校「应用设置」绑定的流程决定),
  声明它会让用户以为选了「存草稿」却仍然直接发出;deptName 由后端按 deptId 反查字典。
- system prompt 加第 10 条说明。
- 测试:新增 NoticeIntentFormTest(7),IntentDirectFormTest 加教师/学生两条公告用例
  (仍用「一调就炸」的假 LLM 证明不过大模型);全量 71/71。
This commit is contained in:
lijing
2026-10-12 18:54:15 +08:00
parent c11d331371
commit d2c6c185b5
7 changed files with 352 additions and 47 deletions
+27 -10
View File
@@ -160,21 +160,34 @@ AgentService/
#### 5.1.0 裸意图直达(`service/IntentShortcut`) #### 5.1.0 裸意图直达(`service/IntentShortcut`)
「只有意图、没有细节」的一句话(典型:**「我要请假」**)由后端**直接**按身份弹表单,不经过大模型。 「只有意图、没有细节」的一句话(典型:**「我要请假」**、**「我要发布校园公告」**)由后端**直接**按身份弹表单,
原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串类型列表、 不经过大模型。原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串
没有表单);② 调了写工具却自行**编造**起止时间直接提交;③ 用文字反问。用户的预期是立刻看到表单。 类型列表、没有表单);② 调了写工具却自行**编造**参数直接提交;③ 用文字反问。用户的预期是立刻看到表单。
命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀 命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀
与「吧/了/一下」后缀后,**正好等于**标准说法(`请假`/`请个假`/`请假申请`…)、且原句 ≤12 字。 与「吧/了/一下」后缀后,**正好等于**标准说法、且原句 ≤12 字。因此带信息的句子
因此带信息的句子(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`)**不会**被劫持, (`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`、`发一条关于社团招新的通知`)
照常交给模型(模型能一次填全参数,或由下面的缺参拦截补表单)。 **不会**被劫持,照常交给模型。
**加新意图只需三步**(不用动 `AgentOrchestrator`):① 一条 `Intent`(工具 + 引导语);
② 一个「标准说法」短语集;③ 往 `RULES` 里追加一条 `Rule`。
同一意图按身份落到不同工具: 同一意图按身份落到不同工具:
| 身份 | 工具 | 表单字段 | 提交后 | | 意图 | 身份 | 工具 | 表单字段 | 提交后 |
|---|---|---|---| |---|---|---|---|---|
| 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 | | 请假 | 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 |
| 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 | | 请假 | 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 |
| 发布校园公告 | 教职工 | `notice.publish` | 公告标题 / 公告正文 / **发文部门**(下拉) | **立即发布、全站可见** |
| 发布校园公告 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
⚠️ **发布公告只认带「发/发布」动词的说法**(`发布公告`/`发个公告`/`我要发布校园公告`/`发通知`…):
光说「公告」「通知」更像**查询**,误判成"发布"会很突兀,故不进短语表。
⚠️ **不要再给 `notice.publish` 加「发布方式」参数**:后端 `/api/notice/Create` 就是**直接发布**
(是否转审批由学校「应用设置」里的流程绑定决定,请求侧绕不过),并不存在 `publishType` 字段 ——
声明它会让用户以为选了「存草稿」却仍然直接发出去。需要草稿/送审请走公告模块页面
(`/api/notice/SaveDraft` 与 `/api/notice/Submit`,后者还需要先有草稿 id,属另一条链路)。
#### 5.1.1 缺参 → 下发补全表单 #### 5.1.1 缺参 → 下发补全表单
`POST /api/agent/chat {"message":"我要请假"}`(学生身份) `POST /api/agent/chat {"message":"我要请假"}`(学生身份)
@@ -224,6 +237,10 @@ AgentService/
> 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取** > 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取**
> (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典, > (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典,
> 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。 > 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。
>
> **字典工具的 `fixed` 参数会被一并拼上** —— 多个模块的字典接口靠它选字典种类,
> 例如公告的发文部门取自 `/api/dict/List?type=1`(`{k,v}` 形状,故 `deptId` 声明
> `.optionsKeys("k","v")`)。早先漏拼固定参数,前端取回的是「没指定类型」的结果,下拉是空的。
**② 填完提交 → 直接执行**:`POST /api/agent/chat` **② 填完提交 → 直接执行**:`POST /api/agent/chat`
@@ -113,8 +113,8 @@ public class AgentOrchestrator {
ChatResponse resp = new ChatResponse(); ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId()); resp.setSessionId(session.getSessionId());
// ★ 裸意图直达:「我要请假」这类只有意图、没有细节的句子,直接按身份弹表单, // ★ 裸意图直达:「我要请假」「发布校园公告」这类只有意图、没有细节的句子,直接按身份弹表单,
// 不交给大模型决策(模型可能先去查字典、或自行编造时间直接提交,都不合预期)。 // 不交给大模型决策(模型可能先去查字典、或自行编造参数直接提交,都不合预期)。
ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out); ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out);
if (direct != null) { if (direct != null) {
return direct; return direct;
@@ -408,8 +408,9 @@ public class AgentOrchestrator {
/* ==================== 表单:缺参拦截 与 直提交 ==================== */ /* ==================== 表单:缺参拦截 与 直提交 ==================== */
/** /**
* 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」)时, * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时,
* 按当前身份直接构建表单返回 —— 学生拿「请假申请」,教职工拿「请假登记(帮学生请假)」。 * 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」;
* 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。
* *
* <p>判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}), * <p>判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}),
* 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null}, * 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null},
@@ -449,8 +450,8 @@ public class AgentOrchestrator {
resp.setForm(form); resp.setForm(form);
resp.setReply(reply); resp.setReply(reply);
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()))); out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing())));
log.info("意图直达弹表单:session={} 身份={} 工具={}", session.getSessionId(), log.info("意图直达弹表单:session={} 意图={} 身份={} 工具={}", session.getSessionId(),
staff ? "教职工" : "学生", def.getName()); intent.getName(), staff ? "教职工" : "学生", def.getName());
finish(resp, session, 0, 0, out); finish(resp, session, 0, 0, out);
return resp; return resp;
} }
@@ -811,11 +812,12 @@ public class AgentOrchestrator {
continue; continue;
} }
JSONObject o = (JSONObject) item; JSONObject o = (JSONObject) item;
String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key"); // "k"/"v" 是平台通用字典(/api/dict/List)的形态,与业务 VO(classId/className)并存
String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key", "k");
if (StringUtils.isBlank(value)) { if (StringUtils.isBlank(value)) {
continue; continue;
} }
String label = firstOf(o, "name", "label", "text", "title", "dictName", "value"); String label = firstOf(o, "name", "label", "text", "title", "dictName", "v", "value");
Map<String, String> one = new LinkedHashMap<>(); Map<String, String> one = new LinkedHashMap<>();
one.put("value", value); one.put("value", value);
one.put("label", StringUtils.isBlank(label) ? value : label); one.put("label", StringUtils.isBlank(label) ? value : label);
@@ -1007,6 +1009,10 @@ public class AgentOrchestrator {
.append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。") .append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。")
.append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n"); .append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n");
sb.append("10. ★ 「发布校园公告」是**立即发布、全站可见**的动作:必须拿到标题、正文、发文部门三项;")
.append("缺哪项就照常调用工具让系统弹表单,不要自己编造发文部门,也不要用文字反复追问。")
.append("该工具只对教职工下发(学生身份看不到),学生要求发布公告时如实说明身份不够。\n");
return sb.toString(); return sb.toString();
} }
@@ -3,13 +3,15 @@ package com.yunhai.agent.service;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:<b>「我要请假」</b>)确定性地 * 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:<b>「我要请假」</b>、
* 映射到对应表单,<b>不经过大模型决策</b>。 * <b>「发布校园公告」</b>)确定性地映射到对应表单,<b>不经过大模型决策</b>。
* *
* <h3>为什么需要它</h3> * <h3>为什么需要它</h3>
* 同一句「我要请假」,模型可能给出三种都不合预期的反应: * 同一句「我要请假」,模型可能给出三种都不合预期的反应:
@@ -26,10 +28,15 @@ import java.util.Set;
* 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子, * 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子,
* 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。 * 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。
* *
* <h3>怎么加新意图</h3>
* 一次加三样:① 一条 {@link Intent}(工具 + 引导语);② 一个「标准说法」短语集;
* ③ 往 {@link #RULES} 里追加一条 {@link Rule}。不需要动 {@code AgentOrchestrator}。
*
* <h3>身份差异</h3> * <h3>身份差异</h3>
* 同一个意图在两个身份下落到<b>不同工具</b>:学生 = 请假申请({@code type=2} 待审核), * 同一个意图在不同身份下可能落到<b>不同工具</b>:请假 → 学生 {@code leave.submit}(type=2 待审核)/
* 教职工 = 请假登记({@code type=1} 免审通过,需先选班级与学生)。表单字段由工具定义推导, * 教职工 {@code leave.register}(type=1 免审,需先选班级与学生)。只在一个身份下成立时,
* 因此「对应好表单」是自动成立的。 * 把另一个身份的工具置 {@code null},该身份就会照常交回模型(由模型的权限准则如实说明)。
* 表单字段由工具定义推导,因此「对应好表单」是自动成立的。
*/ */
@Component @Component
public class IntentShortcut { public class IntentShortcut {
@@ -37,7 +44,10 @@ public class IntentShortcut {
/** 一个意图在两种身份下的落点与引导话术 */ /** 一个意图在两种身份下的落点与引导话术 */
public static class Intent { public static class Intent {
/** 学生身份用的工具(内部点分名) */ /** 意图名(日志/排查用,如 {@code leave}) */
private final String name;
/** 学生身份用的工具(内部点分名;null = 该身份下不直达) */
private final String studentTool; private final String studentTool;
/** 教职工身份用的工具(null = 该身份下不直达) */ /** 教职工身份用的工具(null = 该身份下不直达) */
@@ -49,13 +59,18 @@ public class IntentShortcut {
/** 教职工身份的表单引导语 */ /** 教职工身份的表单引导语 */
private final String staffReply; private final String staffReply;
Intent(String studentTool, String staffTool, String studentReply, String staffReply) { Intent(String name, String studentTool, String staffTool, String studentReply, String staffReply) {
this.name = name;
this.studentTool = studentTool; this.studentTool = studentTool;
this.staffTool = staffTool; this.staffTool = staffTool;
this.studentReply = studentReply; this.studentReply = studentReply;
this.staffReply = staffReply; this.staffReply = staffReply;
} }
public String getName() {
return name;
}
public String getStudentTool() { public String getStudentTool() {
return studentTool; return studentTool;
} }
@@ -73,8 +88,21 @@ public class IntentShortcut {
} }
} }
/** 规则:一组「标准说法」→ 一个意图 */
private static final class Rule {
private final Set<String> phrases;
private final Intent intent;
Rule(Set<String> phrases, Intent intent) {
this.phrases = phrases;
this.intent = intent;
}
}
/** 请假意图:学生 → 请假申请;教职工 → 请假登记(帮学生请假) */ /** 请假意图:学生 → 请假申请;教职工 → 请假登记(帮学生请假) */
private static final Intent LEAVE = new Intent( private static final Intent LEAVE = new Intent("leave",
"leave.submit", "leave.submit",
"leave.register", "leave.register",
"好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交," "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,"
@@ -83,19 +111,50 @@ public class IntentShortcut {
+ "点「确认提交」我直接登记(教师登记免审,立即生效)。"); + "点「确认提交」我直接登记(教师登记免审,立即生效)。");
/** /**
* 标准说法全集(已剥掉语气词与标点后的形态)。 * 发布校园公告意图:<b>仅教职工</b>(学生置 null → 交回模型如实说明身份不够)。
* 新增意图时在这里加词 + 补一条 {@link #match(String)} 分支即可。 *
* <p>工具只有一个,两个身份字段因此填同一个(保持结构统一,便于将来分叉)。
*/
private static final Intent NOTICE_PUBLISH = new Intent("notice.publish",
null,
"notice.publish",
"发布校园公告需要教师或管理员身份;你可以先看看「我收到的公告」,或换个身份账号再来发布。",
"好的,下面是**发布校园公告**表单:填好公告标题和正文,选好发文部门,"
+ "点「确认提交」我就直接发布(立即生效,全站可见)。");
/**
* 请假的标准说法全集(已剥掉语气词与标点后的形态)。
*/ */
private static final Set<String> LEAVE_BARE = new HashSet<>(Arrays.asList( private static final Set<String> LEAVE_BARE = new HashSet<>(Arrays.asList(
"请假", "请个假", "请一个假", "请假申请", "请假登记", "登记请假", "请假", "请个假", "请一个假", "请假申请", "请假登记", "登记请假",
"学生请假", "老师请假", "教师请假", "帮学生请假", "给学生请假" "学生请假", "老师请假", "教师请假", "帮学生请假", "给学生请假"
)); ));
/**
* 发布公告的标准说法全集。
*
* <p>为什么不放光秃秃的「公告」「通知」二字:那更像<b>查询</b>意图
* (「公告」「通知」单独一句,用户多半是想看看有什么),误判成"发布"会很突兀。
* 只认带「发/发布」动词的说法,与「读公告」明确区分开。
*
* <p>⚠️ 现在收「发通知」是安全的:本平台里 {@code campusnotice} 就是「校园公告(校园通知)」,
* 而宿舍通知目前**没有**发布工具({@code dormnotice} 只有家长侧回执)。等将来接上
* 宿舍通知发布后,「发通知」会变成跨模块歧义(猜错模块比不直达更糟)——
* 届时要么把本组里的「通知」说法摘掉只留「公告」,要么交给模型按上下文选。
*/
private static final Set<String> NOTICE_PUBLISH_BARE = new HashSet<>(Arrays.asList(
"发布公告", "发公告", "发个公告", "发布校园公告", "校园公告发布",
"发布一条公告", "发一条公告", "发布一篇公告", "发一篇公告",
"发布通知", "发通知", "发个通知", "发布校园通知", "校园通知发布",
"发布一条通知", "发一条通知"
));
/** /**
* 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。 * 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。
* *
* <p>⚠️ 这里**不能放「请」**:{@code 请假申请} 本身以「请」开头,剥掉就变成「假申请」, * <p>⚠️ 这里**不能放「请」**:{@code 请假申请} 本身以「请」开头,剥掉就变成「假申请」,
* 反而会让标准说法匹配不上。只放意义明确的多字助动词。 * 反而会让标准说法匹配不上。只放意义明确的多字助动词。
* <p>⚠️ 也不能放「发」「发布」:{@code 发布公告} 以「发布」开头,剥掉剩「公告」就落空了。
*/ */
private static final String[] PREFIXES = { private static final String[] PREFIXES = {
"我想要", "我需要", "我要", "我想", "帮我", "帮忙", "麻烦", "我想要", "我需要", "我要", "我想", "帮我", "帮忙", "麻烦",
@@ -107,8 +166,16 @@ public class IntentShortcut {
"一下", "吧", "呢", "啊", "哦", "呀", "嘛", "了", "的" "一下", "吧", "呢", "啊", "哦", "呀", "嘛", "了", "的"
}; };
/** 全部规则(顺序即匹配顺序;短语集之间不应有交集) */
private static final List<Rule> RULES = new ArrayList<>();
static {
RULES.add(new Rule(LEAVE_BARE, LEAVE));
RULES.add(new Rule(NOTICE_PUBLISH_BARE, NOTICE_PUBLISH));
}
/** /**
* 判断一句话是不是「裸请假意图」。 * 判断一句话是不是「裸意图」。
* *
* @return 命中的意图;不是裸意图(含细节、或别的意图)返回 {@code null} * @return 命中的意图;不是裸意图(含细节、或别的意图)返回 {@code null}
*/ */
@@ -118,8 +185,11 @@ public class IntentShortcut {
if (normalized.length() < 2 || normalized.length() > 12) { if (normalized.length() < 2 || normalized.length() > 12) {
return null; return null;
} }
if (LEAVE_BARE.contains(stripDecorations(normalized))) { String key = stripDecorations(normalized);
return LEAVE; for (Rule r : RULES) {
if (r.phrases.contains(key)) {
return r.intent;
}
} }
return null; return null;
} }
@@ -264,6 +264,10 @@ public class ToolExecutor {
* {@code {{字段名}}} 占位符(如 {@code /homeleave/api/leave/ClassStudents?classId={{classId}}}), * {@code {{字段名}}} 占位符(如 {@code /homeleave/api/leave/ClassStudents?classId={{classId}}}),
* 由前端在依赖字段有值后替换再发起请求 —— 这样「学生」这种依赖「班级」的下拉才拉得动。 * 由前端在依赖字段有值后替换再发起请求 —— 这样「学生」这种依赖「班级」的下拉才拉得动。
* *
* <p>★ 字典工具自带的 {@code fixed} 参数<b>必须带上</b>:多个模块的字典接口靠它选字典种类
* (如公告的发文部门取自 {@code /api/dict/List?type=1})。早先漏了这一步,
* 前端取回的是「没指定类型」的结果,下拉会是空的。
*
* @param dict 只读字典工具(如 leave.classStudents) * @param dict 只读字典工具(如 leave.classStudents)
* @param placeholders 需要留占位符的依赖字段名(可为空 = 无参字典) * @param placeholders 需要留占位符的依赖字段名(可为空 = 无参字典)
* @return 调用描述;字典工具非法或模块未配置地址时返回 {@code null} * @return 调用描述;字典工具非法或模块未配置地址时返回 {@code null}
@@ -287,6 +291,19 @@ public class ToolExecutor {
call.setMethod(method); call.setMethod(method);
StringBuilder qs = new StringBuilder(); StringBuilder qs = new StringBuilder();
// 先拼固定参数(如 dict/List?type=1),再拼依赖占位符
if (dict.getFixed() != null) {
for (Map.Entry<String, Object> e : dict.getFixed().entrySet()) {
Object v = e.getValue();
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
continue;
}
qs.append(qs.length() == 0 ? "?" : "&")
.append(urlEncode(e.getKey()))
.append("=")
.append(urlEncode(String.valueOf(v)));
}
}
if (placeholders != null) { if (placeholders != null) {
for (String ph : placeholders) { for (String ph : placeholders) {
if (StringUtils.isBlank(ph)) { if (StringUtils.isBlank(ph)) {
@@ -54,36 +54,35 @@ public final class CampusNoticeTools {
.readAt("/")); .readAt("/"));
list.add(ToolDef.of("notice.deptDics", MODULE, "发文部门字典", list.add(ToolDef.of("notice.deptDics", MODULE, "发文部门字典",
"查询可选的发文部门(发布公告时填 deptId 用)。", "查询本校本模块可选的发文部门(发布公告时填 deptId 用)。"
+ "仅在用户明确问「有哪些发文部门」时使用;"
+ "发布公告时**不要**先调它 —— 表单的「发文部门」下拉会自动带出可选值。",
"GET", "/api/dict/List") "GET", "/api/dict/List")
.fix("type", "1") .fix("type", "1")
.readAt("/list/new")); .readAt("/list/new"));
list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告", list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告",
"发布一条校园公告。调用前必须确认:标题 title、正文 content、发文部门 deptId;" "发布一条校园公告,**立即生效、全站可见**(是否转审批由学校「应用设置」里的流程绑定决定,"
+ "缺失就问用户,不要编造部门。", + "请求侧无法选择)。调用前必须确认:标题 title、正文 content、发文部门 deptId;"
+ "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;"
+ "发文部门必须来自字典,不要编造。",
"POST_JSON", "/api/notice/Create") "POST_JSON", "/api/notice/Create")
.p(ToolParam.required("title", "string", "公告标题") .p(ToolParam.required("title", "string", "公告标题")
.label("公告标题").eg("关于国庆假期安排的通知"), .label("公告标题").eg("关于国庆假期安排的通知"),
ToolParam.required("content", "string", "公告正文(纯文本)") ToolParam.required("content", "string", "公告正文(纯文本)")
.label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"), .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"),
ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics") ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段")
.label("发文部门").optionsFrom("notice.deptDics"), .label("发文部门")
ToolParam.optional("deptName", "string", "发文部门名称") .optionsFrom("notice.deptDics").optionsKeys("k", "v"),
.label("发文部门名称"), ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要")
ToolParam.optional("type", "integer", "重要程度")
.label("重要程度").dft("1") .label("重要程度").dft("1")
.options(new String[]{"1", "2"}, new String[]{"普通", "重要"}), .options(new String[]{"1", "2"}, new String[]{"普通", "重要"}),
ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执") ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执(要回执时收件人需点确认)")
.label("需要阅读回执").dft("0") .label("需要阅读回执").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}), .options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("smsFlag", "integer", "是否同时发短信") ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒")
.label("同时发短信").dft("0") .label("同时发短信").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}), .options(new String[]{"0", "1"}, new String[]{"否", "是"}))
ToolParam.optional("publishType", "string", "发布方式")
.label("发布方式").dft("direct")
.options(new String[]{"direct", "draft", "approve"},
new String[]{"直接发布", "存草稿", "提交审批"}))
.write("/list/new") .write("/list/new")
.modId(null)); .modId(null));
@@ -38,7 +38,13 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
* 「我要请假」在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。 * 裸意图在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。
*
* <p>覆盖两条已落地的意图:
* <ul>
* <li>「我要请假」→ 学生拿请假申请、教职工拿请假登记(帮学生请假);</li>
* <li>「我要发布校园公告」→ 教职工拿发布表单,学生落回模型。</li>
* </ul>
* *
* <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单, * <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
* 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。 * 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。
@@ -70,6 +76,7 @@ class IntentDirectFormTest {
void setUp() { void setUp() {
settings = new AgentSettings(); settings = new AgentSettings();
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave"); settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice");
registry = new ToolRegistry(settings); registry = new ToolRegistry(settings);
registry.init(); registry.init();
@@ -166,6 +173,39 @@ class IntentDirectFormTest {
assertNotNull(field(resp, "remarks")); assertNotNull(field(resp, "remarks"));
} }
/* ==================== 教师:发布校园公告 ==================== */
@Test
void teacherSayingPublishNoticeGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布校园公告", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("notice.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("/campusnotice/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery(),
"字典地址必须带 type=1,否则取不回发文部门");
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
}
@Test
void studentSayingPublishNoticeFallsThroughToModel() {
// 学生没有发布公告能力 → 不直达,交给模型如实说明需要教师/管理员身份
ChatResponse resp = chat(ECHO_LLM, "我要发布校园公告", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布公告表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 不该被劫持的情况 ==================== */ /* ==================== 不该被劫持的情况 ==================== */
@Test @Test
@@ -0,0 +1,156 @@
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>:{@code notice.publish} 在 {@code RolePolicy} 里归入 STAFF_ONLY,
* 学生身份没有落点 → 落回模型如实说明身份不够。</li>
* <li><b>不声明「发布方式」</b>:后端 {@code /api/notice/Create} 就是直接发布,
* 不存在 {@code publishType} 字段;声明它会让用户以为选了「存草稿」,实际仍直接发出。</li>
* </ol>
*/
class NoticeIntentFormTest {
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");
registry = new ToolRegistry(settings);
registry.init();
executor = new ToolExecutor(settings, new RestTemplate());
}
/* ==================== 裸意图规则 ==================== */
@Test
void bareNoticePublishIntentIsRecognized() {
for (String text : Arrays.asList(
"发布公告", "我要发布公告", "发布校园公告", "我要发布校园公告",
"发公告", "发个公告", "帮我发公告", "我要发一条公告",
"发布通知", "发通知", "发布校园通知", "麻烦发个通知吧")) {
IntentShortcut.Intent it = shortcut.match(text);
assertNotNull(it, "「" + text + "」应被识别为裸发布公告意图");
assertEquals("notice.publish", it.getStaffTool(),
"「" + text + "」应落到发布校园公告工具");
}
}
@Test
void noticePublishHasNoStudentLanding() {
IntentShortcut.Intent it = shortcut.match("发布公告");
assertNotNull(it);
assertNull(it.getStudentTool(), "学生没有发布公告能力,该身份下应落回模型");
}
@Test
void readingNoticesIsNotHijacked() {
// 光说「公告」「通知」更像查询;带细节的长句交模型 —— 都不能被"发布"规则抢走
for (String text : Arrays.asList(
"公告", "通知", "有什么公告", "看看未读公告", "公告列表",
"这周有什么新的校园公告?", "发一条关于社团招新的通知")) {
assertNull(shortcut.match(text), "「" + text + "」不是发布意图,不应被直达规则命中");
}
}
/** 请假与公告两组规则不能互相串味 */
@Test
void leaveAndNoticeRulesDoNotOverlap() {
IntentShortcut.Intent leave = shortcut.match("我要请假");
assertNotNull(leave);
assertEquals("leave.register", leave.getStaffTool());
IntentShortcut.Intent notice = shortcut.match("我要发布公告");
assertNotNull(notice);
assertEquals("notice.publish", notice.getStaffTool());
}
/* ==================== 发布工具的真实契约 ==================== */
@Test
void publishToolPublishesDirectlyWithoutPhantomPublishType() {
ToolDef publish = registry.get("notice.publish");
assertNotNull(publish);
assertFalse(publish.isReadOnly(), "发布公告是写操作");
assertEquals("/api/notice/Create", publish.getPath());
for (ToolParam p : publish.getParams()) {
assertFalse("publishType".equals(p.getName()),
"后端 /api/notice/Create 不支持发布方式,声明该参数会造成「选了存草稿却直接发出」");
}
assertTrue(requiredNames(publish).contains("title"));
assertTrue(requiredNames(publish).contains("content"));
assertTrue(requiredNames(publish).contains("deptId"), "发文部门是后端强校验的必填项");
}
@Test
void deptIdDropdownUsesPlatformDictKeyShape() {
ToolParam deptId = param(registry.get("notice.publish"), "deptId");
assertEquals("notice.deptDics", deptId.getOptionsFrom());
assertEquals("k", deptId.getOptionValueKey(), "/api/dict/List 返回 {k,v},必须按键取值");
assertEquals("v", deptId.getOptionLabelKey());
}
/* ==================== 下拉选项的代取地址 ==================== */
@Test
void deptDictOptionCallCarriesFixedTypeParam() {
BrowserToolRequest.BrowserCall call =
executor.prepareOptionCall(registry.get("notice.deptDics"), null);
assertNotNull(call);
assertEquals("GET", call.getMethod());
assertEquals("/campusnotice/api/dict/List?type=1", call.getPathAndQuery(),
"发文部门字典靠 type=1 区分种类,丢了固定参数前端取回来是空的");
}
/* ==================== 辅助 ==================== */
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);
}
}