EventLogChatMemory.java 6.53 KB
package com.xly.agent;

import com.xly.service.EventProjectionService;
import com.xly.service.LedgerService;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.data.message.ChatMessageSerializer;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.memory.ChatMemory;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * 事件日志上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(rightPush 原子——
 * 对话流与确认端点并发写互不覆盖,替代旧 chat:mem 整包读改写),{@link #messages()} 读取
 * {@link EventProjectionService} 的四段式投影。日志即唯一事实源,本类不持有任何会话状态。
 *
 * <p>写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做
 * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条
 * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai)
 * 只由本类落账。
 */
public class EventLogChatMemory implements ChatMemory {

    private static final int LLM_EVENT_WINDOW = 400;

    private final String convId;
    private final LedgerService log;
    private final EventProjectionService projection;
    private final int charBudget;
    private final boolean internalUserTurn;

    /** system prompt 每次由 provider 提供,不落日志(保持日志纯业务事件)。 */
    private volatile String systemText;
    /**
     * 本轮实际喂给模型的用户文本(原话 + 编排层附加的 grounding/状态后缀)。日志只存原话;
     * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。
     */
    private volatile String currentTurnUserText;

    public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection,
                              int charBudget, boolean internalUserTurn) {
        this.convId = convId;
        this.log = log;
        this.projection = projection;
        this.charBudget = charBudget;
        this.internalUserTurn = internalUserTurn;
    }

    @Override
    public Object id() {
        return convId;
    }

    @Override
    public void add(ChatMessage m) {
        if (m instanceof SystemMessage sm) {
            systemText = sm.text();
            return;
        }
        if (m instanceof UserMessage um) {
            String text = um.hasSingleText() ? um.singleText() : String.valueOf(um.contents());
            if (!internalUserTurn && alreadyLoggedPrefixOf(text)) {
                currentTurnUserText = text;
                return;
            }
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("text", text);
            if (internalUserTurn) {
                data.put("internal", true);
            }
            log.append(convId, "user", data);
            return;
        }
        if (m instanceof AiMessage am) {
            if (am.hasToolExecutionRequests()) {
                Map<String, Object> data = new LinkedHashMap<>();
                data.put("payload", ChatMessageSerializer.messagesToJson(List.of(am)));
                data.put("text", am.text() == null ? "" : am.text());
                List<String> tools = new ArrayList<>();
                for (ToolExecutionRequest r : am.toolExecutionRequests()) {
                    tools.add(r.name());
                }
                data.put("tools", tools);
                log.append(convId, "tool_call", data);
            } else if (am.text() != null && !am.text().isBlank()) {
                log.append(convId, "ai", Map.of("text", am.text()));
            }
            return;
        }
        if (m instanceof ToolExecutionResultMessage tr) {
            String text = tr.text() == null ? "" : tr.text();
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("tcId", tr.id() == null ? "" : tr.id());
            data.put("name", tr.toolName() == null ? "" : tr.toolName());
            data.put("text", text);
            data.put("digest", digest(text));
            log.append(convId, "tool_result", data);
        }
    }

    /**
     * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件)
     * → 跳过,避免重复。编排层可能在原话后附加 grounding/状态后缀,故用前缀而非全等判定。
     */
    private boolean alreadyLoggedPrefixOf(String text) {
        List<Map<String, Object>> tail = log.events(convId, 6);
        for (int i = tail.size() - 1; i >= 0; i--) {
            String type = String.valueOf(tail.get(i).get("type"));
            switch (type) {
                case "user":
                    String logged = String.valueOf(tail.get(i).get("text"));
                    return !logged.isEmpty() && text.startsWith(logged);
                case "form_submit", "ai", "tool_call", "tool_result", "assistant":
                    return false;
                default:
                    // confirm/proposal 等按钮事件可能插在中间,继续往前找
            }
        }
        return false;
    }

    @Override
    public List<ChatMessage> messages() {
        List<ChatMessage> out = projection.project(
                systemText, log.events(convId, LLM_EVENT_WINDOW), charBudget);
        String full = currentTurnUserText;
        if (full != null) {
            // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀)
            for (int i = out.size() - 1; i >= 0; i--) {
                if (out.get(i) instanceof UserMessage u && u.hasSingleText()
                        && full.startsWith(u.singleText())) {
                    out.set(i, UserMessage.from(full));
                    break;
                }
            }
        }
        return out;
    }

    /** 日志是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */
    @Override
    public void clear() {
    }

    private static String digest(String text) {
        String t = text.replace('\n', ' ').trim();
        return t.length() > EventProjectionService.TOOL_DIGEST_LEN
                ? t.substring(0, EventProjectionService.TOOL_DIGEST_LEN) + "…" : t;
    }
}