diff --git a/src/main/java/com/yunhai/agent/service/IntentShortcut.java b/src/main/java/com/yunhai/agent/service/IntentShortcut.java index 0119948..2b668f9 100644 --- a/src/main/java/com/yunhai/agent/service/IntentShortcut.java +++ b/src/main/java/com/yunhai/agent/service/IntentShortcut.java @@ -146,6 +146,25 @@ public class IntentShortcut { "好的,下面是**发布家校活动**表单:填好活动标题、说明、分类、地点和起止时间," + "点「确认提交」我就发给全部班级的家长(需要家长报名的话,报名方式保持默认即可)。"); + /** + * 发布专业发展记录意图:仅教职工。 + * + *

★ 2026-10 补录:此前「我要发布专业发展」不在直达表里,这句被交给模型, + * 而工具的说明写着「调用前必须确认所属栏目 moduleId 与标题」—— 于是模型先反问一轮 + * 「你要发到哪个栏目?」,用户答完才弹表单。表单里本来就有「所属栏目」下拉, + * 白白多一轮对话(用户反馈的原话:问完栏目,表单里又要选一次栏目)。 + * + *

为什么不预置栏目:栏目是**学校自建**的(每所学校的栏目 id 都不同), + * 硬编码「教学设计 = 71」在别的学校就是错的。所以直达时**不带任何栏目的预置值**, + * 由用户就在表单里选 —— 这正是「直接出表单选栏目」。 + */ + private static final Intent PROFESSION_PUBLISH = new Intent("profession.publish", + null, + "profession.publish", + "发布专业发展记录需要教师或管理员身份;你可以先看看自己的专业发展记录,或换个身份账号再来发布。", + "好的,下面是**发布专业发展记录**表单:先选「所属栏目」,再填标题和正文," + + "点「确认提交」我就直接发布到你的专业发展空间。"); + /** * 请假的标准说法全集(已剥掉语气词与标点后的形态)。 */ @@ -190,6 +209,18 @@ public class IntentShortcut { "发布家校活动", "发家校活动", "发布校园活动", "发校园活动", "发布亲子活动" )); + /** + * 发布专业发展记录的标准说法全集。 + * + *

⚠️ 只收**带模块限定**的说法。光秃秃的「发布记录」「新增记录」太泛 + * (作业、公告、宿舍都能叫「记录」),收进来会把别的意图劫持掉。 + */ + private static final Set PROFESSION_PUBLISH_BARE = new HashSet<>(Arrays.asList( + "发布专业发展", "发专业发展", "专业发展发布", "发布专业发展记录", "发专业发展记录", + "专业发展记录发布", "发布一条专业发展记录", "新增专业发展记录", "发布我的专业发展", + "上传专业发展记录", "提交专业发展记录", "发布发展记录", "新增发展记录" + )); + /** * 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。 * @@ -215,6 +246,7 @@ public class IntentShortcut { 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)); + RULES.add(new Rule(PROFESSION_PUBLISH_BARE, PROFESSION_PUBLISH)); } /** diff --git a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java index fa6cae0..dfce231 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java @@ -125,7 +125,11 @@ public final class ProfessionTools { list.add(ToolDef.of("profession.publish", MODULE, "发布专业发展记录", "发布一条专业发展记录(教学设计/听课记录/课题研究/教学反思等)。" - + "调用前必须确认所属栏目 moduleId 与标题;" + + "调用前必须确认:所属栏目 moduleId、标题 title;" + // ★ 与公告 / 宿舍 / 活动 / 作业四个发布工具完全同款的一句(此前唯独这里漏了): + // 少了它,模型读到「必须确认」就先去反问用户「你要发到哪个栏目」, + // 答完才弹表单 —— 一件小事被拆成两轮对话(用户反馈的原话)。 + + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + "正文、年级段、学科、是否公开、附件在表单里补齐," + "栏目自定义字段由表单按所选栏目动态渲染。", "POST_FORM", "/api/profession/Create") diff --git a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java index daee665..88e165b 100644 --- a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java +++ b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java @@ -79,6 +79,7 @@ class IntentDirectFormTest { 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"); + settings.getModules().put("profession", "http://base.yunhaixingtong.com/profession"); registry = new ToolRegistry(settings); registry.init(); @@ -281,6 +282,51 @@ class IntentDirectFormTest { assertEquals("(模型回复)", resp.getReply()); } + /* ==================== 教师:发布专业发展记录 ==================== */ + + /** + * 「我要发布专业发展」必须**直接**弹表单,而不是先被模型反问「你要发到哪个栏目」。 + * + *

用户反馈的原话:说「我要发布专业发展」被问了一轮栏目,答完才出表单, + * 表单里却还留着空的「所属栏目」要再选一次 —— 同一件事问了两遍。 + * 栏目是学校自建的(各校 id 不同),所以直达时不预置,直接交给表单里的下拉选。 + */ + @Test + void teacherSayingPublishProfessionRecordGetsPublishFormWithoutCallingLlm() { + ChatResponse resp = chat(EXPLODING_LLM, "我要发布专业发展", caller(AgentRole.TEACHER)); + + assertNotNull(resp.getForm(), "应直接下发表单"); + assertEquals("profession.publish", resp.getForm().getTool()); + assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); + assertEquals("/record/new", resp.getForm().getLandingPath()); + assertTrue(resp.getReply().contains("所属栏目"), + "引导语要点明表单里就能选栏目,实际:" + resp.getReply()); + + // 「所属栏目」是必填下拉,且带着「用用户自己的登录态去取栏目树」的地址 + FormField moduleId = field(resp, "moduleId"); + assertEquals("select", moduleId.getWidget(), "栏目应是下拉,不该让用户手填栏目编号"); + assertTrue(moduleId.isRequired(), "栏目必填"); + assertNotNull(moduleId.getOptionCall(), "栏目下拉应带上「代取地址」"); + assertEquals("/profession/api/moudule/ModuleTree", moduleId.getOptionCall().getPathAndQuery()); + + // 栏目**不能**被直达预置:各校栏目 id 不同,硬编码必然出错 + assertFalse(resp.getForm().getPreset().containsKey("moduleId"), + "直达不应预置栏目,实际 preset:" + resp.getForm().getPreset()); + + // 发布页是全字段表单(与模块页 RecordEdit.vue 对齐) + assertTrue(resp.getForm().isAllFields(), "发布表单应渲染全部字段"); + assertNotNull(field(resp, "title")); + assertNotNull(field(resp, "remark")); + assertNotNull(field(resp, "fileIds")); + } + + @Test + void studentSayingPublishProfessionRecordFallsThroughToModel() { + ChatResponse resp = chat(ECHO_LLM, "我要发布专业发展", caller(AgentRole.STUDENT)); + assertNull(resp.getForm(), "学生不该拿到发布专业发展表单"); + assertEquals("(模型回复)", resp.getReply()); + } + /* ==================== 不该被劫持的情况 ==================== */ @Test diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java index 8f80b1b..3cf504c 100644 --- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java +++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java @@ -474,6 +474,43 @@ class ModuleFormParityTest { AgentOrchestrator.pathPrefix("profession", "http://base.yunhaixingtong.com/profession")); } + /* ==================== 发布类工具的「说明词」不变量 ==================== */ + + /** + * 凡是「叫什么就发什么」的发布工具({@code *.publish}),说明里都必须有 + * 「缺失就直接调用本工具…不要用文字反复追问」。 + * + *

起因(用户反馈:「我说我要发布专业发展,没到表单,先让我选哪个栏目,答完表单里 + * 又要选一次所属栏目,一件事问了两遍」):五个发布工具里有四个都写了这句, + * 唯独 {@code profession.publish} 只写了「调用前必须确认所属栏目 moduleId 与标题」—— + * 模型读到「必须确认」,于是先反问用户要发到哪个栏目,答完才弹表单。 + * + *

这类问题的根子在**说明词**而不是代码:工具说明是模型唯一的施工图, + * 「必须确认」后面缺了那句「缺失就直接调用」,模型就只好照字面去口头确认。 + * 因此在契约测试里钉住「只写 {@code xxx.publish} 的都要带上这句」, + * 将来新增发布工具漏写会被直接测出来。 + * + *

为什么是「名字以 {@code .publish} 结尾」而不是「所有 fullForm 工具」: + * {@code notice.saveDraft} / {@code homework.schedule} 这类变体不在此列 —— + * 它们是「同一个发布动作的另一种落点」,由发布工具自己的说明指向它们, + * 单独调用时也不该反过来抢走发布意图(用户说「发布作业」要落到 {@code homework.publish})。 + */ + @Test + void everyPublishToolTellsTheModelNotToKeepAskingInWords() { + int checked = 0; + for (ToolDef def : registry.all()) { + if (!def.getName().endsWith(".publish")) { + continue; + } + checked++; + assertTrue(def.getDescription() != null && def.getDescription().contains("不要用文字反复追问"), + "发布工具 " + def.getName() + " 的说明缺少「不要用文字反复追问」——" + + "模型会照字面先反问用户要发到哪个栏目/填什么,把一件事拆成两轮。" + + "实际说明:" + def.getDescription()); + } + assertEquals(5, checked, "注册表里应有 5 个发布工具(公告/宿舍/活动/作业/专业发展)"); + } + /* ==================== 辅助 ==================== */ /** 字典返回对象里「值 / 显示名」取的是哪两个键(optionsKeys 的结果) */