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 流式返回。 * *

编排:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。 * 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。 * 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*} * 确定性端点、身份内省 fail-closed、工具内鉴权。 * *

反编造护栏(无条件常开):零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件, * 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。 * *

帧格式:{@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 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)); 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 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); } }); 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 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 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 已结束——忽略。 } } }