diff --git a/README.md b/README.md index 8136969..c2d2a90 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index 23e5bc2..fb274a2 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -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 { /* ==================== 表单:缺参拦截 与 直提交 ==================== */ /** - * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」)时, - * 按当前身份直接构建表单返回 —— 学生拿「请假申请」,教职工拿「请假登记(帮学生请假)」。 + * 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时, + * 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」; + * 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。 * *
判据与能力目录 / 模型侧过滤**完全一致**({@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 工具只有一个,两个身份字段因此填同一个(保持结构统一,便于将来分叉)。
+ */
+ private static final Intent NOTICE_PUBLISH = new Intent("notice.publish",
+ null,
+ "notice.publish",
+ "发布校园公告需要教师或管理员身份;你可以先看看「我收到的公告」,或换个身份账号再来发布。",
+ "好的,下面是**发布校园公告**表单:填好公告标题和正文,选好发文部门,"
+ + "点「确认提交」我就直接发布(立即生效,全站可见)。");
+
+ /**
+ * 请假的标准说法全集(已剥掉语气词与标点后的形态)。
*/
private static final Set 为什么不放光秃秃的「公告」「通知」二字:那更像查询意图
+ * (「公告」「通知」单独一句,用户多半是想看看有什么),误判成"发布"会很突兀。
+ * 只认带「发/发布」动词的说法,与「读公告」明确区分开。
+ *
+ * ⚠️ 现在收「发通知」是安全的:本平台里 {@code campusnotice} 就是「校园公告(校园通知)」,
+ * 而宿舍通知目前**没有**发布工具({@code dormnotice} 只有家长侧回执)。等将来接上
+ * 宿舍通知发布后,「发通知」会变成跨模块歧义(猜错模块比不直达更糟)——
+ * 届时要么把本组里的「通知」说法摘掉只留「公告」,要么交给模型按上下文选。
+ */
+ private static final Set ⚠️ 这里**不能放「请」**:{@code 请假申请} 本身以「请」开头,剥掉就变成「假申请」,
* 反而会让标准说法匹配不上。只放意义明确的多字助动词。
+ * ⚠️ 也不能放「发」「发布」:{@code 发布公告} 以「发布」开头,剥掉剩「公告」就落空了。
*/
private static final String[] PREFIXES = {
"我想要", "我需要", "我要", "我想", "帮我", "帮忙", "麻烦",
@@ -107,8 +166,16 @@ public class IntentShortcut {
"一下", "吧", "呢", "啊", "哦", "呀", "嘛", "了", "的"
};
+ /** 全部规则(顺序即匹配顺序;短语集之间不应有交集) */
+ private static final List ★ 字典工具自带的 {@code fixed} 参数必须带上:多个模块的字典接口靠它选字典种类
+ * (如公告的发文部门取自 {@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 覆盖两条已落地的意图:
+ * 这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
* 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。
@@ -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
diff --git a/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java b/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java
new file mode 100644
index 0000000..bc71ad1
--- /dev/null
+++ b/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java
@@ -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;
+
+/**
+ * 「发布校园公告」这条链路的断言:裸意图识别 + 发布工具的真实契约 + 发文部门下拉的代取地址。
+ *
+ * 三个刻意的约束:
+ * 为什么需要它
* 同一句「我要请假」,模型可能给出三种都不合预期的反应:
@@ -26,10 +28,15 @@ import java.util.Set;
* 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子,
* 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。
*
+ * 怎么加新意图
+ * 一次加三样:① 一条 {@link Intent}(工具 + 引导语);② 一个「标准说法」短语集;
+ * ③ 往 {@link #RULES} 里追加一条 {@link Rule}。不需要动 {@code AgentOrchestrator}。
+ *
* 身份差异
- * 同一个意图在两个身份下落到不同工具:学生 = 请假申请({@code type=2} 待审核),
- * 教职工 = 请假登记({@code type=1} 免审通过,需先选班级与学生)。表单字段由工具定义推导,
- * 因此「对应好表单」是自动成立的。
+ * 同一个意图在不同身份下可能落到不同工具:请假 → 学生 {@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
+ *
*
*
+ *
+ */
+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