FormCollectTool.java
10.2 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
package com.xly.tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.service.FormRenderService;
import com.xly.service.FormResolverService;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* FormCollect 工具(LLM 层的「收集信息」工具)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
* 而不是逐字段追问。
*
* <p>表单骨架来自 {@link FormRenderService#formSkeleton}(与 previewChange 预览共用同一实现层);
* 本类负责预填:用户已说的值(含尺寸表达的确定性拆分)对齐到表单字段。
*
* <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推
* SSE {@code form_collect} 事件,前端渲染表单;用户填完点【保存】→ 确定性端点
* {@code /api/agent/form/submit} 校验并写 ai_op_queue(AI 侧唯一写入口),不再经过 LLM。
*
* <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。
*/
public class FormCollectTool {
// 尺寸拆分正则(实测模型拆不对「长和宽50cm,高5cm」这类表达,必须由代码确定性处理)
// 标注式:"长和宽50"、"高5cm"、"长:50"
private static final Pattern DIM_LABELED =
Pattern.compile("([长宽高厚深](?:[和与、,,及/][长宽高厚深])*)\\s*[::是为]?\\s*(\\d+(?:\\.\\d+)?)");
// 位置式:"50*30*5"、"50x30x5"、"50×30×5"
private static final Pattern DIM_POS =
Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
private final FormRenderService render;
private final FormResolverService resolver;
private final AgentIdentity identity;
private final ObjectMapper mapper;
public FormCollectTool(FormRenderService render, FormResolverService resolver,
AgentIdentity identity, ObjectMapper mapper) {
this.render = render;
this.resolver = resolver;
this.identity = identity;
this.mapper = mapper;
}
@Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问、更不是自己猜)。用于字段较多的新建/录入场景"
+ "(尤其**新建报价**)。entityKeyword = 单据类型(如 报价 / 客户);knownFieldsJson = 用户已说的字段(中文名->值)用于预填"
+ "(如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\",\"长(L)\":\"50\"})。客户/产品/物料会渲染成下拉让用户从真实数据里选。"
+ "用户填完点【保存】后,系统自动校验并提交待办——你**无需**再做任何写操作,也绝不能说已保存。")
public String collectForm(
@P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword,
@P(value = "可选:用户已说的字段 JSON(中文名->值),用于预填表单,如 {\"产品名称\":\"纸盒\",\"数量\":\"1000\"}",
required = false) String knownFieldsJson) {
if (entityKeyword == null || entityKeyword.isBlank()) {
return err("缺少实体类型。");
}
Map<String, String> known = parseKnown(knownFieldsJson);
expandDimensions(known);
Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
if (form == null) {
return err("找不到「" + entityKeyword + "」对应的可新建表单。");
}
String formId = String.valueOf(form.get("sFormId"));
String moduleId = String.valueOf(form.get("sModuleId"));
if (!identity.canAccessModule(moduleId)) {
return err("你没有新建「" + entityKeyword + "」的权限。");
}
String table = String.valueOf(form.get("sDataSource"));
List<Map<String, Object>> fields = render.formSkeleton(table, formId);
if (fields.isEmpty()) {
return err("该表单没有可填写的业务字段。");
}
applyKnownDefaults(known, fields);
Map<String, Object> out = new LinkedHashMap<>();
out.put("type", "form_collect");
out.put("entity", entityKeyword.trim());
out.put("formId", formId);
out.put("moduleId", moduleId);
out.put("title", "新建" + entityKeyword.trim());
out.put("fields", fields);
out.put("message", "请在下方表单里填写,填完点【保存】。");
return toJson(out);
}
/** 解析预填 JSON(中文名->值),键归一化(去括号/空白)。 */
private Map<String, String> parseKnown(String json) {
Map<String, String> m = new HashMap<>();
if (json == null || json.isBlank()) {
return m;
}
try {
com.fasterxml.jackson.databind.JsonNode n = mapper.readTree(json.trim());
Iterator<Map.Entry<String, com.fasterxml.jackson.databind.JsonNode>> it = n.fields();
while (it.hasNext()) {
Map.Entry<String, com.fasterxml.jackson.databind.JsonNode> e = it.next();
String v = e.getValue().asText("");
if (v != null && !v.isBlank()) {
m.put(normLabel(e.getKey()), v);
}
}
} catch (Exception ignore) {
}
return m;
}
private static String normLabel(String s) {
return FormRenderService.normLabel(s);
}
/**
* 把用户已说的值预填进表单字段。键与字段标签先按归一化全等匹配;对不上再做**宽松对齐**
* (「客户」→「客户名称」这类互含关系),多个候选字段时用确定性决胜:唯一的外键下拉字段
* 或唯一以「名称」结尾的字段,仍二义则放弃预填(宁缺勿错,用户在表单里自己填)。
*/
static void applyKnownDefaults(Map<String, String> known, List<Map<String, Object>> fields) {
List<String> normLabels = new ArrayList<>();
for (Map<String, Object> f : fields) {
normLabels.add(normLabel(String.valueOf(f.get("label"))));
}
Map<String, String> aligned = new HashMap<>();
for (Map.Entry<String, String> e : known.entrySet()) {
String key = e.getKey();
if (key.isEmpty() || e.getValue() == null || e.getValue().isBlank()) {
continue;
}
if (normLabels.contains(key)) {
aligned.put(key, e.getValue());
continue;
}
List<Integer> hits = new ArrayList<>();
for (int i = 0; i < normLabels.size(); i++) {
String l = normLabels.get(i);
if (!l.isEmpty() && (l.contains(key) || key.contains(l))) {
hits.add(i);
}
}
Integer pick = null;
if (hits.size() == 1) {
pick = hits.get(0);
} else if (hits.size() > 1) {
List<Integer> fk = hits.stream()
.filter(i -> "fkselect".equals(fields.get(i).get("type"))).toList();
List<Integer> named = hits.stream()
.filter(i -> normLabels.get(i).endsWith("名称")).toList();
if (fk.size() == 1) {
pick = fk.get(0);
} else if (named.size() == 1) {
pick = named.get(0);
}
}
if (pick != null) {
aligned.putIfAbsent(normLabels.get(pick), e.getValue());
}
}
for (int i = 0; i < fields.size(); i++) {
String pre = aligned.get(normLabels.get(i));
if (pre != null && !pre.isBlank()) {
fields.get(i).put("default", pre); // 用户说过的值优先于表单默认值
}
}
}
/**
* 预填值里的尺寸表达(「50*30*5」「长和宽50,高5cm」)确定性拆成 长/宽/高 三个键
* (模型拆不对是实测结论——原 SlotFillService 的正则迁于此)。已有的 长/宽/高 预填不覆盖。
*/
static void expandDimensions(Map<String, String> known) {
Map<String, String> dims = new LinkedHashMap<>();
for (String v : known.values()) {
if (v == null || v.isBlank()) {
continue;
}
Matcher m = DIM_LABELED.matcher(v);
boolean any = false;
while (m.find()) {
String labels = m.group(1);
String val = m.group(2);
for (int i = 0; i < labels.length(); i++) {
String key = dimKey(labels.charAt(i));
if (key != null) {
dims.putIfAbsent(key, val);
any = true;
}
}
}
if (!any) {
Matcher p = DIM_POS.matcher(v);
if (p.find()) {
dims.putIfAbsent("长", p.group(1));
dims.putIfAbsent("宽", p.group(2));
if (p.group(3) != null) {
dims.putIfAbsent("高", p.group(3));
}
}
}
}
dims.forEach(known::putIfAbsent);
}
private static String dimKey(char c) {
switch (c) {
case '长': return "长";
case '宽': return "宽";
case '高':
case '厚':
case '深': return "高";
default: return null;
}
}
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\":\"内部错误\"}";
}
}
}