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; /** * 把模型回复的字符串解析成草稿。 *
* 三级防线里的第一、二级:切片(拿第一个 {@code {} 到最后一个 {@code }}, * 天然处理 markdown 围栏与前后寒暄)+ 反序列化。 * 第三级「归一」在 {@link QcReportAiDraftNormalizer}。 *
* 用字符串切片而不是让模型输出严格 JSON,是因为本项目从 {@code -api} 够不着 Spring AI 的 * {@code BeanOutputConverter}({@code ChatClient} / {@code ChatModel} 都在 {@code yudao-module-ai} * 内部),只能走这条路——这是既有范式,不是偷懒。 *
* 这一层不做任何过滤:模型给什么就解析出什么,合法性判断全部留给归一器,
* 这样「解析失败」与「解析成功但内容不合规」是两件可分辨的事,错误码也才分得开。
*/
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) + "…(已截断)";
}
/**
* 取第一个 { 到最后一个 }。
*/
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 数组。
*
* 容忍模型把数组二次编码成字符串(「回复里套一层 JSON 字符串」是常见失败模式),
* 因为它便宜且无损;除此之外的任何形状都只是「这一项没有」,不在这里抛错——
* 空数组最终由服务层的 {@code AI_IMPORT_DRAFT_EMPTY} 报出,比在这里报错文案更贴切。
*/
private static List