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;
|
|
/**
|
* 提示词构造。
|
* <p>
|
* 守的是「约束有没有被写进去」这件事本身。提示词是这套 AI 导入的唯一防线中最靠前的一环:
|
* 铁律少写一条,模型就会开始输出 HTML 或嵌套结构,而那时下游的过滤只能丢掉结果、
|
* 无法把丢掉的正确内容找回来。所以逐条断言,而不是靠「跑一次看看像不像」。
|
* <p>
|
* 另一条钉死的是**积木清单必须完整进提示词**:清单是模型的认识边界,
|
* 少了某个组件的描述,模型就永远不会输出它。
|
*/
|
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<QcReportComponentSpecVO> 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<QcReportComponentSpecVO> 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);
|
}
|
|
}
|