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();
|
}
|
|
}
|