package com.xly.tool; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.service.FormRenderService; import com.xly.service.FormResolverService; import dev.langchain4j.agent.tool.P; import dev.langchain4j.agent.tool.Tool; 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 工具(LLM 层的「收集信息」工具)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数, * 而不是逐字段追问。 * *

表单骨架来自 {@link FormRenderService#formSkeleton}(与 previewChange 预览共用同一实现层); * 本类负责预填:用户已说的值(含尺寸表达的确定性拆分)对齐到表单字段。 * *

工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推 * SSE {@code form_collect} 事件,前端渲染表单;用户填完点【保存】→ 确定性端点 * {@code /api/agent/form/submit} 校验并写 ai_op_queue(AI 侧唯一写入口),不再经过 LLM。 * *

由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。 */ public class FormCollectTool { // 尺寸拆分正则(实测模型拆不对「长和宽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 FormRenderService render; private final FormResolverService resolver; private final AgentIdentity identity; private final ObjectMapper mapper; public FormCollectTool(FormRenderService render, FormResolverService resolver, AgentIdentity identity, ObjectMapper mapper) { this.render = render; this.resolver = resolver; this.identity = identity; this.mapper = mapper; } @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景" + "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填" + "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。" + "用户填完点【保存】后,系统自动校验并提交待办——你**无需**再做任何写操作,也绝不能说已保存。") 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 = render.formSkeleton(table, formId); 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); } /** 解析预填 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) { return FormRenderService.normLabel(s); } /** * 把用户已说的值预填进表单字段。键与字段标签先按归一化全等匹配;对不上再做**宽松对齐** * (「客户」→「客户名称」这类互含关系),多个候选字段时用确定性决胜:唯一的外键下拉字段 * 或唯一以「名称」结尾的字段,仍二义则放弃预填(宁缺勿错,用户在表单里自己填)。 */ 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\":\"内部错误\"}"; } } }