feat(leave): 裸意图直达表单 + 按身份分离请假表单 + 下拉代取通道
build / build-push (push) Successful in 51s

- 新增 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:
lijing
2026-10-12 17:49:55 +08:00
parent 61a3000bee
commit c11d331371
12 changed files with 896 additions and 46 deletions
+56 -9
View File
@@ -122,11 +122,18 @@ AgentService/
| 名单 | 工具 | 说明 |
|---|---|---|
| 教职工(教师+管理员) | `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.buildings` `dorm.rooms` `dorm.roomStudents` `dorm.dormTree`、`activity.classTree`、`profession.*`(全部) | 审批、发布、统计、宿管、专业发展 |
| 学生 | `homework.*`(全部)、`activity.signup` `activity.cancelSignup`、`leave.reportBack` | 交作业、报名活动、销假 |
| 学生+教职工共用 | `leave.submit` `leave.withdraw` | 发起/撤销自己的事项 |
| 教职工(教师+管理员) | `leave.register` `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.buildings` `dorm.rooms` `dorm.roomStudents` `dorm.dormTree`、`activity.classTree`、`profession.*`(全部) | 请假登记/审批、发布、统计、宿管、专业发展 |
| 学生 | `leave.submit` `leave.reportBack`、`homework.*`(全部)、`activity.signup` `activity.cancelSignup` | 请假申请、交作业、报名活动、销假 |
| 学生+教职工共用 | `leave.withdraw` | 撤销请假(学生=申请撤销,教师=直接作废) |
| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 |
> ★ **请假为何分两个工具**:模块只有一个写接口 `POST /api/leave/Save`,靠 `type` 区分业务——
> `type=2` 学生**申请**(待班主任审核)、`type=1` 教师**登记**(免审直接生效,且必须指明班级+学生)。
> 合成一个工具时写死了 `type=2`,教师「帮学生请假」会被提交成待审核的学生申请;
> 而且「班级/学生」是可选参数、不满足"缺参才出表单"的条件,表单里根本没有这两项。
> 拆成 `leave.submit`(学生)与 `leave.register`(教职工)后,角色过滤天然把错的工具挡在模型视野外,
> 表单字段由各自必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。
### 4.3 两道闸门
1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`,
@@ -151,25 +158,46 @@ AgentService/
### 5.1 表单化补参(两个请求形态)
**① 缺参 → 下发补全表单**:`POST /api/agent/chat {"message":"我要请假"}`
#### 5.1.0 裸意图直达(`service/IntentShortcut`)
「只有意图、没有细节」的一句话(典型:**「我要请假」**)由后端**直接**按身份弹表单,不经过大模型。
原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串类型列表、
没有表单);② 调了写工具却自行**编造**起止时间直接提交;③ 用文字反问。用户的预期是立刻看到表单。
命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀
与「吧/了/一下」后缀后,**正好等于**标准说法(`请假`/`请个假`/`请假申请`…)、且原句 ≤12 字。
因此带信息的句子(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`)**不会**被劫持,
照常交给模型(模型能一次填全参数,或由下面的缺参拦截补表单)。
同一意图按身份落到不同工具:
| 身份 | 工具 | 表单字段 | 提交后 |
|---|---|---|---|
| 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 |
| 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 |
#### 5.1.1 缺参 → 下发补全表单
`POST /api/agent/chat {"message":"我要请假"}`(学生身份)
```jsonc
{
"code": 0, "msg": "",
"data": {
"sessionId": "...",
"reply": "要完成「提交请假申请」,还差这几项:请假类型、开始时间、结束时间……",
"reply": "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,提交后由班主任审核。",
"form": {
"tool": "leave.submit",
"title": "提交请假申请",
"write": true,
"landingPath": "/my",
"missing": ["请假类型", "开始时间", "结束时间"],
"preset": { "remarks": "发烧就医" },
"recognized": [{ "name": "remarks", "label": "请假事由", "text": "发烧就医" }],
"missing": ["请假类型", "开始时间", "结束时间", "请假事由"],
"fields": [
{ "name": "leaveType", "label": "请假类型", "widget": "select", "type": "integer", "required": true,
"options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假" }] },
"optionsFrom": "leave.types", "valueKey": "k", "labelKey": "v",
"optionsFallback": true,
"options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假及其他" }],
"optionCall": { "name": "leave.types", "method": "GET",
"pathAndQuery": "/homeleave/api/dict/LeaveTypeOptions" } },
{ "name": "startDate", "label": "开始时间", "widget": "datetime", "required": true,
"description": "请假开始时间,格式 yyyy-MM-dd HH:mm:ss", "example": "2026-10-12 08:00:00" }
]
@@ -178,6 +206,25 @@ AgentService/
}
```
**教师身份**下同一句「我要请假」得到的是登记表单,`classId` / `stuId` 用占位符地址做联动下拉:
```jsonc
{ "name": "classId", "label": "班级", "widget": "select", "type": "integer", "required": true,
"optionsFrom": "leave.myClasses", "valueKey": "classId", "labelKey": "className",
"optionCall": { "name": "leave.myClasses", "method": "GET",
"pathAndQuery": "/homeleave/api/leave/MyClasses" } },
{ "name": "stuId", "label": "学生", "widget": "select", "type": "integer", "required": true,
"dependsOn": ["classId"], "optionsFrom": "leave.classStudents", "valueKey": "stuId", "labelKey": "stuName",
"optionCall": { "name": "leave.classStudents", "method": "GET",
"pathAndQuery": "/homeleave/api/leave/ClassStudents?classId={{classId}}" } }
```
> **★ 为什么下拉选项要由前端代取**:模块的字典接口(`MyClasses` / `ClassStudents` / `LeaveTypeOptions`)
> 只认浏览器 CAS 会话,服务端直调必然 `code=302`。以前"服务端取一次、失败就退化成数字输入框",
> 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取**
> (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典,
> 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。
**② 填完提交 → 直接执行**:`POST /api/agent/chat`
```jsonc