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; /** * 模型回复的解析。 *

* 全部喂罐头字符串,不 mock {@code AiChatApi}——这里是「字符串 → 草稿」的纯函数, * mock 掉模型再断言解析正确,测的是 mock 的返回值而不是解析逻辑。 *

* 罐头用例挑的都是真实会遇到的回复形状:模型习惯把 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 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); } }