AgentChatController.java 13.7 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.PreviewService;
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/previewChange/askUser,
 * **全部只读/只渲染**)。流程知识在技能文本里(useSkill 载入,投影层把激活技能钉在「进行中的流程」
 * 卡上跨轮续办)。写路径:previewChange/collectForm 只出卡片,用户点卡上按钮 → 确定性端点
 * (/api/agent/preview/{id}/save、/api/agent/form/submit)校验后写 ai_op_queue(AI 侧唯一写入口),
 * 执行由 ERP 侧负责。安全不变量在工具与端点内:身份内省 fail-closed、工具内鉴权、token 不进 prompt。
 *
 * <p><b>反编造护栏(无条件常开)</b>:零工具调用却答出数字 → 注入纠正话术重试一次(internal 事件,
 * 前端不显示),复发则标注未核实;声称「已保存/已提交/已完成」却没出过预览卡/表单 → 附纠正提示。
 *
 * <p>帧格式:{@code {"type":"token|reset|done|error"}}、预览卡 {@code change_preview}、澄清
 * {@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> CARD_TOOLS = Set.of("previewChange", "askUser", "collectForm");
    /** 反编造护栏:agent 声称写操作已发生的说法(本轮没出过预览卡/表单时要纠正)。
     *  负向前瞻排除**状态陈述**(「已审核状态」「已作废的单据」是转述记录现状,不是声称动作已完成)。 */
    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 PreviewService previews;
    private final LedgerService ledger;
    private final ExecutorService exec = Executors.newCachedThreadPool();

    public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
                               ConversationService conversations, PreviewService previews, LedgerService ledger) {
        this.agentFactory = agentFactory;
        this.authz = authz;
        this.mapper = mapper;
        this.conversations = conversations;
        this.previews = previews;
        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 Map<String, String> boundIds; // 外键字段技术名 -> 选择器里点中的记录 id
        public String conversationId;
        public String authorization;
    }

    /**
     * 确定性表单保存:collectForm 表单的结构化字段直达 create 校验(FK/类型/系统列)并写 ai_op_queue
     * (用户点【保存】= 已当面授权 → 入队 pending+100)。**xlyAi 到此为止**,执行由 ERP 侧负责。
     * 同步返回 {queued,opId,summary,message} 或 {error}。
     */
    @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()), identity);

        try {
            Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields, req.boundIds);
            if (r.containsKey("error")) {
                ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity);
            }
            return r;
        } catch (Exception e) {
            log.warn("form submit failed (conv={})", convId, e);
            out.put("error", "服务异常:" + e.getMessage());
            return out;
        }
    }

    /**
     * 运行 ReAct agent,把流式回调转成 SSE。反编造护栏**无条件常开**(实测 Ollama 对
     * tool_choice=required 不硬执行,只能在代码层兜底):零工具调用却答出数字 → 注入纠正话术
     * 重试一次(internal 用户事件,前端不显示),复发则标注「未经核实」;声称已保存/已提交但本轮
     * 没出过预览卡/表单 → 附纠正提示。
     */
    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 carded = new AtomicBoolean(false);
            TokenStream ts = agent.chat(convId, text);
            ts.onPartialResponse(token -> send(emitter, "token", token))
                    .onToolExecuted(te -> {
                        toolCalls.incrementAndGet();
                        handleToolExecuted(emitter, te, carded);
                    })
                    .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 (!carded.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);按工具类型推对应卡片/控件事件
     * (change_preview / question / form_collect)。落账由 EventLogChatMemory 统一完成
     * (tool_call/tool_result 事件),这里只做 SSE 推送。
     */
    private void handleToolExecuted(SseEmitter emitter, ToolExecution te, AtomicBoolean carded) {
        send(emitter, "reset", "");
        try {
            String toolName = te.request() == null ? "" : te.request().name();
            if (te.result() == null || !CARD_TOOLS.contains(toolName)) {
                return;
            }
            JsonNode r = mapper.readTree(te.result());
            String type = r.path("type").asText("");
            if ("change_preview".equals(type) || "question".equals(type) || "form_collect".equals(type)) {
                sendEvent(emitter, mapper.convertValue(r, Map.class));
                if ("change_preview".equals(type) || "form_collect".equals(type)) {
                    carded.set(true);
                }
            }
        } 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 已结束——忽略。
        }
    }
}