- 新增 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:
@@ -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.*`(全部) | 审批、发布、统计、宿管、专业发展 |
|
| 教职工(教师+管理员) | `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.*`(全部) | 请假登记/审批、发布、统计、宿管、专业发展 |
|
||||||
| 学生 | `homework.*`(全部)、`activity.signup` `activity.cancelSignup`、`leave.reportBack` | 交作业、报名活动、销假 |
|
| 学生 | `leave.submit` `leave.reportBack`、`homework.*`(全部)、`activity.signup` `activity.cancelSignup` | 请假申请、交作业、报名活动、销假 |
|
||||||
| 学生+教职工共用 | `leave.submit` `leave.withdraw` | 发起/撤销自己的事项 |
|
| 学生+教职工共用 | `leave.withdraw` | 撤销请假(学生=申请撤销,教师=直接作废) |
|
||||||
| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 |
|
| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 |
|
||||||
|
|
||||||
|
> ★ **请假为何分两个工具**:模块只有一个写接口 `POST /api/leave/Save`,靠 `type` 区分业务——
|
||||||
|
> `type=2` 学生**申请**(待班主任审核)、`type=1` 教师**登记**(免审直接生效,且必须指明班级+学生)。
|
||||||
|
> 合成一个工具时写死了 `type=2`,教师「帮学生请假」会被提交成待审核的学生申请;
|
||||||
|
> 而且「班级/学生」是可选参数、不满足"缺参才出表单"的条件,表单里根本没有这两项。
|
||||||
|
> 拆成 `leave.submit`(学生)与 `leave.register`(教职工)后,角色过滤天然把错的工具挡在模型视野外,
|
||||||
|
> 表单字段由各自必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。
|
||||||
|
|
||||||
### 4.3 两道闸门
|
### 4.3 两道闸门
|
||||||
|
|
||||||
1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`,
|
1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`,
|
||||||
@@ -151,25 +158,46 @@ AgentService/
|
|||||||
|
|
||||||
### 5.1 表单化补参(两个请求形态)
|
### 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
|
```jsonc
|
||||||
{
|
{
|
||||||
"code": 0, "msg": "",
|
"code": 0, "msg": "",
|
||||||
"data": {
|
"data": {
|
||||||
"sessionId": "...",
|
"sessionId": "...",
|
||||||
"reply": "要完成「提交请假申请」,还差这几项:请假类型、开始时间、结束时间……",
|
"reply": "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,提交后由班主任审核。",
|
||||||
"form": {
|
"form": {
|
||||||
"tool": "leave.submit",
|
"tool": "leave.submit",
|
||||||
"title": "提交请假申请",
|
"title": "提交请假申请",
|
||||||
"write": true,
|
"write": true,
|
||||||
"landingPath": "/my",
|
"landingPath": "/my",
|
||||||
"missing": ["请假类型", "开始时间", "结束时间"],
|
"missing": ["请假类型", "开始时间", "结束时间", "请假事由"],
|
||||||
"preset": { "remarks": "发烧就医" },
|
|
||||||
"recognized": [{ "name": "remarks", "label": "请假事由", "text": "发烧就医" }],
|
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "name": "leaveType", "label": "请假类型", "widget": "select", "type": "integer", "required": true,
|
{ "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,
|
{ "name": "startDate", "label": "开始时间", "widget": "datetime", "required": true,
|
||||||
"description": "请假开始时间,格式 yyyy-MM-dd HH:mm:ss", "example": "2026-10-12 08:00:00" }
|
"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`
|
**② 填完提交 → 直接执行**:`POST /api/agent/chat`
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import java.util.Set;
|
|||||||
*
|
*
|
||||||
* <p>名单:
|
* <p>名单:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):审批、发布、统计、宿管、专业发展;</li>
|
* <li>{@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):请假登记/审批/统计、公告发布审批、宿管、专业发展;</li>
|
||||||
* <li>{@link #STUDENT_ONLY} —— 学生侧:交作业、销假;</li>
|
* <li>{@link #STUDENT_ONLY} —— 学生侧:请假申请、交作业、销假;</li>
|
||||||
* <li>{@link #STUDENT_OR_PARENT} —— 家校活动报名:家长与学生都可发起;</li>
|
* <li>{@link #STUDENT_OR_PARENT} —— 家校活动报名:家长与学生都可发起;</li>
|
||||||
* <li>{@link #SHARED} —— 学生与教职工都可能发起的:请假申请/撤销。</li>
|
* <li>{@link #SHARED} —— 学生与教职工都可能发起的:撤销请假。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* 未列入任何名单的工具 = 所有已识别角色都可用(如各类只读查询、宿舍通知回执)。
|
* 未列入任何名单的工具 = 所有已识别角色都可用(如各类只读查询、宿舍通知回执)。
|
||||||
*/
|
*/
|
||||||
@@ -30,8 +30,8 @@ public final class RolePolicy {
|
|||||||
|
|
||||||
/** 教职工专属(教师 + 管理员) */
|
/** 教职工专属(教师 + 管理员) */
|
||||||
private static final Set<String> STAFF_ONLY = new HashSet<>(Arrays.asList(
|
private static final Set<String> STAFF_ONLY = new HashSet<>(Arrays.asList(
|
||||||
// 请假:审批与统计
|
// 请假:登记(教师给学生请假,type=1 免审)、审批与统计
|
||||||
"leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass",
|
"leave.register", "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass",
|
||||||
// 公告:发布与审批
|
// 公告:发布与审批
|
||||||
"notice.sent", "notice.deptDics", "notice.publish",
|
"notice.sent", "notice.deptDics", "notice.publish",
|
||||||
"notice.approvalList", "notice.approve", "notice.reject",
|
"notice.approvalList", "notice.approve", "notice.reject",
|
||||||
@@ -44,7 +44,10 @@ public final class RolePolicy {
|
|||||||
/** 学生专属 */
|
/** 学生专属 */
|
||||||
private static final Set<String> STUDENT_ONLY = new HashSet<>(Arrays.asList(
|
private static final Set<String> STUDENT_ONLY = new HashSet<>(Arrays.asList(
|
||||||
"homework.myList", "homework.detail", "homework.result", "homework.submit",
|
"homework.myList", "homework.detail", "homework.result", "homework.submit",
|
||||||
"leave.reportBack"
|
"leave.reportBack",
|
||||||
|
// 请假申请(type=2 待审核)—— 教师代学生请假走 leave.register,两条链路必须分开,
|
||||||
|
// 否则教师提交会被写死成 type=2 的学生申请(类型错)
|
||||||
|
"leave.submit"
|
||||||
));
|
));
|
||||||
|
|
||||||
/** 学生与家长共用(家校活动报名由家长或学生发起) */
|
/** 学生与家长共用(家校活动报名由家长或学生发起) */
|
||||||
@@ -52,9 +55,9 @@ public final class RolePolicy {
|
|||||||
"activity.signup", "activity.cancelSignup"
|
"activity.signup", "activity.cancelSignup"
|
||||||
));
|
));
|
||||||
|
|
||||||
/** 学生与教职工共用(发起/撤销自己的事项) */
|
/** 学生与教职工共用(撤销自己的事项;教师撤销 = 直接作废) */
|
||||||
private static final Set<String> SHARED = new HashSet<>(Arrays.asList(
|
private static final Set<String> SHARED = new HashSet<>(Arrays.asList(
|
||||||
"leave.submit", "leave.withdraw"
|
"leave.withdraw"
|
||||||
));
|
));
|
||||||
|
|
||||||
/** 模块级规则:整个模块仅教职工可用 */
|
/** 模块级规则:整个模块仅教职工可用 */
|
||||||
|
|||||||
@@ -62,17 +62,19 @@ public class AgentOrchestrator {
|
|||||||
private final ToolExecutor executor;
|
private final ToolExecutor executor;
|
||||||
private final SessionStore sessionStore;
|
private final SessionStore sessionStore;
|
||||||
private final AgentAuditService auditService;
|
private final AgentAuditService auditService;
|
||||||
|
private final IntentShortcut intentShortcut;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
|
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
|
||||||
ToolExecutor executor, SessionStore sessionStore,
|
ToolExecutor executor, SessionStore sessionStore,
|
||||||
AgentAuditService auditService) {
|
AgentAuditService auditService, IntentShortcut intentShortcut) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.llmClient = llmClient;
|
this.llmClient = llmClient;
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
this.executor = executor;
|
this.executor = executor;
|
||||||
this.sessionStore = sessionStore;
|
this.sessionStore = sessionStore;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.intentShortcut = intentShortcut;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +113,13 @@ public class AgentOrchestrator {
|
|||||||
ChatResponse resp = new ChatResponse();
|
ChatResponse resp = new ChatResponse();
|
||||||
resp.setSessionId(session.getSessionId());
|
resp.setSessionId(session.getSessionId());
|
||||||
|
|
||||||
|
// ★ 裸意图直达:「我要请假」这类只有意图、没有细节的句子,直接按身份弹表单,
|
||||||
|
// 不交给大模型决策(模型可能先去查字典、或自行编造时间直接提交,都不合预期)。
|
||||||
|
ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out);
|
||||||
|
if (direct != null) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
List<ChatMessage> messages = session.getMessages();
|
List<ChatMessage> messages = session.getMessages();
|
||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
|
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
|
||||||
@@ -398,6 +407,54 @@ public class AgentOrchestrator {
|
|||||||
|
|
||||||
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
|
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」)时,
|
||||||
|
* 按当前身份直接构建表单返回 —— 学生拿「请假申请」,教职工拿「请假登记(帮学生请假)」。
|
||||||
|
*
|
||||||
|
* <p>判据与能力目录 / 模型侧过滤**完全一致**({@code visibleTools}),
|
||||||
|
* 因此不存在「直达出一个本身份无权使用的表单」的可能;不命中或不适用就返回 {@code null},
|
||||||
|
* 照常交给大模型处理。
|
||||||
|
*/
|
||||||
|
private ChatResponse directForm(String message, CallerContext caller, Set<Integer> authorizedModIds,
|
||||||
|
Set<AgentRole> roles, SessionStore.SessionState session, EventSink out) {
|
||||||
|
IntentShortcut.Intent intent = intentShortcut.match(message);
|
||||||
|
if (intent == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean staff = roles != null
|
||||||
|
&& (roles.contains(AgentRole.TEACHER) || roles.contains(AgentRole.ADMIN));
|
||||||
|
String toolName = staff ? intent.getStaffTool() : intent.getStudentTool();
|
||||||
|
ToolDef def = toolName == null ? null : registry.get(toolName);
|
||||||
|
if (def == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean visible = registry.visibleTools(authorizedModIds, roles).stream()
|
||||||
|
.anyMatch(t -> t.getName().equals(def.getName()));
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> empty = new LinkedHashMap<>();
|
||||||
|
List<ToolParam> missing = missingRequired(def, empty);
|
||||||
|
if (missing.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String reply = staff ? intent.getStaffReply() : intent.getStudentReply();
|
||||||
|
FormRequest form = buildForm(def, empty, missing, caller);
|
||||||
|
form.setDescription(reply);
|
||||||
|
|
||||||
|
ChatResponse resp = new ChatResponse();
|
||||||
|
resp.setSessionId(session.getSessionId());
|
||||||
|
resp.setForm(form);
|
||||||
|
resp.setReply(reply);
|
||||||
|
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing())));
|
||||||
|
log.info("意图直达弹表单:session={} 身份={} 工具={}", session.getSessionId(),
|
||||||
|
staff ? "教职工" : "学生", def.getName());
|
||||||
|
finish(resp, session, 0, 0, out);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
|
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
|
||||||
*
|
*
|
||||||
@@ -592,10 +649,21 @@ public class AgentOrchestrator {
|
|||||||
List<String> missingLabels = new ArrayList<>();
|
List<String> missingLabels = new ArrayList<>();
|
||||||
for (ToolParam p : missing) {
|
for (ToolParam p : missing) {
|
||||||
FormField f = p.toFormField();
|
FormField f = p.toFormField();
|
||||||
// 字典类字段(如请假类型):先跑一次它的来源工具,把可选项直接摆到下拉框里
|
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
||||||
// 判据是「声明了来源」而不是「当前没有选项」—— 参数可能已带内置兜底,
|
//
|
||||||
// 实时字典取到就覆盖兜底,取不到则保留兜底(下拉框不会退化成数字框)。
|
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
|
||||||
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
|
// 服务端直调必然 code=302(README 第 11 节)。以前"服务端取一次、失败就退化成
|
||||||
|
// 数字输入框",结果教师登记请假时得手填 classId/stuId 编号——根本没法用。
|
||||||
|
// 现在把「去哪儿取」以 optionCall 下发(依赖字段留 {{占位符}}),
|
||||||
|
// 前端在学生选完班级后自己拉名单,下拉框才是真的下拉框。
|
||||||
|
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
|
||||||
|
if (optionCall != null) {
|
||||||
|
f.setOptionCall(optionCall);
|
||||||
|
f.setWidget("select");
|
||||||
|
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
|
||||||
|
f.setOptionsFallback(p.isUsingFallbackOptions());
|
||||||
|
} else if (StringUtils.isNotBlank(p.getOptionsFrom())) {
|
||||||
|
// 没有代取通道(模块未配地址 / 越权)→ 退回服务端取一次,取不到就保留原控件让用户手填编号
|
||||||
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
|
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
|
||||||
if (!opts.isEmpty()) {
|
if (!opts.isEmpty()) {
|
||||||
f.setOptions(opts);
|
f.setOptions(opts);
|
||||||
@@ -666,9 +734,43 @@ public class AgentOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 服务端取不到实时字典时,至少把内置兜底的中文名翻出来(leaveType=1 → 病假),
|
||||||
|
// 免得「已识别」标签里显示一个裸编号让用户看不懂。
|
||||||
|
if (p.getFallbackValues() != null && !p.getFallbackValues().isEmpty()) {
|
||||||
|
int idx = p.getFallbackValues().indexOf(text);
|
||||||
|
if (idx >= 0 && p.getFallbackLabels() != null && idx < p.getFallbackLabels().size()) {
|
||||||
|
return p.getFallbackLabels().get(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成某个字段「浏览器代取选项」的调用描述;不可用时返回 {@code null}。
|
||||||
|
*
|
||||||
|
* <p>用于业务模块的字典接口(班级、学生名单、请假类型…)——这些接口只认浏览器 CAS 会话,
|
||||||
|
* 服务端直调必然 302,所以只能把地址交给前端、由前端带用户登录态去取。
|
||||||
|
*/
|
||||||
|
private BrowserToolRequest.BrowserCall buildOptionCall(ToolParam p, CallerContext caller) {
|
||||||
|
if (StringUtils.isBlank(p.getOptionsFrom())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ToolDef dict = registry.get(p.getOptionsFrom());
|
||||||
|
if (dict == null || !dict.isReadOnly()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 与 fetchDictOptions 同源的角色约束:越权的字典不外泄地址
|
||||||
|
if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return executor.prepareOptionCall(dict, p.getDependsOn());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("生成代取选项描述失败 {}:{}", p.getOptionsFrom(), e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */
|
/** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */
|
||||||
private List<Map<String, String>> dictOptions(String toolName, CallerContext caller,
|
private List<Map<String, String>> dictOptions(String toolName, CallerContext caller,
|
||||||
Map<String, List<Map<String, String>>> cache) {
|
Map<String, List<Map<String, String>>> cache) {
|
||||||
@@ -898,8 +1000,12 @@ public class AgentOrchestrator {
|
|||||||
sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n");
|
sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n");
|
||||||
sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n");
|
sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n");
|
||||||
|
|
||||||
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求“审批请假”“发布公告”,")
|
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求「审批请假」「发布公告」),")
|
||||||
.append("或教师要求“提交学生的请假申请”),要如实说明“该操作需要 XX 身份”,不要试图越权,也不要承诺你会去办。\n");
|
.append("要如实说明「该操作需要 XX 身份」,不要试图越权,也不要承诺你会去办。\n");
|
||||||
|
|
||||||
|
sb.append("9. ★ 请假要分清两条链路:「提交请假申请」= 学生给自己请假(提交后待班主任审核);")
|
||||||
|
.append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。")
|
||||||
|
.append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n");
|
||||||
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.yunhai.agent.service;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「裸意图」直达:把只有意图、没有任何细节的一句话(典型:<b>「我要请假」</b>)确定性地
|
||||||
|
* 映射到对应表单,<b>不经过大模型决策</b>。
|
||||||
|
*
|
||||||
|
* <h3>为什么需要它</h3>
|
||||||
|
* 同一句「我要请假」,模型可能给出三种都不合预期的反应:
|
||||||
|
* <ol>
|
||||||
|
* <li>先去调只读字典工具({@code leave.types})→ 用户看到一串「病假 / 事假」列表,没有表单;</li>
|
||||||
|
* <li>调了写工具却<b>自行编造</b>起止时间 → 直接提交出一条错误的请假;</li>
|
||||||
|
* <li>用文字反问「请问您要请哪一天?」→ 用户还得再打字,不如直接给表单。</li>
|
||||||
|
* </ol>
|
||||||
|
* 用户的预期是<b>立刻看到表单</b>。表单本来就能承载「补全 + 确认」,所以这里直接短路到表单,
|
||||||
|
* 既准(参数一定是用户填的)又快(省掉一轮模型往返)。
|
||||||
|
*
|
||||||
|
* <h3>为什么用「显式短语表」而不是宽松正则</h3>
|
||||||
|
* 宽松匹配(如"含『请假』二字就直达")会把「我请假那天作业怎么办」「10月12日病假发烧就医」
|
||||||
|
* 这类**带信息**的句子也劫持掉,反而更不准。这里只认「剥掉语气词后正好等于某个标准说法」的句子,
|
||||||
|
* 命中即直达、不命中就照常交给模型 —— 宁可少直达,不可错直达。
|
||||||
|
*
|
||||||
|
* <h3>身份差异</h3>
|
||||||
|
* 同一个意图在两个身份下落到<b>不同工具</b>:学生 = 请假申请({@code type=2} 待审核),
|
||||||
|
* 教职工 = 请假登记({@code type=1} 免审通过,需先选班级与学生)。表单字段由工具定义推导,
|
||||||
|
* 因此「对应好表单」是自动成立的。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class IntentShortcut {
|
||||||
|
|
||||||
|
/** 一个意图在两种身份下的落点与引导话术 */
|
||||||
|
public static class Intent {
|
||||||
|
|
||||||
|
/** 学生身份用的工具(内部点分名) */
|
||||||
|
private final String studentTool;
|
||||||
|
|
||||||
|
/** 教职工身份用的工具(null = 该身份下不直达) */
|
||||||
|
private final String staffTool;
|
||||||
|
|
||||||
|
/** 学生身份的表单引导语 */
|
||||||
|
private final String studentReply;
|
||||||
|
|
||||||
|
/** 教职工身份的表单引导语 */
|
||||||
|
private final String staffReply;
|
||||||
|
|
||||||
|
Intent(String studentTool, String staffTool, String studentReply, String staffReply) {
|
||||||
|
this.studentTool = studentTool;
|
||||||
|
this.staffTool = staffTool;
|
||||||
|
this.studentReply = studentReply;
|
||||||
|
this.staffReply = staffReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStudentTool() {
|
||||||
|
return studentTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStaffTool() {
|
||||||
|
return staffTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStudentReply() {
|
||||||
|
return studentReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStaffReply() {
|
||||||
|
return staffReply;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请假意图:学生 → 请假申请;教职工 → 请假登记(帮学生请假) */
|
||||||
|
private static final Intent LEAVE = new Intent(
|
||||||
|
"leave.submit",
|
||||||
|
"leave.register",
|
||||||
|
"好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,"
|
||||||
|
+ "提交后由班主任审核。",
|
||||||
|
"好的,下面是**请假登记**表单:先选班级、再选学生,填好请假类型、起止时间和事由,"
|
||||||
|
+ "点「确认提交」我直接登记(教师登记免审,立即生效)。");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准说法全集(已剥掉语气词与标点后的形态)。
|
||||||
|
* 新增意图时在这里加词 + 补一条 {@link #match(String)} 分支即可。
|
||||||
|
*/
|
||||||
|
private static final Set<String> LEAVE_BARE = new HashSet<>(Arrays.asList(
|
||||||
|
"请假", "请个假", "请一个假", "请假申请", "请假登记", "登记请假",
|
||||||
|
"学生请假", "老师请假", "教师请假", "帮学生请假", "给学生请假"
|
||||||
|
));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前缀(语气/助动词),逐个尝试剥离后看余下是否等于标准说法。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 这里**不能放「请」**:{@code 请假申请} 本身以「请」开头,剥掉就变成「假申请」,
|
||||||
|
* 反而会让标准说法匹配不上。只放意义明确的多字助动词。
|
||||||
|
*/
|
||||||
|
private static final String[] PREFIXES = {
|
||||||
|
"我想要", "我需要", "我要", "我想", "帮我", "帮忙", "麻烦",
|
||||||
|
"给我", "替我", "替", "申请", "提交", "办理", "办"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 后缀(语气词),同上 */
|
||||||
|
private static final String[] SUFFIXES = {
|
||||||
|
"一下", "吧", "呢", "啊", "哦", "呀", "嘛", "了", "的"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一句话是不是「裸请假意图」。
|
||||||
|
*
|
||||||
|
* @return 命中的意图;不是裸意图(含细节、或别的意图)返回 {@code null}
|
||||||
|
*/
|
||||||
|
public Intent match(String rawMessage) {
|
||||||
|
String normalized = normalize(rawMessage);
|
||||||
|
// 长度门槛:真·裸意图都很短;长句几乎必然带时间/事由等细节,交给模型更合适
|
||||||
|
if (normalized.length() < 2 || normalized.length() > 12) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (LEAVE_BARE.contains(stripDecorations(normalized))) {
|
||||||
|
return LEAVE;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉空白与标点(中英文都要),便于按「标准说法」比对 */
|
||||||
|
private String normalize(String s) {
|
||||||
|
if (StringUtils.isBlank(s)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return s.replaceAll("[\\s\\u3000]+", "")
|
||||||
|
.replaceAll("[,。!?、,.!?~~\"'“”‘’::;;()()《》\\[\\]【】]", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剥掉「我要 / 帮我 / 麻烦…」这类前缀与「吧 / 了 / 一下」这类后缀,只留核心说法 */
|
||||||
|
private String stripDecorations(String n) {
|
||||||
|
String key = n;
|
||||||
|
// 前缀可能叠加(如「麻烦帮我」),循环剥到不能再剥
|
||||||
|
boolean changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (String p : PREFIXES) {
|
||||||
|
if (key.length() > p.length() && key.startsWith(p)) {
|
||||||
|
key = key.substring(p.length());
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String suf : SUFFIXES) {
|
||||||
|
if (key.length() > suf.length() && key.endsWith(suf)) {
|
||||||
|
return key.substring(0, key.length() - suf.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,4 +49,24 @@ public class FormField {
|
|||||||
|
|
||||||
/** 下拉选项 [{value,label}] */
|
/** 下拉选项 [{value,label}] */
|
||||||
private List<Map<String, String>> options = new ArrayList<>();
|
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<>();
|
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 对齐)。
|
* 平台菜单模块号(与 GetUserMod 的 modId 对齐)。
|
||||||
@@ -103,7 +109,11 @@ public class ToolDef {
|
|||||||
return this;
|
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);
|
this.fixed.put(key, value);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,50 @@ public class ToolExecutor {
|
|||||||
return parseResponse(def, new ResponseEntity<>(body == null ? "" : body, hs), costMs);
|
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(本服务的模块地址都由配置拼出,格式可控) */
|
/** 去掉 URL 里的协议与域名,只留 path+query(本服务的模块地址都由配置拼出,格式可控) */
|
||||||
private String stripOrigin(String fullUrl) {
|
private String stripOrigin(String fullUrl) {
|
||||||
int idx = fullUrl.indexOf("://");
|
int idx = fullUrl.indexOf("://");
|
||||||
|
|||||||
@@ -75,6 +75,26 @@ public class ToolParam {
|
|||||||
*/
|
*/
|
||||||
private String optionsFrom;
|
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() {
|
public ToolParam() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +169,22 @@ public class ToolParam {
|
|||||||
return this;
|
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() {
|
public String labelOf() {
|
||||||
return StringUtils.isNotBlank(label) ? label : name;
|
return StringUtils.isNotBlank(label) ? label : name;
|
||||||
@@ -225,6 +261,9 @@ public class ToolParam {
|
|||||||
f.setOptions(optionList());
|
f.setOptions(optionList());
|
||||||
// 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」
|
// 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」
|
||||||
f.setOptionsFrom(optionsFrom);
|
f.setOptionsFrom(optionsFrom);
|
||||||
|
f.setDependsOn(dependsOn);
|
||||||
|
f.setValueKey(optionValueKey);
|
||||||
|
f.setLabelKey(optionLabelKey);
|
||||||
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
||||||
f.setOptionsFallback(isUsingFallbackOptions());
|
f.setOptionsFallback(isUsingFallbackOptions());
|
||||||
return f;
|
return f;
|
||||||
|
|||||||
@@ -8,9 +8,24 @@ import java.util.Arrays;
|
|||||||
import java.util.List;
|
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 {
|
public final class LeaveTools {
|
||||||
|
|
||||||
@@ -22,7 +37,8 @@ public final class LeaveTools {
|
|||||||
/**
|
/**
|
||||||
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。
|
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。
|
||||||
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义),
|
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义),
|
||||||
* 但该接口需登录会话、Agent 侧常取不到;没有兜底时表单会退化成让用户填「类型编号」的数字框。
|
* 该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给浏览器用用户登录态取实时字典,
|
||||||
|
* 这份兜底保证「取不到时下拉里也还有标准项」,不会退化成让用户填「类型编号」的数字框。
|
||||||
*/
|
*/
|
||||||
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
|
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
|
||||||
|
|
||||||
@@ -36,19 +52,25 @@ public final class LeaveTools {
|
|||||||
|
|
||||||
// ---------- 只读:辅助信息 ----------
|
// ---------- 只读:辅助信息 ----------
|
||||||
list.add(ToolDef.of("leave.types", MODULE, "请假类型",
|
list.add(ToolDef.of("leave.types", MODULE, "请假类型",
|
||||||
"查询本校本模块可选的请假类型(病假/事假等)及其编号。用户说“请假”但没给类型时,先调它拿到 leaveType 编号。",
|
"查询本校本模块可选的请假类型(病假/事假等)及其编号。"
|
||||||
|
+ "仅在用户明确问「有哪些请假类型」时使用;"
|
||||||
|
+ "用户说要请假时**不要**先调它 —— 请假表单里的「请假类型」下拉会自动带出可选值。",
|
||||||
"GET", "/api/dict/LeaveTypeOptions")
|
"GET", "/api/dict/LeaveTypeOptions")
|
||||||
.readAt("/apply")
|
.readAt("/apply")
|
||||||
.modId(2));
|
.modId(2));
|
||||||
|
|
||||||
list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级",
|
list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级",
|
||||||
"查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。登记请假、筛选前先调它拿 classId。",
|
"查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。"
|
||||||
|
+ "仅在用户明确询问自己的班级时使用(如「我教哪几个班」);"
|
||||||
|
+ "填表/登记请假时**不要**先调它 —— 表单的「班级」下拉会自动取到这个列表。",
|
||||||
"GET", "/api/leave/MyClasses")
|
"GET", "/api/leave/MyClasses")
|
||||||
.readAt("/apply")
|
.readAt("/apply")
|
||||||
.modId(2));
|
.modId(2));
|
||||||
|
|
||||||
list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单",
|
list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单",
|
||||||
"查询某班级的学生名单(教师给学生登记请假时选人用)。",
|
"查询某班级的学生名单。"
|
||||||
|
+ "仅在用户明确要看某个班的学生名单时使用;"
|
||||||
|
+ "登记请假时**不要**先调它 —— 表单的「学生」下拉会在选好班级后自动带出名单。",
|
||||||
"GET", "/api/leave/ClassStudents")
|
"GET", "/api/leave/ClassStudents")
|
||||||
.p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses"))
|
.p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses"))
|
||||||
.readAt("/register")
|
.readAt("/register")
|
||||||
@@ -57,7 +79,7 @@ public final class LeaveTools {
|
|||||||
// ---------- 只读:查询 ----------
|
// ---------- 只读:查询 ----------
|
||||||
list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录",
|
list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录",
|
||||||
"分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。"
|
"分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。"
|
||||||
+ "用户问“我请了几天假”“上次请假通过没”时用它。",
|
+ "用户问「我请了几天假」「上次请假通过没」时用它。",
|
||||||
"GET", "/api/leave/List")
|
"GET", "/api/leave/List")
|
||||||
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
|
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
|
||||||
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
|
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
|
||||||
@@ -86,31 +108,61 @@ public final class LeaveTools {
|
|||||||
.readAt("/stats")
|
.readAt("/stats")
|
||||||
.modId(6));
|
.modId(6));
|
||||||
|
|
||||||
// ---------- 写:提交 / 审批 / 销假 ----------
|
// ---------- 写:学生请假申请(type=2,待审核) ----------
|
||||||
list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请",
|
list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请",
|
||||||
"学生提交一条请假申请(提交后状态为待审核,等班主任审批)。"
|
"学生提交一条请假申请(提交后状态为待审核,等班主任审批)。"
|
||||||
|
+ "教师给学生请假请改用「帮学生登记请假」,不要用本工具。"
|
||||||
+ "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;"
|
+ "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;"
|
||||||
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
|
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
|
||||||
"POST_JSON", "/api/leave/Save")
|
"POST_JSON", "/api/leave/Save")
|
||||||
.p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
|
.p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
|
||||||
.label("请假类型")
|
.label("请假类型")
|
||||||
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
|
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
|
||||||
.optionsFrom("leave.types"),
|
.optionsFrom("leave.types").optionsKeys("k", "v"),
|
||||||
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT)
|
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT)
|
||||||
.label("开始时间").eg("2026-10-12 08:00:00"),
|
.label("开始时间").eg("2026-10-12 08:00:00"),
|
||||||
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT)
|
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT)
|
||||||
.label("结束时间").eg("2026-10-12 17:00:00"),
|
.label("结束时间").eg("2026-10-12 17:00:00"),
|
||||||
ToolParam.required("remarks", "string", "请假事由")
|
ToolParam.required("remarks", "string", "请假事由")
|
||||||
.label("请假事由").as("textarea").eg("发烧就医,需在家休息"),
|
.label("请假事由").as("textarea").eg("发烧就医,需在家休息"),
|
||||||
ToolParam.optional("classId", "integer", "班级 id;用户只有唯一班级时可不传")
|
ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)")
|
||||||
.label("班级"),
|
.label("班级")
|
||||||
ToolParam.optional("stuId", "integer", "学生 id(教师代学生登记时用)")
|
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"))
|
||||||
.label("学生"))
|
.fix("type", 2)
|
||||||
.fix("type", "2")
|
.fix("continuousLeave", 0)
|
||||||
.fix("continuousLeave", "0")
|
|
||||||
.write("/my")
|
.write("/my")
|
||||||
.modId(2));
|
.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, "撤销请假",
|
list.add(ToolDef.of("leave.withdraw", MODULE, "撤销请假",
|
||||||
"撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。",
|
"撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。",
|
||||||
"POST_FORM", "/api/leave/Withdraw")
|
"POST_FORM", "/api/leave/Withdraw")
|
||||||
@@ -128,7 +180,7 @@ public final class LeaveTools {
|
|||||||
.label("实际返校时间").eg("2026-10-12 08:00:00"),
|
.label("实际返校时间").eg("2026-10-12 08:00:00"),
|
||||||
ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT)
|
ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT)
|
||||||
.label("实际结束时间").eg("2026-10-12 17:00:00"))
|
.label("实际结束时间").eg("2026-10-12 17:00:00"))
|
||||||
.fix("continuousLeave", "0")
|
.fix("continuousLeave", 0)
|
||||||
.write("/my")
|
.write("/my")
|
||||||
.modId(3));
|
.modId(3));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package com.yunhai.agent;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONArray;
|
||||||
|
import com.yunhai.agent.auth.AgentRole;
|
||||||
|
import com.yunhai.agent.config.AgentSettings;
|
||||||
|
import com.yunhai.agent.llm.LlmClient;
|
||||||
|
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||||
|
import com.yunhai.agent.llm.dto.ChatResult;
|
||||||
|
import com.yunhai.agent.model.ChatRequest;
|
||||||
|
import com.yunhai.agent.model.ChatResponse;
|
||||||
|
import com.yunhai.agent.service.AgentAuditService;
|
||||||
|
import com.yunhai.agent.service.AgentOrchestrator;
|
||||||
|
import com.yunhai.agent.service.EventSink;
|
||||||
|
import com.yunhai.agent.service.IntentShortcut;
|
||||||
|
import com.yunhai.agent.service.SessionStore;
|
||||||
|
import com.yunhai.agent.tool.CallerContext;
|
||||||
|
import com.yunhai.agent.tool.FormField;
|
||||||
|
import com.yunhai.agent.tool.ToolExecutor;
|
||||||
|
import com.yunhai.agent.tool.ToolRegistry;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「我要请假」在编排层的端到端断言:**同一句话、不同身份 → 不同表单**,且**不经过大模型**。
|
||||||
|
*
|
||||||
|
* <p>这是本次修复的核心承诺:用户说「我要请假」应当**直接**看到表单,
|
||||||
|
* 而不是先被模型拿去查字典、或自行编造时间直接提交、或反过来追问用户。
|
||||||
|
* 因此测试里的 {@link LlmClient} 被设成「一旦被调用就失败」,用来证明这条链路真的没走模型。
|
||||||
|
*/
|
||||||
|
class IntentDirectFormTest {
|
||||||
|
|
||||||
|
private AgentSettings settings;
|
||||||
|
private ToolRegistry registry;
|
||||||
|
private SessionStore sessionStore;
|
||||||
|
private AgentOrchestrator orchestrator;
|
||||||
|
|
||||||
|
/** 被调用即失败的模型客户端:证明「意图直达」路径压根不碰大模型 */
|
||||||
|
private static final LlmClient EXPLODING_LLM = new LlmClient() {
|
||||||
|
@Override
|
||||||
|
public ChatResult chat(List<ChatMessage> messages, JSONArray tools) {
|
||||||
|
throw new AssertionError("裸意图(我要请假)不应调用大模型");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 正常应答的模型客户端:用于断言「非裸意图」确实回落到模型流程 */
|
||||||
|
private static final LlmClient ECHO_LLM = (messages, tools) -> {
|
||||||
|
ChatResult r = new ChatResult();
|
||||||
|
r.setContent("(模型回复)");
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
settings = new AgentSettings();
|
||||||
|
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
|
||||||
|
registry = new ToolRegistry(settings);
|
||||||
|
registry.init();
|
||||||
|
|
||||||
|
sessionStore = Mockito.mock(SessionStore.class);
|
||||||
|
SessionStore.SessionState state = new SessionStore.SessionState();
|
||||||
|
state.setSessionId("s-test");
|
||||||
|
state.setMessages(new ArrayList<>());
|
||||||
|
when(sessionStore.getOrCreate(any(), any(), any(), anyString())).thenReturn(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentOrchestrator orchestrator(LlmClient llm) {
|
||||||
|
return new AgentOrchestrator(settings, llm, registry,
|
||||||
|
new ToolExecutor(settings, new RestTemplate()), sessionStore,
|
||||||
|
Mockito.mock(AgentAuditService.class), new IntentShortcut());
|
||||||
|
}
|
||||||
|
|
||||||
|
private CallerContext caller(AgentRole... roles) {
|
||||||
|
CallerContext c = new CallerContext();
|
||||||
|
Set<AgentRole> set = new LinkedHashSet<>();
|
||||||
|
Collections.addAll(set, roles);
|
||||||
|
c.setRoles(set);
|
||||||
|
c.setUserId("10008");
|
||||||
|
c.setUserName("测试用户");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatResponse chat(LlmClient llm, String message, CallerContext caller) {
|
||||||
|
ChatRequest req = new ChatRequest();
|
||||||
|
req.setMessage(message);
|
||||||
|
return orchestrator(llm).chat(req, "姓名 测试用户", caller, null, EventSink.NOOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FormField field(ChatResponse resp, String name) {
|
||||||
|
for (FormField f : resp.getForm().getFields()) {
|
||||||
|
if (name.equals(f.getName())) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AssertionError("表单里没有字段 " + name + ",实际:" + resp.getForm().getFields());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 学生:请假申请 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void studentSayingWantLeaveGetsApplicationFormWithoutCallingLlm() {
|
||||||
|
ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.STUDENT));
|
||||||
|
|
||||||
|
assertNotNull(resp.getForm(), "应直接下发表单");
|
||||||
|
assertEquals("leave.submit", resp.getForm().getTool(), "学生走「提交请假申请」");
|
||||||
|
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
|
||||||
|
assertEquals("/my", resp.getForm().getLandingPath());
|
||||||
|
assertTrue(resp.getReply().contains("请假申请"), "引导语应说明这是请假申请,实际:" + resp.getReply());
|
||||||
|
|
||||||
|
// 学生表单:类型 / 起止时间 / 事由;不应出现「学生」这种教师侧字段
|
||||||
|
assertNotNull(field(resp, "leaveType"));
|
||||||
|
assertNotNull(field(resp, "startDate"));
|
||||||
|
assertNotNull(field(resp, "endDate"));
|
||||||
|
assertNotNull(field(resp, "remarks"));
|
||||||
|
for (FormField f : resp.getForm().getFields()) {
|
||||||
|
assertFalse("stuId".equals(f.getName()), "学生自查请假不该出现「学生」字段");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 教师:帮学生登记 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teacherSayingWantLeaveGetsRegisterFormWithClassAndStudentPickers() {
|
||||||
|
ChatResponse resp = chat(EXPLODING_LLM, "我要请假", caller(AgentRole.TEACHER));
|
||||||
|
|
||||||
|
assertNotNull(resp.getForm(), "应直接下发表单");
|
||||||
|
assertEquals("leave.register", resp.getForm().getTool(), "教师走「帮学生登记请假」");
|
||||||
|
assertEquals("/register", resp.getForm().getLandingPath());
|
||||||
|
assertTrue(resp.getReply().contains("登记"), "引导语应说明这是登记,实际:" + resp.getReply());
|
||||||
|
|
||||||
|
// 教师表单必须能选到人:班级 + 学生 都是下拉,且学生依赖班级
|
||||||
|
FormField classId = field(resp, "classId");
|
||||||
|
assertEquals("select", classId.getWidget());
|
||||||
|
assertNotNull(classId.getOptionCall(), "班级下拉应带上「代取地址」");
|
||||||
|
assertEquals("/homeleave/api/leave/MyClasses", classId.getOptionCall().getPathAndQuery());
|
||||||
|
|
||||||
|
FormField stuId = field(resp, "stuId");
|
||||||
|
assertEquals("select", stuId.getWidget());
|
||||||
|
assertEquals(Collections.singletonList("classId"), stuId.getDependsOn());
|
||||||
|
assertNotNull(stuId.getOptionCall(), "学生下拉应带上「代取地址」");
|
||||||
|
assertEquals("/homeleave/api/leave/ClassStudents?classId={{classId}}",
|
||||||
|
stuId.getOptionCall().getPathAndQuery(),
|
||||||
|
"学生名单的地址要留 {{classId}} 占位符,前端选完班级再替换");
|
||||||
|
assertEquals("stuId", stuId.getValueKey());
|
||||||
|
assertEquals("stuName", stuId.getLabelKey());
|
||||||
|
|
||||||
|
assertNotNull(field(resp, "leaveType"));
|
||||||
|
assertNotNull(field(resp, "startDate"));
|
||||||
|
assertNotNull(field(resp, "endDate"));
|
||||||
|
assertNotNull(field(resp, "remarks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 不该被劫持的情况 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detailedSentenceStillGoesToTheModel() {
|
||||||
|
// 带时间与事由的句子由模型处理(它能一次把参数填全),不能被直达规则截走
|
||||||
|
ChatResponse resp = chat(ECHO_LLM, "我要请10月12日上午的病假,发烧就医", caller(AgentRole.STUDENT));
|
||||||
|
assertNull(resp.getForm(), "含细节的句子不应被直达成空表单");
|
||||||
|
assertEquals("(模型回复)", resp.getReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void guestSayingWantLeaveFallsThroughToModel() {
|
||||||
|
// 访客无权使用请假工具 → 不直达,交给模型如实说明需要什么身份
|
||||||
|
ChatResponse resp = chat(ECHO_LLM, "我要请假", caller(AgentRole.GUEST));
|
||||||
|
assertNull(resp.getForm(), "访客不该拿到请假表单");
|
||||||
|
assertEquals("(模型回复)", resp.getReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unrelatedMessageStillGoesToTheModel() {
|
||||||
|
ChatResponse resp = chat(ECHO_LLM, "这周有什么新的校园公告?", caller(AgentRole.STUDENT));
|
||||||
|
assertNull(resp.getForm());
|
||||||
|
assertEquals("(模型回复)", resp.getReply());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「一句话请假」这条链路的断言:意图直达规则 + 学生/教师两条表单链路的字段推导。
|
||||||
|
*
|
||||||
|
* <p>背景:同一句「我要请假」在两个身份下要落到不同工具、不同表单 ——
|
||||||
|
* 学生 = 请假申请(type=2 待审核),教职工 = 请假登记(type=1 免审,须先选班级与学生)。
|
||||||
|
*/
|
||||||
|
class LeaveIntentFormTest {
|
||||||
|
|
||||||
|
private IntentShortcut shortcut;
|
||||||
|
private ToolRegistry registry;
|
||||||
|
private AgentSettings settings;
|
||||||
|
private ToolExecutor executor;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
shortcut = new IntentShortcut();
|
||||||
|
settings = new AgentSettings();
|
||||||
|
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
|
||||||
|
registry = new ToolRegistry(settings);
|
||||||
|
registry.init();
|
||||||
|
executor = new ToolExecutor(settings, new RestTemplate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 意图直达规则 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareLeaveIntentIsRecognized() {
|
||||||
|
for (String text : Arrays.asList(
|
||||||
|
"我要请假", "请假", "我想请假", "请个假", "麻烦帮我请假吧",
|
||||||
|
"申请请假", "提交请假申请", "请假申请", "我要请假了", "帮学生请假", "登记请假")) {
|
||||||
|
assertNotNull(shortcut.match(text), "「" + text + "」应被识别为裸请假意图");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareIntentResolvesToDifferentToolsPerIdentity() {
|
||||||
|
IntentShortcut.Intent it = shortcut.match("我要请假");
|
||||||
|
assertNotNull(it);
|
||||||
|
assertEquals("leave.submit", it.getStudentTool(), "学生应走「提交请假申请」");
|
||||||
|
assertEquals("leave.register", it.getStaffTool(), "教职工应走「帮学生登记请假」");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detailedSentencesAreNotHijacked() {
|
||||||
|
// 带时间/事由的句子交给模型(模型能一次填全参数),不能被直接劫持成空表单
|
||||||
|
for (String text : Arrays.asList(
|
||||||
|
"我要请10月12日上午的病假,发烧就医",
|
||||||
|
"我请假那天作业怎么办",
|
||||||
|
"请假条怎么写",
|
||||||
|
"我要请假一天",
|
||||||
|
"请假流程是什么")) {
|
||||||
|
assertNull(shortcut.match(text), "「" + text + "」含细节,不应被直达规则命中");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unrelatedOrEmptyInputIsIgnored() {
|
||||||
|
assertNull(shortcut.match(null));
|
||||||
|
assertNull(shortcut.match(""));
|
||||||
|
assertNull(shortcut.match(" "));
|
||||||
|
assertNull(shortcut.match("我要吃饭"));
|
||||||
|
assertNull(shortcut.match("我要请假我要请假我要请假我要请假"));
|
||||||
|
// 销假是另一个动作,不能当成请假
|
||||||
|
assertNull(shortcut.match("我要销假"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 两条请假链路的工具定义 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void studentFlowSubmitsApplicationWithType2() {
|
||||||
|
ToolDef submit = registry.get("leave.submit");
|
||||||
|
assertNotNull(submit);
|
||||||
|
assertFalse(submit.isReadOnly(), "请假申请是写操作");
|
||||||
|
assertEquals(2, ((Number) submit.getFixed().get("type")).intValue(),
|
||||||
|
"学生申请必须固定 type=2(待审核)");
|
||||||
|
assertEquals(0, ((Number) submit.getFixed().get("continuousLeave")).intValue());
|
||||||
|
|
||||||
|
// 学生表单的必填项:类型 / 起止时间 / 事由
|
||||||
|
assertTrue(requiredNames(submit).contains("leaveType"));
|
||||||
|
assertTrue(requiredNames(submit).contains("startDate"));
|
||||||
|
assertTrue(requiredNames(submit).contains("endDate"));
|
||||||
|
assertTrue(requiredNames(submit).contains("remarks"));
|
||||||
|
assertFalse(requiredNames(submit).contains("stuId"), "学生自查请假不该出现「学生」字段");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teacherFlowRegistersLeaveWithType1AndStudentPicker() {
|
||||||
|
ToolDef register = registry.get("leave.register");
|
||||||
|
assertNotNull(register);
|
||||||
|
assertFalse(register.isReadOnly(), "请假登记是写操作");
|
||||||
|
assertEquals(1, ((Number) register.getFixed().get("type")).intValue(),
|
||||||
|
"教师登记必须固定 type=1(免审直接生效)");
|
||||||
|
|
||||||
|
// 教师必须选到人:班级 + 学生 都是必填,表单才会把这两项摆出来
|
||||||
|
assertTrue(requiredNames(register).contains("classId"), "教师登记必须选班级");
|
||||||
|
assertTrue(requiredNames(register).contains("stuId"), "教师登记必须选学生");
|
||||||
|
|
||||||
|
ToolParam stuId = param(register, "stuId");
|
||||||
|
assertEquals(Collections.singletonList("classId"), stuId.getDependsOn(),
|
||||||
|
"「学生」下拉依赖先选好「班级」");
|
||||||
|
assertEquals("stuId", stuId.getOptionValueKey());
|
||||||
|
assertEquals("stuName", stuId.getOptionLabelKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 下拉选项的「代取地址」 ==================== */
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionCallKeepsPlaceholderForDependentField() {
|
||||||
|
ToolDef dict = registry.get("leave.classStudents");
|
||||||
|
BrowserToolRequest.BrowserCall call = executor.prepareOptionCall(dict, Collections.singletonList("classId"));
|
||||||
|
assertNotNull(call);
|
||||||
|
assertEquals("GET", call.getMethod());
|
||||||
|
assertEquals("/homeleave/api/leave/ClassStudents?classId={{classId}}", call.getPathAndQuery(),
|
||||||
|
"依赖字段必须留成占位符,由前端填入用户选的班级后自取名单");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionCallWithoutDependencyIsPlainUrl() {
|
||||||
|
BrowserToolRequest.BrowserCall call =
|
||||||
|
executor.prepareOptionCall(registry.get("leave.types"), null);
|
||||||
|
assertNotNull(call);
|
||||||
|
assertEquals("/homeleave/api/dict/LeaveTypeOptions", call.getPathAndQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionCallIsNullWhenModuleAddressMissing() {
|
||||||
|
AgentSettings bare = new AgentSettings();
|
||||||
|
ToolExecutor noModule = new ToolExecutor(bare, new RestTemplate());
|
||||||
|
assertNull(noModule.prepareOptionCall(registry.get("leave.types"), null),
|
||||||
|
"模块没配地址时不该下发一个必然打不通的代取地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 辅助 ==================== */
|
||||||
|
|
||||||
|
private java.util.List<String> requiredNames(ToolDef def) {
|
||||||
|
java.util.List<String> names = new java.util.ArrayList<>();
|
||||||
|
for (ToolParam p : def.getParams()) {
|
||||||
|
if (p.isRequired()) {
|
||||||
|
names.add(p.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolParam param(ToolDef def, String name) {
|
||||||
|
for (ToolParam p : def.getParams()) {
|
||||||
|
if (name.equals(p.getName())) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AssertionError("工具 " + def.getName() + " 缺少参数 " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,9 +173,10 @@ class RolePermissionTest {
|
|||||||
assertTrue(visible(visible, "homework.submit"), "学生应可用 homework.submit");
|
assertTrue(visible(visible, "homework.submit"), "学生应可用 homework.submit");
|
||||||
assertTrue(visible(visible, "activity.signup"), "学生应可用 activity.signup");
|
assertTrue(visible(visible, "activity.signup"), "学生应可用 activity.signup");
|
||||||
assertTrue(visible(visible, "leave.submit"), "学生应可用 leave.submit");
|
assertTrue(visible(visible, "leave.submit"), "学生应可用 leave.submit");
|
||||||
// 学生不能做:审批、发布、专业发展、宿管管理
|
// 学生不能做:审批、发布、专业发展、宿管管理、教师代登记
|
||||||
assertFalse(visible(visible, "leave.audit"), "学生不应看到 leave.audit");
|
assertFalse(visible(visible, "leave.audit"), "学生不应看到 leave.audit");
|
||||||
assertFalse(visible(visible, "leave.batchPass"), "学生不应看到 leave.batchPass");
|
assertFalse(visible(visible, "leave.batchPass"), "学生不应看到 leave.batchPass");
|
||||||
|
assertFalse(visible(visible, "leave.register"), "学生不应看到「帮学生登记请假」(教师链路)");
|
||||||
assertFalse(visible(visible, "notice.publish"), "学生不应看到 notice.publish");
|
assertFalse(visible(visible, "notice.publish"), "学生不应看到 notice.publish");
|
||||||
assertFalse(visible(visible, "profession.myPoints"), "学生不应看到 profession.*");
|
assertFalse(visible(visible, "profession.myPoints"), "学生不应看到 profession.*");
|
||||||
assertFalse(visible(visible, "dorm.buildings"), "学生不应看到宿舍管理侧");
|
assertFalse(visible(visible, "dorm.buildings"), "学生不应看到宿舍管理侧");
|
||||||
@@ -209,12 +210,14 @@ class RolePermissionTest {
|
|||||||
assertTrue(roles.contains(AgentRole.TEACHER), "roleId=1 应识别为教师(老师)");
|
assertTrue(roles.contains(AgentRole.TEACHER), "roleId=1 应识别为教师(老师)");
|
||||||
|
|
||||||
List<ToolDef> visible = registry.visibleTools(null, roles);
|
List<ToolDef> visible = registry.visibleTools(null, roles);
|
||||||
// 教师能做:审批、发布、专业发展
|
// 教师能做:登记请假、审批、发布、专业发展
|
||||||
|
assertTrue(visible(visible, "leave.register"), "教师应可用「帮学生登记请假」");
|
||||||
assertTrue(visible(visible, "leave.audit"), "教师应可用 leave.audit");
|
assertTrue(visible(visible, "leave.audit"), "教师应可用 leave.audit");
|
||||||
assertTrue(visible(visible, "notice.publish"), "教师应可用 notice.publish");
|
assertTrue(visible(visible, "notice.publish"), "教师应可用 notice.publish");
|
||||||
assertTrue(visible(visible, "profession.myPoints"), "教师应可用 profession.*");
|
assertTrue(visible(visible, "profession.myPoints"), "教师应可用 profession.*");
|
||||||
assertTrue(visible(visible, "leave.stats"), "教师应可用 leave.stats");
|
assertTrue(visible(visible, "leave.stats"), "教师应可用 leave.stats");
|
||||||
// 教师不做:学生交作业、报名活动
|
// 教师不做:学生交作业、报名活动、学生自己的请假申请
|
||||||
|
assertFalse(visible(visible, "leave.submit"), "教师不应看到学生侧的「提交请假申请」");
|
||||||
assertFalse(visible(visible, "homework.submit"), "教师不应看到 homework.submit");
|
assertFalse(visible(visible, "homework.submit"), "教师不应看到 homework.submit");
|
||||||
assertFalse(visible(visible, "activity.signup"), "教师不应看到 activity.signup");
|
assertFalse(visible(visible, "activity.signup"), "教师不应看到 activity.signup");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user