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.Intent; import com.xly.agent.ReActAgent; import com.xly.config.AgentFactory; import com.xly.config.RedisChatMemoryStore; import com.xly.service.AuthzService; import com.xly.service.ConversationService; import com.xly.service.FormResolverService; import com.xly.service.IntentService; import com.xly.service.LedgerService; import com.xly.service.OpService; import com.xly.service.SlotFillService; import com.xly.service.StateService; import com.xly.tool.FormCollectTool; 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.List; 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 流式返回。 * *

编排(架构 §5 意图门 + 确定性路由):每轮先用 {@link IntentService} 做**受约束 JSON** * 的 4 类意图+实体分类,再确定性路由: *

* 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。 * *

帧格式:{@code {"type":"token|reset|done|error"}}、写提议 {@code write_proposal}、澄清 {@code question}、 * 表单收集 {@code form_collect}。确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。 */ @RestController @RequestMapping("/api/agent") public class AgentChatController { private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); private static final Set 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 IntentService intentService; private final SlotFillService slotFill; private final FormResolverService resolver; private final LedgerService ledger; private final StateService state; private final RedisChatMemoryStore memoryStore; private final ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, ConversationService conversations, OpService ops, IntentService intentService, SlotFillService slotFill, FormResolverService resolver, LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; this.conversations = conversations; this.ops = ops; this.intentService = intentService; this.slotFill = slotFill; this.resolver = resolver; this.ledger = ledger; this.state = state; this.memoryStore = memoryStore; } 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)); exec.submit(() -> { try { route(emitter, convId, identity, userInput); } catch (Exception e) { log.error("agent route failed (conv={})", convId, e); send(emitter, "error", "服务异常:" + e.getMessage()); emitter.complete(); } }); return emitter; } public static class FormSubmitReq { public String entity; public Map fields; // 字段中文名 -> 值 public String conversationId; public String authorization; } /** * 确定性表单提交:collectForm 表单的结构化字段**直达** proposeWrite(action=create), * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。 */ @PostMapping("/form/submit") public Map formSubmit(@RequestBody FormSubmitReq req, @RequestHeader(value = "Authorization", required = false) String authHeader) { Map 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 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); } }); String userText = "提交「" + entity + "」新增表单:" + parts; conversations.touch(identity.userId(), convId, userText); ledger.append(convId, "user", Map.of("text", userText)); 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)); state.setActiveDoc(convId, entity, "", opId, "proposed"); appendMemoryTurn(convId, userText, "已生成待确认提议:" + 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)); appendMemoryTurn(convId, userText, err); out.put("error", err); } } catch (Exception e) { out.put("error", "服务异常:" + e.getMessage()); } return out; } /** 意图门 + 确定性路由。表单提交不再走这里——见 {@link #formSubmit}(结构化直达,确定性)。 */ private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { // 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。 String digest = state.digest(convId); Intent it = intentService.classify(userInput, digest); state.recordIntent(convId, it.intent, it.danju); state.mergeEntities(convId, it.entities); log.info("intent(conv={}): {} / {} / entities={} / missing={}", convId, it.intent, it.danju, it.describeEntities(), it.missing); switch (it.intent) { case Intent.CREATE: if (handleCreate(emitter, convId, identity, userInput, it)) { return; } // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), false); return; case Intent.OPERATE: handleWrite(emitter, convId, identity, userInput, it, digest); return; case Intent.QUERY: runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); return; default: // 其他/分类失败:原文交给 agent,尽量不丢能力。门失败时不能顺带关掉反编造护栏。 runAgent(emitter, convId, identity, withState(userInput, digest), it.failed); } } /** 状态槽注入在用户消息尾部(空状态时原样返回,保持 KV 前缀稳定)。 */ private static String withState(String text, String digest) { if (digest == null || digest.isBlank()) { return text; } return text + "\n\n(会话状态,仅供参考:" + digest + ")"; } /** * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 */ private boolean handleCreate(SseEmitter emitter, String convId, AgentIdentity identity, String userInput, Intent it) { String entity = it.danju == null ? "" : it.danju.trim(); if (entity.isEmpty()) { return false; } Map form = resolver.resolveMasterForm(entity); if (form == null) { return false; } String table = String.valueOf(form.get("sDataSource")); // 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。 List> fields = resolver.businessFields(table, 40); Map known = slotFill.buildCreateFields(userInput, entity, fields, it); FormCollectTool fct = agentFactory.formCollectTool(identity); String payload = fct.collectForm(entity, slotFill.toJson(known)); try { JsonNode r = mapper.readTree(payload); if ("form_collect".equals(r.path("type").asText(""))) { sendEvent(emitter, mapper.convertValue(r, Map.class)); String hint = "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"; send(emitter, "token", hint); ledger.append(convId, "form", Map.of("entity", entity, "message", hint)); state.setActiveDoc(convId, entity, "", "", "collecting"); appendMemoryTurn(convId, userInput, "已为「" + entity + "」弹出新建表单,等待用户填写提交。"); send(emitter, "done", ""); emitter.complete(); return true; } // collectForm 返回 error(如无权限/无可填字段)→ 如实反馈,不再兜底重复。 String err = r.path("error").asText(""); if (!err.isBlank()) { send(emitter, "token", err); ledger.append(convId, "assistant", Map.of("text", err)); appendMemoryTurn(convId, userInput, err); send(emitter, "done", ""); emitter.complete(); return true; } } catch (Exception e) { log.warn("handleCreate parse failed (conv={}): {}", convId, e.getMessage()); } return false; } /** 确定性路径不经过 LLM 记忆——把这轮 用户话+系统答复 补进对话记忆,修补记忆空洞。 */ private void appendMemoryTurn(String convId, String userText, String aiText) { try { memoryStore.appendTurn(convId, userText, aiText); } catch (Exception e) { log.warn("append memory turn failed (conv={}): {}", convId, e.getMessage()); } } /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */ private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, String text, boolean queryGuard) { runAgentAttempt(emitter, convId, identity, text, queryGuard, true); } /** * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行): * queryGuard 轮(查询 / 意图门失败)**零工具调用**却答出数字 → 重试一次强制先查数, * 仍复发则标注「未经核实」;任意轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示。 */ private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, String text, boolean queryGuard, boolean allowRetry) { try { ReActAgent agent = agentFactory.build(identity); 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 (queryGuard && 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, true, false); return; } send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); } if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) { send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); } if (!answer.isBlank()) { ledger.append(convId, "assistant", Map.of("text", answer)); } 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; } /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ private String ground(String userInput, Intent it) { StringBuilder g = new StringBuilder(userInput); g.append("\n\n(意图分析,仅供参考:意图=").append(it.intent); if (it.danju != null && !it.danju.isBlank()) { g.append(",单据/实体类型=").append(it.danju); } // 用 角色=值 的形式,避免下游把"值(角色)"整体误当作参数值 StringBuilder pairs = new StringBuilder(); for (Intent.Entity e : it.entities) { if (e == null || e.value == null || e.value.isBlank()) continue; if (pairs.length() > 0) pairs.append(","); pairs.append(e.role == null ? "其他" : e.role).append("=").append(e.value); } if (pairs.length() > 0) { g.append(",识别到 ").append(pairs); } g.append("。调用工具时请用上面的**值**作为参数(不要把角色名或括号写进参数),实体角色不要弄错。)"); return g.toString(); } /** * 「操作已有单据」处理:先用**写槽位抽取**得到 记录/字段/新值(比通用 missing 可靠), * 齐了就确定性调 proposeWrite(它会自定位主表/记录), * 缺了就**确定性问一次**并停下——两头都不进失控循环。 */ private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, String userInput, Intent it, String digest) { Intent.WriteSlots w = intentService.extractWrite(userInput, digest); log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", convId, w.entityType, w.record, w.field, w.newValue); String action = deriveWriteAction(userInput); String ent = pickEntity(w.entityType, it.danju); // 关键信息不全 → 确定性问一次并停下(不进任何 LLM 循环) if ("update".equals(action)) { java.util.List need = new java.util.ArrayList<>(); if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); if (isBlank(w.newValue)) need.add("改成什么新值"); if (!need.isEmpty()) { clarifyWrite(emitter, convId, userInput, "修改", w.record, need); return; } } else if (isBlank(w.record)) { clarifyWrite(emitter, convId, userInput, actionVerb(action), "", java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); return; } // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 String result = agentFactory.proposeWriteTool(identity) .proposeWrite(action, ent, w.record, w.field, w.newValue, null); emitWriteResult(emitter, convId, userInput, ent, w.record, result); } /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ private void emitWriteResult(SseEmitter emitter, String convId, String userInput, String ent, String record, String result) { 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(""); Map card = new LinkedHashMap<>(); card.put("type", "write_proposal"); card.put("opId", opId); card.put("summary", summary); sendEvent(emitter, card); send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); state.setActiveDoc(convId, ent, record, opId, "proposed"); appendMemoryTurn(convId, userInput, "已生成待确认提议:" + summary + "(等待用户点确认/取消)"); } else { String err = r.path("error").asText("无法完成该操作。"); send(emitter, "token", err); ledger.append(convId, "assistant", Map.of("text", err)); appendMemoryTurn(convId, userInput, err); } } catch (Exception e) { send(emitter, "error", "服务异常:" + e.getMessage()); } send(emitter, "done", ""); emitter.complete(); } /** 实体类型:抽取到的太泛(单据/记录/数据/单)时用意图门的 danju 兜底。 */ private static String pickEntity(String entityType, String danju) { boolean generic = entityType == null || entityType.isBlank() || entityType.equals("单据") || entityType.equals("记录") || entityType.equals("数据") || entityType.equals("单"); if (!generic) return entityType.trim(); return (danju != null && !danju.isBlank()) ? danju.trim() : (entityType == null ? "" : entityType.trim()); } /** 从用户原话推出具体写动作(对齐 ERP:作废/复原/销审,而非物理删;「删除」默认=作废,安全可复原)。 */ private String deriveWriteAction(String text) { String t = text == null ? "" : text; if (t.contains("反审核") || t.contains("消审") || t.contains("销审")) return "cancelExamine"; if (t.contains("取消作废") || t.contains("复原") || t.contains("还原") || t.contains("恢复")) return "cancelInvalid"; if (t.contains("作废")) return "invalid"; if (t.contains("物理删除") || t.contains("彻底删除")) return "delete"; if (t.contains("审核") || t.contains("审批")) return "examine"; if (t.contains("删除") || t.contains("删掉") || t.contains("删了")) return "invalid"; return "update"; } private static String actionVerb(String action) { switch (action) { case "invalid": return "作废"; case "cancelInvalid": return "复原"; case "examine": return "审核"; case "cancelExamine": return "销审"; case "delete": return "删除"; default: return "操作"; } } private void clarifyWrite(SseEmitter emitter, String convId, String userInput, String verb, String record, java.util.List need) { String who = isBlank(record) ? "" : ("(记录:" + record + ")"); String text = "要" + verb + who + ",我还需要您补充:" + String.join("、", need) + "。请一起告诉我,我再为你生成待确认的操作。"; send(emitter, "token", text); ledger.append(convId, "clarify", Map.of("text", text)); appendMemoryTurn(convId, userInput, text); send(emitter, "done", ""); emitter.complete(); } private static boolean isBlank(String s) { return s == null || s.isBlank(); } private static String firstNonBlank(String a, String b) { if (a != null && !a.isBlank()) return a; return b; } /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */ 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); String summary = r.path("summary").asText(""); Map card = new LinkedHashMap<>(); card.put("type", "write_proposal"); card.put("opId", opId); card.put("summary", summary); sendEvent(emitter, card); proposed.set(true); ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); state.setActiveDoc(convId, "", summary, opId, "proposed"); } } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { JsonNode r = mapper.readTree(te.result()); String type = r.path("type").asText(""); if ("question".equals(type)) { sendEvent(emitter, mapper.convertValue(r, Map.class)); ledger.append(convId, "question", Map.of( "question", r.path("question").asText(""), "options", mapper.convertValue(r.path("options"), List.class))); } else if ("form_collect".equals(type)) { sendEvent(emitter, mapper.convertValue(r, Map.class)); String entity = r.path("entity").asText(""); ledger.append(convId, "form", Map.of("entity", entity, "message", r.path("message").asText(""))); state.setActiveDoc(convId, entity, "", "", "collecting"); } } else { String digest = te.result().replace('\n', ' ').trim(); if (digest.length() > 100) { digest = digest.substring(0, 100) + "…"; } ledger.append(convId, "tool", Map.of("name", toolName, "digest", digest)); } } 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 m = new LinkedHashMap<>(); m.put("type", type); m.put("content", content); sendEvent(emitter, m); } private void sendEvent(SseEmitter emitter, Map m) { try { emitter.send(SseEmitter.event().data(mapper.writeValueAsString(m), MediaType.APPLICATION_JSON)); } catch (IOException | IllegalStateException e) { // 客户端已断开或 emitter 已结束——忽略。 } } }