10 小时以前 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
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
 
import cn.iocoder.yudao.module.qcreport.service.aiimport.document.QcReportDocumentExtract;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
 
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
 
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
/**
 * 调用计划。
 * <p>
 * 这是「一个文件该发几次模型调用」这个控制流的唯一落点,所以它被抽成纯函数、可以在这里被完整跑一遍,
 * 而不必在单测里 mock 掉真正的外部依赖。
 * <p>
 * 另有一条接口细节必须钉死:交给 {@code AiChatApi.chatWithImage} 的必须是<b>裸 base64</b>。
 * 那边拿到参数直接 {@code Base64.getDecoder().decode(...)},带 {@code data:image/png;base64,}
 * 前缀就会抛 {@code IllegalArgumentException}——这个失败在联调时才暴露,且报错信息不会提到前缀。
 */
class QcReportLlmCallPlannerTest {
 
    @Test
    @DisplayName("有文本层的文件整份拼一次调用,不逐页")
    void textChannelMakesSingleCall() {
        QcReportDocumentExtract extract = QcReportDocumentExtract.ofText(
                "第一页内容\n--- 第 2 页 ---\n第二页内容", 2, "pdfbox-text", List.of());
 
        List<QcReportLlmCall> calls = QcReportLlmCallPlanner.plan(extract, "报告.pdf");
 
        assertEquals(1, calls.size(), "文本通道逐页发调用既费额度又会让页间上下文断掉");
        assertEquals(QcReportLlmCall.Kind.TEXT, calls.get(0).kind());
        assertEquals("报告.pdf", calls.get(0).sourceLabel());
        assertTrue(calls.get(0).text().contains("第二页内容"), "整份正文必须一次带上");
    }
 
    @Test
    @DisplayName("扫描件逐页一次调用,标签带页码以便定位是哪一页出的问题")
    void imageChannelMakesOneCallPerPage() {
        QcReportDocumentExtract extract = QcReportDocumentExtract.ofImages(List.of(
                image("第 1 页"),
                image("第 2 页"),
                image("第 3 页")), 3, "pdfbox-render", List.of());
 
        List<QcReportLlmCall> calls = QcReportLlmCallPlanner.plan(extract, "扫描件.pdf");
 
        assertEquals(3, calls.size(), "多模态接口一次只收一张图,页数就是调用数");
        for (QcReportLlmCall call : calls) {
            assertEquals(QcReportLlmCall.Kind.IMAGE, call.kind());
            assertTrue(call.sourceLabel().startsWith("扫描件.pdf 第"), "标签必须能定位到具体页");
            assertEquals("image/png", call.mimeType());
            assertTrue(call.text().isEmpty(), "图片调用不该再带正文");
        }
        assertEquals(List.of("扫描件.pdf 第 1 页", "扫描件.pdf 第 2 页", "扫描件.pdf 第 3 页"),
                calls.stream().map(QcReportLlmCall::sourceLabel).toList());
    }
 
    @Test
    @DisplayName("图片是裸 base64:带 data: 前缀会让 AiChatApiImpl 的 Base64 解码直接抛异常")
    void imageBase64IsRaw() {
        byte[] png = "假装这是 PNG 字节".getBytes(StandardCharsets.UTF_8);
        QcReportDocumentExtract extract = QcReportDocumentExtract.ofImages(
                List.of(new QcReportDocumentExtract.ImagePart(png, "image/png", "第 1 页")),
                1, "raw-image", List.of());
 
        String base64 = QcReportLlmCallPlanner.plan(extract, "a.png").get(0).imageBase64();
 
        assertEquals(Base64.getEncoder().encodeToString(png), base64,
                "必须是裸 base64:接口那边不做剥前缀处理就直接解码,多一个字符都会炸");
        assertArrayEquals(png, Base64.getDecoder().decode(base64), "解码回来必须还是原字节");
        assertThrows(IllegalArgumentException.class,
                () -> Base64.getDecoder().decode("data:image/png;base64," + base64),
                "带前缀会解码失败——这条断言把那个坑钉在单测里,免得联调时才发现");
    }
 
    private static QcReportDocumentExtract.ImagePart image(String label) {
        return new QcReportDocumentExtract.ImagePart(label.getBytes(StandardCharsets.UTF_8), "image/png", label);
    }
 
}