FormCollectTool.java 10.2 KB
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 个参数,
 * 而不是逐字段追问。
 *
 * <p>表单骨架来自 {@link FormRenderService#formSkeleton}(与 previewChange 预览共用同一实现层);
 * 本类负责预填:用户已说的值(含尺寸表达的确定性拆分)对齐到表单字段。
 *
 * <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推
 * SSE {@code form_collect} 事件,前端渲染表单;用户填完点【保存】→ 确定性端点
 * {@code /api/agent/form/submit} 校验并写 ai_op_queue(AI 侧唯一写入口),不再经过 LLM。
 *
 * <p>由 {@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<String, String> known = parseKnown(knownFieldsJson);
        expandDimensions(known);
        Map<String, Object> 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<Map<String, Object>> fields = render.formSkeleton(table, formId);
        if (fields.isEmpty()) {
            return err("该表单没有可填写的业务字段。");
        }
        applyKnownDefaults(known, fields);

        Map<String, Object> 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<String, String> parseKnown(String json) {
        Map<String, String> m = new HashMap<>();
        if (json == null || json.isBlank()) {
            return m;
        }
        try {
            com.fasterxml.jackson.databind.JsonNode n = mapper.readTree(json.trim());
            Iterator<Map.Entry<String, com.fasterxml.jackson.databind.JsonNode>> it = n.fields();
            while (it.hasNext()) {
                Map.Entry<String, com.fasterxml.jackson.databind.JsonNode> 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<String, String> known, List<Map<String, Object>> fields) {
        List<String> normLabels = new ArrayList<>();
        for (Map<String, Object> f : fields) {
            normLabels.add(normLabel(String.valueOf(f.get("label"))));
        }
        Map<String, String> aligned = new HashMap<>();
        for (Map.Entry<String, String> 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<Integer> 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<Integer> fk = hits.stream()
                        .filter(i -> "fkselect".equals(fields.get(i).get("type"))).toList();
                List<Integer> 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<String, String> known) {
        Map<String, String> 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<String, Object> m = new LinkedHashMap<>();
        m.put("error", msg);
        return toJson(m);
    }

    private String toJson(Map<String, Object> m) {
        try {
            return mapper.writeValueAsString(m);
        } catch (Exception e) {
            return "{\"error\":\"内部错误\"}";
        }
    }
}