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; /** * 把模型解析出来的草稿归一成「只含合法组件、合法属性」的清单。 *

* 三级防线的第三级,也是既有范式(ERP / CRM / MES 的 AI 调用)没有的一段。 * 那三处是扁平单层结构、字段写死在代码里,错了就是错了;这次要喂给不可信的模型去产出嵌套结构, * 少这一层就会出现「模型编了个组件类型,前端装配器跳过它,用户看到的画布和预览对不上」。 * *

原则:单个元素坏掉不能让整个请求失败

* 坏元素进 warnings、其余的照常返回。整体失败只在服务层判断(最终一项都不剩 → DRAFT_EMPTY), * 因为「大部分识别对了」和「全都没识别出来」对用户的含义完全不同。 * *

为什么后端也要过滤一遍(前端装配器已经过滤了)

* 前端过滤发生在装配那一刻,用户看到的是「我上传了 5 个组件,画布上只出来 3 个」——没有解释。 * 后端在预览阶段就把「哪一项被丢了、为什么」讲出来,用户才知道该补什么。 * 这不是重复防线,是把静默失败变成可见失败。 */ public class QcReportAiDraftNormalizer { private QcReportAiDraftNormalizer() { } /** * 归一结果。 * * @param components 过滤后的组件,可能为空(空的最终由服务层报 DRAFT_EMPTY) * @param warnings 需要展示给用户的软提示 */ public record Result(List components, List warnings) { } /** * 归一一次模型回复得到的组件。 * * @param raw 解析出来的原始组件,允许含 null 元素 * @param catalog 可用组件清单,决定哪些 type 与 prop key 合法 * @param sourceLabel 来源标签(文件名 / 文件名+页码),会拼进提示文案 */ public static Result normalize(List raw, List catalog, String sourceLabel) { Map specByType = new TreeMap<>(); for (QcReportComponentSpecVO spec : catalog) { if (spec != null && StrUtil.isNotBlank(spec.getType())) { specByType.put(spec.getType(), spec); } } List kept = new ArrayList<>(); List 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 props = item.getProps(); if (props == null || props.isEmpty()) { warnings.add(prefix + "组件「" + displayName(spec) + "」未识别出任何属性,已忽略"); continue; } Map 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); } /** * 合并多次调用的结果并去重,最后按上限截断。 *

* 去重是多页扫描件的必需品:页眉、报告标题、表头在每一页都会出现,逐页识别就会得到 N 份, * 不去重的话画布上会有 5 个一样的抬头。判定同一是 * {@code (type, props 的稳定 JSON)}——props 先按 key 排序,因为不同页返回的 key 顺序 * 不保证一致,直接比原始顺序会把同一个组件当成两个。 * * @param perCallResults 各次调用归一后的结果,按执行顺序 * @param maxComponents 组件总数上限,超出截断 */ public static Result mergeAndDedup(List perCallResults, int maxComponents) { List merged = new ArrayList<>(); Set 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 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 results) { return results.stream().mapToInt(r -> r.components().size()).sum(); } /** * 丢掉清单未声明的 prop key。 *

* {@code fields} 为 null(清单压根没声明)与为 空列表(明确声明「这个组件不吃任何属性」) * 是两回事:前者无从判断,原样放行;后者才是真的要一个个丢掉。 * 混为一谈会在前端漏传 fields 时把用户识别出来的属性静默抹平。 */ private static Map filterProps(Map props, QcReportComponentSpecVO spec, String prefix, List warnings) { if (spec.getFields() == null) { return props; } Set allowed = new LinkedHashSet<>(); for (QcReportComponentSpecVO.Field field : spec.getFields()) { if (field != null && StrUtil.isNotBlank(field.getKey())) { allowed.add(field.getKey()); } } Map filtered = new TreeMap<>(); List dropped = new ArrayList<>(); for (Map.Entry 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 props, QcReportComponentSpecVO spec, String prefix, List 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 的稳定序列化。 *

* props 先按 key 排序再用 {@link TreeMap},保证「同样的组件、不同的 key 顺序」也能判为同一个。 */ private static String dedupKey(QcReportAiDraftRespVO.DraftComponent component) { Map 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(); } }