ProposeWriteTool.java
16.4 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
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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
package com.xly.tool;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.service.AuthzService;
import com.xly.service.ErpClient;
import com.xly.service.OpService;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* ProposeWrite 工具(写操作,人在环)。
*
* <p>**只提议并暂存,绝不立即执行**:把「改某条记录的某字段」解析成具体的 表/记录id/列/新值,
* 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里
* (见 OpController),不经过 LLM。
*/
@Component
public class ProposeWriteTool {
private final ErpClient erp;
private final JdbcTemplate jdbc;
private final OpService ops;
private final ObjectMapper mapper;
private final AuthzService authz;
public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AuthzService authz) {
this.erp = erp;
this.jdbc = jdbc;
this.ops = ops;
this.mapper = mapper;
this.authz = authz;
}
@Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——"
+ "必须等用户在对话内点【确认】后才真正修改。用于「把某个客户/物料的某字段改成X」这类需求。"
+ "直接给出实体类型(如 客户)即可,本工具会自行定位主表,无需先 findForms。")
public String proposeUpdate(
@P("实体/单据类型关键词,如 客户 / 物料 / 供应商") String entityKeyword,
@P("要修改的那条记录的名称关键词(如某个客户名)") String recordKeyword,
@P("要修改的字段中文名(如 简称 / 备注 / 联系电话)") String fieldChinese,
@P("修改后的新值") String newValue) {
if (isBlank(entityKeyword) || isBlank(recordKeyword) || isBlank(fieldChinese)) {
return err("缺少信息:需要实体类型、记录名称关键词、字段中文名、新值。");
}
// 1) 定位可修改的主表(该实体名下、table 类型、最常用的一张)
Map<String, Object> form = resolveForm(entityKeyword.trim());
if (form == null) {
return err("找不到「" + entityKeyword + "」对应的可修改主表。");
}
String formId = str(form.get("sFormId"));
String moduleId = str(form.get("sModuleId"));
String table = str(form.get("sDataSource"));
if (!authz.canAccessModule(moduleId)) {
return err("你没有修改「" + entityKeyword + "」的权限。");
}
// 2) 字段中文名 -> 技术列名
String field = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese=? ORDER BY iFormUses DESC LIMIT 1",
table, fieldChinese.trim());
if (field == null) {
field = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sChinese LIKE ? ORDER BY iFormUses DESC LIMIT 1",
table, "%" + fieldChinese.trim() + "%");
}
if (field == null) {
return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
}
// 3) 定位唯一记录 + 旧值(用名称字段过滤)
String nameField = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +
"ORDER BY iFormUses DESC LIMIT 1", table);
JsonNode root;
try {
root = erp.readForm(formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim());
} catch (Exception e) {
return err("定位记录时读取失败:" + e.getMessage());
}
if (root.path("code").asInt(0) < 0) {
return err("定位记录失败:" + root.path("msg").asText("未知错误"));
}
JsonNode rows = root.path("dataset").path("rows");
JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
int n = (data != null && data.isArray()) ? data.size() : 0;
if (n == 0) {
return err("没有找到名称含「" + recordKeyword + "」的记录,无法修改。");
}
if (n > 1) {
StringBuilder names = new StringBuilder();
for (int i = 0; i < data.size() && i < 5; i++) {
if (i > 0) names.append("、");
names.append(data.get(i).path(nameField == null ? "" : nameField).asText(""));
}
return err("匹配到多条记录(" + names + "),请提供更精确的名称,只改其中一条。");
}
JsonNode rec = data.get(0);
String billId = rec.path("sId").asText(null);
if (isBlank(billId)) {
return err("定位到的记录缺少主键 sId,无法安全修改。");
}
String oldValue = rec.path(field).asText("");
String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword);
// 4) 暂存 draft(不执行)
String description = "将【" + recordName + "】的【" + fieldChinese + "】"
+ (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」";
String opId = ops.createDraft("agent", "update", formId.trim(), moduleId.trim(), table, billId,
field, fieldChinese, oldValue, newValue, description);
Map<String, Object> out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的修改,请在下方点【确认】执行、或【取消】。");
return toJson(out);
}
@Tool("提议**删除**某条现有记录(写操作)。本工具只提议并暂存、绝不立即执行——必须等用户点【确认】后才真正删除。"
+ "删除不可恢复,请慎用。直接给出实体类型与记录名即可。")
public String proposeDelete(
@P("实体类型,如 客户 / 物料 / 供应商") String entityKeyword,
@P("要删除的记录的名称关键词") String recordKeyword) {
if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
return err("缺少实体类型或记录名称。");
}
Map<String, Object> form = resolveForm(entityKeyword.trim());
if (form == null) {
return err("找不到「" + entityKeyword + "」对应的可操作主表。");
}
String formId = str(form.get("sFormId"));
String moduleId = str(form.get("sModuleId"));
String table = str(form.get("sDataSource"));
if (!authz.canAccessModule(moduleId)) {
return err("你没有操作「" + entityKeyword + "」的权限。");
}
String nameField = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +
"ORDER BY iFormUses DESC LIMIT 1", table);
JsonNode root;
try {
root = erp.readForm(formId, moduleId, 1, 5, nameField, recordKeyword.trim());
} catch (Exception e) {
return err("定位记录失败:" + e.getMessage());
}
if (root.path("code").asInt(0) < 0) {
return err("定位记录失败:" + root.path("msg").asText("未知错误"));
}
JsonNode rows = root.path("dataset").path("rows");
JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
int n = (data != null && data.isArray()) ? data.size() : 0;
if (n == 0) {
return err("没有找到名称含「" + recordKeyword + "」的记录。");
}
if (n > 1) {
StringBuilder names = new StringBuilder();
for (int i = 0; i < data.size() && i < 5; i++) {
if (i > 0) names.append("、");
names.append(nameField == null ? "" : data.get(i).path(nameField).asText(""));
}
return err("匹配到多条记录(" + names + "),请提供更精确的名称,只删其中一条。");
}
JsonNode rec = data.get(0);
String billId = rec.path("sId").asText(null);
if (isBlank(billId)) {
return err("定位到的记录缺少主键 sId,无法安全删除。");
}
String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword);
String description = "删除【" + recordName + "】(" + entityKeyword + ")";
String opId = ops.createDraft("agent", "delete", formId, moduleId, table, billId,
null, null, recordName, null, description);
Map<String, Object> out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的删除,请在下方点【确认】执行、或【取消】。删除不可恢复,请谨慎。");
return toJson(out);
}
@Tool("提议**新增**一条记录(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正新增。"
+ "给出实体类型 + 已知字段(JSON:字段中文名->值);主键与必填字段会自动补齐。")
public String proposeCreate(
@P("实体类型,如 客户 / 物料") String entityKeyword,
@P("已知字段的 JSON,键=字段中文名、值=字段值,例如 {\"客户名称\":\"常州测试公司\",\"客户简称\":\"常测\"}") String fieldsJson) {
if (isBlank(entityKeyword)) {
return err("缺少实体类型。");
}
Map<String, Object> form = resolveForm(entityKeyword.trim());
if (form == null) {
return err("找不到「" + entityKeyword + "」对应的可新增主表。");
}
String formId = str(form.get("sFormId"));
String moduleId = str(form.get("sModuleId"));
String table = str(form.get("sDataSource"));
if (!authz.canAccessModule(moduleId)) {
return err("你没有新增「" + entityKeyword + "」的权限。");
}
Map<String, Object> col = new LinkedHashMap<>();
List<String> descParts = new ArrayList<>();
try {
if (!isBlank(fieldsJson)) {
JsonNode fj = mapper.readTree(fieldsJson.trim());
Iterator<Map.Entry<String, JsonNode>> it = fj.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> e = it.next();
String zh = e.getKey();
String v = e.getValue().asText("");
String colName = queryOne(
"SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND (sChinese=? OR sChinese LIKE ?) " +
"ORDER BY iFormUses DESC LIMIT 1", table, zh, "%" + zh + "%");
if (colName != null) {
col.put(colName, v);
descParts.add(zh + "=" + v);
}
}
}
} catch (Exception ex) {
return err("字段 JSON 解析失败:" + ex.getMessage());
}
if (descParts.isEmpty()) {
return err("请至少提供一个有效字段(如客户名称)。");
}
// 自动补齐 NOT-NULL 无默认列(租户/制单人由 ERP 注入,跳过)
for (String rc : requiredCols(table)) {
if (col.containsKey(rc)) {
continue;
}
if ("sId".equals(rc)) {
col.put(rc, erp.newUuid());
} else if (rc.endsWith("Id")) {
String d = commonValue(table, rc); // 外键:取现有最常见值兜底
col.put(rc, d == null ? "" : d);
} else if (rc.endsWith("No")) {
col.put(rc, "AI" + (System.currentTimeMillis() % 1000000000L));
} else {
col.put(rc, "");
}
}
col.putIfAbsent("sId", erp.newUuid());
String payload;
try {
payload = mapper.writeValueAsString(col);
} catch (Exception e) {
return err("内部错误:" + e.getMessage());
}
String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts);
String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description);
Map<String, Object> out = new LinkedHashMap<>();
out.put("opId", opId);
out.put("summary", description);
out.put("message", "已为你生成一条待确认的新增,请在下方点【确认】执行、或【取消】。");
return toJson(out);
}
/** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */
private List<String> requiredCols(String table) {
List<String> out = new ArrayList<>();
try {
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " +
"AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%'", table);
Set<String> skip = Set.of("sBrandsId", "sSubsidiaryId", "sMakePerson");
for (Map<String, Object> r : rows) {
String c = str(r.get("COLUMN_NAME"));
if (c != null && !skip.contains(c)) {
out.add(c);
}
}
} catch (Exception ignore) {
}
return out;
}
/** 外键兜底:该列现有的最常见非空值。列名来自 KG/info_schema(可信)。 */
private String commonValue(String table, String col) {
try {
return queryOne("SELECT `" + col + "` FROM `" + table + "` WHERE `" + col + "` IS NOT NULL AND `" + col +
"`<>'' GROUP BY `" + col + "` ORDER BY COUNT(*) DESC LIMIT 1");
} catch (Exception e) {
return null;
}
}
/** 定位实体的可修改主表:该实体名下 table 类型、最常用(AI工具/连接度)的一张。 */
private Map<String, Object> resolveForm(String entityKeyword) {
try {
List<Map<String, Object>> r = jdbc.queryForList(
"SELECT af.sFormId, af.sModuleId, af.sDataSource FROM viw_ai_useful_forms af " +
"LEFT JOIN viw_kg_form f ON f.sFormId = af.sFormId " +
"WHERE af.sFormTitle LIKE ? AND af.sExecType='table' " +
// 排除报表视图(viw_*),只取可直接改的基础主表
"AND af.sDataSource NOT LIKE 'viw%' " +
"ORDER BY COALESCE(f.bAiTool,0) DESC, " +
"(COALESCE(f.iUpstream,0)+COALESCE(f.iDownstream,0)) DESC, CHAR_LENGTH(af.sFormTitle) ASC LIMIT 1",
"%" + entityKeyword + "%");
return r.isEmpty() ? null : r.get(0);
} catch (Exception e) {
return null;
}
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private String queryOne(String sql, Object... args) {
try {
List<Map<String, Object>> r = jdbc.queryForList(sql, args);
if (!r.isEmpty()) {
Object v = r.get(0).values().iterator().next();
return v == null ? null : v.toString();
}
} catch (Exception ignore) {
}
return null;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private String err(String msg) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("error", msg);
return toJson(m);
}
private String toJson(Map<String, Object> m) {
try {
return mapper.writeValueAsString(m);
} catch (Exception e) {
return "{\"error\":\"内部错误\"}";
}
}
}