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() + " |