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