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; /** * 拼「文件 → 模板草稿」的提示词。 *
* 纯函数:同样的入参永远给同样的字符串,因此提示词里该有的约束是否在场可以被单测逐条断言, * 而不必真的调一次模型去「感觉一下」。 * *
* 清单是「模型能用的积木」的完整描述,正常十几项组件也就几 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} 的字段名。 *
* 不手写常量:手写的清单会在别人给报告加字段时静默过期,而模型正是靠这份清单才知道
* {@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
* 用示例而不是 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