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.QcReportComponentSpecVO; import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 提示词构造。 *

* 守的是「约束有没有被写进去」这件事本身。提示词是这套 AI 导入的唯一防线中最靠前的一环: * 铁律少写一条,模型就会开始输出 HTML 或嵌套结构,而那时下游的过滤只能丢掉结果、 * 无法把丢掉的正确内容找回来。所以逐条断言,而不是靠「跑一次看看像不像」。 *

* 另一条钉死的是**积木清单必须完整进提示词**:清单是模型的认识边界, * 少了某个组件的描述,模型就永远不会输出它。 */ class QcReportTemplatePromptBuilderTest { @Test @DisplayName("积木清单完整进提示词:type、显示名、属性 key 一个都不能少") void catalogIsFullySerialized() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("QualityTable"), "清单里的 type 没进提示词,模型不可能输出它"); assertTrue(prompt.contains("检验项目表格"), "组件的显示名没进提示词,模型只能靠 type 猜语义"); assertTrue(prompt.contains("itemsPath"), "属性的 key 没进提示词,模型不知道能填什么"); assertTrue(prompt.contains("检验项数据源"), "属性的显示名没进提示词"); assertTrue(prompt.contains("dataPath"), "属性的类型没进提示词,模型会拿数字填文本字段"); } @Test @DisplayName("五条铁律逐条在场") void ironRulesAreAllPresent() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("只能使用《可用组件清单》里列出的 type"), "缺少「只能用清单内 type」这条"); assertTrue(prompt.contains("严禁输出 HTML"), "缺少禁止 HTML 这条,模型会开始吐标签"); assertTrue(prompt.contains("完全扁平"), "缺少扁平结构这条,模型会输出嵌套的 children"); assertTrue(prompt.contains("未声明的 key 会被丢弃"), "缺少属性白名单这条"); assertTrue(prompt.contains("不要用 HTML 硬凑"), "缺少兜底丢弃这条"); } @Test @DisplayName("两条来自语义层边界的抽取规则在场:itemsPath 固定值、绑定占位符原样保留") void extractionRulesCoverSemanticBoundary() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("inspectionItems"), "缺少 itemsPath 的固定取值指导,模型会把文件里的数据行写进模板——语义层没地方放"); assertTrue(prompt.contains("{{report.reportNo}}") && prompt.contains("原样保留"), "缺少绑定占位符原样保留这条,模型会把 {{report.reportNo}} 替换成样例报告号"); assertTrue(prompt.contains("具体数据行不要写进模板"), "这条一旦缺失,模型吐出来的模板会带着某一份报告的字面数据"); } @Test @DisplayName("落款签署行有豁免:夹在检验表末行也算落款,但人名与日期留空手填") void signatureRowIsCarvedOutOfTheDataRowRule() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("落款**签署行**"), "缺少落款签署行这条:签署行夹在检验表最后一行,只按「具体数据行不要写进模板」会被整行丢掉," + "落款就再也找不回来"); assertTrue(prompt.contains("即使它排在检验表格的最后一行也一样"), "要点名「表格最后一行」这个位置,否则模型仍会把它当成检验数据的一部分"); assertTrue(prompt.contains("人名与日期属于"), "豁免的是栏目不是取值:不写明的话模型会把「检验员:张三」连人带日期刻进模板," + "所有报告都会印成同一个检验员"); assertTrue(prompt.contains("打印出来由人手填"), "留空的目的要说清(打印后手填),否则模型会退回去填它看到的那个名字"); } @Test @DisplayName("禁止自造占位符:模型不知道上下文有哪些字段,编出来的键只会在报告上留白") void inventedPlaceholdersAreForbidden() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("不要自己发明新的绑定键"), "实测模型会给签署行编出 {{report.auditor}} / {{report.date}} 这类不存在的键" + "(上下文里只有 inspector 与 inspectDate,没有 auditor),渲染成空白并触发告警"); } @Test @DisplayName("报告上下文的字段全量进提示词:只禁编造、不给可选集合,等于让模型靠猜") void reportFieldWhitelistIsEnumerated() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("【报告上下文的字段】"), "第 7 条只写「不要发明新键」却不说有哪些键,模型只能猜——" + "实测直连 5 轮,5 轮都编出了 {{report.productionDate}} / {{report.expiryDate}}"); // 逐个字段核对而不是抽查两个:白名单一旦从 ReportFields 反射改成手写常量就会静默过期, // 那时模型照样「不知道」新增字段,用户只会在报告上看到一片空白 for (Field field : ReportFields.class.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers())) { continue; } assertTrue(prompt.contains("{{report." + field.getName() + "}}"), "报告字段 " + field.getName() + " 没进白名单,模型写了它渲染时也取不到值"); } assertFalse(prompt.contains("{{report.productionDate}}"), "白名单里混进了 ReportFields 里不存在的键,等于在教模型编造"); } @Test @DisplayName("输出格式示例在场,且明确禁止 markdown 围栏") void outputFormatExample() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("\"components\""), "缺少输出示例里的 components 字段"); assertTrue(prompt.contains("\"summary\""), "缺少输出示例里的 summary 字段"); assertTrue(prompt.contains("\"ReportFooter\""), "示例里只有顶部组件时,模型自然想不到报告底部还有落款,会把落款写成文本组件"); assertTrue(prompt.contains("不要用 markdown 代码块包裹"), "不写明的话模型常把 JSON 包在 ```json 里,虽然后续切片能兜住,但白白多一层风险"); } @Test @DisplayName("组件选型说明进了提示词,并明确禁止拿通用组件凑数") void componentHintAndSelectionRule() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("报告最顶部的抬头块"), "组件的 hint 没进提示词,模型面对「报告抬头」只会在 Text / Heading / ReportHeader 之间瞎猜"); assertTrue(prompt.contains("选型时以 hint 为准"), "要指明看 hint 这个字段,否则它只是清单里一段不起眼的文字"); assertTrue(prompt.contains("Text / Heading"), "要点名通用组件:只讲专门组件的用途挡不住误用,模型仍会挑字段最简单的那个"); assertTrue(prompt.contains("[页眉]") && prompt.contains("[页脚]"), "docx 抽取出的 [页眉] / [页脚] 标记要在这里被解释,模型才知道那段是每页重复的抬头落款"); } @Test @DisplayName("清单为空或超长时直接抛错,而不是把畸形提示词发出去") void invalidCatalogThrows() { assertThrows(ServiceException.class, () -> QcReportTemplatePromptBuilder.buildSystemPrompt(List.of()), "清单为空时应当报错:模型没有任何积木可选用,发出去只会得到幻觉"); assertThrows(ServiceException.class, () -> QcReportTemplatePromptBuilder.buildSystemPrompt(null)); String big = "x".repeat(QcReportTemplatePromptBuilder.MAX_CATALOG_JSON_LENGTH); List oversized = new ArrayList<>(); for (int i = 0; i < 40; i++) { QcReportComponentSpecVO spec = new QcReportComponentSpecVO(); spec.setType("T" + i); spec.setLabel(big); oversized.add(spec); } assertThrows(ServiceException.class, () -> QcReportTemplatePromptBuilder.buildSystemPrompt(oversized), "清单超长应当报错:撑爆提示词上下文的表现是模型开始忽略后半段,而不是报错"); } @Test @DisplayName("用户消息带来源与补充说明;两者都为空时不出现空行噪音") void userMessage() { String withHint = QcReportTemplatePromptBuilder.buildUserMessage( "这是 IQC 来料检验报告", "扫描件.pdf 第 2 页", "正文内容"); assertTrue(withHint.contains("这是 IQC 来料检验报告")); assertTrue(withHint.contains("扫描件.pdf 第 2 页")); assertTrue(withHint.contains("正文内容")); String bare = QcReportTemplatePromptBuilder.buildUserMessage(null, null, "正文内容"); assertFalse(bare.contains("来源:"), "来源为空时不该留下一个空的「来源:」行"); assertFalse(bare.contains("用户补充说明:")); assertTrue(bare.endsWith("正文内容")); } @Test @DisplayName("列/字段可覆盖的规则在场:列多出默认列时写 columns,两层表头写 headerSpans") void columnOverrideRulesArePresent() { String prompt = QcReportTemplatePromptBuilder.buildSystemPrompt(catalog()); assertTrue(prompt.contains("columns"), "缺少 columns 这条:原件检验表的列比组件默认多时(检测方法/结论/备注),模型会把整列丢掉"); assertTrue(prompt.contains("列标题=绑定表达式"), "语法示例缺失时模型会自己发明一种格式,解析器认不出来,等于没写这条规则"); assertTrue(prompt.contains("{{item.checkMethod}}"), "可用绑定路径的白名单要写出来,否则模型会现编 {{item.method}} 这类取不到值的键," + "报告上只留一片空白"); assertTrue(prompt.contains("不要丢列"), "要明确「列多出来也不要换组件」:上一轮选型不稳的根因就是提示词里多了一处指向平铺表的措辞"); assertTrue(prompt.contains("子项={{item.group.childName}}"), "分组表写 columns 时必须点名保留子项列:实测模型给分组表写自定列时把「子项」整列丢了" + "(3/3 轮都只写 4 列),而分组表的默认列本来就有这一列——覆盖反而比不覆盖更差"); assertTrue(prompt.contains("一漏子项名整列从报告上消失"), "只说「要保留子项列」镇不住模型:要写清漏掉的后果是子项名整列从报告上消失," + "它才会认真数这一列"); assertTrue(prompt.contains("headerSpans") && prompt.contains("标题^跨越列数"), "缺少两层表头这条:原件「检验结果 | 结论」上面那一层会被拍平"); assertTrue(prompt.contains("之和必须等于列数"), "跨列数合计这条不写,模型给的 headerSpans 会在校验里被整层丢掉、退回单层表头"); assertTrue(prompt.contains("检验结果^5|结论^1") && prompt.contains("要**单独写成一段**"), "实测模型会把「结论」列的跨列数并进「检验结果」(写成 检验结果^6)," + "表头就会把结论列画到检验结果底下;要点名结论列单独成段"); assertTrue(prompt.contains("一字不差") && prompt.contains("合出一格跨两行"), "「结论」这类列在原件里是一格跨住上下两层,该段标题必须与列名一字不差才能被认成" + "纵向合并格;不说清这一点,模型写出的上格会被当成另一层分组标题," + "「结论」二字在表头上印两遍——用户看到的就是「多了一个结论」"); assertTrue(prompt.contains("不要自作主张把它改名"), "只说「两边要一字不差」挡不住 1/5 轮:模型会把 columns 里那一列改名成「判定」," + "headerSpans 却还写着原件上的「结论」,两边对不上又退回去多印一个表头。" + "要明说别改名,并给出改名的后果"); assertTrue(prompt.contains("fields"), "缺少 SampleInfo 的 fields 这条:样品信息的字段与默认不一致时仍会丢字段"); assertTrue(prompt.contains("字段名前**不允许**加"), "`#` 的适用范围要写清,否则模型会给样品信息的字段也加上合并标记"); } private static List catalog() { QcReportComponentSpecVO table = new QcReportComponentSpecVO(); table.setType("QualityTable"); table.setLabel("检验项目表格"); table.setCategory("data"); table.setHint("多行检验数据的表格。报告正文最主要的组件。"); QcReportComponentSpecVO.Field itemsPath = new QcReportComponentSpecVO.Field(); itemsPath.setKey("itemsPath"); itemsPath.setLabel("检验项数据源"); itemsPath.setType("dataPath"); itemsPath.setRequired(true); itemsPath.setBindable(true); table.setFields(List.of(itemsPath)); QcReportComponentSpecVO header = new QcReportComponentSpecVO(); header.setType("ReportHeader"); header.setLabel("报告抬头"); header.setCategory("basic"); header.setHint("报告最顶部的抬头块:公司名称、报告标题、报告编号。"); return List.of(header, table); } }