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