6 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
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);
    }
 
}