package com.xly.tool; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; import org.springframework.jdbc.core.JdbcTemplate; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, * 而不是逐字段追问。 * *

字段来源优先 {@link FormResolverService#businessFields}(精选映射 + 字段字典,即"真正要填的业务参数"), * 每个字段带控件类型 {@code fkselect|select|number|date|text};外键字段的下拉选项由前端另调 * {@code GET /api/agent/form/options} 从来源表实时取。只有该表在字段字典里没有映射时,才退回 * ERP 界面元数据 {@code gdsconfigformslave} 的可见可写字段。 * *

工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推 * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回, * agent 再据此走 {@code proposeWrite(action=create)}(人在环写入)。适合报价这类字段多的新建场景。 * *

由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。 */ public class FormCollectTool { private static final int MAX_FIELDS = 40; // 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理) // 标注式:"长和宽50"、"高5cm"、"长:50" private static final Pattern DIM_LABELED = Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)"); // 位置式:"50*30*5"、"50x30x5"、"50×30×5" private static final Pattern DIM_POS = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?"); private final JdbcTemplate jdbc; private final FormResolverService resolver; private final AgentIdentity identity; private final ObjectMapper mapper; public FormCollectTool(JdbcTemplate jdbc, FormResolverService resolver, AgentIdentity identity, ObjectMapper mapper) { this.jdbc = jdbc; this.resolver = resolver; this.identity = identity; this.mapper = mapper; } @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景" + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填" + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。" + "用户填完提交后,你再用 proposeWrite(action=create) 生成待确认的新增。") public String collectForm( @P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword, @P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}", required = false) String knownFieldsJson) { if (entityKeyword == null || entityKeyword.isBlank()) { return err("缺少实体类型。"); } Map known = parseKnown(knownFieldsJson); expandDimensions(known); Map form = resolver.resolveMasterForm(entityKeyword.trim()); if (form == null) { return err("找不到「" + entityKeyword + "」对应的可新建表单。"); } String formId = String.valueOf(form.get("sFormId")); String moduleId = String.valueOf(form.get("sModuleId")); if (!identity.canAccessModule(moduleId)) { return err("你没有新建「" + entityKeyword + "」的权限。"); } String table = String.valueOf(form.get("sDataSource")); List> fields = new ArrayList<>(); // 主字段来源 = 该表的**业务录入字段**(字段字典,排除系统/审计/计算列)——比 gdsconfigformslave 的 // 界面布局字段更贴近"要填的业务参数"(报价的 客户/产品/数量/尺寸/单价,而不是 制单人/单据日期)。 List> biz = resolver.businessFields(table, MAX_FIELDS); Map types = resolver.columnTypes(table); for (Map b : biz) { String col = str(b.get("col")); if (col == null || col.isBlank()) { continue; } Map f = new LinkedHashMap<>(); f.put("name", col); f.put("label", firstNonBlank(str(b.get("label")), col)); f.put("required", false); Object fk = b.get("fk"); Object opts = b.get("options"); String type = str(b.get("type")); if (fk != null && !String.valueOf(fk).isBlank()) { // 外键字段:下拉从对应表实时取选项(客户/产品/物料…),用户填名称、写入时解析成 id f.put("type", "fkselect"); f.put("fkTable", String.valueOf(fk)); } else if (opts instanceof List && !((List) opts).isEmpty()) { f.put("type", "select"); f.put("options", opts); } else if (type != null && !type.isBlank()) { f.put("type", type); } else { f.put("type", inferType(types.get(col))); // 通用表:按列类型推断 number/date/text } Object hint = b.get("hint"); if (hint != null && !String.valueOf(hint).isBlank()) { f.put("hint", String.valueOf(hint)); } fields.add(f); } // 兜底:字段字典没有该表映射时,退回 gdsconfigformslave 的非只读可见字段(排除系统列)。 if (fields.isEmpty()) { try { List> cols = jdbc.queryForList( "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId); for (Map c : cols) { String name = str(c.get("sName")); if (name == null || name.isBlank() || resolver.isSystemColumn(name)) { continue; } Map f = new LinkedHashMap<>(); f.put("name", name); f.put("label", firstNonBlank(str(c.get("sChinese")), name)); f.put("required", truthy(c.get("bNotEmpty"))); String def = str(c.get("sDefault")); if (def != null && !def.isBlank()) { f.put("default", def); } List opts = simpleOptions(str(c.get("sChineseDropDown"))); if (!opts.isEmpty()) { f.put("type", "select"); f.put("options", opts); } else { f.put("type", "text"); } fields.add(f); } } catch (Exception e) { return err("读取表单结构失败:" + e.getMessage()); } } if (fields.isEmpty()) { return err("该表单没有可填写的业务字段。"); } applyKnownDefaults(known, fields); Map out = new LinkedHashMap<>(); out.put("type", "form_collect"); out.put("entity", entityKeyword.trim()); out.put("formId", formId); out.put("moduleId", moduleId); out.put("title", "新建" + entityKeyword.trim()); out.put("fields", fields); out.put("message", "请在下方表单里填写,填完点【提交】。"); return toJson(out); } /** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */ private List simpleOptions(String dropdown) { List out = new ArrayList<>(); if (dropdown == null || dropdown.isBlank()) { return out; } String d = dropdown.trim(); if (d.toLowerCase().contains("select ") || d.length() > 200) { return out; // SQL 或过长 -> 不当作枚举 } for (String o : d.split("[、,,|]")) { String t = o.trim(); if (!t.isEmpty() && out.size() < 30) { out.add(t); } } return out; } /** 按列的 MySQL 类型推断表单控件类型。 */ private static String inferType(String dataType) { if (dataType == null) { return "text"; } String t = dataType.toLowerCase(); if (t.contains("int") || t.equals("decimal") || t.contains("double") || t.contains("float") || t.contains("numeric")) { return "number"; } if (t.contains("date") || t.contains("time")) { return "date"; } return "text"; } private static boolean truthy(Object o) { if (o == null) { return false; } if (o instanceof Boolean) { return (Boolean) o; } if (o instanceof Number) { return ((Number) o).intValue() != 0; } if (o instanceof byte[]) { byte[] b = (byte[]) o; return b.length > 0 && b[0] != 0; } String s = o.toString().trim(); return s.equals("1") || s.equalsIgnoreCase("true"); } private static String firstNonBlank(String a, String b) { return (a != null && !a.isBlank()) ? a : b; } private static String str(Object o) { return o == null ? null : o.toString(); } /** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */ private Map parseKnown(String json) { Map m = new HashMap<>(); if (json == null || json.isBlank()) { return m; } try { com.fasterxml.jackson.databind.JsonNode n = mapper.readTree(json.trim()); Iterator> it = n.fields(); while (it.hasNext()) { Map.Entry e = it.next(); String v = e.getValue().asText(""); if (v != null && !v.isBlank()) { m.put(normLabel(e.getKey()), v); } } } catch (Exception ignore) { } return m; } private static String normLabel(String s) { if (s == null) { return ""; } return s.replaceAll("[((].*?[))]", "").replaceAll("\\s+", "").trim(); } /** * 把用户已说的值预填进表单字段。键与字段标签先按归一化全等匹配;对不上再做**宽松对齐** * (「客户」→「客户名称」这类互含关系),多个候选字段时用确定性决胜:唯一的外键下拉字段 * 或唯一以「名称」结尾的字段,仍二义则放弃预填(宁缺勿错,用户在表单里自己填)。 */ static void applyKnownDefaults(Map known, List> fields) { List normLabels = new ArrayList<>(); for (Map f : fields) { normLabels.add(normLabel(String.valueOf(f.get("label")))); } Map aligned = new HashMap<>(); for (Map.Entry e : known.entrySet()) { String key = e.getKey(); if (key.isEmpty() || e.getValue() == null || e.getValue().isBlank()) { continue; } if (normLabels.contains(key)) { aligned.put(key, e.getValue()); continue; } List hits = new ArrayList<>(); for (int i = 0; i < normLabels.size(); i++) { String l = normLabels.get(i); if (!l.isEmpty() && (l.contains(key) || key.contains(l))) { hits.add(i); } } Integer pick = null; if (hits.size() == 1) { pick = hits.get(0); } else if (hits.size() > 1) { List fk = hits.stream() .filter(i -> "fkselect".equals(fields.get(i).get("type"))).toList(); List named = hits.stream() .filter(i -> normLabels.get(i).endsWith("名称")).toList(); if (fk.size() == 1) { pick = fk.get(0); } else if (named.size() == 1) { pick = named.get(0); } } if (pick != null) { aligned.putIfAbsent(normLabels.get(pick), e.getValue()); } } for (int i = 0; i < fields.size(); i++) { String pre = aligned.get(normLabels.get(i)); if (pre != null && !pre.isBlank()) { fields.get(i).put("default", pre); // 用户说过的值优先于表单默认值 } } } /** * 预填值里的尺寸表达(「50*30*5」「长和宽50,高5cm」)确定性拆成 长/宽/高 三个键 * (模型拆不对是实测结论——原 SlotFillService 的正则迁于此)。已有的 长/宽/高 预填不覆盖。 */ static void expandDimensions(Map known) { Map dims = new LinkedHashMap<>(); for (String v : known.values()) { if (v == null || v.isBlank()) { continue; } Matcher m = DIM_LABELED.matcher(v); boolean any = false; while (m.find()) { String labels = m.group(1); String val = m.group(2); for (int i = 0; i < labels.length(); i++) { String key = dimKey(labels.charAt(i)); if (key != null) { dims.putIfAbsent(key, val); any = true; } } } if (!any) { Matcher p = DIM_POS.matcher(v); if (p.find()) { dims.putIfAbsent("长", p.group(1)); dims.putIfAbsent("宽", p.group(2)); if (p.group(3) != null) { dims.putIfAbsent("高", p.group(3)); } } } } dims.forEach(known::putIfAbsent); } private static String dimKey(char c) { switch (c) { case '长': return "长"; case '宽': return "宽"; case '高': case '厚': case '深': return "高"; default: return null; } } private String err(String msg) { Map m = new LinkedHashMap<>(); m.put("error", msg); return toJson(m); } private String toJson(Map m) { try { return mapper.writeValueAsString(m); } catch (Exception e) { return "{\"error\":\"内部错误\"}"; } } }