Files
yunhai-agent-service/src/main/java/com/yunhai/agent/tool/ToolRegistry.java
T
lijing 9410599008
build / build-push (push) Successful in 1m7s
feat(通知公告): 接入 NoticeFront(模块 notice、工具前缀 notify.*)
★「通知公告」与既有「校园公告」是两个应用,不是同一个:
  · 校园公告 CampusAnnouncement → key=campusnotice、前缀 /campusnotice、按班级下发、带审批流/草稿箱(既有 notice.*)
  · 通知公告 NoticeFront       → key=notice、前缀 /notice、按用户下发,无审批/草稿/推送范围(本次新增 notify.*)
  两者路由与页面名几乎同名(/list、/list/new、已收到/已发送),极易拿错源码,故工具前缀避开已占用的 notice.*。

新增 NoticeTools(9 个工具):收件箱/详情/一键已读、发布/编辑/我发出的/阅读情况、发文部门字典、组织树。
发布表单逐项对齐 NoticeFront/src/views/notice/NewNotice.vue#doAdd:
  title / content / deptId / receiveList / fileIds / timingSendTime
(smsFlag 在页面里没有对应控件、恒为 0,故不放出;receiveList 的「全部用户」哨兵是 all。)

配套:ToolRegistry 注册、RolePolicy 把发布闭环与两个字典工具列为教职工专属、
application.yml 补地址 http://base.yunhaixingtong.com/notice 与默认落地页 /
(AgentSettings 出厂默认表也要补 notice —— 契约测试用的是 new AgentSettings(),不读 yml)。

fix(ToolExecutor): 已按 joinSeparator 拼成逗号串的字段不再被包回数组
  normalizeJsonArgs 的第二个循环「数组参数给了标量 → 包成单元素数组」会作用到
  刚拼好的 receiveList/fileIds 上,把 101,102 变成 [101,102],
  而模块侧 NoticeRequest.receiveList 是 String ⇒ 直接判非法。
  此前一直没暴露:本地 mock 的提交体是原样 JSON.stringify(args)(本来就是数组),
  看不出差异,只有真实后端才走到这一步。现在 join 过的字段直接跳过该循环。

测试:ModuleFormParityTest 新增「通知公告发布字段集不与校园公告互串」+
「代取地址带 /notice 前缀」+「提交体是逗号串、哨兵互斥、提交路径带前缀」;全量 126/126 绿。
2026-10-14 12:27:31 +08:00

175 lines
7.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.agent.tool;
import com.alibaba.fastjson.JSONArray;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.auth.RolePolicy;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.tool.defs.ActivityTools;
import com.yunhai.agent.tool.defs.CampusNoticeTools;
import com.yunhai.agent.tool.defs.DormNoticeTools;
import com.yunhai.agent.tool.defs.HomeworkTools;
import com.yunhai.agent.tool.defs.LeaveTools;
import com.yunhai.agent.tool.defs.NoticeTools;
import com.yunhai.agent.tool.defs.ProfessionTools;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 工具注册表:所有可被大模型调用的业务动作都在这里汇总。
*
* <p>新增一个模块的接入方式:写一个 {@code XxxTools#defs()} 返回 {@code List<ToolDef>},
* 在下面的 {@link #build()} 里加一行。执行、鉴权、schema 生成全部复用通用实现。
*
* <p>可见性由两个维度共同决定:
* <ol>
* <li>{@code modId} —— 平台菜单授权({@code agent.filter-tools-by-authorized-module=true} 时生效);</li>
* <li>{@code roles} —— 学生 / 教师 / 管理员角色(「分学生、教师权限」的落点)。</li>
* </ol>
*/
@Slf4j
@Component
public class ToolRegistry {
private final AgentSettings settings;
/** name → ToolDef(保持插入顺序,便于目录展示) */
private final Map<String, ToolDef> tools = new LinkedHashMap<>();
/**
* 大模型侧工具名(点换下划线)→ 内部点分名。
*
* <p>大模型对 {@code function.name} 只接受 {@code ^[a-zA-Z0-9_-]+$},点号非法,
* 所以下发时用 {@link ToolDef#wireName()},模型回传的也是这个名字,这里负责还原。
*/
private final Map<String, String> wireAliases = new LinkedHashMap<>();
public ToolRegistry(AgentSettings settings) {
this.settings = settings;
}
@PostConstruct
public void init() {
build();
log.info("智能体工具注册完成,共 {} 个:{}", tools.size(), String.join(", ", tools.keySet()));
// 校验:工具引用的模块必须在 agent.modules 里配了地址,否则运行期必然失败
for (ToolDef d : tools.values()) {
if (!settings.getModules().containsKey(d.getModule())) {
log.warn("工具 {} 引用了未配置地址的模块「{}」,请检查 agent.modules", d.getName(), d.getModule());
}
}
}
private void build() {
register(LeaveTools.defs());
register(CampusNoticeTools.defs());
register(NoticeTools.defs());
register(DormNoticeTools.defs());
register(ActivityTools.defs());
register(HomeworkTools.defs());
register(ProfessionTools.defs());
// 套用「学生 / 教师」角色能力策略(未显式声明角色的工具在此落定)
RolePolicy.apply(tools.values());
}
private void register(List<ToolDef> defs) {
for (ToolDef d : defs) {
if (tools.containsKey(d.getName())) {
throw new IllegalStateException("工具名重复:" + d.getName());
}
String wire = d.wireName();
// wire 名撞车会导致模型回传无法还原(点→下划线是单向压缩),注册期就拦下来
if (tools.containsKey(wire)) {
throw new IllegalStateException("工具名冲突:" + d.getName() + " 的模型侧名字「" + wire
+ "」与已有工具同名,请改名");
}
if (wire != null && !wire.equals(d.getName())) {
String prev = wireAliases.put(wire, d.getName());
if (prev != null) {
throw new IllegalStateException("模型侧工具名冲突:" + d.getName() + " 与 " + prev
+ " 换成模型可用名后都是「" + wire + "」,请改名");
}
}
// ★ 深链兜底:工具没声明落地页时,取该模块的默认页面(agent.module-landing)。
// 这样"查询失败 / 越权拦截"也能带着一个真实可打开的模块地址回到前端。
if (StringUtils.isBlank(d.getLandingPath())) {
d.setLandingPath(settings.getModuleLanding().get(d.getModule()));
}
tools.put(d.getName(), d);
}
}
/**
* 按名字取工具:**同时兼容内部点分名与大模型侧下划线名**。
*
* <p>模型回传的 {@code function.name} 是 {@link ToolDef#wireName()}(点已换成下划线),
* 而前端表单回传、字典工具引用用的是内部点分名,两种都要能命中。
*/
public ToolDef get(String name) {
if (name == null) {
return null;
}
ToolDef d = tools.get(name);
if (d != null) {
return d;
}
String real = wireAliases.get(name);
return real == null ? null : tools.get(real);
}
public Collection<ToolDef> all() {
return tools.values();
}
/**
* 按「已授权模块 + 角色」过滤后,转成给模型看的 tools schema。
*
* @param authorizedModIds 当前用户已授权的 modId 集合;为 null 表示不按 modId 过滤
* @param roles 当前用户角色集合;为 null 表示不按角色过滤(排障用)
*/
public JSONArray toOpenAiTools(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
JSONArray arr = new JSONArray();
for (ToolDef d : tools.values()) {
if (!visible(d, authorizedModIds, roles)) {
continue;
}
arr.add(d.toOpenAiTool());
}
return arr;
}
/** 供前端「我能用这个智能体做什么」的目录展示 */
public List<ToolDef> visibleTools(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
return tools.values().stream()
.filter(d -> visible(d, authorizedModIds, roles))
.collect(Collectors.toList());
}
private boolean visible(ToolDef d, Set<Integer> authorizedModIds, Set<AgentRole> roles) {
// 1) modId 维度:仅在开启「按授权模块过滤」时才过滤(modId 为 null 的工具不参与该维度)
if (d.getModuleId() != null && authorizedModIds != null && !authorizedModIds.contains(d.getModuleId())) {
return false;
}
// 2) 角色维度:未声明角色的工具人人可用
return d.allowsAny(roles);
}
/** 按模块分组统计(目录接口用) */
public Map<String, List<ToolDef>> groupByModule(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
Map<String, List<ToolDef>> map = new LinkedHashMap<>();
for (ToolDef d : visibleTools(authorizedModIds, roles)) {
map.computeIfAbsent(d.getModule(), k -> new ArrayList<>()).add(d);
}
return map;
}
}