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 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 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; } }