SystemPromptService.java
9.68 KB
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
package com.xly.service;
import com.xly.agent.ToolScope;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 构建 agent 的 system prompt——**按工具范围(ToolScope)分版**。
*
* <p>新架构里,控制器先用意图门({@code IntentService})判类,再把「查询/写」分派给只暴露相关工具的 agent。
* 因此系统提示也分版:READ 版只讲读工具 + 业务域地图;WRITE 版只讲写工具 + 人在环规则。
* 每版都遵循弱模型提示工程:关键规则前置、正向表述、工具清单短、去掉会误导的示例。
*/
@Service
public class SystemPromptService {
private final JdbcTemplate jdbc;
private final SkillService skills;
private volatile String readPrompt;
private volatile String writePrompt;
private volatile String fullPrompt;
public SystemPromptService(JdbcTemplate jdbc, SkillService skills) {
this.jdbc = jdbc;
this.skills = skills;
}
private static final String HEADER =
"【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,"
+ "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n"
+ "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n";
public String buildPrompt(ToolScope scope) {
switch (scope) {
case READ:
if (readPrompt == null) readPrompt = renderRead();
return readPrompt;
case WRITE:
if (writePrompt == null) writePrompt = renderWrite();
return writePrompt;
default:
if (fullPrompt == null) fullPrompt = renderFull();
return fullPrompt;
}
}
private String renderRead() {
return HEADER + """
本轮任务是**查询 / 读取**数据(不做任何写操作)。
【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】
%s
【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做:
%s
【可用工具(只有这些)】
- findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
- kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。
- readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。
- lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问"某记录的某字段"优先用它。
- queryData(question):没有现成表单能直接答的临时统计/排名/计数/分组,用只读 SQL 回答。仅当上面两个都答不了时才用。
- loadSkill(name)、askUser(question, options?)。
【准则】
1. 典型流程:findForms 定位 → readFormData 读 → 如实汇报(可小结总条数、列前几条)。同名多张表单优先选检索结果靠前那张(通常是主表,数据源常以 ele 开头)。绝不编造表单名或数据。
2. 信息足够就**直接读取并如实回答**,不要无谓反问;只有确实缺关键参数(如不知道查哪张单据)时才用 askUser 问**一次**。
3. 回答面向业务人员:**用记录名称而不是内部ID**——若结果里是一串 ID(如产品/客户ID),要转成对应名称再给用户,不要把裸 ID 丢给用户。
""".formatted(renderDomainMap(), renderSkills());
}
private String renderWrite() {
return HEADER + """
本轮任务是**写操作**(新增/修改/删除/审核)。**一律人在环**:所有写工具只生成"待确认提议"、
绝不立即执行;必须用户在对话内点【确认】后才真正写入。你**绝不能声称已经完成/已写入**。
【可用工具(只有这些)】
- collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。客户/产品/物料会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,用户提交后你再用 proposeWrite(action=create)。
- proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**,action 指定动作:
· create=新增(用 fieldsJson,通常在用户表单提交后)
· update=改某字段(用 recordKeyword+fieldChinese+newValue)
· invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废
· examine=审核;cancelExamine=销审/反审核
· delete=物理删除(明细行/极少用,业务单据别用)
它自行定位主表与记录,无需先 findForms。
- lookupRecord(entityKeyword, recordKeyword):定位记录时可先用它确认对象。
- askUser(question, options?)、loadSkill(name)。
【准则】
1. **实体角色不能错**:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,绝不是客户;只有明确的购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选或换个名字,**绝不新建不存在的客户**。
2. 修改/作废/审核等:信息足够就直接调 proposeWrite 对应 action(它会自行定位主表和记录,无需先 findForms)。若缺"具体记录名"或"新值",用 askUser 问**一次**、然后停下等用户回答——**不要反复追问同一个问题**。删除业务单据用 action=invalid(作废),不要用 delete。
3. proposeWrite 只生成一条待确认提议;生成后就停下、提示用户点【确认】,不要继续调别的工具。
""".formatted();
}
private String renderFull() {
// 兜底:读写工具都给,规则合并(意图门失败时用,尽量少走到这里)。
return HEADER + """
【业务域地图】
%s
【可用技能】命中「何时用」时先 loadSkill(技能名):
%s
【可用工具】读:findForms/kgSearch/readFormData/lookupRecord/queryData;
写(人在环,仅提议):collectForm,以及唯一的写工具
proposeWrite(action=create|update|invalid|cancelInvalid|examine|cancelExamine|delete, ...);
以及 askUser/loadSkill。
【准则】
1. 先判断是查询还是写操作。查询→findForms 定位→readFormData/lookupRecord/queryData 读并如实回答,用名称而非裸ID。
2. 写操作一律人在环:只生成待确认提议、绝不声称已完成。新增报价先 collectForm,用户提交后再 proposeWrite(action=create)。删除业务单据用 action=invalid(作废),不要用 delete。
3. 实体角色不能错:要报价的物品是产品不是客户;客户/产品/物料要用系统里已有的真实记录,找不到让用户从下拉选,绝不新建不存在的客户,也不要把产品/规格当客户。
4. 缺关键参数时 askUser 问一次即停,不要反复追问;绝不编造表单名/单号/数据。
""".formatted(renderDomainMap(), renderSkills());
}
private String renderSkills() {
String digest = skills.promptDigest();
return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest;
}
private String renderDomainMap() {
List<Map<String, Object>> rows;
try {
rows = jdbc.queryForList(
"SELECT sDomain, sAiScene, iForms, sDownstreamDomains, sUpstreamDomains " +
"FROM viw_kg_domain ORDER BY iForms DESC");
} catch (Exception e) {
org.slf4j.LoggerFactory.getLogger(SystemPromptService.class)
.warn("加载 L1 业务域地图失败(viw_kg_domain 不可用),system prompt 将不含域图:{}", e.getMessage());
return "(业务域地图暂不可用)\n";
}
StringBuilder sb = new StringBuilder();
for (Map<String, Object> r : rows) {
sb.append("- ").append(r.get("sDomain"))
.append("(").append(r.get("sAiScene")).append(",")
.append(r.get("iForms")).append(" 张单据)");
String up = str(r.get("sUpstreamDomains"));
String down = str(r.get("sDownstreamDomains"));
if (notEmpty(up)) sb.append(" 上游←[").append(up).append("]");
if (notEmpty(down)) sb.append(" 下游→[").append(down).append("]");
sb.append('\n');
}
return sb.toString();
}
private static boolean notEmpty(String s) {
return s != null && !s.isBlank() && !"NULL".equalsIgnoreCase(s);
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
}