- 新增 IntentShortcut:说「我要请假」这类只有意图的短句时,
编排器在调模型前先做窄匹配(去空白标点 + 剥前后缀后正好等于标准说法、
且原句 ≤12 字),命中即直接下发表单并跳过 LLM,消除「偶尔不准」。
- 请假拆两个工具:leave.submit(type=2 学生申请,待审核)与
leave.register(type=1 教师帮助登记,免审,必填 classId+stuId)。
RolePolicy 分别归入 STUDENT_ONLY / STAFF_ONLY,模型视野里只有一个。
- ToolDef.fixed 由 Map<String,String> 改为 Map<String,Object>:
type/continuousLeave 必须按数字下发,字符串会被模块侧 Jackson 判非法。
- ToolParam/FormField 新增 optionsKeys(valueKey,labelKey) 与 dependsOn:
下拉改为下发 BrowserCall optionCall(含 {{占位符}}),由前端带用户登录态
代取后按 valueKey/labelKey 映射;stuId 依赖 classId,选完班级才解锁。
ToolExecutor#prepareOptionCall 不做必填校验,只拼占位查询串。
- 修 leave.types/myClasses/classStudents 描述,明确「填表时不要先调它」。
- README:4.2 能力划分更新 + 新增「请假为何分两个工具」;5.1 增加
5.1.0 裸意图直达(含身份对照表)与表单 JSON 示例。
- 测试:新增 IntentDirectFormTest(5)、LeaveIntentFormTest(9),
以「被调用即失败」的假 LLM 断言「我要请假」不经过大模型;
RolePermissionTest 补 leave.submit/register 的互斥断言。全量 62/62 通过。
This commit is contained in:
@@ -49,4 +49,24 @@ public class FormField {
|
||||
|
||||
/** 下拉选项 [{value,label}] */
|
||||
private List<Map<String, String>> options = new ArrayList<>();
|
||||
|
||||
/* ==================== 动态选项(浏览器代取 + 联动) ==================== */
|
||||
|
||||
/**
|
||||
* 「浏览器代取选项」的调用描述:业务模块的字典接口(班级、学生名单…)只认浏览器 CAS 会话,
|
||||
* 服务端代取必然 302,所以把地址下发前端,由前端用**用户自己的登录态**取回原始字典再渲染下拉。
|
||||
*
|
||||
* <p>地址串里可能含 {@code {{字段名}}} 占位符(见 {@link #dependsOn}),
|
||||
* 前端在依赖字段有值时做替换(值需 URL 编码)后才发起请求。
|
||||
*/
|
||||
private com.yunhai.agent.model.BrowserToolRequest.BrowserCall optionCall;
|
||||
|
||||
/** 本字段选项依赖的先序字段名(如「学生」依赖「班级」);依赖未填时前端应清空并提示先选 */
|
||||
private List<String> dependsOn = new ArrayList<>();
|
||||
|
||||
/** 从字典返回对象里取哪个字段作为选项「值」(如 classId / stuId) */
|
||||
private String valueKey;
|
||||
|
||||
/** 从字典返回对象里取哪个字段作为选项「显示名」(如 className / stuName) */
|
||||
private String labelKey;
|
||||
}
|
||||
|
||||
@@ -49,8 +49,14 @@ public class ToolDef {
|
||||
/** 参数定义 */
|
||||
private List<ToolParam> params = new ArrayList<>();
|
||||
|
||||
/** 固定参数(接口要求但对话里无需用户提供,如学生申请固定 type=2) */
|
||||
private Map<String, String> fixed = new LinkedHashMap<>();
|
||||
/**
|
||||
* 固定参数(接口要求但对话里无需用户提供,如学生申请固定 {@code type=2})。
|
||||
*
|
||||
* <p>★ 值类型用 {@code Object} 而不是 {@code String}:{@code type}/{@code continuousLeave}
|
||||
* 这类字段在模块侧是**整型/枚举**,下发字符串 {@code "2"} 可能被 Jackson 判为非法枚举名,
|
||||
* 必须按数字下发({@code {"type":2}})。需要字符串就直接传 String,不必再转换。
|
||||
*/
|
||||
private Map<String, Object> fixed = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 平台菜单模块号(与 GetUserMod 的 modId 对齐)。
|
||||
@@ -103,7 +109,11 @@ public class ToolDef {
|
||||
return this;
|
||||
}
|
||||
|
||||
public ToolDef fix(String key, String value) {
|
||||
/**
|
||||
* 追加一个固定参数。值请用与模块字段一致的类型:
|
||||
* 整型/枚举传 {@code Integer}({@code fix("type", 2)}),字符串传 {@code String}。
|
||||
*/
|
||||
public ToolDef fix(String key, Object value) {
|
||||
this.fixed.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -257,6 +257,50 @@ public class ToolExecutor {
|
||||
return parseResponse(def, new ResponseEntity<>(body == null ? "" : body, hs), costMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成「代取字典选项」的调用描述(供表单下拉使用)。
|
||||
*
|
||||
* <p>与 {@link #prepareBrowserCall} 的差别:**不做必填校验**,而是把依赖字段写成
|
||||
* {@code {{字段名}}} 占位符(如 {@code /homeleave/api/leave/ClassStudents?classId={{classId}}}),
|
||||
* 由前端在依赖字段有值后替换再发起请求 —— 这样「学生」这种依赖「班级」的下拉才拉得动。
|
||||
*
|
||||
* @param dict 只读字典工具(如 leave.classStudents)
|
||||
* @param placeholders 需要留占位符的依赖字段名(可为空 = 无参字典)
|
||||
* @return 调用描述;字典工具非法或模块未配置地址时返回 {@code null}
|
||||
*/
|
||||
public com.yunhai.agent.model.BrowserToolRequest.BrowserCall prepareOptionCall(
|
||||
ToolDef dict, java.util.Collection<String> placeholders) {
|
||||
if (dict == null || !dict.isReadOnly() || StringUtils.isBlank(dict.getPath())) {
|
||||
return null;
|
||||
}
|
||||
String base = trimEnd(settings.getModules().get(dict.getModule()));
|
||||
if (StringUtils.isBlank(base)) {
|
||||
return null;
|
||||
}
|
||||
String method = dict.getHttpMethod() == null ? "GET" : dict.getHttpMethod().toUpperCase();
|
||||
|
||||
com.yunhai.agent.model.BrowserToolRequest.BrowserCall call =
|
||||
new com.yunhai.agent.model.BrowserToolRequest.BrowserCall();
|
||||
call.setName(dict.getName());
|
||||
call.setTitle(dict.getTitle());
|
||||
call.setWrite(false);
|
||||
call.setMethod(method);
|
||||
|
||||
StringBuilder qs = new StringBuilder();
|
||||
if (placeholders != null) {
|
||||
for (String ph : placeholders) {
|
||||
if (StringUtils.isBlank(ph)) {
|
||||
continue;
|
||||
}
|
||||
qs.append(qs.length() == 0 ? "?" : "&")
|
||||
.append(urlEncode(ph))
|
||||
.append("={{").append(ph).append("}}");
|
||||
}
|
||||
}
|
||||
call.setPathAndQuery(stripOrigin(base + dict.getPath()) + qs);
|
||||
return call;
|
||||
}
|
||||
|
||||
/** 去掉 URL 里的协议与域名,只留 path+query(本服务的模块地址都由配置拼出,格式可控) */
|
||||
private String stripOrigin(String fullUrl) {
|
||||
int idx = fullUrl.indexOf("://");
|
||||
|
||||
@@ -75,6 +75,26 @@ public class ToolParam {
|
||||
*/
|
||||
private String optionsFrom;
|
||||
|
||||
/**
|
||||
* 选项取自字典返回对象的哪个字段(如 {@code classId} / {@code stuId})。
|
||||
*
|
||||
* <p>★ 为什么必须显式声明:模块字典返回的是业务 VO({@code TeachingClassVo{classId,className}}、
|
||||
* {@code ClassStudentVo{stuId,stuName}}),字段名五花八门,靠「猜常见键名」不可靠。
|
||||
* 显式声明后,前端在浏览器里取到原始数据也能**确定性地**映射成下拉项。
|
||||
*/
|
||||
private String optionValueKey;
|
||||
|
||||
/** 选项显示名取自字典返回对象的哪个字段(如 {@code className} / {@code stuName}) */
|
||||
private String optionLabelKey;
|
||||
|
||||
/**
|
||||
* 本字段的选项依赖哪些其它字段(如「学生」下拉依赖先选好「班级」)。
|
||||
*
|
||||
* <p>作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项;
|
||||
* ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。
|
||||
*/
|
||||
private List<String> dependsOn;
|
||||
|
||||
public ToolParam() {
|
||||
}
|
||||
|
||||
@@ -149,6 +169,22 @@ public class ToolParam {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典返回对象里,取哪个字段做「值」、哪个字段做「显示名」。
|
||||
* <p>例:{@code optionsKeys("classId","className")} 对应 {@code {classId,className}} 这种业务 VO。
|
||||
*/
|
||||
public ToolParam optionsKeys(String valueKey, String labelKey) {
|
||||
this.optionValueKey = valueKey;
|
||||
this.optionLabelKey = labelKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 本字段的选项依赖的先序字段(用于联动下拉,如「学生」依赖「班级」) */
|
||||
public ToolParam dependsOn(String... fieldNames) {
|
||||
this.dependsOn = Arrays.asList(fieldNames);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 表单里显示的名字 */
|
||||
public String labelOf() {
|
||||
return StringUtils.isNotBlank(label) ? label : name;
|
||||
@@ -225,6 +261,9 @@ public class ToolParam {
|
||||
f.setOptions(optionList());
|
||||
// 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」
|
||||
f.setOptionsFrom(optionsFrom);
|
||||
f.setDependsOn(dependsOn);
|
||||
f.setValueKey(optionValueKey);
|
||||
f.setLabelKey(optionLabelKey);
|
||||
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
||||
f.setOptionsFallback(isUsingFallbackOptions());
|
||||
return f;
|
||||
|
||||
@@ -8,9 +8,24 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学生请假模块工具(后端 HomeSchLeave,前缀 /api/leave、/api/dict、/api/leavestats)。
|
||||
* 学生请假模块工具(后端 HomeSchLeave / HomeLeave,前缀 /api/leave、/api/dict、/api/leavestats)。
|
||||
*
|
||||
* <p>平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 / 5 请假登记 / 6 请假统计 / 7 应用设置
|
||||
* <p>平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 /
|
||||
* 5 请假登记 / 6 请假统计 / 7 应用设置
|
||||
*
|
||||
* <h3>★ 为什么「请假」要拆成两个工具</h3>
|
||||
* 模块只有一个写接口 {@code POST /api/leave/Save},靠 {@code type} 区分两种业务:
|
||||
* <ul>
|
||||
* <li>{@code type=2} 学生<b>申请</b> → 状态「待审核」,等班主任审批;</li>
|
||||
* <li>{@code type=1} 教师<b>登记</b> → 免审直接通过,且必须指明是给<b>哪个班、哪个学生</b>请的。</li>
|
||||
* </ul>
|
||||
* 早先合成一个工具并写死 {@code type=2},结果是:教师说「帮学生请假」会被提交成一条
|
||||
* 待审核的<b>学生申请</b>(类型错),而且表单里根本没有「班级 / 学生」可选(那两个参数是可选,
|
||||
* 不满足"缺参才出表单"的条件)。拆开之后:
|
||||
* <ul>
|
||||
* <li>角色过滤天然生效 —— 学生只看得见「请假申请」,教职工只看得见「请假登记」,模型不可能选错;</li>
|
||||
* <li>表单字段由各自工具的<b>必填参数</b>推导,教师表单自动带出「班级 → 学生」联动下拉。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class LeaveTools {
|
||||
|
||||
@@ -22,7 +37,8 @@ public final class LeaveTools {
|
||||
/**
|
||||
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。
|
||||
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义),
|
||||
* 但该接口需登录会话、Agent 侧常取不到;没有兜底时表单会退化成让用户填「类型编号」的数字框。
|
||||
* 该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给浏览器用用户登录态取实时字典,
|
||||
* 这份兜底保证「取不到时下拉里也还有标准项」,不会退化成让用户填「类型编号」的数字框。
|
||||
*/
|
||||
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
|
||||
|
||||
@@ -36,19 +52,25 @@ public final class LeaveTools {
|
||||
|
||||
// ---------- 只读:辅助信息 ----------
|
||||
list.add(ToolDef.of("leave.types", MODULE, "请假类型",
|
||||
"查询本校本模块可选的请假类型(病假/事假等)及其编号。用户说“请假”但没给类型时,先调它拿到 leaveType 编号。",
|
||||
"查询本校本模块可选的请假类型(病假/事假等)及其编号。"
|
||||
+ "仅在用户明确问「有哪些请假类型」时使用;"
|
||||
+ "用户说要请假时**不要**先调它 —— 请假表单里的「请假类型」下拉会自动带出可选值。",
|
||||
"GET", "/api/dict/LeaveTypeOptions")
|
||||
.readAt("/apply")
|
||||
.modId(2));
|
||||
|
||||
list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级",
|
||||
"查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。登记请假、筛选前先调它拿 classId。",
|
||||
"查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。"
|
||||
+ "仅在用户明确询问自己的班级时使用(如「我教哪几个班」);"
|
||||
+ "填表/登记请假时**不要**先调它 —— 表单的「班级」下拉会自动取到这个列表。",
|
||||
"GET", "/api/leave/MyClasses")
|
||||
.readAt("/apply")
|
||||
.modId(2));
|
||||
|
||||
list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单",
|
||||
"查询某班级的学生名单(教师给学生登记请假时选人用)。",
|
||||
"查询某班级的学生名单。"
|
||||
+ "仅在用户明确要看某个班的学生名单时使用;"
|
||||
+ "登记请假时**不要**先调它 —— 表单的「学生」下拉会在选好班级后自动带出名单。",
|
||||
"GET", "/api/leave/ClassStudents")
|
||||
.p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses"))
|
||||
.readAt("/register")
|
||||
@@ -57,7 +79,7 @@ public final class LeaveTools {
|
||||
// ---------- 只读:查询 ----------
|
||||
list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录",
|
||||
"分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。"
|
||||
+ "用户问“我请了几天假”“上次请假通过没”时用它。",
|
||||
+ "用户问「我请了几天假」「上次请假通过没」时用它。",
|
||||
"GET", "/api/leave/List")
|
||||
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
|
||||
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
|
||||
@@ -86,31 +108,61 @@ public final class LeaveTools {
|
||||
.readAt("/stats")
|
||||
.modId(6));
|
||||
|
||||
// ---------- 写:提交 / 审批 / 销假 ----------
|
||||
// ---------- 写:学生请假申请(type=2,待审核) ----------
|
||||
list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请",
|
||||
"学生提交一条请假申请(提交后状态为待审核,等班主任审批)。"
|
||||
+ "教师给学生请假请改用「帮学生登记请假」,不要用本工具。"
|
||||
+ "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;"
|
||||
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
|
||||
"POST_JSON", "/api/leave/Save")
|
||||
.p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
|
||||
.label("请假类型")
|
||||
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
|
||||
.optionsFrom("leave.types"),
|
||||
.optionsFrom("leave.types").optionsKeys("k", "v"),
|
||||
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT)
|
||||
.label("开始时间").eg("2026-10-12 08:00:00"),
|
||||
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT)
|
||||
.label("结束时间").eg("2026-10-12 17:00:00"),
|
||||
ToolParam.required("remarks", "string", "请假事由")
|
||||
.label("请假事由").as("textarea").eg("发烧就医,需在家休息"),
|
||||
ToolParam.optional("classId", "integer", "班级 id;用户只有唯一班级时可不传")
|
||||
.label("班级"),
|
||||
ToolParam.optional("stuId", "integer", "学生 id(教师代学生登记时用)")
|
||||
.label("学生"))
|
||||
.fix("type", "2")
|
||||
.fix("continuousLeave", "0")
|
||||
ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)")
|
||||
.label("班级")
|
||||
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"))
|
||||
.fix("type", 2)
|
||||
.fix("continuousLeave", 0)
|
||||
.write("/my")
|
||||
.modId(2));
|
||||
|
||||
// ---------- 写:教师代学生登记请假(type=1,免审通过) ----------
|
||||
list.add(ToolDef.of("leave.register", MODULE, "帮学生登记请假",
|
||||
"教师/管理员给学生登记请假(教师登记免审,直接生效)。"
|
||||
+ "需要先确定班级与学生,再填请假类型、起止时间与事由。"
|
||||
+ "学生自己请假请改用「提交请假申请」,不要用本工具。"
|
||||
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
|
||||
"POST_JSON", "/api/leave/Save")
|
||||
.p(ToolParam.required("classId", "integer", "学生所在班级 id(决定下面「学生」下拉的可选项)")
|
||||
.label("班级")
|
||||
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"),
|
||||
ToolParam.required("stuId", "integer", "学生 id(该班里要登记请假的那个学生)")
|
||||
.label("学生")
|
||||
.optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName")
|
||||
.dependsOn("classId"),
|
||||
ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
|
||||
.label("请假类型")
|
||||
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
|
||||
.optionsFrom("leave.types").optionsKeys("k", "v"),
|
||||
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT)
|
||||
.label("开始时间").eg("2026-10-12 08:00:00"),
|
||||
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT)
|
||||
.label("结束时间").eg("2026-10-12 17:00:00"),
|
||||
ToolParam.required("remarks", "string", "请假事由")
|
||||
.label("请假事由").as("textarea").eg("家中有事,需请假一天"))
|
||||
.fix("type", 1)
|
||||
.fix("continuousLeave", 0)
|
||||
.write("/register")
|
||||
.modId(5));
|
||||
|
||||
// ---------- 写:撤销 / 销假 / 审批 ----------
|
||||
list.add(ToolDef.of("leave.withdraw", MODULE, "撤销请假",
|
||||
"撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。",
|
||||
"POST_FORM", "/api/leave/Withdraw")
|
||||
@@ -128,7 +180,7 @@ public final class LeaveTools {
|
||||
.label("实际返校时间").eg("2026-10-12 08:00:00"),
|
||||
ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT)
|
||||
.label("实际结束时间").eg("2026-10-12 17:00:00"))
|
||||
.fix("continuousLeave", "0")
|
||||
.fix("continuousLeave", 0)
|
||||
.write("/my")
|
||||
.modId(3));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user