- 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:
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user