cc 1 tydzień temu
rodzic
commit
b2106b5370

+ 1 - 1
ai-electron/frontend/src/ai/views/aiModel/index.vue

@@ -235,7 +235,7 @@
       }模型。`,
       okText: '删除',
       okType: 'primary',
-                    okButtonProps: { danger: true },
+      okButtonProps: { danger: true },
       cancelText: '取消',
       onOk: async () => {
         await modelApi.deleteModel(record.id);

+ 21 - 14
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -19,10 +19,11 @@
         </template>
 
         <div class="ai-plugin__hint">
-          这里展示 agent 在对话里可调用的全部工具(共 <b>{{ tools.length }}</b> 个),
-          由服务端启动时<b>反射扫描 @Tool 方法</b>并对照运行时装配后写入 <b>agent_tool</b> 表,页面只读。
-          新会话默认只装备「{{ defaultGroupLabel || '人员画像' }}」组,其余组由模型在对话中按需装备;
-          <b>基础工具</b>不属于任何域,会话里始终可用。点任意卡片看说明。
+          这里展示 agent 在对话里可调用的全部工具(共 <b>{{ tools.length }}</b> 个), 由服务端启动时<b
+            >反射扫描 @Tool 方法</b
+          >并对照运行时装配后写入 <b>agent_tool</b> 表,页面只读。 新会话默认只装备「{{
+            defaultGroupLabel || '人员画像'
+          }}」组,其余组由模型在对话中按需装备; <b>基础工具</b>不属于任何域,会话里始终可用。点任意卡片看说明。
         </div>
 
         <div class="ai-plugin__ops">
@@ -43,7 +44,11 @@
             <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
           </a-input>
           <span class="ai-plugin__mono ai-plugin__filter-count">
-            {{ filteredTools.length === tools.length ? `共 ${tools.length} 个` : `匹配 ${filteredTools.length} / ${tools.length} 个` }}
+            {{
+              filteredTools.length === tools.length
+                ? `共 ${tools.length} 个`
+                : `匹配 ${filteredTools.length} / ${tools.length} 个`
+            }}
           </span>
         </div>
 
@@ -70,7 +75,9 @@
             </article>
           </div>
           <div v-else class="ai-plugin__empty">
-            {{ tools.length ? '没有匹配的工具' : '还没有同步到工具清单(服务端启动时会自动同步,需先建 agent_tool 表)' }}
+            {{
+              tools.length ? '没有匹配的工具' : '还没有同步到工具清单(服务端启动时会自动同步,需先建 agent_tool 表)'
+            }}
           </div>
 
           <!-- 卡片量大,分页兜住首屏;翻页不改变筛选结果 -->
@@ -95,9 +102,10 @@
         </template>
 
         <div class="ai-plugin__hint">
-          技能是放在服务端本机的 <b>SKILL.md</b> 包,按<b>用户</b>隔离、跨案件共享,不写数据库。
-          装着目录:<b>{{ skillRootHint }}</b>。停用后<b>下一轮对话立即生效</b>(不用重建会话),
-          卸载会直接删除该技能目录。
+          技能是放在服务端本机的 <b>SKILL.md</b> 包,按<b>用户</b>隔离、跨案件共享,不写数据库。 装着目录:<b>{{
+            skillRootHint
+          }}</b
+          >。停用后<b>下一轮对话立即生效</b>(不用重建会话), 卸载会直接删除该技能目录。
         </div>
 
         <div class="ai-plugin__ops">
@@ -177,7 +185,9 @@
             <b class="ai-plugin__mono">{{ mcp?.name || '—' }} {{ mcp?.version ? `v${mcp.version}` : '' }}</b>
 
             <span class="ai-plugin__mono">已暴露工具</span>
-            <b class="ai-plugin__mono">{{ mcp?.toolCount ?? '—' }} 个(含 {{ mcp?.caseTools?.length || 0 }} 个案件管理工具)</b>
+            <b class="ai-plugin__mono"
+              >{{ mcp?.toolCount ?? '—' }} 个(含 {{ mcp?.caseTools?.length || 0 }} 个案件管理工具)</b
+            >
 
             <span class="ai-plugin__mono">SSE 端点</span>
             <b class="ai-plugin__mono ai-plugin__ellipsis" :title="mcp?.sseEndpoint || ''">
@@ -374,9 +384,7 @@
   });
 
   /** 默认装备的组名(提示文案里用,不写死「人员画像」) */
-  const defaultGroupLabel = computed(
-    () => tools.value.find((t) => t.defaultActive)?.groupLabel || '',
-  );
+  const defaultGroupLabel = computed(() => tools.value.find((t) => t.defaultActive)?.groupLabel || '');
 
   // 筛选/搜索变化后回到第一页,否则会停在越界页码上看到空列表
   watch([groupFilter, toolKeyword], () => {
@@ -858,7 +866,6 @@
       color: var(--ai-text-2);
     }
 
-
     &__detail-text {
       margin: 0;
       font-size: 12.5px;

+ 7 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProvider.java

@@ -11,10 +11,12 @@ import com.zsjz.ai.module.agent.rag.RagSchemaService;
 import com.zsjz.ai.module.agent.artifact.ArtifactService;
 import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
 import com.zsjz.ai.module.agent.tools.GraphRenderTool;
+import com.zsjz.ai.module.agent.tools.GovernTableTool;
 import com.zsjz.ai.module.agent.tools.PythonAnalysisTool;
 import com.zsjz.ai.module.agent.tools.RagSchemaSearchTool;
 import com.zsjz.ai.module.agent.tools.SqlAnalysisTool;
 import com.zsjz.ai.module.agent.tools.WorkspaceInfoTool;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
 import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
 import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
@@ -109,6 +111,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
     private final CaseInfoMapper caseInfoMapper;
     private final RagSchemaService ragSchemaService;
     private final ArtifactService artifactService;
+    private final GovernTreeService governTreeService;
 
     /**
      * MCP 专用工具容器:全组激活,与内置 Agent 的实例隔离
@@ -126,7 +129,8 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
                                      AgentChatSessionMapper chatSessionMapper,
                                      CaseInfoMapper caseInfoMapper,
                                      RagSchemaService ragSchemaService,
-                                     ArtifactService artifactService) {
+                                     ArtifactService artifactService,
+                                     GovernTreeService governTreeService) {
         this.agentToolRegistry = agentToolRegistry;
         this.caseSession = caseSession;
         this.sqlQueryMapper = sqlQueryMapper;
@@ -137,6 +141,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
         this.caseInfoMapper = caseInfoMapper;
         this.ragSchemaService = ragSchemaService;
         this.artifactService = artifactService;
+        this.governTreeService = governTreeService;
     }
 
     /**
@@ -190,6 +195,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
         }
         tk.registerTool(new WorkspaceInfoTool(chatSessionMapper, caseInfoMapper));
         tk.registerTool(new GraphRenderTool());
+        tk.registerTool(new GovernTableTool(governTreeService, artifactService));
         // 内置 Agent 只默认装备 person 组;MCP 侧外部模型没有 reset_equipped_tools,全部激活
         // (新增工具组时必须同步加到这里,否则外部客户端会「看不到」该组工具)
         agentToolRegistry.registerBusinessTools(tk, List.of(

+ 6 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -29,6 +29,7 @@ import com.zsjz.ai.module.agent.scaffold.WorkspaceScaffolder;
 import com.zsjz.ai.module.agent.artifact.ArtifactService;
 import com.zsjz.ai.module.agent.tools.*;
 import com.zsjz.ai.module.agent.vo.ExpertVO;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
 import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
 import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
@@ -87,6 +88,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
     private final AgentToolRegistry agentToolRegistry;
     private final WorkspaceManagerFactory workspaceManagerFactory;
     private final ArtifactService artifactService;
+    private final GovernTreeService governTreeService;
 
     /**
      * agent 实例池:key = w{workspaceId}-a{agentRowId}-m{modelId}-u{userId}
@@ -109,7 +111,8 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
             CaseInfoMapper caseInfoMapper,
             WorkspaceManagerFactory workspaceManagerFactory,
             AgentToolRegistry agentToolRegistry,
-            ArtifactService artifactService) {
+            ArtifactService artifactService,
+            GovernTreeService governTreeService) {
         this.agentModelFactory = agentModelFactory;
         this.modelMapper = modelMapper;
         this.agentStateStore = stateStoreOpt.orElseGet(InMemoryAgentStateStore::new);
@@ -126,6 +129,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         this.workspaceManagerFactory = workspaceManagerFactory;
         this.agentToolRegistry = agentToolRegistry;
         this.artifactService = artifactService;
+        this.governTreeService = governTreeService;
     }
 
     /**
@@ -179,6 +183,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         }
         toolkit.registerTool(new WorkspaceInfoTool(chatSessionMapper, caseInfoMapper));
         toolkit.registerTool(new GraphRenderTool());
+        toolkit.registerTool(new GovernTableTool(governTreeService, artifactService));
     }
 
     /**

+ 2 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImpl.java

@@ -56,8 +56,8 @@ public class AgentToolCatalogServiceImpl implements AgentToolCatalogService {
     private static final String BASE_GROUP_LABEL = "基础工具";
 
     private static final String BASE_GROUP_DESCRIPTION =
-            "不属于任何业务域、会话中始终可用的工具:SQL 取数与结果翻页、表结构检索(RAG)、"
-                    + "Python 分析、关系图谱渲染、当前工作空间信息。它们不会被工具组切换收起。";
+            "不属于任何业务域、会话中始终可用的工具:SQL 取数与结果翻页、治理表直查(表名+结构化条件),"
+                    + "表结构检索(RAG)、Python 分析、关系图谱渲染、当前工作空间信息。它们不会被工具组切换收起。";
 
     private final AgentToolMapper agentToolMapper;
 

+ 295 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GovernTableTool.java

@@ -0,0 +1,295 @@
+package com.zsjz.ai.module.agent.tools;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.zsjz.ai.common.base.BasicColumn;
+import com.zsjz.ai.common.model.govern.query.ConditionQuery;
+import com.zsjz.ai.common.model.govern.query.Conditions;
+import com.zsjz.ai.common.model.govern.query.GovernTreeQuery;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.artifact.ArtifactService;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * 治理树表格通用查询工具({@code query_table_data})—— 封装 {@link GovernTreeService#getTreeTablePage}。
+ *
+ * <p><b>为什么是基础工具</b>:它按「表名 + 结构化条件」直查任意治理表,字段是动态的
+ * (列名与中文名随查询结果的 {@code head} 返回),模型不需要为每张表记一份工具。
+ * 故注册在 {@code AgentService.registerInfraTools}——所有 agent(含能力清单枚举)恒可见,
+ * 不受专家工具组配置影响,也不会被 {@code reset_equipped_tools} 收起。
+ *
+ * <p><b>条件口径</b>:入参 {@code conditions}(列名 + 比较符 + 值)在工具内拼成
+ * {@link ConditionQuery} 交给服务端 {@code generateConditionSql},与前端治理树表格
+ * 走的是同一条链路。列名/比较符/连接符全部白名单校验、值转义单引号——原实现是裸 SQL 拼接,
+ * 工具面向 LLM 开放必须先把注入面收掉。
+ *
+ * <p><b>结果口径</b>与其它表格工具一致:全量行落 CSV 产物(artifact 前置),模型只拿
+ * 列定义 + 头部预览 + 总行数;{@code head} 额外带上列的中文名,供模型下一轮组条件用。
+ */
+@Slf4j
+public class GovernTableTool {
+
+    /** 进产物文件名与结果里的工具标识 */
+    static final String TOOL_NAME = "query_table_data";
+
+    /** 列名白名单:字母/数字/下划线(治理表列名全部是该形态) */
+    private static final Pattern COLUMN_RE = Pattern.compile("^[A-Za-z0-9_]{1,64}$");
+
+    /**
+     * 比较符白名单 —— 与服务端 {@code generateConditionSql} 的三种拼装分支一一对应:
+     * 含 LIKE → 值套 {@code '%..%'};含 IS → 不拼值;其余 → 值套单引号。
+     */
+    private static final Set<String> OPERATORS =
+            Set.of("=", "!=", "<>", ">", ">=", "<", "<=", "LIKE", "IS NULL", "IS NOT NULL");
+
+    private final GovernTreeService governTreeService;
+    private final ArtifactService artifacts;
+
+    public GovernTableTool(GovernTreeService governTreeService, ArtifactService artifacts) {
+        this.governTreeService = governTreeService;
+        this.artifacts = artifacts;
+    }
+
+    @Tool(name = "query_table_data",
+          description = "要查询某个表的数据,就调用本工具——治理树业务表的通用查询入口,按「表名 + 条件」直查,"
+                  + "所有表共用这一个工具(字段动态,不需要为每张表记单独的工具)。"
+                  + "传 tableName 指定要查的表,conditions 每项 {column, operator, value}:column=表列名、"
+                  + "operator 取 = != > >= < <= LIKE IS NULL IS NOT NULL、value 取比较值(IS NULL/IS NOT NULL 不传 value);"
+                  + "多条件用 conditionRun=AND(默认)/OR 连接,工具自动拼 WHERE,不用写 SQL;不传 conditions = 取该表全量。"
+                  + "表名与列名可用 list_tables / search_table_schema 查询。"
+                  + "返回 JSON:artifact(全量 CSV 产物,用户在产物栏预览/下载)+ head(列中文名)+ 列定义 + 头部预览行 + 总行数,"
+                  + "回复里不要罗列数据明细。只有聚合统计、JOIN、复杂表达式这类结构化条件表达不了的查询才改用 execute_sql。",
+          readOnly = true)
+    public ToolResultBlock queryTableData(
+            @ToolParam(name = "query",
+                       description = "查询对象:tableName 必填(要查询的表英文名),conditions 可选(不传=取该表全量)")
+            QuerySpec query) {
+        try {
+            if (query == null) {
+                return ToolResults.error("缺少 query:请至少传 tableName 指定要查询的表");
+            }
+            String tableName = StrUtil.trimToNull(query.tableName);
+            if (tableName == null) {
+                return ToolResults.error("缺少 tableName:请指定要查询的表(表名可用 list_tables 查询)");
+            }
+            if (!isRegistered(tableName)) {
+                return ToolResults.error("表不存在或不支持直查: " + tableName
+                        + "。请核对表名(可用 list_tables / search_table_schema 查询可用表)。");
+            }
+
+            GovernTreeQuery dto = new GovernTreeQuery();
+            dto.setTableName(tableName);
+            dto.setPage(1);
+            // 产物口径:不限条数全量导出(queryRowLimit>0 时按配置封顶)
+            int cap = artifacts == null ? 0 : artifacts.queryRowLimit();
+            dto.setLimit(cap > 0 ? cap : Integer.MAX_VALUE);
+
+            ConditionQuery condition = buildCondition(query);
+            if (condition != null) {
+                dto.setConditionSql(condition);
+            }
+            String orderKey = StrUtil.trimToNull(query.orderKey);
+            if (orderKey != null) {
+                if (!COLUMN_RE.matcher(orderKey).matches()) {
+                    return ToolResults.error("非法排序列: " + orderKey + "(只允许字母/数字/下划线)");
+                }
+                dto.setOrderKey(orderKey);
+            }
+            dto.setSort(Boolean.FALSE.equals(query.asc) ? "desc" : "asc");
+
+            Map<String, Object> out = governTreeService.getTreeTablePage(dto);
+            // 行数保险丝(artifact.max-rows > 0 时):与业务工具同口径——报错让模型收窄,不静默截断
+            if (cap > 0) {
+                @SuppressWarnings("unchecked")
+                Page<Object> page = (Page<Object>) out.get("pages");
+                long total = page == null ? 0 : page.getTotal();
+                if (total > cap) {
+                    return ToolResults.error("结果集过大(" + total + " 行,上限 " + cap
+                            + "),请加更严格的条件收窄后再查。");
+                }
+            }
+            return ToolResults.ok(buildResult(tableName, out));
+        } catch (IllegalArgumentException e) {
+            return ToolResults.error(e.getMessage());
+        } catch (Exception e) {
+            log.warn("表格查询失败: table={}, error={}", query == null ? null : query.tableName, e.getMessage());
+            return ToolResults.error("表格查询失败: " + e.getMessage()
+                    + "。请核对列名是否为该表真实字段(可用 list_tables 查看)、比较值类型是否与列匹配。");
+        }
+    }
+
+    /**
+     * 结构化条件 → {@link ConditionQuery};无条件返回 null(服务端走默认搜索分支)。
+     *
+     * @throws IllegalArgumentException 校验失败,信息面向模型(可据此自行纠正重试)
+     */
+    private ConditionQuery buildCondition(QuerySpec query) {
+        List<ConditionSpec> specs = query.conditions;
+        if (specs == null || specs.isEmpty()) {
+            return null;
+        }
+        String run = StrUtil.blankToDefault(query.conditionRun, "AND").trim().toUpperCase(Locale.ROOT);
+        if (!"AND".equals(run) && !"OR".equals(run)) {
+            throw new IllegalArgumentException("conditionRun 只支持 AND 或 OR,收到: " + query.conditionRun);
+        }
+        List<Conditions> conditions = new ArrayList<>(specs.size());
+        for (ConditionSpec spec : specs) {
+            if (spec == null) {
+                continue;
+            }
+            String column = StrUtil.trimToNull(spec.column);
+            if (column == null || !COLUMN_RE.matcher(column).matches()) {
+                throw new IllegalArgumentException("非法列名: " + spec.column
+                        + "(只允许字母/数字/下划线,应取该表的真实列名,可用 list_tables 查询)");
+            }
+            String operator = StrUtil.trimToNull(spec.operator);
+            if (operator == null) {
+                throw new IllegalArgumentException("条件缺 operator: " + column);
+            }
+            operator = operator.toUpperCase(Locale.ROOT);
+            if (!OPERATORS.contains(operator)) {
+                throw new IllegalArgumentException("不支持的比较符: " + spec.operator
+                        + ",可用: = != > >= < <= LIKE IS NULL IS NOT NULL");
+            }
+            String value = spec.value;
+            if (!operator.contains("IS") && StrUtil.trimToNull(value) == null) {
+                throw new IllegalArgumentException("条件缺 value: " + column + " " + operator);
+            }
+            if (value != null) {
+                // 服务端 generateConditionSql 是裸拼接:值里的单引号先转义,收掉注入面
+                value = value.replace("'", "''");
+            }
+            conditions.add(new Conditions(operator, column, value));
+        }
+        if (conditions.isEmpty()) {
+            return null;
+        }
+        ConditionQuery query0 = new ConditionQuery(run);
+        query0.setConditions(conditions);
+        return query0;
+    }
+
+    /**
+     * 服务端返回(pages/head)→ 给模型的统一 JSON:table/head 在前,artifact 紧随其后。
+     */
+    private String buildResult(String tableName, Map<String, Object> out) {
+        @SuppressWarnings("unchecked")
+        Page<Object> page = (Page<Object>) (out == null ? null : out.get("pages"));
+        List<Object> records = page == null || page.getRecords() == null ? List.of() : page.getRecords();
+
+        List<LinkedHashMap<String, Object>> rows = ToolResultTable.toRows(records);
+        List<Map<String, Object>> columns = ToolResultTable.buildColumns(rows);
+        Map<String, Object> artifact = artifacts == null ? null : artifacts.export(TOOL_NAME, columns, rows);
+
+        int headMax = artifacts == null ? Integer.MAX_VALUE : Math.max(1, artifacts.headRows());
+        boolean truncated = rows.size() > headMax;
+        List<LinkedHashMap<String, Object>> preview =
+                truncated ? new ArrayList<>(rows.subList(0, headMax)) : rows;
+        String core = ToolResultTable.page(artifact, truncated, columns, preview, rows.size());
+        Map<String, Object> coreMap = Json.toCls(core, new TypeReference<>() {
+        });
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("table", tableName);
+        @SuppressWarnings("unchecked")
+        List<BasicColumn> head = (List<BasicColumn>) (out == null ? null : out.get("head"));
+        if (head != null && !head.isEmpty()) {
+            List<Map<String, Object>> headCols = new ArrayList<>(head.size());
+            for (BasicColumn col : head) {
+                if (col == null || StrUtil.isBlank(col.getDataIndex())) {
+                    continue;
+                }
+                Map<String, Object> c = new LinkedHashMap<>();
+                c.put("key", col.getDataIndex());
+                c.put("label", StrUtil.nullToEmpty(col.getTitle()));
+                headCols.add(c);
+            }
+            result.put("head", headCols);
+        }
+        result.putAll(coreMap);
+        return Json.toStr(result);
+    }
+
+    /**
+     * 表名是否已注册为 MyBatis-Plus 实体表({@code getTreeTablePage} 的硬前提)。
+     * 包级可见:单测用 spy 覆盖,免得依赖 MP 静态注册表。
+     */
+    boolean isRegistered(String tableName) {
+        try {
+            return TableInfoHelper.getTableInfo(tableName) != null;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    // ==================== 入参 ====================
+
+    /**
+     * 查询对象({@code query_table_data} 的唯一入参)。
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class QuerySpec {
+
+        /** 目标表英文名(必填) */
+        @ToolParam(name = "tableName",
+                   description = "要查询的表英文名(必填,可用 list_tables 查询)")
+        public String tableName;
+
+        /** 结构化过滤条件;不传 = 该表全量 */
+        @ToolParam(name = "conditions",
+                   description = "过滤条件列表,每项 {column, operator, value};不传=取全量", required = false)
+        public List<ConditionSpec> conditions;
+
+        /** 多条件连接符 */
+        @ToolParam(name = "conditionRun",
+                   description = "多条件连接符:AND(默认)或 OR", required = false)
+        public String conditionRun;
+
+        /** 排序列 */
+        @ToolParam(name = "orderKey",
+                   description = "排序列名(表列名,可选)", required = false)
+        public String orderKey;
+
+        /** 是否升序 */
+        @ToolParam(name = "asc",
+                   description = "true=升序,false=降序,默认 false", required = false)
+        public Boolean asc;
+    }
+
+    /**
+     * 单条过滤条件:列名 + 比较符 + 值。
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class ConditionSpec {
+
+        /** 表列名 */
+        @ToolParam(name = "column",
+                   description = "表列名(英文,可用 list_tables 查询)", required = false)
+        public String column;
+
+        /** 比较符 */
+        @ToolParam(name = "operator",
+                   description = "比较符:= != > >= < <= LIKE IS NULL IS NOT NULL", required = false)
+        public String operator;
+
+        /** 比较值 */
+        @ToolParam(name = "value",
+                   description = "比较值;IS NULL / IS NOT NULL 时不用传", required = false)
+        public String value;
+    }
+}

+ 3 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProviderTest.java

@@ -54,6 +54,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -131,7 +132,8 @@ class AgentScopeMcpToolProviderTest {
                 mock(SqlQueryMapper.class), mock(TableInfoMapper.class), mock(TableFieldMapper.class),
                 mock(PythonExecutor.class), mock(AgentChatSessionMapper.class),
                 mock(CaseInfoMapper.class), mock(RagSchemaService.class),
-                new ArtifactService(new ArtifactProperties()));
+                new ArtifactService(new ArtifactProperties()),
+                mock(GovernTreeService.class));
     }
 
     private static Map<String, ToolCallback> byName(ToolCallback[] callbacks) {

+ 3 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java

@@ -51,6 +51,7 @@ import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -104,7 +105,8 @@ class AgentCapabilityToolkitTest {
                 mock(CaseInfoMapper.class),
                 new WorkspaceManagerFactory(),
                 registry,
-                new ArtifactService(new ArtifactProperties()));
+                new ArtifactService(new ArtifactProperties()),
+                mock(GovernTreeService.class));
     }
 
     @Test

+ 3 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java

@@ -57,6 +57,7 @@ import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -129,7 +130,8 @@ class AgentExpertTest {
                         mock(PersonBasicInfoService.class),
                         mock(FileAiProfileService.class),
                         new ArtifactService(new ArtifactProperties())),
-                new ArtifactService(new ArtifactProperties())));
+                new ArtifactService(new ArtifactProperties()),
+                mock(GovernTreeService.class)));
         pool = (Map<String, HarnessAgent>) ReflectionTestUtils.getField(service, "agentPool");
 
         // create 路径:库里那行还没建,回读刚 save 的实体(rowId 已被 save 桩回填)

+ 3 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImplTest.java

@@ -55,6 +55,7 @@ import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -118,7 +119,8 @@ class AgentToolCatalogServiceImplTest {
                 mock(CaseInfoMapper.class),
                 new WorkspaceManagerFactory(),
                 toolRegistry(),
-                new ArtifactService(new ArtifactProperties()));
+                new ArtifactService(new ArtifactProperties()),
+                mock(GovernTreeService.class));
     }
 
     @Test

+ 293 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/tools/GovernTableToolTest.java

@@ -0,0 +1,293 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zsjz.ai.common.base.BasicColumn;
+import com.zsjz.ai.common.constants.PathConst;
+import com.zsjz.ai.common.context.CaseContextHolder;
+import com.zsjz.ai.common.model.govern.query.GovernTreeQuery;
+import com.zsjz.ai.common.model.trans.entity.TransRecord;
+import com.zsjz.ai.module.agent.artifact.ArtifactService;
+import com.zsjz.ai.module.agent.config.ArtifactProperties;
+import com.zsjz.ai.module.govern.serivce.GovernTreeService;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolResultState;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@code query_table_data} 的契约:表清单发现 / 条件白名单校验 / dto 组装 / 结果形态。
+ *
+ * <p>治理表查询走的是 {@link GovernTreeService#getTreeTablePage}——工具侧职责只有
+ * 「组 dto + 校验」,查询本身用 mock 打桩;{@code isRegistered} 用真实的 MyBatis-Plus
+ * 注册表({@link BeforeAll} 里注册 {@link TransRecord}),不走 spy。
+ */
+class GovernTableToolTest {
+
+    private static final Long CASE_ID = 952709L;
+
+    @BeforeAll
+    static void initTableInfoCache() {
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+        assistant.setCurrentNamespace("com.zsjz.ai.module.agent.tools.GovernTableToolTest");
+        TableInfoHelper.initTableInfo(assistant, TransRecord.class);
+    }
+
+    private final GovernTreeService governTreeService = mock(GovernTreeService.class);
+    private final ArtifactProperties artifactProps = new ArtifactProperties();
+    private final GovernTableTool tool =
+            new GovernTableTool(governTreeService, new ArtifactService(artifactProps));
+
+    /** 本次测试产生的产物文件(用例结束清理,避免污染 workspace) */
+    private final List<Path> created = new ArrayList<>();
+
+    @AfterEach
+    void cleanup() {
+        for (Path file : created) {
+            try {
+                Files.deleteIfExists(file);
+            } catch (Exception ignored) {
+                // 尽力而为
+            }
+        }
+        created.clear();
+        // 空目录一并收掉(非空说明是别的用例/真实数据,保留)
+        try {
+            Files.deleteIfExists(PathConst.WORKSPACE.resolve(String.valueOf(CASE_ID)).resolve("artifacts"));
+            Files.deleteIfExists(PathConst.WORKSPACE.resolve(String.valueOf(CASE_ID)));
+        } catch (Exception ignored) {
+            // 尽力而为
+        }
+    }
+
+    private static String text(ToolResultBlock block) {
+        StringBuilder sb = new StringBuilder();
+        for (var content : block.getOutput()) {
+            if (content instanceof TextBlock textBlock) {
+                sb.append(textBlock.getText());
+            }
+        }
+        return sb.toString();
+    }
+
+    private static JsonNode json(ToolResultBlock block) {
+        try {
+            return new ObjectMapper().readTree(text(block));
+        } catch (Exception e) {
+            throw new IllegalArgumentException("结果不是 JSON: " + text(block), e);
+        }
+    }
+
+    /** 让服务端返回一份固定分页结果,并捕获工具组装的 dto */
+    private AtomicReference<GovernTreeQuery> stubService(List<Object> records, List<BasicColumn> head) {
+        AtomicReference<GovernTreeQuery> captured = new AtomicReference<>();
+        when(governTreeService.getTreeTablePage(any(GovernTreeQuery.class))).thenAnswer(inv -> {
+            captured.set(inv.getArgument(0));
+            Page<Object> page = new Page<>(1, 100, records.size());
+            page.setRecords(records);
+            Map<String, Object> out = new LinkedHashMap<>();
+            out.put("pages", page);
+            out.put("head", head);
+            out.put("detail", false);
+            return out;
+        });
+        return captured;
+    }
+
+    // ==================== 校验 ====================
+
+    @Test
+    @DisplayName("缺 tableName 直接报错(没有表清单发现模式)")
+    void missingTableNameRejected() {
+        ToolResultBlock nullQuery = tool.queryTableData(null);
+        assertEquals(ToolResultState.ERROR, nullQuery.getState(), text(nullQuery));
+        assertTrue(text(nullQuery).contains("缺少 query"), text(nullQuery));
+
+        GovernTableTool.QuerySpec spec = new GovernTableTool.QuerySpec();
+        ToolResultBlock blankName = tool.queryTableData(spec);
+        assertEquals(ToolResultState.ERROR, blankName.getState(), text(blankName));
+        assertTrue(text(blankName).contains("缺少 tableName"), text(blankName));
+    }
+
+    @Test
+    @DisplayName("未注册的表名被拒,并指回 list_tables")
+    void unknownTableRejected() {
+        GovernTableTool.QuerySpec spec = new GovernTableTool.QuerySpec();
+        spec.tableName = "no_such_table";
+
+        ToolResultBlock result = tool.queryTableData(spec);
+        assertEquals(ToolResultState.ERROR, result.getState(), text(result));
+        assertTrue(text(result).contains("表不存在"), text(result));
+        assertTrue(text(result).contains("list_tables"), text(result));
+    }
+
+    @Test
+    @DisplayName("条件白名单:非法列名 / 比较符 / 缺值 / 连接符逐个被拒")
+    void conditionValidation() {
+        // 非法列名(注入面)
+        assertEquals(ToolResultState.ERROR, errorOf(c -> c.column = "a; drop table x").getState());
+        assertTrue(text(errorOf(c -> c.column = "a; drop table x")).contains("非法列名"));
+        // 比较符白名单外
+        assertTrue(text(errorOf(c -> c.operator = "IN")).contains("不支持的比较符"));
+        // 缺 value
+        assertTrue(text(errorOf(c -> {
+            c.operator = ">=";
+            c.value = null;
+        })).contains("缺 value"));
+        // 连接符白名单(无条件时 conditionRun 不参与拼装,得带上一条条件才会校验)
+        GovernTableTool.QuerySpec spec = baseSpec();
+        spec.conditionRun = "XOR";
+        spec.conditions = List.of(cond("personCardNo", "=", "x"));
+        assertTrue(text(tool.queryTableData(spec)).contains("conditionRun"));
+        // 排序列注入
+        spec = baseSpec();
+        spec.orderKey = "id; delete from trans_record";
+        assertTrue(text(tool.queryTableData(spec)).contains("非法排序列"));
+    }
+
+    // ==================== 查询 ====================
+
+    @Test
+    @DisplayName("组 dto:tableName/limit/排序/条件转义齐全,结果带 head 中文列名与预览行")
+    void queryBuildsDtoAndReturnsRows() {
+        // LinkedHashMap:列序 = 插入序(Map.of 的遍历序不稳定,会让 columns 顺序抖动)
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("personCardNo", "62220001");
+        row.put("transAmount", "100");
+        AtomicReference<GovernTreeQuery> captured = stubService(
+                List.of(row),
+                List.of(new BasicColumn("卡号", "personCardNo"), new BasicColumn("金额", "transAmount")));
+
+        GovernTableTool.QuerySpec spec = baseSpec();
+        spec.conditions = List.of(cond("personCardNo", "=", "O'Brien"),
+                cond("transAmount", ">=", "100"));
+        spec.conditionRun = "AND";
+        spec.orderKey = "transDate";
+        spec.asc = true;
+
+        ToolResultBlock result = tool.queryTableData(spec);
+        assertEquals(ToolResultState.SUCCESS, result.getState(), text(result));
+
+        // dto 组装
+        GovernTreeQuery dto = captured.get();
+        assertNotNull(dto);
+        assertEquals("trans_record", dto.getTableName());
+        assertEquals(1, dto.getPage());
+        assertEquals(Integer.MAX_VALUE, dto.getLimit(), "默认 max-rows=0:全量导出不限条数");
+        assertEquals("transDate", dto.getOrderKey());
+        assertTrue(dto.hasAsc());
+        assertNotNull(dto.getConditionSql());
+        assertEquals("AND", dto.getConditionSql().getConditionRun());
+        assertEquals(2, dto.getConditionSql().getConditions().size());
+        assertEquals("O''Brien", dto.getConditionSql().getConditions().get(0).value(),
+                "值里的单引号必须转义——服务端是裸拼接");
+        assertEquals("=", dto.getConditionSql().getConditions().get(0).condition());
+
+        // 结果形态:table/head 在前,artifact(无案件上下文时省略)之后是 columns/rows/totalRows
+        JsonNode body = json(result);
+        assertEquals("trans_record", body.get("table").asText());
+        assertEquals("卡号", body.get("head").get(0).get("label").asText());
+        assertEquals("personCardNo", body.get("columns").get(0).get("key").asText());
+        assertEquals("62220001", body.get("rows").get(0).get("personCardNo").asText());
+        assertEquals(1, body.get("totalRows").asInt());
+        assertFalse(body.has("artifact"), "无案件上下文不产文件,artifact 键省略");
+    }
+
+    @Test
+    @DisplayName("IS NULL 不需要 value;不传条件时 conditionSql 为 null(走服务端默认分支)")
+    void isNullAndNoConditions() {
+        AtomicReference<GovernTreeQuery> captured = stubService(List.of(), List.of());
+
+        GovernTableTool.QuerySpec spec = baseSpec();
+        spec.conditions = List.of(cond("otherCardNo", "is null", null));
+        assertEquals(ToolResultState.SUCCESS, tool.queryTableData(spec).getState(), text(tool.queryTableData(spec)));
+        assertEquals("IS NULL", captured.get().getConditionSql().getConditions().get(0).condition());
+
+        spec = baseSpec();
+        assertEquals(ToolResultState.SUCCESS, tool.queryTableData(spec).getState());
+        assertNull(captured.get().getConditionSql());
+    }
+
+    @Test
+    @DisplayName("行数保险丝:max-rows>0 且总数超限时报错让模型收窄,不静默截断")
+    void oversizedResultRejected() {
+        artifactProps.setMaxRows(1);
+        stubService(List.of(Map.of("a", "1"), Map.of("a", "2")), List.of(new BasicColumn("A", "a")));
+
+        ToolResultBlock result = tool.queryTableData(baseSpec());
+        assertEquals(ToolResultState.ERROR, result.getState(), text(result));
+        assertTrue(text(result).contains("结果集过大"), text(result));
+        assertTrue(text(result).contains("收窄"), text(result));
+    }
+
+    @Test
+    @DisplayName("有案件上下文时产出 query_table_data 的 CSV 产物并回传 artifact 元数据")
+    void artifactFlowsThrough() {
+        AtomicReference<GovernTreeQuery> captured = stubService(
+                List.of(Map.of("personCardNo", "62220001")),
+                List.of(new BasicColumn("卡号", "personCardNo")));
+
+        ToolResultBlock result = CaseContextHolder.callWith(CASE_ID, 1L,
+                () -> tool.queryTableData(baseSpec()));
+        assertEquals(ToolResultState.SUCCESS, result.getState(), text(result));
+        assertNotNull(captured.get());
+
+        JsonNode artifact = json(result).get("artifact");
+        assertNotNull(artifact, "有案件上下文必须回传 artifact");
+        String id = artifact.get("id").asText();
+        assertTrue(id.startsWith("query_table_data_"), id);
+        assertEquals(1, artifact.get("totalRows").asInt());
+        created.add(PathConst.WORKSPACE
+                .resolve(String.valueOf(CASE_ID)).resolve("artifacts").resolve(id));
+    }
+
+    // ==================== 辅助 ====================
+
+    private GovernTableTool.QuerySpec baseSpec() {
+        GovernTableTool.QuerySpec spec = new GovernTableTool.QuerySpec();
+        spec.tableName = "trans_record";
+        return spec;
+    }
+
+    private GovernTableTool.ConditionSpec cond(String column, String operator, String value) {
+        GovernTableTool.ConditionSpec c = new GovernTableTool.ConditionSpec();
+        c.column = column;
+        c.operator = operator;
+        c.value = value;
+        return c;
+    }
+
+    /** 只需要失败文本时的快捷入口 */
+    private ToolResultBlock errorOf(java.util.function.Consumer<GovernTableTool.ConditionSpec> filler) {
+        GovernTableTool.QuerySpec spec = baseSpec();
+        GovernTableTool.ConditionSpec c = cond("personCardNo", "=", "x");
+        filler.accept(c);
+        spec.conditions = List.of(c);
+        return tool.queryTableData(spec);
+    }
+}