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