AgentChatController.java
14.3 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
package com.xly.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.agent.ReActAgent;
import com.xly.config.AgentFactory;
import com.xly.service.AuthzService;
import com.xly.service.ConversationService;
import com.xly.service.LedgerService;
import com.xly.service.OpService;
import dev.langchain4j.service.TokenStream;
import dev.langchain4j.service.tool.ToolExecution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
/**
* 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
*
* <p><b>编排</b>:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent
* (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。
* 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。
* 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*}
* 确定性端点、身份内省 fail-closed、工具内鉴权。
*
* <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
* 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。
*
* <p>帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清
* {@code question}、表单收集 {@code form_collect}。确认/取消与表单提交走确定性端点,不经过 LLM。
*/
@RestController
@RequestMapping("/api/agent")
public class AgentChatController {
private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite");
/** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */
private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)");
private final AgentFactory agentFactory;
private final AuthzService authz;
private final ObjectMapper mapper;
private final ConversationService conversations;
private final OpService ops;
private final LedgerService ledger;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
ConversationService conversations, OpService ops, LedgerService ledger) {
this.agentFactory = agentFactory;
this.authz = authz;
this.mapper = mapper;
this.conversations = conversations;
this.ops = ops;
this.ledger = ledger;
}
public static class ChatReq {
public String text;
public String conversationId;
/** 透传的 ERP 会话 token(token 绝不进 prompt)。身份一律由它内省得出,不接受客户端自报。 */
public String authorization;
}
@PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
public SseEmitter chat(@RequestBody ChatReq req,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
SseEmitter emitter = new SseEmitter(180_000L);
final String userInput = req.text == null ? "" : req.text;
final AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
if (identity == null) {
send(emitter, "error", "登录已过期或未登录,请重新登录后再试。");
emitter.complete();
return emitter;
}
// 会话键绑定服务端身份 → 换个 conversationId 只能命中自己的会话,碰不到别人的记忆
final String convId = conversations.scopedId(identity, req.conversationId);
conversations.touch(identity.userId(), convId, userInput);
ledger.append(convId, "user", Map.of("text", userInput), identity);
exec.submit(() -> {
try {
runAgentAttempt(emitter, convId, identity, userInput, true);
} catch (Exception e) {
log.error("agent chat failed (conv={})", convId, e);
send(emitter, "error", "服务异常:" + e.getMessage());
emitter.complete();
}
});
return emitter;
}
public static class FormSubmitReq {
public String entity;
public Map<String, String> fields; // 字段中文名 -> 值
public String conversationId;
public String authorization;
}
/**
* 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create),
* 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
*/
@PostMapping("/form/submit")
public Map<String, Object> formSubmit(@RequestBody FormSubmitReq req,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map<String, Object> out = new LinkedHashMap<>();
AgentIdentity identity = authz.resolveIdentity(firstNonBlank(authHeader, req.authorization));
if (identity == null) {
out.put("error", "登录已过期或未登录,请重新登录后再试。");
return out;
}
final String convId = conversations.scopedId(identity, req.conversationId);
String entity = req.entity == null ? "" : req.entity.trim();
Map<String, String> fields = req.fields == null ? Map.of() : req.fields;
if (entity.isEmpty() || fields.isEmpty()) {
out.put("error", "缺少单据类型或表单字段。");
return out;
}
StringBuilder parts = new StringBuilder();
fields.forEach((k, v) -> {
if (v != null && !v.isBlank()) {
if (parts.length() > 0) parts.append(",");
parts.append(k).append("=").append(v);
}
});
conversations.touch(identity.userId(), convId, "提交「" + entity + "」新增表单");
ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()));
String fieldsJson;
try {
fieldsJson = mapper.writeValueAsString(fields);
} catch (Exception e) {
out.put("error", "字段序列化失败:" + e.getMessage());
return out;
}
String result = agentFactory.proposeWriteTool(identity)
.proposeWrite("create", entity, null, null, null, fieldsJson);
try {
JsonNode r = mapper.readTree(result);
String opId = r.path("opId").asText(null);
if (opId != null && !opId.isBlank()) {
ops.attachConversation(opId, convId);
String summary = r.path("summary").asText("");
ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
out.put("opId", opId);
out.put("summary", summary);
out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
} else {
String err = r.path("error").asText("无法完成该新增。");
ledger.append(convId, "assistant", Map.of("text", err));
out.put("error", err);
}
} catch (Exception e) {
out.put("error", "服务异常:" + e.getMessage());
}
return out;
}
/**
* 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
* tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
* 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已完成但没
* proposeWrite → 附纠正提示。
*/
private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
String text, boolean allowRetry) {
try {
// 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
ReActAgent agent = agentFactory.build(identity, convId, !allowRetry);
AtomicInteger toolCalls = new AtomicInteger();
AtomicBoolean proposed = new AtomicBoolean(false);
TokenStream ts = agent.chat(convId, text);
ts.onPartialResponse(token -> send(emitter, "token", token))
.onToolExecuted(te -> {
toolCalls.incrementAndGet();
handleToolExecuted(emitter, convId, te, proposed);
})
.onCompleteResponse(resp -> {
String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null
? "" : resp.aiMessage().text();
if (toolCalls.get() == 0 && hasDigits(answer)) {
if (allowRetry) {
log.warn("anti-fab retry (conv={}): zero tools + digits", convId);
send(emitter, "reset", "");
runAgentAttempt(emitter, convId, identity,
"你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text,
false);
return;
}
send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。");
}
if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。");
}
// 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写
send(emitter, "done", "");
emitter.complete();
})
.onError(err -> {
log.warn("agent chat stream error (conv={})", convId, err);
send(emitter, "error", err.getMessage() == null ? "服务异常" : err.getMessage());
emitter.complete();
})
.start();
} catch (Exception e) {
log.error("agent invoke failed (conv={})", convId, e);
send(emitter, "error", "服务异常:" + e.getMessage());
emitter.complete();
}
}
private static boolean hasDigits(String s) {
if (s == null) {
return false;
}
for (int i = 0; i < s.length(); i++) {
if (Character.isDigit(s.charAt(i))) {
return true;
}
}
return false;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.isBlank()) return a;
return b;
}
/**
* 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件。
* 落账由 EventLogChatMemory 统一完成(tool_call/tool_result 事件),这里只做 SSE 与 op 绑定。
*/
private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) {
send(emitter, "reset", "");
try {
String toolName = te.request() == null ? "" : te.request().name();
if (te.result() == null) {
return;
}
if (WRITE_TOOLS.contains(toolName)) {
JsonNode r = mapper.readTree(te.result());
String opId = r.path("opId").asText(null);
if (opId != null && !opId.isBlank()) {
ops.attachConversation(opId, convId);
Map<String, Object> card = new LinkedHashMap<>();
card.put("type", "write_proposal");
card.put("opId", opId);
card.put("summary", r.path("summary").asText(""));
sendEvent(emitter, card);
proposed.set(true);
}
} else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
JsonNode r = mapper.readTree(te.result());
String type = r.path("type").asText("");
if ("question".equals(type) || "form_collect".equals(type)) {
sendEvent(emitter, mapper.convertValue(r, Map.class));
}
}
} catch (Exception e) {
log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e);
}
}
private void send(SseEmitter emitter, String type, String content) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("type", type);
m.put("content", content);
sendEvent(emitter, m);
}
private void sendEvent(SseEmitter emitter, Map<String, Object> m) {
try {
emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException e) {
// 客户端已断开或 emitter 已结束——忽略。
}
}
}