AgentChatController.java 18.9 KB
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 流式返回。
 *
 * <p><b>编排(架构 §5 意图门 + 确定性路由)</b>:每轮先用 {@link IntentService} 做**受约束 JSON**
 * 的 4 类意图+实体分类,再确定性路由:
 * <ul>
 *   <li><b>新增</b> → 直接用 {@link SlotFillService} 按表单真实字段做受约束槽位填充,弹 collectForm 表单
 *       (完全不经 LLM 选工具,从机制上杜绝「把产品当客户」);</li>
 *   <li><b>操作已有单据</b> → 写槽位抽取 + 确定性 proposeWrite(人在环,具体动作从原话推出);</li>
 *   <li><b>查询/其他/分类失败</b> → 统一的 6 工具 ReAct agent(查询附意图 grounding)。</li>
 * </ul>
 * 确定性路径 + 意图 grounding + 循环护栏,共同解决「查询错当新增」「更新流程死循环」等问题。
 *
 * <p>帧格式:{@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<String> 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<String, Object> form = resolver.resolveMasterForm(entity);
        if (form == null) {
            return false;
        }
        String table = String.valueOf(form.get("sDataSource"));
        // 确定性槽位映射:角色实体(意图门已给) + 正则尺寸/数量 → 真实字段,绝不让模型乱放槽位。
        List<Map<String, Object>> fields = resolver.businessFields(table, 40);
        Map<String, String> 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<String> 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<String, Object> 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<String> 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<String, Object> 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<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 已结束——忽略。
        }
    }
}