EventProjectionTest.java
13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
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 正文");
}
}