AgentChatController.java 14.3 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.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 流式返回。
 *
 * <p><b>编排</b>:没有意图门、没有确定性路由——全部自由文本进唯一 ReAct agent
 * (固定 7 工具:useSkill/findForms/readFormData/lookupRecord/collectForm/proposeWrite/askUser)。
 * 流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」卡上跨轮续办)。
 * 安全不变量在工具与确认端点内,与编排无关:proposeWrite 只出草稿、确认走 {@code /api/agent/op/*}
 * 确定性端点、身份内省 fail-closed、工具内鉴权。
 *
 * <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
 * 前端不显示),复发则标注未核实;声称「已生成/已完成」却没真正 proposeWrite → 附纠正提示。
 *
 * <p>帧格式:{@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<String> 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), 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 表单的结构化字段**直达** proposeWrite(action=create),
     * 不再拼自然语言消息让 LLM 重新编码(旧路径会丢字段/错角色)。同步返回提议或错误。
     */
    @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()));

        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<String, Object> 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<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 已结束——忽略。
        }
    }
}