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