package com.xly.tool; import com.fasterxml.jackson.databind.JsonNode; import com.xly.agent.AgentIdentity; import com.xly.service.ErpClient; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * Read 工具(架构 §5 #1):读取 ERP 表单的真实业务数据。两个能力: * {@link #readFormData} 列表/计数(前若干行 + 总条数),{@link #lookupRecord} 单条命名记录的全字段。 * *

走 ERP 后端通用表单读接口({@link ErpClient})——即架构里"Read = 薄 HTTP 客户端、 * 业务逻辑在 ERP 后端、得到租户+行级范围"。列名用字段字典({@code viw_kg_field_dict}) * 渲染成中文,只展示前几列/前几行,避免吐爆上下文。 * *

安全:按请求身份({@link AgentIdentity})做表单级授权;只传分页/过滤参数、 * 不传任何写参数(如 bUpdate),读不会变写。 */ public class ErpReadTool { private static final int MAX_ROWS = 10; private static final int MAX_COLS = 6; private final ErpClient erp; private final FormResolverService resolver; private final AgentIdentity identity; public ErpReadTool(ErpClient erp, FormResolverService resolver, AgentIdentity identity) { this.erp = erp; this.resolver = resolver; this.identity = identity; } @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" + "用于「某客户 / 某物料 的详细资料 / 某个具体字段(如联系电话、销售员)」这类精确查询。") public String lookupRecord( @P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword, @P("记录名称关键词,如某个客户名") String recordKeyword) { if (entityKeyword == null || entityKeyword.isBlank() || recordKeyword == null || recordKeyword.isBlank()) { return "请提供实体类型和记录名称关键词。"; } Map form = resolver.resolveMasterForm(entityKeyword.trim()); if (form == null) { return "找不到「" + entityKeyword + "」对应的主表。"; } String formId = String.valueOf(form.get("sFormId")); String moduleId = String.valueOf(form.get("sModuleId")); String table = String.valueOf(form.get("sDataSource")); if (!identity.canAccessModule(moduleId)) { return "你没有访问「" + entityKeyword + "」的权限。"; } String nameField = resolver.searchField(table, recordKeyword.trim()); JsonNode root; try { root = erp.readForm(identity.token(), formId, moduleId, 1, 3, nameField, recordKeyword.trim()); } catch (Exception e) { return "读取失败:" + e.getMessage(); } if (root.path("code").asInt(0) < 0) { return "读取失败:" + root.path("msg").asText("未知错误"); } JsonNode rows = root.path("dataset").path("rows"); JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; int n = (data != null && data.isArray()) ? data.size() : 0; if (n == 0) { return "没有找到名称含「" + recordKeyword + "」的记录。"; } if (n > 1) { StringBuilder names = new StringBuilder(); for (int i = 0; i < data.size() && i < 5; i++) { if (i > 0) names.append("、"); names.append(nameField == null ? "" : data.get(i).path(nameField).asText("")); } return "匹配到多条(" + names + "),请说得更具体一点。"; } return renderRecord(data.get(0), resolver.fieldLabels(formId)); } /** 把一条记录渲染成「中文名:值」清单(跳过空值、id/token/布尔噪声),最多 20 行。 */ private String renderRecord(JsonNode rec, Map labels) { StringBuilder sb = new StringBuilder(); int count = 0; Iterator it = rec.fieldNames(); while (it.hasNext() && count < 20) { String c = it.next(); if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) { continue; } JsonNode v = rec.path(c); if (v.isContainerNode() || v.isNull()) { continue; } String val = v.asText("").trim(); String label = labels.get(c); if (val.isEmpty() || label == null) { continue; } sb.append("- ").append(label).append(":").append(val.length() > 60 ? val.substring(0, 60) + "…" : val).append("\n"); count++; } return sb.length() == 0 ? "找到了该记录,但没有可展示的字段。" : sb.toString(); } @Tool("读取指定 ERP 表单的真实业务数据(返回前若干行 + 总条数)。可选 keyword 用于按名称模糊过滤" + "(如查某个客户 / 物料);可选 page 翻页(从 1 开始,用户要看「下一页/更多」时递增)。" + "必须先用 findForms 得到目标表单的 formId 与 moduleId。") public String readFormData( @P("表单id(findForms 返回的 formId)") String formId, @P("菜单id(findForms 返回的 moduleId)") String moduleId, @P(value = "可选:仅当要查找某个具体名称的记录时才填(如某个客户名/物料名);" + "问数量 / 全部 / 概况时必须留空", required = false) String keyword, @P(value = "可选:页码,从 1 开始;默认 1", required = false) Integer page) { if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; } // 鉴权与请求都用表单目录反查出的**权威** moduleId——模型自报的 moduleId 只做形参, // 否则「无权表单 formId + 有权模块 moduleId」混搭即可绕过表单级白名单。 String authModule = resolver.moduleIdOfForm(formId.trim()); if (authModule == null) { return "该 formId 不在业务表单目录里,请先用 findForms 检索。"; } if (!identity.canAccessModule(authModule)) { return "你没有访问该表单的权限。"; } String kw = (keyword == null) ? "" : keyword.trim(); String nameField = kw.isEmpty() ? null : resolver.resolveNameFieldByFormId(formId.trim()); int pageNo = page == null || page < 1 ? 1 : page; JsonNode root; try { root = erp.readForm(identity.token(), formId.trim(), authModule, pageNo, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); } catch (Exception e) { return "读取失败:" + e.getMessage(); } int code = root.path("code").asInt(0); if (code < 0) { return "读取失败:" + root.path("msg").asText("未知错误"); } JsonNode ds = root.path("dataset"); int total = ds.path("totalCount").asInt(0); JsonNode rows = ds.path("rows"); JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null; if (data == null || !data.isArray() || data.isEmpty()) { return "该表单当前没有数据(共 " + total + " 条)。"; } Map labels = resolver.fieldLabels(formId.trim()); List cols = pickColumns(data.get(0), labels); String scope; if (!kw.isEmpty() && nameField != null) { scope = "(按名称含「" + kw + "」筛选)"; } else if (!kw.isEmpty()) { scope = "(未识别到该表单的名称字段,无法按「" + kw + "」过滤,返回前几条)"; } else { scope = ""; } StringBuilder sb = new StringBuilder(); sb.append(scope).append("共 ").append(total).append(" 条,第 ").append(pageNo) .append(" 页 ").append(data.size()).append(" 条:\n\n"); sb.append("| ") .append(cols.stream().map(c -> labels.getOrDefault(c, c)).collect(Collectors.joining(" | "))) .append(" |\n"); sb.append("|").append(" --- |".repeat(cols.size())).append("\n"); for (JsonNode r : data) { List vals = new ArrayList<>(cols.size()); for (String c : cols) { vals.add(cell(r.path(c))); } sb.append("| ").append(String.join(" | ", vals)).append(" |\n"); } return sb.toString(); } /** 选择展示列:优先有中文名的短文本列,跳过 id/token/布尔噪声,最多 MAX_COLS 列。 */ private List pickColumns(JsonNode first, Map labels) { List labeled = new ArrayList<>(); List others = new ArrayList<>(); Iterator it = first.fieldNames(); while (it.hasNext()) { String c = it.next(); if (c.equalsIgnoreCase("sToken") || c.endsWith("Id") || (c.length() > 1 && c.charAt(0) == 'b')) { continue; // 跳过 id / token / 布尔字段 } JsonNode v = first.path(c); if (v.isContainerNode()) { continue; } if (labels.containsKey(c)) { labeled.add(c); } else { others.add(c); } } List cols = new ArrayList<>(labeled); for (String o : others) { if (cols.size() >= MAX_COLS) { break; } cols.add(o); } if (cols.size() > MAX_COLS) { cols = new ArrayList<>(cols.subList(0, MAX_COLS)); } if (cols.isEmpty()) { Iterator it2 = first.fieldNames(); while (it2.hasNext() && cols.size() < 4) { String c = it2.next(); if (!first.path(c).isContainerNode()) { cols.add(c); } } } return cols; } private String cell(JsonNode v) { if (v == null || v.isNull() || v.isMissingNode()) { return ""; } String s = v.asText("").replace("|", "/").replace("\n", " ").trim(); return s.length() > 24 ? s.substring(0, 24) + "…" : s; } }