5 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
 
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO;
 
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
 
/**
 * 把模型解析出来的草稿归一成「只含合法组件、合法属性」的清单。
 * <p>
 * 三级防线的第三级,也是既有范式(ERP / CRM / MES 的 AI 调用)<b>没有</b>的一段。
 * 那三处是扁平单层结构、字段写死在代码里,错了就是错了;这次要喂给不可信的模型去产出嵌套结构,
 * 少这一层就会出现「模型编了个组件类型,前端装配器跳过它,用户看到的画布和预览对不上」。
 *
 * <h3>原则:单个元素坏掉不能让整个请求失败</h3>
 * 坏元素进 warnings、其余的照常返回。整体失败只在服务层判断(最终一项都不剩 → DRAFT_EMPTY),
 * 因为「大部分识别对了」和「全都没识别出来」对用户的含义完全不同。
 *
 * <h3>为什么后端也要过滤一遍(前端装配器已经过滤了)</h3>
 * 前端过滤发生在装配那一刻,用户看到的是「我上传了 5 个组件,画布上只出来 3 个」——没有解释。
 * 后端在预览阶段就把「哪一项被丢了、为什么」讲出来,用户才知道该补什么。
 * 这不是重复防线,是把静默失败变成可见失败。
 */
public class QcReportAiDraftNormalizer {
 
    private QcReportAiDraftNormalizer() {
    }
 
    /**
     * 归一结果。
     *
     * @param components 过滤后的组件,可能为空(空的最终由服务层报 DRAFT_EMPTY)
     * @param warnings   需要展示给用户的软提示
     */
    public record Result(List<QcReportAiDraftRespVO.DraftComponent> components, List<String> warnings) {
    }
 
    /**
     * 归一一次模型回复得到的组件。
     *
     * @param raw         解析出来的原始组件,允许含 null 元素
     * @param catalog     可用组件清单,决定哪些 type 与 prop key 合法
     * @param sourceLabel 来源标签(文件名 / 文件名+页码),会拼进提示文案
     */
    public static Result normalize(List<QcReportAiDraftRespVO.DraftComponent> raw,
                                   List<QcReportComponentSpecVO> catalog,
                                   String sourceLabel) {
        Map<String, QcReportComponentSpecVO> specByType = new TreeMap<>();
        for (QcReportComponentSpecVO spec : catalog) {
            if (spec != null && StrUtil.isNotBlank(spec.getType())) {
                specByType.put(spec.getType(), spec);
            }
        }
        List<QcReportAiDraftRespVO.DraftComponent> kept = new ArrayList<>();
        List<String> warnings = new ArrayList<>();
        String prefix = StrUtil.isBlank(sourceLabel) ? "" : sourceLabel + ":";
 
        if (raw == null) {
            return new Result(kept, warnings);
        }
        for (int i = 0; i < raw.size(); i++) {
            QcReportAiDraftRespVO.DraftComponent item = raw.get(i);
            int no = i + 1;
            if (item == null) {
                warnings.add(prefix + "第 " + no + " 项不是一个有效的组件对象,已忽略");
                continue;
            }
            String type = item.getType();
            if (StrUtil.isBlank(type)) {
                warnings.add(prefix + "第 " + no + " 项缺少组件类型,已忽略");
                continue;
            }
            QcReportComponentSpecVO spec = specByType.get(type);
            if (spec == null) {
                warnings.add(prefix + "AI 输出了组件清单外的组件类型「" + type + "」,已忽略");
                continue;
            }
            Map<String, Object> props = item.getProps();
            if (props == null || props.isEmpty()) {
                warnings.add(prefix + "组件「" + displayName(spec) + "」未识别出任何属性,已忽略");
                continue;
            }
            Map<String, Object> filtered = filterProps(props, spec, prefix, warnings);
            if (filtered.isEmpty()) {
                warnings.add(prefix + "组件「" + displayName(spec)
                        + "」的属性都不在清单声明范围内,已忽略");
                continue;
            }
            warnMissingRequired(filtered, spec, prefix, warnings);
            QcReportAiDraftRespVO.DraftComponent draft = new QcReportAiDraftRespVO.DraftComponent();
            draft.setType(type);
            draft.setProps(filtered);
            kept.add(draft);
        }
        return new Result(kept, warnings);
    }
 
    /**
     * 合并多次调用的结果并去重,最后按上限截断。
     * <p>
     * 去重是多页扫描件的必需品:页眉、报告标题、表头在每一页都会出现,逐页识别就会得到 N 份,
     * 不去重的话画布上会有 5 个一样的抬头。判定同一是
     * {@code (type, props 的稳定 JSON)}——props 先按 key 排序,因为不同页返回的 key 顺序
     * 不保证一致,直接比原始顺序会把同一个组件当成两个。
     *
     * @param perCallResults 各次调用归一后的结果,按执行顺序
     * @param maxComponents  组件总数上限,超出截断
     */
    public static Result mergeAndDedup(List<Result> perCallResults, int maxComponents) {
        List<QcReportAiDraftRespVO.DraftComponent> merged = new ArrayList<>();
        Set<String> seen = new LinkedHashSet<>();
        for (Result result : perCallResults) {
            for (QcReportAiDraftRespVO.DraftComponent component : result.components()) {
                if (seen.add(dedupKey(component))) {
                    merged.add(component);
                }
            }
        }
        int total = merged.size();
        List<String> warnings = new ArrayList<>();
        for (Result result : perCallResults) {
            warnings.addAll(result.warnings());
        }
        if (perCallResults.size() > 1 && total < countRaw(perCallResults)) {
            warnings.add(StrUtil.format(
                    "多页识别共得到 {} 项组件,其中有 {} 项在其它页已出现(页眉、标题、表头等重复内容),已合并",
                    countRaw(perCallResults), countRaw(perCallResults) - total));
        }
        if (maxComponents > 0 && total > maxComponents) {
            warnings.add(StrUtil.format("识别出的组件共 {} 项,超过单次上限 {} 项,已保留前 {} 项,其余请手工补充",
                    total, maxComponents, maxComponents));
            merged = new ArrayList<>(merged.subList(0, maxComponents));
        }
        return new Result(merged, warnings);
    }
 
    private static int countRaw(List<Result> results) {
        return results.stream().mapToInt(r -> r.components().size()).sum();
    }
 
    /**
     * 丢掉清单未声明的 prop key。
     * <p>
     * {@code fields} 为 <b>null</b>(清单压根没声明)与为 <b>空列表</b>(明确声明「这个组件不吃任何属性」)
     * 是两回事:前者无从判断,原样放行;后者才是真的要一个个丢掉。
     * 混为一谈会在前端漏传 fields 时把用户识别出来的属性静默抹平。
     */
    private static Map<String, Object> filterProps(Map<String, Object> props, QcReportComponentSpecVO spec,
                                                   String prefix, List<String> warnings) {
        if (spec.getFields() == null) {
            return props;
        }
        Set<String> allowed = new LinkedHashSet<>();
        for (QcReportComponentSpecVO.Field field : spec.getFields()) {
            if (field != null && StrUtil.isNotBlank(field.getKey())) {
                allowed.add(field.getKey());
            }
        }
        Map<String, Object> filtered = new TreeMap<>();
        List<String> dropped = new ArrayList<>();
        for (Map.Entry<String, Object> entry : props.entrySet()) {
            if (allowed.contains(entry.getKey())) {
                filtered.put(entry.getKey(), entry.getValue());
            } else {
                dropped.add(entry.getKey());
            }
        }
        if (!dropped.isEmpty()) {
            warnings.add(prefix + "组件「" + displayName(spec) + "」中不属于该组件的属性 "
                    + String.join("、", dropped) + " 已丢弃");
        }
        return filtered;
    }
 
    private static void warnMissingRequired(Map<String, Object> props, QcReportComponentSpecVO spec,
                                            String prefix, List<String> warnings) {
        if (spec.getFields() == null) {
            return;
        }
        for (QcReportComponentSpecVO.Field field : spec.getFields()) {
            if (field == null || !Boolean.TRUE.equals(field.getRequired())) {
                continue;
            }
            if (isBlankValue(props.get(field.getKey()))) {
                warnings.add(prefix + "组件「" + displayName(spec) + "」缺少必填属性「"
                        + fieldLabel(field) + "」,请在设计器属性面板中补上后再保存");
            }
        }
    }
 
    private static boolean isBlankValue(Object value) {
        if (value == null) {
            return true;
        }
        if (value instanceof CharSequence cs) {
            return StrUtil.isBlank(cs.toString());
        }
        return value instanceof Collection<?> collection && collection.isEmpty();
    }
 
    /**
     * 去重键:type + props 的稳定序列化。
     * <p>
     * props 先按 key 排序再用 {@link TreeMap},保证「同样的组件、不同的 key 顺序」也能判为同一个。
     */
    private static String dedupKey(QcReportAiDraftRespVO.DraftComponent component) {
        Map<String, Object> props = component.getProps();
        return component.getType() + "" + JSONUtil.toJsonStr(props == null ? Map.of() : new TreeMap<>(props));
    }
 
    private static String displayName(QcReportComponentSpecVO spec) {
        return StrUtil.isNotBlank(spec.getLabel()) ? spec.getLabel() : spec.getType();
    }
 
    private static String fieldLabel(QcReportComponentSpecVO.Field field) {
        return StrUtil.isNotBlank(field.getLabel()) ? field.getLabel() : field.getKey();
    }
 
}