package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
|
|
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.json.JSONArray;
|
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONUtil;
|
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
|
import cn.iocoder.yudao.module.qcreport.dal.dataobject.version.ReportTemplateSchema;
|
|
import java.util.ArrayList;
|
import java.util.List;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_RESPONSE_UNPARSEABLE;
|
|
/**
|
* 把模型回复的字符串解析成草稿。
|
* <p>
|
* 三级防线里的第一、二级:<b>切片</b>(拿第一个 {@code {} 到最后一个 {@code }},
|
* 天然处理 markdown 围栏与前后寒暄)+ <b>反序列化</b>。
|
* 第三级「归一」在 {@link QcReportAiDraftNormalizer}。
|
* <p>
|
* 用字符串切片而不是让模型输出严格 JSON,是因为本项目从 {@code -api} 够不着 Spring AI 的
|
* {@code BeanOutputConverter}({@code ChatClient} / {@code ChatModel} 都在 {@code yudao-module-ai}
|
* 内部),只能走这条路——这是既有范式,不是偷懒。
|
* <p>
|
* <b>这一层不做任何过滤</b>:模型给什么就解析出什么,合法性判断全部留给归一器,
|
* 这样「解析失败」与「解析成功但内容不合规」是两件可分辨的事,错误码也才分得开。
|
*/
|
public class QcReportAiDraftParser {
|
|
/** 回填 {@code rawText} 时的截断长度:够排查,又不会把整段回复灌进响应体 */
|
private static final int RAW_TEXT_LIMIT = 4000;
|
|
private QcReportAiDraftParser() {
|
}
|
|
/**
|
* 解析模型回复。
|
*
|
* @param aiResponse 模型原始回复
|
* @return 未过滤的草稿,至少 components 字段非 null
|
* @throws cn.iocoder.yudao.framework.common.exception.ServiceException 完全解析不出 JSON 对象时
|
*/
|
public static QcReportAiDraftRespVO parse(String aiResponse) {
|
String sliced = sliceJsonObject(aiResponse);
|
JSONObject json;
|
try {
|
json = JSONUtil.parseObj(sliced);
|
} catch (Exception e) {
|
throw exception(AI_IMPORT_RESPONSE_UNPARSEABLE, StrUtil.length(aiResponse));
|
}
|
QcReportAiDraftRespVO resp = new QcReportAiDraftRespVO();
|
resp.setSummary(json.getStr("summary"));
|
resp.setComponents(readComponents(json));
|
resp.setPage(readPage(json));
|
return resp;
|
}
|
|
/**
|
* 整体解析失败时回填给前端的截断原文,便于排查模型到底返回了什么。
|
*/
|
public static String truncate(String raw) {
|
if (raw == null) {
|
return null;
|
}
|
return raw.length() <= RAW_TEXT_LIMIT ? raw : raw.substring(0, RAW_TEXT_LIMIT) + "…(已截断)";
|
}
|
|
/**
|
* 取第一个 <code>{</code> 到最后一个 <code>}</code>。
|
*/
|
private static String sliceJsonObject(String aiResponse) {
|
if (StrUtil.isBlank(aiResponse)) {
|
throw exception(AI_IMPORT_RESPONSE_UNPARSEABLE, 0);
|
}
|
int start = aiResponse.indexOf('{');
|
int end = aiResponse.lastIndexOf('}');
|
if (start < 0 || end <= start) {
|
throw exception(AI_IMPORT_RESPONSE_UNPARSEABLE, aiResponse.length());
|
}
|
return aiResponse.substring(start, end + 1);
|
}
|
|
/**
|
* 读 components 数组。
|
* <p>
|
* 容忍模型把数组二次编码成字符串(「回复里套一层 JSON 字符串」是常见失败模式),
|
* 因为它便宜且无损;除此之外的任何形状都只是「这一项没有」,不在这里抛错——
|
* 空数组最终由服务层的 {@code AI_IMPORT_DRAFT_EMPTY} 报出,比在这里报错文案更贴切。
|
*/
|
private static List<QcReportAiDraftRespVO.DraftComponent> readComponents(JSONObject json) {
|
Object raw = json.get("components");
|
JSONArray array = null;
|
if (raw instanceof CharSequence cs && StrUtil.isNotBlank(cs)) {
|
try {
|
array = JSONUtil.parseArray(cs.toString());
|
} catch (Exception ignored) {
|
// 解析不出来就按「没有组件」处理,最终由 DRAFT_EMPTY 统一报出
|
}
|
} else if (raw instanceof JSONArray ja) {
|
array = ja;
|
}
|
if (array == null) {
|
return new ArrayList<>();
|
}
|
List<QcReportAiDraftRespVO.DraftComponent> components = new ArrayList<>(array.size());
|
for (Object item : array) {
|
if (item instanceof JSONObject obj) {
|
components.add(obj.toBean(QcReportAiDraftRespVO.DraftComponent.class));
|
}
|
}
|
return components;
|
}
|
|
/**
|
* 读 page。模型只给 size 或只给 orientation 都算数,缺的部分让设计器按当前模板走。
|
*/
|
private static ReportTemplateSchema.Page readPage(JSONObject json) {
|
Object raw = json.get("page");
|
if (!(raw instanceof JSONObject pageObj)) {
|
return null;
|
}
|
ReportTemplateSchema.Page page = new ReportTemplateSchema.Page();
|
page.setSize(pageObj.getStr("size"));
|
page.setOrientation(pageObj.getStr("orientation"));
|
Object margin = pageObj.get("margin");
|
if (margin instanceof JSONObject marginObj) {
|
ReportTemplateSchema.Margin m = new ReportTemplateSchema.Margin();
|
m.setTop(marginObj.getDouble("top"));
|
m.setRight(marginObj.getDouble("right"));
|
m.setBottom(marginObj.getDouble("bottom"));
|
m.setLeft(marginObj.getDouble("left"));
|
page.setMargin(m);
|
}
|
return (page.getSize() == null && page.getOrientation() == null && page.getMargin() == null)
|
? null : page;
|
}
|
|
}
|