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; /** * 调用计划。 *

* 这是「一个文件该发几次模型调用」这个控制流的唯一落点,所以它被抽成纯函数、可以在这里被完整跑一遍, * 而不必在单测里 mock 掉真正的外部依赖。 *

* 另有一条接口细节必须钉死:交给 {@code AiChatApi.chatWithImage} 的必须是裸 base64。 * 那边拿到参数直接 {@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 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 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); } }