5 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
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 &#123;} 到最后一个 {@code &#125;},
 * 天然处理 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;
    }
 
}