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.service.AuthzService; import com.xly.service.ConversationService; import com.xly.service.FormResolverService; import com.xly.service.IntentService; import com.xly.service.OpService; import com.xly.service.SlotFillService; 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.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; /** * 单 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"); /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)"; 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 ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, ConversationService conversations, OpService ops, IntentService intentService, SlotFillService slotFill, FormResolverService resolver) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; this.conversations = conversations; this.ops = ops; this.intentService = intentService; this.slotFill = slotFill; this.resolver = resolver; } public static class ChatReq { public String text; public String userid; public String conversationId; // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt) public String authorization; public String brandsid; public String subsidiaryid; public String usertype; } @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") public SseEmitter chat(@RequestBody ChatReq req) { SseEmitter emitter = new SseEmitter(180_000L); final String userInput = req.text == null ? "" : req.text; final String convId = (req.conversationId != null && !req.conversationId.isBlank()) ? req.conversationId : ((req.userid == null ? "anon" : req.userid) + ":default"); conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); final AgentIdentity identity = resolveIdentity(req); 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; } /** 意图门 + 确定性路由。 */ private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 if (userInput.contains(FORM_SUBMIT_MARK)) { runAgent(emitter, convId, identity, userInput); return; } // 1) 意图门(失败时返回 其他,走兜底)。 Intent it = intentService.classify(userInput); 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, ground(userInput, it)); return; case Intent.OPERATE: handleWrite(emitter, convId, identity, userInput, it); return; case Intent.QUERY: runAgent(emitter, convId, identity, ground(userInput, it)); return; default: // 其他/分类失败:原文交给 agent,尽量不丢能力。 runAgent(emitter, convId, identity, userInput); } } /** * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 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)); send(emitter, "token", "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"); send(emitter, "done", ""); emitter.complete(); return true; } // collectForm 返回 error(如无权限/无可填字段)→ 如实反馈,不再兜底重复。 String err = r.path("error").asText(""); if (!err.isBlank()) { send(emitter, "token", err); send(emitter, "done", ""); emitter.complete(); return true; } } catch (Exception e) { log.warn("handleCreate parse failed (conv={}): {}", convId, e.getMessage()); } return false; } /** 运行 ReAct agent,把流式回调转成 SSE。 */ private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, String text) { try { ReActAgent agent = agentFactory.build(identity); TokenStream ts = agent.chat(convId, text); ts.onPartialResponse(token -> send(emitter, "token", token)) .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) .onCompleteResponse(resp -> { 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(); } } /** 把意图门结果作为 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) { Intent.WriteSlots w = intentService.extractWrite(userInput); 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, "修改", w.record, need); return; } } else if (isBlank(w.record)) { clarifyWrite(emitter, 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, result); } /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ private void emitWriteResult(SseEmitter emitter, String convId, String result) { try { JsonNode r = mapper.readTree(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); send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); } else { send(emitter, "token", r.path("error").asText("无法完成该操作。")); } } 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 verb, String record, java.util.List need) { String who = isBlank(record) ? "" : ("(记录:" + record + ")"); send(emitter, "token", "要" + verb + who + ",我还需要您补充:" + String.join("、", need) + "。请一起告诉我,我再为你生成待确认的操作。"); send(emitter, "done", ""); emitter.complete(); } private static boolean isBlank(String s) { return s == null || s.isBlank(); } /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */ private AgentIdentity resolveIdentity(ChatReq req) { try { if (req.authorization != null && !req.authorization.isBlank()) { return authz.userIdentity(req.authorization.trim(), req.userid, req.brandsid, req.subsidiaryid, req.usertype); } } catch (Exception e) { log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage()); } return authz.devIdentity(); } /** 工具执行回调:清掉工具前旁白(reset);再按工具类型推对应卡片/控件事件。 */ private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { 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); } } 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 已结束——忽略。 } } }