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