5 小时以前 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
245
246
247
package cn.iocoder.yudao.module.qcreport.service.aiimport.llm;
 
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.qcreport.controller.admin.aiimport.vo.QcReportComponentSpecVO;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportFields;
 
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_CATALOG_INVALID;
 
/**
 * 拼「文件 → 模板草稿」的提示词。
 * <p>
 * 纯函数:同样的入参永远给同样的字符串,因此提示词里该有的约束是否在场可以被单测逐条断言,
 * 而不必真的调一次模型去「感觉一下」。
 *
 * <h3>为什么积木清单是入参而不是常量</h3>
 * 组件注册表的唯一真相来源在前端({@code components/quality/index.ts})。后端硬编码一份
 * 就是第二个真相来源,且是最坏的那种:前端加组件、改必填字段时前端立刻生效、后端副本静默过期,
 * 模型随即会编出注册表里根本不存在的 type。所以清单随请求传上来,这里只负责序列化进提示词。
 *
 * <h3>边界没有因此被削弱</h3>
 * 清单进提示词后的唯一出口是「被模型抄成 JSON 字符串」。真正把关的是前端装配器对着活注册表查
 * {@code getQualityComponent}:未注册的 type 在那边会被跳过,产不出任意 HTML。就算有人伪造一份
 * 含 {@code CustomHtml} 的清单,也只会在装配阶段被丢弃。
 */
public class QcReportTemplatePromptBuilder {
 
    /**
     * 积木清单序列化后的长度上限。
     * <p>
     * 清单是「模型能用的积木」的完整描述,正常十几项组件也就几 KB。超过 32KB 说明调用方传错了东西
     * (比如把整个注册表连同实现一起序列化上来),此时直接报错比让提示词悄悄撑爆上下文安全。
     */
    public static final int MAX_CATALOG_JSON_LENGTH = 32 * 1024;
 
    /**
     * 铁律。这是整套设计的地基,不是「建议」——它把模型限制在「只能用已注册组件搭扁平结构」之内。
     */
    private static final String IRON_RULES = """
            【必须遵守的硬性约束】
            1. 你只能使用《可用组件清单》里列出的 type,大小写敏感,一个字都不能改。
            2. 严禁输出 HTML / CSS / script / style / 自定义 div、table 标签。你产出的不是网页,是组件清单。
            3. 结构必须完全扁平:一个 components 数组,元素没有 children、没有嵌套。数组顺序即报告从上到下的顺序。
            4. 每个组件只能填清单里为该 type 声明过的 props 的 key。未声明的 key 会被丢弃;
               不确定的字段不要编造,整个 key 省略即可。
            5. 无法用清单组件表达的内容(自由排版的图形、非标准表格的具体数据行)请丢弃,
               并在 summary 里说明丢弃了什么,不要用 HTML 硬凑。
            """;
 
    /**
     * 抽取规则。前两条直接来自「语义层是刻意收窄的子集」这条边界。
     */
    private static final String EXTRACTION_RULES = """
            【抽取规则】
            1. 只保留报告的**结构**:标题、栏目、表头、字段名。文件里的**具体数据行不要写进模板**——
               凡是带检验项的表格组件,itemsPath 一律填 "inspectionItems",
               真实数据在出件时由业务单据提供。
            2. 形如 {{report.reportNo}}、{{report.conclusion}} 的是**数据绑定占位符**,请原样保留,
               不要替换成你在文件里看到的字面值。
            3. 扫描件/照片可能有多页,页眉、标题、表头在多页重复出现时只保留一份。
            4. 认不出的内容宁可省略也不要猜:省略只会少一个组件,猜错会让用户以为识别对了。
            5. 清单里每一项的 hint 字段写明该组件用来放什么、不要用来放什么,选型时以 hint 为准:
               凡是清单里存在专门组件的内容,就不要退而用 Text / Heading 这类通用组件凑数。
               文件里带 [页眉] / [页脚] 标记的部分是原件每页重复的抬头与落款,同样按 hint 判断。
            6. 报告末尾的落款**签署行**(检验员、审核人、批准人、日期等栏目)不是检验数据,
               即使它排在检验表格的最后一行也一样:请把它取出来放进 ReportFooter,与原件页脚合成一处,
               不要因为「它夹在表格里」就按第 1 条当成数据行丢掉。
               但签署行的人名与日期属于每一份报告各自的数据,不要写死原件上的那几个人——
               请**保留栏目名、值留空**,写成「检验员:__________ 审核人:__________ 日期:__________」
               这样留出填写位即可,打印出来由人手填。
            7. 只保留原件里**本来就有**的 {{...}} 占位符,不要自己发明新的绑定键:
               报告上下文里有哪些字段见上面【报告上下文的字段】,编出来的键渲染时取不到值,
               报告上只会留一片空白和一条「绑定取不到值」的告警。
            8. 表格里的「↑同上」不是内容,而是标注「这一格与**上一行是同一个值」——原件里它是一个
               纵向合并的单元格。请把它当作「合并」来读,不要当成四个字的普通文字写进模板。
            9. 由此可以断定:**同一列里连续出现「↑同上」,这张表就是两层结构**——最上面那个值是
               检验项目名,标了「↑同上」的这几行都是它的子项。这不是猜测,是原件里真实存在的层级,
               因而必须当成事实参与选型:清单里哪一种表格能表达「项目 + 子项」两级,就选哪一种
               (以该组件的 hint 为准)。只放得下项目名的单层检验表会把子项整条丢掉,
               而子项往往才是标准真正考核的对象,丢掉等于报告失真。
               哪怕表里只有一部分项目带子项、另一部分不带,同样按两层结构处理。
            10. 原件的检验表**列比组件默认的多**时(例如还有「检测方法」「结论」「备注」列),
                不要丢列、也不要为了凑列去换组件:把这张表的全部列写进该组件 columns 属性的值里。
                值是一行字符串,每列写作 `列标题=绑定表达式`,列与列之间用 `|` 分隔,例如:
                序号={{index}}|检验项目={{item.itemName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}
                列标题前加 `#` 表示这一列要纵向合并跨住整组(分组表里放组名的那一列)。
                columns 的绑定表达式**只允许用下面这些路径,不许发明新的**:
                {{index}} 行序号、{{item.itemName}} 检验项目名、{{item.group.childName}} 子项名、
                {{item.checkMethod}} 检测方法、{{item.requirement}} 检测要求、{{item.standardValue}} 标准值、
                {{item.actualValue}} 实测值、{{item.unit}} 单位、{{item.resultText}} 判定结论、{{item.remark}} 备注。
                原件的列与清单里该组件 hint 写明的默认列一致时**不要写 columns**,让它用默认列即可。
                给**分组检验项表**写 columns 时另有一条硬要求:原件里放子项名的那一列
                (标了「↑同上」的那几行旁边、写着「20目上」「40目上」这类子项名的列)
                **必须**保留,写成 `子项={{item.group.childName}}`,且**不要**加 `#`;
                分组表的默认列里本来就有这一列,把 columns 写全时最容易漏掉它,
                一漏子项名整列从报告上消失——而子项往往才是标准真正考核的对象。例如:
                #检验项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|结果={{item.actualValue}}|判定={{item.resultText}}
            11. 表头是**两层**的(上面一层是「检验结果」这类分组标题、下面一层才是各列名)时,
                把上面那一层写进该组件 headerSpans 属性的值里,格式同样是 `|` 分隔、
                每个单元格写作 `标题^跨越列数`,例如:检验结果^5|结论^1
                下面那一层由 columns 各列的标题自动拼出,不要重复写。
                各段 `^` 后面的数字之和必须等于列数,对不上就说明写错了,请重新数一遍。
                最右边那一列「结论」/「判定」在原件里不属于上层那个分组标题(它的上方就是「结论」
                二字,不是「检验结果」)时要**单独写成一段**(如 `结论^1`),
                不要图省事把它的列数并进「检验结果」,那样表头会把结论列画进检验结果底下。
                这种列在原件里是**一格纵向合并、自己占满上下两层表头**(提取文本里表现为下层
                表头那一格写着「↑同上」)——它自己既是列名又是上格。写它时该段的标题必须与
                columns 里这一列的列标题**一字不差**:报告认出两者相同时会把它合出一格跨两行;
                一旦标题与列名不一致,就会被当成另一层的新分组标题,「结论」二字在表头上印两遍。
                所以原件那一列叫「结论」,columns 里就照写「结论」,这一段的标题也写「结论」,
                不要自作主张把它改名成「判定」这类近义词——改名之后两边对不上,就会多印一个表头。
            12. 样品信息块(SampleInfo)要展示的字段与清单里该组件的默认字段不一致时,
                把全部字段写进它的 fields 属性,语法与 columns 相同(字段名=绑定表达式,
                多段用 `|` 分隔),例如:
                样品编号={{report.sampleNo}}|产品名称={{report.productName}}|规格={{report.spec}}|批号={{report.batchNo}}|检验日期={{report.inspectDate}}|检验员={{report.inspector}}
                字段名前**不允许**加 `#`(那是分组表放组名那一列专用的)。
                fields 的绑定表达式同样受第 7 条约束:只能写报告上下文里确实存在的字段;
                原件里有、上下文里没有的(如「产品数量」「土豆品种」)请按第 5 条丢弃并在 summary 里说明。
            """;
 
    /**
     * 报告级可绑定字段的白名单,取自 {@link ReportFields} 的字段名。
     * <p>
     * 不手写常量:手写的清单会在别人给报告加字段时静默过期,而模型正是靠这份清单才知道
     * {@code {{report.xxx}}} 里能写什么。清单里少一个键,用户就会在报告上看到一片空白和
     * 一条「绑定取不到值」的告警,且看不出是提示词过期造成的。
     */
    private static final String REPORT_FIELD_KEYS = reportFieldKeys();
 
    /**
     * 报告字段白名单。第 7 条只写「不要自己发明新的绑定键」,却不说究竟有哪些键,
     * 模型就只能靠猜——实测直连 5 轮,5 轮都编出了 {@code {{report.productionDate}}} /
     * {@code {{report.expiryDate}}} 这类上下文里不存在的键。禁止编造的前提是把可选集合摊开给它看。
     */
    private static final String REPORT_FIELDS_RULE = """
            【报告上下文的字段】
            报告上下文里确实存在的字段只有下面这些,不多不少:
            %s
            第 7 条要求「不要自己发明新的绑定键」,说的就是只能从上面这些里挑。
            原件里出现、上面没有的栏目(如「产品数量」「土豆品种」「生产日期」「有效日期」)
            一律不要写进模板,按第 5 条丢弃并在 summary 里说明。
            """;
 
    private static String reportFieldKeys() {
        List<String> keys = new ArrayList<>();
        // 不按类型筛:漏掉一个可绑定的字段(如原始 int 的 total)比多列一个不能绑的字段危险得多——
        // 前者让模型以为这个键不存在、只能靠猜,后者只是多给一个用不上的名字
        for (Field field : ReportFields.class.getDeclaredFields()) {
            if (Modifier.isStatic(field.getModifiers())) {
                continue;
            }
            keys.add("{{report." + field.getName() + "}}");
        }
        return String.join("、", keys);
    }
 
    /**
     * 输出格式的字面示例。
     * <p>
     * 用示例而不是 JSON Schema:本项目从 {@code -api} 够不着 Spring AI 的
     * {@code BeanOutputConverter},只能靠「提示词里给足样子 + 拿到回复后切片反序列化」这条路,
     * 这是既有范式(ERP / CRM / MES 三处 AI 调用)的统一做法。
     */
    private static final String OUTPUT_FORMAT = """
            【输出格式】
            只输出一个 JSON 对象,不要有任何解释文字、不要用 markdown 代码块包裹。字段如下:
            {
              "summary": "一句话说明这份文件是什么报告,以及有没有丢弃无法表达的内容",
              "page": { "size": "A4", "orientation": "portrait" },
              "components": [
                { "type": "ReportHeader", "props": { "title": "来料检验报告" } },
                { "type": "QualityTable", "props": { "itemsPath": "inspectionItems" } },
                { "type": "ReportFooter", "props": {} }
              ]
            }
            说明:page 可以省略(省略表示沿用当前模板的纸张);size 取值 A3/A4/A5/Letter,
            orientation 取值 portrait/landscape;components 至少要有一项。
            上面示例里的 type **只是格式示意**,选型一律以《可用组件清单》里各组件 hint 为准,
            不要因为示例里写了某个 type 就照抄它。
            """;
 
    private QcReportTemplatePromptBuilder() {
    }
 
    /**
     * 拼系统提示词。
     *
     * @param catalog 可用组件积木清单,由前端从活注册表生成
     * @return 系统提示词
     * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 清单为空或序列化后超长时
     */
    public static String buildSystemPrompt(List<QcReportComponentSpecVO> catalog) {
        if (catalog == null || catalog.isEmpty()) {
            throw exception(AI_IMPORT_CATALOG_INVALID, "组件清单为空");
        }
        String catalogJson = JSONUtil.toJsonStr(catalog);
        if (catalogJson.length() > MAX_CATALOG_JSON_LENGTH) {
            throw exception(AI_IMPORT_CATALOG_INVALID,
                    StrUtil.format("组件清单序列化后 {} 字符,超过上限 {} 字符",
                            catalogJson.length(), MAX_CATALOG_JSON_LENGTH));
        }
        return """
                你是一名质检报告模板结构分析助手。用户会给你一份已有的检验报告文件内容(可能是文本,
                也可能是扫描件/照片),你要把它拆解成《可用组件清单》里的组件,供用户在设计器里确认后使用。
 
                %s
                【可用组件清单】
                下面每个 type 就是一个可用积木;fields 里声明了该积木允许填写的属性。
                %s
 
                %s
 
                %s
 
                %s
                """.formatted(IRON_RULES, catalogJson,
                REPORT_FIELDS_RULE.formatted(REPORT_FIELD_KEYS), EXTRACTION_RULES, OUTPUT_FORMAT);
    }
 
    /**
     * 拼用户消息。
     *
     * @param hint        用户补充说明,可为空
     * @param sourceLabel 来源标签(文件名 / 文件名+页码),便于模型理解上下文
     * @param content     文档正文(TEXT 通道)或对图片的识别指令(IMAGE 通道)
     * @return 用户消息
     */
    public static String buildUserMessage(String hint, String sourceLabel, String content) {
        StringBuilder sb = new StringBuilder();
        if (StrUtil.isNotBlank(sourceLabel)) {
            sb.append("来源:").append(sourceLabel).append('\n');
        }
        if (StrUtil.isNotBlank(hint)) {
            sb.append("用户补充说明:").append(hint).append('\n');
        }
        sb.append("以下是文件内容,请按系统提示的规则输出 JSON:\n");
        sb.append(content);
        return sb.toString();
    }
 
}