EventProjectionTest.java 13.4 KB
package com.xly.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.EventLogChatMemory;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
 * 事件日志 → LLM 四段投影 / 前端历史 的正确性:当前轮原样载荷保真(工具配对不可破坏)、
 * 旧轮工具结果压摘要、预算按整轮收拢、超预算旧轮进确定性摘要区、internal 用户事件
 * LLM 可见但历史不显示、进行中流程卡的钉住与摘下。
 */
class EventProjectionTest {

    private static final String CONV = "u1:c-test";

    private final EventProjectionService proj = new EventProjectionService(new ObjectMapper());

    private EventLogChatMemory memory(InMemoryLedger log) {
        return memory(log, false);
    }

    private EventLogChatMemory memory(InMemoryLedger log, boolean internal) {
        return new EventLogChatMemory(CONV, log, proj, 6000, internal);
    }

    private static AiMessage toolCall(String id, String name, String args) {
        return AiMessage.from(ToolExecutionRequest.builder().id(id).name(name).arguments(args).build());
    }

    @Test
    void currentTurnIsReconstructedExactly() {
        InMemoryLedger log = new InMemoryLedger();
        EventLogChatMemory mem = memory(log);

        log.append(CONV, "user", Map.of("text", "查一下必胜客"));
        int before = log.size();
        mem.add(UserMessage.from("查一下必胜客")); // 控制器已落账 → 去重跳过
        assertEquals(before, log.size(), "同文用户消息不能重复落账");

        String longResult = "必胜客:地址上海市静安区南京西路1266号,".repeat(10); // >120 字
        mem.add(toolCall("t1", "lookupRecord", "{\"entityKeyword\":\"客户\",\"recordKeyword\":\"必胜客\"}"));
        mem.add(ToolExecutionResultMessage.from("t1", "lookupRecord", longResult));
        mem.add(SystemMessage.from("SYS"));

        List<ChatMessage> ms = mem.messages();
        assertTrue(ms.get(0) instanceof SystemMessage s && s.text().startsWith("SYS"));
        AiMessage call = (AiMessage) ms.stream().filter(m -> m instanceof AiMessage a && a.hasToolExecutionRequests())
                .findFirst().orElseThrow();
        assertEquals("t1", call.toolExecutionRequests().get(0).id());
        assertTrue(call.toolExecutionRequests().get(0).arguments().contains("必胜客"), "工具参数原样保真");
        ToolExecutionResultMessage tr = (ToolExecutionResultMessage) ms.stream()
                .filter(m -> m instanceof ToolExecutionResultMessage).findFirst().orElseThrow();
        assertEquals(longResult, tr.text(), "当前轮工具结果不截断");
        assertEquals("t1", tr.id(), "工具调用/结果配对保持");
    }

    @Test
    void previousTurnToolResultsAreCollapsed() {
        InMemoryLedger log = new InMemoryLedger();
        EventLogChatMemory mem = memory(log);

        log.append(CONV, "user", Map.of("text", "查一下必胜客"));
        String longResult = "共 6 条记录:".repeat(40);
        mem.add(toolCall("t1", "readFormData", "{}"));
        mem.add(ToolExecutionResultMessage.from("t1", "readFormData", longResult));
        mem.add(AiMessage.from("共 6 个客户。"));
        log.append(CONV, "user", Map.of("text", "下一页"));

        mem.add(SystemMessage.from("SYS"));
        List<ChatMessage> ms = mem.messages();
        ToolExecutionResultMessage tr = (ToolExecutionResultMessage) ms.stream()
                .filter(m -> m instanceof ToolExecutionResultMessage).findFirst().orElseThrow();
        assertTrue(tr.text().length() <= EventProjectionService.TOOL_DIGEST_LEN + 1, "旧轮工具结果压一行摘要");
        assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && "共 6 个客户。".equals(a.text())),
                "旧轮最终答复保留");
        assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("下一页"));
    }

    @Test
    void turnsBeyondBudgetBecomeDeterministicDigest() {
        InMemoryLedger log = new InMemoryLedger();
        for (int i = 1; i <= 10; i++) {
            log.append(CONV, "user", Map.of("text", "问题" + i + ":" + "长".repeat(1500)));
            log.append(CONV, "ai", Map.of("text", "回答" + i));
        }
        log.append(CONV, "user", Map.of("text", "当前问题"));

        List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
        UserMessage digest = (UserMessage) ms.stream()
                .filter(m -> m instanceof UserMessage u && u.singleText().startsWith("【更早对话摘要"))
                .findFirst().orElseThrow();
        assertTrue(digest.singleText().contains("问题1:"), "最早轮进摘要区");
        assertTrue(digest.singleText().contains("⇒ 回答1"), "摘要含结果");
        long verbatimUsers = ms.stream().filter(m -> m instanceof UserMessage u
                && !u.singleText().startsWith("【更早对话摘要")).count();
        assertTrue(verbatimUsers <= 5, "近期原文区按 6000 字预算收拢(每轮约 1500 字)");
        assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("当前问题"),
                "当前轮永在末尾且完整");
    }

    @Test
    void groundedSuffixDoesNotDuplicateUserEventButFeedsLlm() {
        InMemoryLedger log = new InMemoryLedger();
        EventLogChatMemory mem = memory(log);

        log.append(CONV, "user", Map.of("text", "有哪些客户是上海的?"));
        int before = log.size();
        String grounded = "有哪些客户是上海的?\n\n(意图分析,仅供参考:意图=查询)\n\n(会话状态,仅供参考:上轮意图=查询)";
        mem.add(UserMessage.from(grounded));
        assertEquals(before, log.size(), "加注后缀的同轮用户消息不能落成第二条事件");

        mem.add(SystemMessage.from("SYS"));
        List<ChatMessage> ms = mem.messages();
        assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals(grounded),
                "LLM 看到的是完整加注文本");
        List<Map<String, String>> hist = proj.historyView(log.events(CONV));
        assertEquals("有哪些客户是上海的?", hist.get(0).get("content"), "前端历史只显示原话");
    }

    @Test
    void internalUserTurnVisibleToLlmButHiddenFromHistory() {
        InMemoryLedger log = new InMemoryLedger();
        log.append(CONV, "user", Map.of("text", "有多少个客户?"));
        log.append(CONV, "ai", Map.of("text", "大约有 1286 个客户。"));
        memory(log, true).add(UserMessage.from("你上一条回答没有调用任何工具,请先查询真实数据。"));

        List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
        assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u
                && u.singleText().contains("没有调用任何工具")), "注入话术 LLM 可见");

        List<Map<String, String>> hist = proj.historyView(log.events(CONV));
        assertEquals(2, hist.size(), "internal 事件不进前端历史");
        assertEquals("有多少个客户?", hist.get(0).get("content"));
    }

    @Test
    void buttonPathEventsRenderInBothProjections() {
        InMemoryLedger log = new InMemoryLedger();
        log.append(CONV, "form_submit", Map.of("entity", "报价", "fields", "客户=必胜客,产品=纸盒,数量=5000"));
        log.append(CONV, "proposal", Map.of("opId", "OP1", "summary", "新增报价:必胜客/纸盒 5000 个"));
        log.append(CONV, "confirm", Map.of("opId", "OP1", "status", "executed", "msg", "", "description", "新增报价单"));

        List<Map<String, String>> hist = proj.historyView(log.events(CONV));
        assertEquals("user", hist.get(0).get("role"));
        assertTrue(hist.get(0).get("content").contains("提交「报价」新增表单"));
        assertTrue(hist.get(1).get("content").startsWith("【待确认】"));
        assertTrue(hist.get(2).get("content").startsWith("【已执行】"));

        List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
        assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u && u.singleText().contains("提交「报价」")));
        assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("已生成待确认提议")));
        assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("操作执行成功")));
    }

    @Test
    void agentPathQuestionAndProposalDeriveFromToolResults() {
        InMemoryLedger log = new InMemoryLedger();
        EventLogChatMemory mem = memory(log);
        log.append(CONV, "user", Map.of("text", "作废那张送货单"));
        mem.add(toolCall("t1", "askUser", "{}"));
        mem.add(ToolExecutionResultMessage.from("t1", "askUser",
                "{\"type\":\"question\",\"question\":\"请问是哪一张送货单?\",\"options\":[\"DH1\",\"DH2\"]}"));
        log.append(CONV, "user", Map.of("text", "第一张"));
        mem.add(toolCall("t2", "proposeWrite", "{\"action\":\"invalid\"}"));
        mem.add(ToolExecutionResultMessage.from("t2", "proposeWrite",
                "{\"opId\":\"OP2\",\"summary\":\"作废 送货单 DH1\",\"message\":\"请点确认\"}"));

        List<Map<String, String>> hist = proj.historyView(log.events(CONV));
        assertTrue(hist.stream().anyMatch(h -> h.get("content").equals("请问是哪一张送货单?")),
                "askUser 的问题从工具结果同源推导进历史");
        assertTrue(hist.stream().anyMatch(h -> h.get("content").equals("【待确认】作废 送货单 DH1")));
    }

    @Test
    void activeFlowCardPinsAndUnpins() {
        InMemoryLedger log = new InMemoryLedger();
        EventLogChatMemory mem = memory(log);

        // 表单弹出(agent 路径)→ 卡钉住
        log.append(CONV, "user", Map.of("text", "报价纸盒"));
        mem.add(toolCall("t1", "collectForm", "{\"entityKeyword\":\"报价\"}"));
        mem.add(ToolExecutionResultMessage.from("t1", "collectForm",
                "{\"type\":\"form_collect\",\"entity\":\"报价\",\"message\":\"请填写\"}"));
        String card = proj.activeCard(log.events(CONV));
        assertTrue(card != null && card.contains("「报价」"), "弹表单后流程卡钉住");
        assertTrue(proj.project("SYS", log.events(CONV), 6000).get(0).toString().contains("进行中的流程"));

        // 中途插入无关查询轮 → 卡不摘
        log.append(CONV, "user", Map.of("text", "先查下必胜客电话"));
        log.append(CONV, "ai", Map.of("text", "13912345678"));
        assertTrue(proj.activeCard(log.events(CONV)).contains("「报价」"), "隔轮流程卡仍在");

        // 表单提交 + 提议 → 卡变为待确认
        log.append(CONV, "form_submit", Map.of("entity", "报价", "fields", "客户=必胜客"));
        log.append(CONV, "proposal", Map.of("opId", "OP1", "summary", "新增报价"));
        card = proj.activeCard(log.events(CONV));
        assertTrue(card.contains("待确认提议") && card.contains("OP1"));

        // 执行完成 → 卡摘下
        log.append(CONV, "confirm", Map.of("opId", "OP1", "status", "executed", "msg", "", "description", "新增报价"));
        assertNull(proj.activeCard(log.events(CONV)), "executed 后流程卡摘下");
    }

    @Test
    void skillCardPinsFullTextUntilDone() {
        InMemoryLedger log = new InMemoryLedger();
        log.append(CONV, "user", Map.of("text", "报价纸盒"));
        log.append(CONV, "skill_active", Map.of("name", "新建报价", "text", "【skill:新建报价】步骤:1…2…3…"));
        String card = proj.activeCard(log.events(CONV));
        assertTrue(card.contains("【skill:新建报价】步骤"), "激活 skill 全文钉在流程卡");

        log.append(CONV, "skill_done", Map.of("name", "新建报价"));
        assertNull(proj.activeCard(log.events(CONV)), "skill_done 后摘下");
    }

    @Test
    void legacyEventsStillRender() {
        InMemoryLedger log = new InMemoryLedger();
        log.append(CONV, "user", Map.of("text", "改必胜客电话"));
        log.append(CONV, "clarify", Map.of("text", "请问改成什么号码?"));
        log.append(CONV, "question", Map.of("question", "哪个客户?"));
        log.append(CONV, "form", Map.of("entity", "报价", "message", "请填写"));
        log.append(CONV, "assistant", Map.of("text", "好的。"));
        log.append(CONV, "tool", Map.of("name", "findForms", "digest", "…"));

        List<Map<String, String>> hist = proj.historyView(log.events(CONV));
        assertEquals(5, hist.size(), "tool 摘要事件不进历史,其余照旧");
        assertTrue(hist.get(3).get("content").contains("【表单】新建报价"));

        List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
        assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("请问改成什么号码")));
        assertFalse(ms.stream().anyMatch(m -> m.toString().contains("findForms")), "旧 tool 摘要不进 LLM 正文");
    }
}