build / build-push (push) Successful in 1m2s
把 5 个模块「新建/发布」页的字段与提交体逐个核对后,补齐此前缺失或形态不符的部分:
- 部门名镜像:公告/宿舍/活动三处在模块页是「字典 id + 反查名称」一起提交。
新增 mirrorLabelOf 机制 —— 该字段不渲染控件、不进模型 schema,提交时按源字段
选中项的显示名填充(deptName)
- 公告审批流:新增 flow 控件(widget=flow)+ 教师组织树字典 notice.teaTree,
提交 flow:[{name,vote,approverTeaIds}];publishType 值域修正为 direct/approve;
存草稿改用模块自己的独立接口 notice.saveDraft(POST /api/notice/SaveDraft)
- 活动分类:category 提交的是**分类名称**(模块页 :value="String(c.v)"),
optionsKeys 由 ("k","v") 改为 ("v","v")
- 请假附件:fileIds 在 HomeLeave 是 number[](JSON 数组),去掉逗号串拼接;
连续请假按 continuousLeaveNum 逐天展开成 continuousLeaveTime[](服务端/浏览器同源)
- 作业发布(整块新增):homework.publish / schedule / published / drafts /
teachingSubjects / classTree。模块没有一步发布的接口,用 ToolDef.then 声明两步提交
(Create 落草稿 → Publish | Schedule);附件是对象数组
attachments:[{fileId,fileUrl,fileType,fileName,sort}],上传走 file 域
/coursehomework/api/file/Upload
- 执行器:新增 followUp 两步提交(浏览器侧同源)、数组参数给标量时包成单元素数组、
首步响应 data 取资源 id(对象形态取 .id,避免拼出 /homeworks/{"id":1}/publish)
- 新增 ModuleFormParityTest(13 项)逐条锁住上述契约
回归:mvn clean package → Tests run: 103, Failures: 0, Errors: 0, BUILD SUCCESS
真机:headless Chrome 走「表单 → 浏览器代执行 → 模块接口」,50 项断言全绿
1098 lines
54 KiB
Java
1098 lines
54 KiB
Java
package com.yunhai.agent.service;
|
||
|
||
import com.alibaba.fastjson.JSON;
|
||
import com.alibaba.fastjson.JSONArray;
|
||
import com.alibaba.fastjson.JSONObject;
|
||
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.llm.dto.ToolCall;
|
||
import com.yunhai.agent.model.BrowserToolRequest;
|
||
import com.yunhai.agent.model.ChatRequest;
|
||
import com.yunhai.agent.model.ChatResponse;
|
||
import com.yunhai.agent.model.ToolCallTrace;
|
||
import com.yunhai.agent.tool.CallerContext;
|
||
import com.yunhai.agent.tool.FormField;
|
||
import com.yunhai.agent.tool.FormRequest;
|
||
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 com.yunhai.agent.tool.ToolResult;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.apache.commons.lang3.StringUtils;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.text.SimpleDateFormat;
|
||
import java.util.ArrayList;
|
||
import java.util.Collections;
|
||
import java.util.Date;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.Set;
|
||
|
||
/**
|
||
* 智能体编排核心(ReAct 风格的工具调用循环):
|
||
*
|
||
* <pre>
|
||
* 用户输入
|
||
* └─► 组装 system + 历史 + 本轮输入
|
||
* └─► 调大模型(带上「按角色过滤后」的可用工具 schema)
|
||
* ├─ 模型返回 tool_calls ─┬─ 写操作缺必填参数 ─► 【回补全表单】给用户填(不再口头追问)
|
||
* │ └─ 参数齐全 ─► 逐个执行(转成对该模块的真实 HTTP 调用)
|
||
* │ ├─ 角色越权 ─► 服务端直接拒绝(留痕)
|
||
* │ └─► 结果回填为 role=tool 消息 ─► 再次调模型
|
||
* └─ 模型返回普通文本 ─► 这就是最终回复,结束
|
||
* </pre>
|
||
*
|
||
* <p>表单提交({@link ChatRequest#getFormSubmit()})走另一条短路:<b>直接执行</b>指定工具,
|
||
* 不再让模型二次决策,保证「用户填了什么就提交什么」。
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
public class AgentOrchestrator {
|
||
|
||
private final AgentSettings settings;
|
||
private final LlmClient llmClient;
|
||
private final ToolRegistry registry;
|
||
private final ToolExecutor executor;
|
||
private final SessionStore sessionStore;
|
||
private final AgentAuditService auditService;
|
||
private final IntentShortcut intentShortcut;
|
||
|
||
@Autowired
|
||
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
|
||
ToolExecutor executor, SessionStore sessionStore,
|
||
AgentAuditService auditService, IntentShortcut intentShortcut) {
|
||
this.settings = settings;
|
||
this.llmClient = llmClient;
|
||
this.registry = registry;
|
||
this.executor = executor;
|
||
this.sessionStore = sessionStore;
|
||
this.auditService = auditService;
|
||
this.intentShortcut = intentShortcut;
|
||
}
|
||
|
||
/**
|
||
* 执行一轮对话。
|
||
*
|
||
* @param req 请求(含用户输入、会话 id,或表单提交)
|
||
* @param userDesc 用户身份描述(姓名/角色/学校),用于写进 system prompt
|
||
* @param caller 调用者身份(透传给业务模块 + 角色过滤/校验)
|
||
* @param authorizedModIds 已授权模块号集合(可空 = 不过滤)
|
||
* @param sink 阶段事件出口(同步传 NOOP,SSE 传真实推送)
|
||
*/
|
||
public ChatResponse chat(ChatRequest req, String userDesc, CallerContext caller,
|
||
Set<Integer> authorizedModIds, EventSink sink) {
|
||
EventSink out = sink == null ? EventSink.NOOP : sink;
|
||
|
||
// 浏览器代执行结果回传:续跑(方案 A)
|
||
if (req != null && req.getToolResult() != null && !req.getToolResult().isEmpty()) {
|
||
return resumeBrowser(req, caller, authorizedModIds, out);
|
||
}
|
||
|
||
// 表单提交:短路直达执行
|
||
if (req != null && req.getFormSubmit() != null
|
||
&& StringUtils.isNotBlank(req.getFormSubmit().getTool())) {
|
||
return submitForm(req, userDesc, caller, authorizedModIds, out);
|
||
}
|
||
|
||
if (req == null || StringUtils.isBlank(req.getMessage())) {
|
||
throw new IllegalArgumentException("请输入内容");
|
||
}
|
||
|
||
Set<AgentRole> roles = caller.getRoles();
|
||
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
|
||
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
|
||
session.setTitle(abbreviate(req.getMessage()));
|
||
|
||
ChatResponse resp = new ChatResponse();
|
||
resp.setSessionId(session.getSessionId());
|
||
|
||
// ★ 裸意图直达:「我要请假」「发布校园公告」这类只有意图、没有细节的句子,直接按身份弹表单,
|
||
// 不交给大模型决策(模型可能先去查字典、或自行编造参数直接提交,都不合预期)。
|
||
ChatResponse direct = directForm(req.getMessage(), caller, authorizedModIds, roles, session, out);
|
||
if (direct != null) {
|
||
return direct;
|
||
}
|
||
|
||
List<ChatMessage> messages = session.getMessages();
|
||
if (messages.isEmpty()) {
|
||
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
|
||
}
|
||
messages.add(ChatMessage.user(req.getMessage()));
|
||
|
||
boolean enableTools = req.getEnableTools() == null || req.getEnableTools();
|
||
return runLoop(session, messages, resp, caller, authorizedModIds, roles, enableTools, false, out);
|
||
}
|
||
|
||
/**
|
||
* 模型主循环(ReAct):思考 → 需要工具时「挂起等浏览器」或「服务端执行」→ 再思考,直到给出自然语言回复。
|
||
*
|
||
* <p>★ 浏览器代执行(方案 A):业务模块的用户态接口只认<b>浏览器 CAS 会话</b>,
|
||
* 服务端直调必然 {@code code=302}(README 第 11 节四条证据)。开启 {@code agent.browser-exec.enabled}
|
||
* 且本轮工具都能准备成功时,整批挂起下发给前端,用用户自己的登录态执行后回传续跑({@link #resumeBrowser});
|
||
* 准备失败(未知工具 / 模块未配置地址 / 缺参 / 越权)则回退服务端直调老路径(行为与历史版本一致)。
|
||
*/
|
||
private ChatResponse runLoop(SessionStore.SessionState session, List<ChatMessage> messages,
|
||
ChatResponse resp, CallerContext caller, Set<Integer> authorizedModIds,
|
||
Set<AgentRole> roles, boolean enableTools, boolean resuming, EventSink out) {
|
||
JSONArray tools = enableTools ? registry.toOpenAiTools(authorizedModIds, roles) : null;
|
||
|
||
int maxRounds = settings.getLoop().getMaxRounds();
|
||
int totalPrompt = 0;
|
||
int totalCompletion = 0;
|
||
String lastContent = null;
|
||
|
||
for (int round = 1; round <= maxRounds; round++) {
|
||
resp.setRounds(round);
|
||
String thinking = round == 1
|
||
? (resuming ? "正在根据查询结果整理回答…" : "正在理解你的需求…")
|
||
: "正在根据结果继续处理…";
|
||
out.emit(AgentEvent.of("thinking", thinking));
|
||
|
||
ChatResult result;
|
||
try {
|
||
result = llmClient.chat(messages, tools);
|
||
} catch (Exception e) {
|
||
String msg = e.getMessage() == null ? "大模型调用失败" : e.getMessage();
|
||
out.emit(AgentEvent.of("error", msg));
|
||
resp.setReply("抱歉,我这边连接智能服务失败了:" + msg);
|
||
resp.setWarning(msg);
|
||
return resp;
|
||
}
|
||
|
||
totalPrompt += nz(result.getPromptTokens());
|
||
totalCompletion += nz(result.getCompletionTokens());
|
||
lastContent = StringUtils.isNotBlank(result.getContent()) ? result.getContent() : lastContent;
|
||
|
||
if (!result.hasToolCalls()) {
|
||
// 没有工具调用 → 这一条就是最终回复
|
||
String reply = StringUtils.isNotBlank(result.getContent())
|
||
? result.getContent()
|
||
: "(模型没有返回内容,请换个说法再试一次)";
|
||
messages.add(assistantOf(reply, result.getReasoningContent()));
|
||
resp.setReply(reply);
|
||
finish(resp, session, totalPrompt, totalCompletion, out);
|
||
return resp;
|
||
}
|
||
|
||
// 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法)
|
||
// ★ 同时带上 reasoning_content:thinking mode 下带 tools 的请求要求历史 assistant 原样回传该字段,
|
||
// 否则下一轮请求会被接口判 400(The reasoning_content ... must be passed back to the API)。
|
||
ChatMessage assistantMsg = assistantOf(result.getContent(), result.getReasoningContent());
|
||
assistantMsg.setToolCalls(result.getToolCalls());
|
||
messages.add(assistantMsg);
|
||
|
||
// ★ 预检:只要有一个「写操作」缺必填参数,就整体中断并改出表单。
|
||
// 放在执行任何工具之前,避免消息历史里出现「有 tool_call 却没有对应 tool 结果」的非法上下文。
|
||
ChatResponse pending = interceptMissing(result.getToolCalls(), messages, caller,
|
||
session.getSessionId(), out);
|
||
if (pending != null) {
|
||
pending.setSessionId(session.getSessionId());
|
||
pending.setRounds(round);
|
||
finish(pending, session, totalPrompt, totalCompletion, out);
|
||
return pending;
|
||
}
|
||
|
||
// ★ 浏览器代执行:整批可准备成功 → 挂起(撤销本轮 assistant(tool_calls),等浏览器回传续跑)
|
||
if (settings.getBrowserExec().isEnabled()) {
|
||
List<BrowserToolRequest.BrowserCall> batch =
|
||
tryPrepareBrowserBatch(result.getToolCalls(), caller);
|
||
if (batch != null) {
|
||
messages.remove(messages.size() - 1);
|
||
|
||
BrowserToolRequest bt = new BrowserToolRequest();
|
||
bt.setCalls(batch);
|
||
String titles = batch.get(0).getTitle()
|
||
+ (batch.size() > 1 ? " 等 " + batch.size() + " 项查询" : "");
|
||
bt.setNotice("「" + titles + "」需要用你的登录态获取,正在通过你的浏览器执行…");
|
||
resp.setBrowserTool(bt);
|
||
resp.setReply(bt.getNotice());
|
||
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
|
||
log.info("浏览器代执行挂起:session={} 工具={}", session.getSessionId(),
|
||
batch.stream().map(BrowserToolRequest.BrowserCall::getName)
|
||
.collect(java.util.stream.Collectors.joining(",")));
|
||
finish(resp, session, totalPrompt, totalCompletion, out);
|
||
return resp;
|
||
}
|
||
}
|
||
|
||
for (ToolCall call : result.getToolCalls()) {
|
||
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
|
||
ToolDef def = toolName == null ? null : registry.get(toolName);
|
||
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
|
||
|
||
if (def == null) {
|
||
// 模型幻觉出了不存在的工具:如实告知,让它改正
|
||
String err = "不存在的工具:" + toolName;
|
||
out.emit(AgentEvent.of("tool_result", "工具不可用:" + toolName));
|
||
messages.add(ChatMessage.tool(call.getId(), "{\"success\":false,\"error\":\"" + err + "\"}"));
|
||
ToolCallTrace trace = new ToolCallTrace();
|
||
trace.setName(String.valueOf(toolName));
|
||
trace.setTitle("未知工具");
|
||
trace.setSuccess(false);
|
||
trace.setError(err);
|
||
resp.getToolCalls().add(trace);
|
||
continue;
|
||
}
|
||
|
||
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(),
|
||
payloadOf(def, args)));
|
||
|
||
ToolResult tr = executor.execute(def, args, caller);
|
||
auditService.record(session.getSessionId(), caller, def, args, tr);
|
||
|
||
messages.add(ChatMessage.tool(call.getId(), tr.getContent()));
|
||
|
||
ToolCallTrace trace = buildTrace(def, args, tr);
|
||
resp.getToolCalls().add(trace);
|
||
|
||
if (!def.isReadOnly()) {
|
||
resp.setWroteSomething(true);
|
||
if (StringUtils.isNotBlank(def.getLandingPath())) {
|
||
resp.setLandingPath(def.getLandingPath());
|
||
}
|
||
}
|
||
|
||
out.emit(AgentEvent.of("tool_result",
|
||
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
|
||
payloadOf(def, args)));
|
||
|
||
log.info("工具 {} 执行 {} 耗时 {}ms", def.getName(),
|
||
tr.isSuccess() ? "成功" : "失败(" + tr.getError() + ")", tr.getCostMs());
|
||
}
|
||
// 继续下一轮,让模型基于工具结果生成自然语言回复
|
||
}
|
||
|
||
// 超过最大轮数:把最后一次内容兜底返回,避免"空转"
|
||
String reply = StringUtils.isNotBlank(lastContent)
|
||
? lastContent
|
||
: "这个需求需要多步操作,我已经执行到这里,建议你到对应页面确认一下结果。";
|
||
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
|
||
resp.setReply(reply);
|
||
resp.setWarning("已达到单轮最大处理步数");
|
||
finish(resp, session, totalPrompt, totalCompletion, out);
|
||
return resp;
|
||
}
|
||
|
||
/**
|
||
* 尝试把本轮所有工具调用准备成一个「浏览器代执行」批次。
|
||
*
|
||
* @return 批次;null = 无法整批代执行(存在未知工具 / 模块未配置地址 / 参数不合法 / 越权),
|
||
* 调用方回退服务端直调老路径 —— 保证任何情况下消息历史的 tool_call/tool 结果配对完整。
|
||
*/
|
||
private List<BrowserToolRequest.BrowserCall> tryPrepareBrowserBatch(List<ToolCall> calls,
|
||
CallerContext caller) {
|
||
List<BrowserToolRequest.BrowserCall> batch = new ArrayList<>();
|
||
for (ToolCall call : calls) {
|
||
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
|
||
ToolDef def = registry.get(toolName);
|
||
if (def == null || !settings.getModules().containsKey(def.getModule())) {
|
||
return null;
|
||
}
|
||
// 越权的调用不进批次(浏览器白跑一趟没有意义),交回老路径立即拒绝
|
||
if (executor.checkAccess(def, caller) != null) {
|
||
return null;
|
||
}
|
||
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
|
||
try {
|
||
batch.add(executor.prepareBrowserCall(def, args));
|
||
} catch (Exception e) {
|
||
log.info("代执行批次准备失败({}),回退服务端执行:{}", def.getName(), e.getMessage());
|
||
return null;
|
||
}
|
||
}
|
||
return batch;
|
||
}
|
||
|
||
/**
|
||
* 浏览器代执行的续跑:前端已用<b>用户自己的登录态</b>调完模块接口并带回原始响应。
|
||
*
|
||
* <p>服务端重新查注册表解析结果(角色校验、{@code code=302} 识别等与服务端执行完全同源,
|
||
* <b>不信任</b>前端回传的任何路径信息),构造合法的 {@code assistant(tool_calls) + tool} 消息
|
||
* 喂回模型继续 —— 模型既可以整理出最终回答,也可以发起下一批工具调用(链式查询)。
|
||
*/
|
||
private ChatResponse resumeBrowser(ChatRequest req, CallerContext caller,
|
||
Set<Integer> authorizedModIds, EventSink out) {
|
||
Set<AgentRole> roles = caller.getRoles();
|
||
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
|
||
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
|
||
|
||
ChatResponse resp = new ChatResponse();
|
||
resp.setSessionId(session.getSessionId());
|
||
|
||
List<ChatMessage> messages = session.getMessages();
|
||
if (messages.isEmpty()) {
|
||
resp.setReply("会话已过期,请重新说一次你的需求。");
|
||
resp.setWarning("browser-resume:会话没有历史消息(服务重启或已过期)");
|
||
finish(resp, session, 0, 0, out);
|
||
return resp;
|
||
}
|
||
|
||
String idPrefix = "br_" + System.currentTimeMillis() + "_";
|
||
// 注:这条 assistant 是「服务端补造」的(模型当轮的消息在挂起时已撤销),没有真实 reasoning_content。
|
||
// thinking mode 关闭时(agent.llm.thinking=disabled,默认)不需要该字段;若改为开启,此处可能被接口判 400。
|
||
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
|
||
List<ToolCall> calls = new ArrayList<>();
|
||
List<String> contents = new ArrayList<>();
|
||
int idx = 0;
|
||
|
||
for (ChatRequest.ToolResultItem item : req.getToolResult()) {
|
||
Map<String, Object> args = item.getArguments() == null
|
||
? new LinkedHashMap<>() : new LinkedHashMap<>(item.getArguments());
|
||
ToolDef def = registry.get(item.getName());
|
||
|
||
ToolResult tr;
|
||
if (def == null) {
|
||
tr = unknownToolResult(item.getName());
|
||
} else {
|
||
ToolResult denied = executor.checkAccess(def, caller);
|
||
if (denied != null) {
|
||
tr = denied;
|
||
} else if (StringUtils.isNotBlank(item.getError())) {
|
||
tr = ToolResult.fail(def, "浏览器调用未完成:" + item.getError(), 0);
|
||
} else {
|
||
tr = executor.parseBrowserResponse(def, item.getStatus(), item.getBody(), 0);
|
||
}
|
||
auditService.record(session.getSessionId(), caller, def, args, tr);
|
||
resp.getToolCalls().add(buildTrace(def, args, tr));
|
||
if (!def.isReadOnly()) {
|
||
resp.setWroteSomething(true);
|
||
if (StringUtils.isNotBlank(def.getLandingPath())) {
|
||
resp.setLandingPath(def.getLandingPath());
|
||
}
|
||
}
|
||
}
|
||
|
||
out.emit(AgentEvent.of("tool_result",
|
||
(def == null ? item.getName() : def.getTitle())
|
||
+ (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError())));
|
||
log.info("浏览器代执行回传 {} {} ", item.getName(), tr.isSuccess() ? "成功" : "失败");
|
||
|
||
ToolCall tc = new ToolCall();
|
||
tc.setId(idPrefix + idx);
|
||
ToolCall.Function fn = new ToolCall.Function();
|
||
fn.setName(def != null ? def.getName() : item.getName());
|
||
fn.setArguments(JSON.toJSONString(args));
|
||
tc.setFunction(fn);
|
||
calls.add(tc);
|
||
contents.add(tr.getContent());
|
||
idx++;
|
||
}
|
||
|
||
assistant.setToolCalls(calls);
|
||
messages.add(assistant);
|
||
for (int i = 0; i < contents.size(); i++) {
|
||
messages.add(ChatMessage.tool(idPrefix + i, contents.get(i)));
|
||
}
|
||
|
||
return runLoop(session, messages, resp, caller, authorizedModIds, roles, true, true, out);
|
||
}
|
||
|
||
/** 前端回传了未注册的工具名(正常不会发生):构造统一的失败结果回填给模型 */
|
||
private ToolResult unknownToolResult(String name) {
|
||
ToolResult tr = new ToolResult();
|
||
tr.setToolName(name);
|
||
tr.setTitle("未知工具");
|
||
tr.setSuccess(false);
|
||
tr.setError("不存在的工具:" + name);
|
||
tr.setContent("{\"success\":false,\"error\":\"不存在的工具:" + name + "\"}");
|
||
return tr;
|
||
}
|
||
|
||
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
|
||
|
||
/**
|
||
* 裸意图直达:命中「只有意图、没有细节」的句子(如「我要请假」「发布校园公告」)时,
|
||
* 按当前身份直接构建表单返回 —— 请假:学生拿「请假申请」、教职工拿「请假登记(帮学生请假)」;
|
||
* 发布公告:只有教职工能直达,学生落回模型(由模型的权限准则如实说明)。
|
||
*
|
||
* <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(),
|
||
intent.getName(), staff ? "教职工" : "学生", def.getName());
|
||
finish(resp, session, 0, 0, out);
|
||
return resp;
|
||
}
|
||
|
||
/**
|
||
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
|
||
*
|
||
* @return 需要用户补全信息时返回响应(含 form);一切正常返回 null
|
||
*/
|
||
private ChatResponse interceptMissing(List<ToolCall> calls, List<ChatMessage> messages,
|
||
CallerContext caller, String sessionId, EventSink out) {
|
||
for (ToolCall call : calls) {
|
||
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
|
||
ToolDef def = toolName == null ? null : registry.get(toolName);
|
||
if (def == null || def.isReadOnly()) {
|
||
continue;
|
||
}
|
||
// 越权的写操作不走表单(表单填了也执行不了),交由执行期统一拒绝并告知
|
||
if (settings.isEnforceRolePermission() && !def.allowsAny(caller.getRoles())) {
|
||
continue;
|
||
}
|
||
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
|
||
List<ToolParam> missing = missingRequired(def, args);
|
||
if (missing.isEmpty()) {
|
||
continue;
|
||
}
|
||
|
||
// 撤销本轮的 assistant(tool_calls),保持历史自洽(用户可能不填表、转而说别的)
|
||
if (!messages.isEmpty()) {
|
||
messages.remove(messages.size() - 1);
|
||
}
|
||
|
||
FormRequest form = buildForm(def, args, missing, caller);
|
||
ChatResponse resp = new ChatResponse();
|
||
resp.setForm(form);
|
||
resp.setReply(askText(def, missing));
|
||
|
||
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()),
|
||
payloadOf(def, args)));
|
||
return resp;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 表单直提交:用户已填好参数 → 直接执行工具 → 再由模型把结果说成自然语言。
|
||
*/
|
||
private ChatResponse submitForm(ChatRequest req, String userDesc, CallerContext caller,
|
||
Set<Integer> authorizedModIds, EventSink out) {
|
||
ChatRequest.FormSubmit fs = req.getFormSubmit();
|
||
Set<AgentRole> roles = caller.getRoles();
|
||
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
|
||
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
|
||
|
||
ChatResponse resp = new ChatResponse();
|
||
resp.setSessionId(session.getSessionId());
|
||
|
||
ToolDef def = registry.get(fs.getTool());
|
||
if (def == null) {
|
||
resp.setReply("抱歉,这个操作暂时不可用。");
|
||
resp.setWarning("未注册的工具:" + fs.getTool());
|
||
return resp;
|
||
}
|
||
// 角色门禁:提交时再校验一次(模型侧过滤可被绕过,这里是硬约束)
|
||
if (!def.allowsAny(roles)) {
|
||
resp.setReply("抱歉,当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。");
|
||
resp.setWarning("越权操作已被拒绝:" + def.getName());
|
||
return resp;
|
||
}
|
||
|
||
Map<String, Object> args = fs.getArgs() == null
|
||
? new LinkedHashMap<>() : new LinkedHashMap<>(fs.getArgs());
|
||
|
||
// 兜底:表单若有漏填(前端校验被绕过),仍然回到表单而不是带着残缺参数去打接口
|
||
List<ToolParam> missing = missingRequired(def, args);
|
||
if (!missing.isEmpty()) {
|
||
resp.setForm(buildForm(def, args, missing, caller));
|
||
resp.setReply(askText(def, missing));
|
||
return resp;
|
||
}
|
||
|
||
List<ChatMessage> messages = session.getMessages();
|
||
if (messages.isEmpty()) {
|
||
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
|
||
}
|
||
// 把"用户填表提交了什么"写入历史,让后续对话有上下文
|
||
messages.add(ChatMessage.user(renderSubmission(def, args)));
|
||
|
||
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(), payloadOf(def, args)));
|
||
|
||
// ★ 浏览器代执行(方案 A):模块接口只认浏览器会话,服务端提交必然 302。
|
||
// 表单提交同样挂起给前端用用户登录态执行,结果回传后由 resumeBrowser 续跑;
|
||
// 准备失败(缺参/模块未配置)时回退服务端直调老路径。
|
||
if (settings.getBrowserExec().isEnabled()
|
||
&& settings.getModules().containsKey(def.getModule())) {
|
||
try {
|
||
BrowserToolRequest.BrowserCall bc = executor.prepareBrowserCall(def, args);
|
||
BrowserToolRequest bt = new BrowserToolRequest();
|
||
bt.setCalls(new ArrayList<>(Collections.singletonList(bc)));
|
||
bt.setNotice("「" + def.getTitle() + "」需要用你的登录态提交,正在通过你的浏览器执行…");
|
||
resp.setBrowserTool(bt);
|
||
resp.setReply(bt.getNotice());
|
||
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
|
||
log.info("表单提交转浏览器代执行:session={} 工具={}", session.getSessionId(), def.getName());
|
||
finish(resp, session, 0, 0, out);
|
||
return resp;
|
||
} catch (Exception e) {
|
||
log.warn("表单代执行准备失败,回退服务端执行:{} - {}", def.getName(), e.toString());
|
||
}
|
||
}
|
||
|
||
ToolResult tr = executor.execute(def, args, caller);
|
||
auditService.record(session.getSessionId(), caller, def, args, tr);
|
||
|
||
// 构造合法的 assistant(tool_calls) + tool 结果,模型据此组织语言
|
||
String callId = "form_" + System.currentTimeMillis();
|
||
ToolCall tc = new ToolCall();
|
||
tc.setId(callId);
|
||
ToolCall.Function fn = new ToolCall.Function();
|
||
fn.setName(def.getName());
|
||
fn.setArguments(JSON.toJSONString(args));
|
||
tc.setFunction(fn);
|
||
// 同上:服务端补造的 assistant(无真实 reasoning_content),thinking 关闭时无需该字段
|
||
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
|
||
assistant.setToolCalls(Collections.singletonList(tc));
|
||
messages.add(assistant);
|
||
messages.add(ChatMessage.tool(callId, tr.getContent()));
|
||
|
||
resp.getToolCalls().add(buildTrace(def, args, tr));
|
||
if (!def.isReadOnly()) {
|
||
resp.setWroteSomething(true);
|
||
if (StringUtils.isNotBlank(def.getLandingPath())) {
|
||
resp.setLandingPath(def.getLandingPath());
|
||
}
|
||
}
|
||
out.emit(AgentEvent.of("tool_result",
|
||
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
|
||
payloadOf(def, args)));
|
||
log.info("表单提交执行 {} {} 耗时 {}ms", def.getName(), tr.isSuccess() ? "成功" : "失败", tr.getCostMs());
|
||
|
||
String reply;
|
||
int promptTokens = 0;
|
||
int completionTokens = 0;
|
||
try {
|
||
ChatResult r = llmClient.chat(messages, null);
|
||
promptTokens = nz(r.getPromptTokens());
|
||
completionTokens = nz(r.getCompletionTokens());
|
||
reply = StringUtils.isNotBlank(r.getContent()) ? r.getContent() : fallbackReply(def, tr);
|
||
} catch (Exception e) {
|
||
log.warn("表单提交后生成回复失败,改用兜底话术:{}", e.toString());
|
||
reply = fallbackReply(def, tr);
|
||
}
|
||
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
|
||
resp.setReply(reply);
|
||
finish(resp, session, promptTokens, completionTokens, out);
|
||
return resp;
|
||
}
|
||
|
||
/** 找出缺失的必填参数(fixed 固定参数视为已提供) */
|
||
private List<ToolParam> missingRequired(ToolDef def, Map<String, Object> args) {
|
||
Map<String, Object> effective = new LinkedHashMap<>();
|
||
if (def.getFixed() != null) {
|
||
effective.putAll(def.getFixed());
|
||
}
|
||
if (args != null) {
|
||
effective.putAll(args);
|
||
}
|
||
|
||
List<ToolParam> missing = new ArrayList<>();
|
||
for (ToolParam p : def.getParams()) {
|
||
if (!p.isRequired() || p.isHidden()) {
|
||
continue;
|
||
}
|
||
Object v = effective.get(p.getName());
|
||
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
|
||
missing.add(p);
|
||
}
|
||
}
|
||
return missing;
|
||
}
|
||
|
||
/** 由工具定义生成补全表单 */
|
||
private FormRequest buildForm(ToolDef def, Map<String, Object> args, List<ToolParam> missing,
|
||
CallerContext caller) {
|
||
FormRequest form = new FormRequest();
|
||
form.setTool(def.getName());
|
||
form.setTitle(def.getTitle());
|
||
form.setModule(def.getModule());
|
||
form.setWrite(!def.isReadOnly());
|
||
form.setLandingPath(def.getLandingPath());
|
||
|
||
// 本次构建内的字典缓存:同一字典只请求一次(字段下拉与已识别回显共用)
|
||
Map<String, List<Map<String, String>>> dictCache = new LinkedHashMap<>();
|
||
|
||
List<FormField> fields = new ArrayList<>();
|
||
List<String> missingLabels = new ArrayList<>();
|
||
// ★ 表单包含哪些字段:发布类工具(fullForm)渲染**全部字段**,与模块自己的「新建/发布」页一致
|
||
// (置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项也摆出来,否则用户设不了、发出去的数据与模块页不同);
|
||
// 其余工具仍只补「缺失的必填项」。
|
||
List<ToolParam> shown = new ArrayList<>();
|
||
if (def.isFormAll()) {
|
||
for (ToolParam p : def.getParams()) {
|
||
if (!p.isHidden()) {
|
||
shown.add(p);
|
||
}
|
||
}
|
||
} else {
|
||
shown.addAll(missing);
|
||
}
|
||
for (ToolParam p : shown) {
|
||
FormField f = p.toFormField();
|
||
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。
|
||
// 缺省按 「/<模块>/api/base/FileUpload」,个别模块不同(作业是 /api/file/Upload)时由参数覆盖。
|
||
if ("file".equals(f.getWidget())) {
|
||
f.setUploadPath(StringUtils.isNotBlank(p.getUploadPathOverride())
|
||
? p.getUploadPathOverride()
|
||
: "/" + def.getModule() + "/api/base/FileUpload");
|
||
}
|
||
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
||
//
|
||
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
|
||
// 服务端直调必然 code=302(README 第 11 节)。以前"服务端取一次、失败就退化成
|
||
// 数字输入框",结果教师登记请假时得手填 classId/stuId 编号——根本没法用。
|
||
// 现在把「去哪儿取」以 optionCall 下发(依赖字段留 {{占位符}}),
|
||
// 前端在学生选完班级后自己拉名单,下拉框才是真的下拉框。
|
||
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
|
||
if (optionCall != null) {
|
||
f.setOptionCall(optionCall);
|
||
// ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、
|
||
// flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。
|
||
String w = f.getWidget();
|
||
if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) {
|
||
f.setWidget("select");
|
||
}
|
||
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
|
||
f.setOptionsFallback(p.isUsingFallbackOptions());
|
||
} else if (StringUtils.isNotBlank(p.getOptionsFrom())) {
|
||
// 没有代取通道(模块未配地址 / 越权)→ 退回服务端取一次,取不到就保留原控件让用户手填编号
|
||
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
|
||
if (!opts.isEmpty()) {
|
||
f.setOptions(opts);
|
||
String w2 = f.getWidget();
|
||
if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)) {
|
||
f.setWidget("select");
|
||
}
|
||
f.setOptionsFallback(false);
|
||
}
|
||
}
|
||
fields.add(f);
|
||
}
|
||
for (ToolParam p : missing) {
|
||
missingLabels.add(p.labelOf());
|
||
}
|
||
form.setFields(fields);
|
||
form.setMissing(missingLabels);
|
||
form.setAllFields(def.isFormAll());
|
||
|
||
// 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对)
|
||
Map<String, Object> preset = new LinkedHashMap<>();
|
||
List<Map<String, String>> recognized = new ArrayList<>();
|
||
if (args != null) {
|
||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||
Object v = e.getValue();
|
||
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
|
||
continue;
|
||
}
|
||
preset.put(e.getKey(), v);
|
||
|
||
ToolParam p = findParam(def, e.getKey());
|
||
Map<String, String> row = new LinkedHashMap<>();
|
||
row.put("name", e.getKey());
|
||
row.put("label", p != null ? p.labelOf() : e.getKey());
|
||
row.put("text", humanize(p, v, caller, dictCache));
|
||
recognized.add(row);
|
||
}
|
||
}
|
||
form.setPreset(preset);
|
||
form.setRecognized(recognized);
|
||
|
||
form.setDescription("补齐下面的信息,我就直接帮你执行「" + def.getTitle() + "」。");
|
||
form.setHint("带 * 为必填;时间类字段请选到具体时刻,我会按你选的时间提交。");
|
||
return form;
|
||
}
|
||
|
||
private ToolParam findParam(ToolDef def, String name) {
|
||
for (ToolParam p : def.getParams()) {
|
||
if (name.equals(p.getName())) {
|
||
return p;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 参数值 → 可读文本:有静态枚举或动态字典时翻译成中文 */
|
||
private String humanize(ToolParam p, Object value, CallerContext caller,
|
||
Map<String, List<Map<String, String>>> cache) {
|
||
String text = String.valueOf(value);
|
||
if (p == null) {
|
||
return text;
|
||
}
|
||
if (p.getEnumValues() != null && !p.getEnumValues().isEmpty()) {
|
||
int idx = p.getEnumValues().indexOf(text);
|
||
if (idx >= 0 && p.getEnumLabels() != null && idx < p.getEnumLabels().size()) {
|
||
return p.getEnumLabels().get(idx);
|
||
}
|
||
return text;
|
||
}
|
||
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
|
||
for (Map<String, String> o : dictOptions(p.getOptionsFrom(), caller, cache)) {
|
||
if (text.equals(o.get("value"))) {
|
||
return o.get("label");
|
||
}
|
||
}
|
||
}
|
||
// 服务端取不到实时字典时,至少把内置兜底的中文名翻出来(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;
|
||
}
|
||
|
||
/**
|
||
* 生成某个字段「浏览器代取选项」的调用描述;不可用时返回 {@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,
|
||
Map<String, List<Map<String, String>>> cache) {
|
||
if (cache.containsKey(toolName)) {
|
||
return cache.get(toolName);
|
||
}
|
||
List<Map<String, String>> opts = fetchDictOptions(toolName, caller);
|
||
cache.put(toolName, opts == null ? new ArrayList<>() : opts);
|
||
return cache.get(toolName);
|
||
}
|
||
|
||
/**
|
||
* 执行字典工具并尽力解析成 [{value,label}]。
|
||
* <p>解析失败(结构不认识、接口不通)一律返回 null,前端退化成普通输入框,不影响主流程。
|
||
*/
|
||
private List<Map<String, String>> fetchDictOptions(String toolName, CallerContext caller) {
|
||
try {
|
||
ToolDef dict = registry.get(toolName);
|
||
if (dict == null || !dict.isReadOnly()) {
|
||
return null;
|
||
}
|
||
// 字典工具也受角色约束:越权则取不到选项,退化为普通输入框
|
||
if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) {
|
||
return null;
|
||
}
|
||
ToolResult tr = executor.execute(dict, new LinkedHashMap<>(), caller);
|
||
if (!tr.isSuccess()) {
|
||
return null;
|
||
}
|
||
JSONArray arr = findArray(JSON.toJSON(tr.getData()), 0);
|
||
if (arr == null || arr.isEmpty()) {
|
||
return null;
|
||
}
|
||
List<Map<String, String>> options = new ArrayList<>();
|
||
for (int i = 0; i < arr.size(); i++) {
|
||
Object item = arr.get(i);
|
||
if (!(item instanceof JSONObject)) {
|
||
continue;
|
||
}
|
||
JSONObject o = (JSONObject) item;
|
||
// "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", "v", "value");
|
||
Map<String, String> one = new LinkedHashMap<>();
|
||
one.put("value", value);
|
||
one.put("label", StringUtils.isBlank(label) ? value : label);
|
||
options.add(one);
|
||
}
|
||
return options.isEmpty() ? null : options;
|
||
} catch (Exception e) {
|
||
log.warn("拉取字典选项失败 {}:{}", toolName, e.toString());
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private String firstOf(JSONObject o, String... keys) {
|
||
for (String k : keys) {
|
||
Object v = o.get(k);
|
||
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
|
||
return String.valueOf(v);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 递归找第一个数组(限深,避免异常结构下深度遍历) */
|
||
private JSONArray findArray(Object node, int depth) {
|
||
if (node == null || depth > 3) {
|
||
return null;
|
||
}
|
||
if (node instanceof JSONArray) {
|
||
return (JSONArray) node;
|
||
}
|
||
if (node instanceof JSONObject) {
|
||
JSONObject o = (JSONObject) node;
|
||
for (String k : o.keySet()) {
|
||
JSONArray a = findArray(o.get(k), depth + 1);
|
||
if (a != null) {
|
||
return a;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 缺参时的引导话术(表单之外的文字说明) */
|
||
private String askText(ToolDef def, List<ToolParam> missing) {
|
||
List<String> parts = new ArrayList<>();
|
||
for (ToolParam p : missing) {
|
||
String l = p.labelOf();
|
||
if (StringUtils.isNotBlank(p.getExample())) {
|
||
l = l + "(格式如 " + p.getExample() + ")";
|
||
}
|
||
parts.add(l);
|
||
}
|
||
return "要完成「" + def.getTitle() + "」,还差这几项:" + String.join("、", parts)
|
||
+ "。\n可以直接在下面的表单里填,填好点「提交」就行。";
|
||
}
|
||
|
||
/** 把表单提交渲染成一条用户消息,写入会话历史 */
|
||
private String renderSubmission(ToolDef def, Map<String, Object> args) {
|
||
StringBuilder sb = new StringBuilder();
|
||
sb.append("我已在表单里填好并提交「").append(def.getTitle()).append("」,参数为:");
|
||
for (Map.Entry<String, Object> e : args.entrySet()) {
|
||
sb.append("\n- ").append(e.getKey()).append(" = ").append(String.valueOf(e.getValue()));
|
||
}
|
||
return sb.toString();
|
||
}
|
||
|
||
private ToolCallTrace buildTrace(ToolDef def, Map<String, Object> args, ToolResult tr) {
|
||
ToolCallTrace trace = new ToolCallTrace();
|
||
trace.setName(def.getName());
|
||
trace.setTitle(def.getTitle());
|
||
trace.setModule(def.getModule());
|
||
trace.setArguments(args);
|
||
trace.setSuccess(tr.isSuccess());
|
||
trace.setError(tr.getError());
|
||
trace.setWrite(!def.isReadOnly());
|
||
trace.setLandingPath(def.getLandingPath());
|
||
trace.setCostMs(tr.getCostMs());
|
||
return trace;
|
||
}
|
||
|
||
/** 大模型不可用时的兜底话术(表单提交场景,结果已经确定) */
|
||
private String fallbackReply(ToolDef def, ToolResult tr) {
|
||
if (tr.isSuccess()) {
|
||
return "「" + def.getTitle() + "」已执行完成。";
|
||
}
|
||
return "「" + def.getTitle() + "」执行失败:" + (tr.getError() == null ? "未知原因" : tr.getError());
|
||
}
|
||
|
||
/**
|
||
* 构造 assistant 消息,并统一挂上 thinking mode 的思维链。
|
||
*
|
||
* <p>★ DeepSeek V4 系列默认开启 thinking mode,官方协议要求:只要请求带 {@code tools},
|
||
* 历史里所有 assistant 消息的 {@code reasoning_content} 必须原样回传,否则 HTTP 400。
|
||
* 因此模型每一次返回的思维链都要落进对应的 assistant 消息里。
|
||
*/
|
||
private ChatMessage assistantOf(String content, String reasoningContent) {
|
||
ChatMessage m = new ChatMessage(ChatMessage.ROLE_ASSISTANT, content);
|
||
m.setReasoningContent(reasoningContent);
|
||
return m;
|
||
}
|
||
|
||
private void finish(ChatResponse resp, SessionStore.SessionState session,
|
||
int promptTokens, int completionTokens, EventSink out) {
|
||
resp.setPromptTokens(promptTokens);
|
||
resp.setCompletionTokens(completionTokens);
|
||
sessionStore.trim(session);
|
||
sessionStore.persist(session);
|
||
out.emit(AgentEvent.of("message", resp.getReply()));
|
||
out.emit(AgentEvent.of("done", "ok"));
|
||
}
|
||
|
||
private int nz(Integer v) {
|
||
return v == null ? 0 : v;
|
||
}
|
||
|
||
/** 容错解析模型给的参数 JSON(模型经常返回非法 JSON 或空串) */
|
||
private Map<String, Object> parseArgs(String arguments) {
|
||
if (StringUtils.isBlank(arguments)) {
|
||
return new LinkedHashMap<>();
|
||
}
|
||
try {
|
||
JSONObject obj = JSON.parseObject(arguments);
|
||
if (obj == null) {
|
||
return new LinkedHashMap<>();
|
||
}
|
||
Map<String, Object> map = new LinkedHashMap<>();
|
||
for (String k : obj.keySet()) {
|
||
map.put(k, obj.get(k));
|
||
}
|
||
return map;
|
||
} catch (Exception e) {
|
||
log.warn("工具参数不是合法 JSON,已忽略:{}", arguments);
|
||
return new LinkedHashMap<>();
|
||
}
|
||
}
|
||
|
||
private Object payloadOf(ToolDef def, Map<String, Object> args) {
|
||
Map<String, Object> p = new LinkedHashMap<>();
|
||
p.put("name", def.getName());
|
||
p.put("title", def.getTitle());
|
||
p.put("module", def.getModule());
|
||
p.put("arguments", args);
|
||
p.put("write", !def.isReadOnly());
|
||
return p;
|
||
}
|
||
|
||
/** 构造系统提示词:身份 + 角色 + 能力清单 + 行为准则 */
|
||
private String buildSystemPrompt(String userDesc, Set<Integer> authorizedModIds, Set<AgentRole> roles) {
|
||
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
||
StringBuilder sb = new StringBuilder();
|
||
|
||
sb.append("你是「云海星通校园平台」的智能助手,帮助用户在系统里直接完成操作,而不是只做问答。\n\n");
|
||
|
||
sb.append("【当前用户】").append(StringUtils.isBlank(userDesc) ? "未知用户" : userDesc).append("\n");
|
||
sb.append("【当前时间】").append(now).append("(星期").append(weekday()).append(")\n\n");
|
||
|
||
sb.append("【可用能力】以下是你当前身份可以调用的业务能力。调用后系统会把真实结果给你,")
|
||
.append("你必须依据结果回答,绝不能编造成功或数据:\n");
|
||
|
||
Map<String, List<ToolDef>> grouped = registry.groupByModule(authorizedModIds, roles);
|
||
for (Map.Entry<String, List<ToolDef>> e : grouped.entrySet()) {
|
||
sb.append("- ").append(moduleLabel(e.getKey())).append(":");
|
||
List<String> names = new ArrayList<>();
|
||
for (ToolDef d : e.getValue()) {
|
||
names.add(d.getTitle());
|
||
}
|
||
sb.append(String.join("、", names)).append("\n");
|
||
}
|
||
|
||
sb.append("\n【行为准则】\n");
|
||
sb.append("1. 用户表达一个操作意图(如“我要请假”“帮我看看作业”)时,直接调用对应工具完成,不要只给操作指引。\n");
|
||
|
||
sb.append("2. ★ 写操作(提交/审批/发布/撤销)缺参数时,**不要用文字反复追问**:\n")
|
||
.append(" 请照常调用该工具,把能从用户话里确定的参数填上,**不确定的字段直接省略**。\n")
|
||
.append(" 系统会自动弹出带中文标签、格式示例和下拉选项的表单让用户补齐,比口头追问准确得多。\n");
|
||
|
||
sb.append("3. 时间类参数统一用 yyyy-MM-dd HH:mm:ss;用户说“明天”“下周一”这类相对时间,")
|
||
.append("请基于上面的当前时间换算成具体日期时间后再填,并在回答里说明你换算成的日期。\n");
|
||
|
||
sb.append("4. 一次可以调用多个工具(例如先查列表再取详情),但不要重复调用同一个工具做同样的事。\n");
|
||
sb.append("5. 工具返回 success=false 时,把失败原因用通俗的话告诉用户,并给出下一步建议;不要假装成功。\n");
|
||
sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n");
|
||
sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n");
|
||
|
||
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求「审批请假」「发布公告」),")
|
||
.append("要如实说明「该操作需要 XX 身份」,不要试图越权,也不要承诺你会去办。\n");
|
||
|
||
sb.append("9. ★ 请假要分清两条链路:「提交请假申请」= 学生给自己请假(提交后待班主任审核);")
|
||
.append("「帮学生登记请假」= 教师给学生登记(免审直接生效,必须先选班级与学生)。")
|
||
.append("下发给你的工具已按当前身份过滤,按标题选即可,不要互换。\n");
|
||
|
||
sb.append("10. ★ 「发布校园公告」是**立即发布、全站可见**的动作:必须拿到标题、正文、发文部门三项;")
|
||
.append("缺哪项就照常调用工具让系统弹表单,不要自己编造发文部门,也不要用文字反复追问。")
|
||
.append("该工具只对教职工下发(学生身份看不到),学生要求发布公告时如实说明身份不够。\n");
|
||
|
||
sb.append("11. ★ 「发布宿舍通知」与「发布校园公告」是**两个不同模块**,不要混用:")
|
||
.append("宿舍通知发给**家长**、可要求家长回执(回执方式:无需/确认/填报),发布时要填通知类型与发文部门;")
|
||
.append("校园公告是全校范围的公告。用户说「发通知」而没指明模块时,先问清楚是校园公告还是宿舍通知。\n");
|
||
|
||
sb.append("12. ★ 「发布家校活动」要拿到标题、说明、活动分类、主办部门、活动地点与起止时间;")
|
||
.append("活动默认要求家长报名。缺项就照常调用工具让系统弹表单,分类与主办部门必须来自字典,不要编造。\n");
|
||
|
||
return sb.toString();
|
||
}
|
||
|
||
private String weekday() {
|
||
String[] w = {"日", "一", "二", "三", "四", "五", "六"};
|
||
java.util.Calendar c = java.util.Calendar.getInstance();
|
||
return w[c.get(java.util.Calendar.DAY_OF_WEEK) - 1];
|
||
}
|
||
|
||
/** 角色集合 → 可读描述(落库快照用) */
|
||
private String rolesText(Set<AgentRole> roles) {
|
||
if (roles == null || roles.isEmpty()) {
|
||
return "访客";
|
||
}
|
||
StringBuilder sb = new StringBuilder();
|
||
for (AgentRole r : roles) {
|
||
if (sb.length() > 0) {
|
||
sb.append("+");
|
||
}
|
||
sb.append(r.getLabel());
|
||
}
|
||
return sb.toString();
|
||
}
|
||
|
||
private String abbreviate(String s) {
|
||
if (s == null) {
|
||
return null;
|
||
}
|
||
String t = s.trim().replaceAll("\\s+", " ");
|
||
return t.length() <= 60 ? t : t.substring(0, 60);
|
||
}
|
||
|
||
private String moduleLabel(String module) {
|
||
switch (module) {
|
||
case "homeleave": return "学生请假";
|
||
case "campusnotice": return "校园公告";
|
||
case "dormnotice": return "宿舍通知";
|
||
case "familyactivity": return "家校活动";
|
||
case "coursehomework": return "课程作业";
|
||
case "profession": return "教师专业发展";
|
||
default: return module;
|
||
}
|
||
}
|
||
}
|