12 小时以前 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
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
 
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportAiDraftRespVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
 
import java.util.List;
import java.util.Map;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
/**
 * 模型回复的解析。
 * <p>
 * 全部喂<b>罐头字符串</b>,不 mock {@code AiChatApi}——这里是「字符串 → 草稿」的纯函数,
 * mock 掉模型再断言解析正确,测的是 mock 的返回值而不是解析逻辑。
 * <p>
 * 罐头用例挑的都是真实会遇到的回复形状:模型习惯把 JSON 包进 markdown 围栏、习惯在前后加一句
 * 「好的,这是识别结果」,也习惯在输出被截断时留下半个对象。这三种必须在切片这一步就被吃掉,
 * 否则用户看到的是「AI 返回的内容无法解析」——而其实回复里内容是全的。
 */
class QcReportAiDraftParserTest {
 
    @Test
    @DisplayName("裸 JSON 原样解析")
    void plainJson() {
        QcReportAiDraftRespVO resp = QcReportAiDraftParser.parse("""
                {"summary":"来料检验报告","page":{"size":"A4","orientation":"portrait"},
                 "components":[{"type":"ReportHeader","props":{"title":"来料检验报告"}}]}""");
 
        assertEquals("来料检验报告", resp.getSummary());
        assertEquals("A4", resp.getPage().getSize());
        assertEquals("portrait", resp.getPage().getOrientation());
        assertEquals(1, resp.getComponents().size());
        assertEquals("ReportHeader", resp.getComponents().get(0).getType());
        assertEquals("来料检验报告", resp.getComponents().get(0).getProps().get("title"));
    }
 
    @Test
    @DisplayName("markdown 围栏包裹的 JSON 照样解析(模型最常见的输出形状)")
    void markdownFenced() {
        QcReportAiDraftRespVO resp = QcReportAiDraftParser.parse("""
                ```json
                {"summary":"扫描件","components":[{"type":"Heading","props":{"text":"检验项目"}}]}
                ```""");
 
        assertEquals(1, resp.getComponents().size());
        assertEquals("Heading", resp.getComponents().get(0).getType());
    }
 
    @Test
    @DisplayName("前后带寒暄的 JSON 照样解析")
    void withChatterAround() {
        QcReportAiDraftRespVO resp = QcReportAiDraftParser.parse("""
                好的,我已经分析了这份文件。识别结果如下:
                {"summary":"识别完成","components":[{"type":"Result","props":{"conclusion":"合格"}}]}
                如果需要调整请告诉我。""");
 
        assertEquals("识别完成", resp.getSummary());
        assertEquals("Result", resp.getComponents().get(0).getType());
    }
 
    @Test
    @DisplayName("props 里有嵌套的数组与对象也不炸——props 的合法 key 由前端清单决定,后端不该强类型化")
    void nestedPropValues() {
        QcReportAiDraftRespVO resp = QcReportAiDraftParser.parse("""
                {"components":[{"type":"QualityTable","props":{
                  "itemsPath":"inspectionItems",
                  "columns":[{"key":"size","label":"尺寸"}],
                  "meta":{"source":"iqc"}}}]}""");
 
        Map<String, Object> props = resp.getComponents().get(0).getProps();
        assertTrue(props.get("columns") instanceof List, "数组型属性应当原样保留为集合");
        assertTrue(props.get("meta") instanceof Map, "对象型属性应当原样保留为 Map");
    }
 
    @Test
    @DisplayName("components 被二次编码成字符串时也能读出来")
    void doubleEncodedComponents() {
        QcReportAiDraftRespVO resp = QcReportAiDraftParser.parse("""
                {"summary":"x","components":"[{\\"type\\":\\"Heading\\",\\"props\\":{\\"text\\":\\"标题\\"}}]"}""");
 
        assertEquals(1, resp.getComponents().size());
        assertEquals("Heading", resp.getComponents().get(0).getType());
    }
 
    @Test
    @DisplayName("components 为空数组不算解析失败:这是「解析成功但内容为空」,由服务层报更贴切的错")
    void emptyComponentsIsNotAParseFailure() {
        QcReportAiDraftRespVO resp = QcReportAiDraftParser.parse("{\"summary\":\"没识别出来\",\"components\":[]}");
 
        assertNotNull(resp.getComponents());
        assertTrue(resp.getComponents().isEmpty());
    }
 
    @Test
    @DisplayName("没有 page 字段时为 null,交给设计器沿用当前模板纸张")
    void missingPageIsNull() {
        assertNull(QcReportAiDraftParser.parse("{\"components\":[{\"type\":\"X\",\"props\":{}}]}").getPage());
        // 只给了 size 也算有效,缺的部分让设计器补
        assertEquals("A3", QcReportAiDraftParser.parse(
                "{\"page\":{\"size\":\"A3\"},\"components\":[]}").getPage().getSize());
    }
 
    @Test
    @DisplayName("完全不含 JSON 对象、空串、截断成半个对象时统一抛错")
    void unparseableThrows() {
        assertThrows(ServiceException.class, () -> QcReportAiDraftParser.parse("我不太明白你的意思"));
        assertThrows(ServiceException.class, () -> QcReportAiDraftParser.parse(""));
        assertThrows(ServiceException.class, () -> QcReportAiDraftParser.parse(null));
        assertThrows(ServiceException.class, () -> QcReportAiDraftParser.parse("{\"components\": ["),
                "被 max_tokens 截断的回复会在切片后拿到畸形 JSON,必须抛错而不是给出半份草稿");
    }
 
    @Test
    @DisplayName("回填原文按 4000 字符截断,够排查又不灌爆响应体")
    void rawTextTruncation() {
        assertNull(QcReportAiDraftParser.truncate(null));
        assertEquals("abc", QcReportAiDraftParser.truncate("abc"));
 
        String truncated = QcReportAiDraftParser.truncate("x".repeat(5000));
        assertTrue(truncated.startsWith("x".repeat(100)));
        assertTrue(truncated.contains("已截断"));
        assertTrue(truncated.length() < 4100);
    }
 
}