AgentChatController.java
13.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
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.PreviewService;
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/previewChange/askUser,
* **全部只读/只渲染**)。流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」
* 卡上跨轮续办)。写路径:previewChange/collectForm 只出卡片,用户点卡上按钮 → 确定性端点
* (/api/agent/preview/{id}/save、/api/agent/form/submit)校验后写 ai_op_queue(AI 侧唯一写入口),
* 执行由 ERP 侧负责。安全不变量在工具与端点内:身份内省 fail-closed、工具内鉴权、token 不进 prompt。
*
* <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
* 前端不显示),复发则标注未核实;声称「已保存/已提交/已完成」却没出过预览卡/表单 → 附纠正提示。
*
* <p>帧格式:{@code {"type":"token|reset|done|error"}}、预览卡 {@code change_preview}、澄清
* {@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> CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm");
/** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。 */
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 PreviewService previews;
private final LedgerService ledger;
private final ExecutorService exec = Executors.newCachedThreadPool();
public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
ConversationService conversations, PreviewService previews, LedgerService ledger) {
this.agentFactory = agentFactory;
this.authz = authz;
this.mapper = mapper;
this.conversations = conversations;
this.previews = previews;
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 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue
* (用户点【保存】= 已当面授权 → sStatus=confirmed)。**xlyAi 到此为止**,执行由 ERP 侧负责。
* 同步返回 {queued,opId,summary,message} 或 {error}。
*/
@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()), identity);
try {
Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields);
if (r.containsKey("error")) {
ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity);
}
return r;
} catch (Exception e) {
log.warn("form submit failed (conv={})", convId, e);
out.put("error", "服务异常:" + e.getMessage());
return out;
}
}
/**
* 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
* tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
* 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已保存/已提交但本轮
* 没出过预览卡/表单 → 附纠正提示。
*/
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 carded = new AtomicBoolean(false);
TokenStream ts = agent.chat(convId, text);
ts.onPartialResponse(token -> send(emitter, "token", token))
.onToolExecuted(te -> {
toolCalls.incrementAndGet();
handleToolExecuted(emitter, te, carded);
})
.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 (!carded.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);按工具类型推对应卡片/控件事件
* (change_preview / question / form_collect)。落账由 EventLogChatMemory 统一完成
* (tool_call/tool_result 事件),这里只做 SSE 推送。
*/
private void handleToolExecuted(SseEmitter emitter, ToolExecution te, AtomicBoolean carded) {
send(emitter, "reset", "");
try {
String toolName = te.request() == null ? "" : te.request().name();
if (te.result() == null || !CARD_TOOLS.contains(toolName)) {
return;
}
JsonNode r = mapper.readTree(te.result());
String type = r.path("type").asText("");
if ("change_preview".equals(type) || "question".equals(type) || "form_collect".equals(type)) {
sendEvent(emitter, mapper.convertValue(r, Map.class));
if ("change_preview".equals(type) || "form_collect".equals(type)) {
carded.set(true);
}
}
} 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 已结束——忽略。
}
}
}