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 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 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 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 ms = mem.messages(); assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals(grounded), "LLM 看到的是完整加注文本"); List> 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 ms = proj.project("SYS", log.events(CONV), 6000); assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u && u.singleText().contains("没有调用任何工具")), "注入话术 LLM 可见"); List> 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> 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 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> 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> hist = proj.historyView(log.events(CONV)); assertEquals(5, hist.size(), "tool 摘要事件不进历史,其余照旧"); assertTrue(hist.get(3).get("content").contains("【表单】新建报价")); List 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 正文"); } }