Commit 7e8560c481b08350c5f75e84e273d65ea643010f

Authored by zichun
1 parent 8bd01fbd

P1: event log as single source of truth; 4-zone LLM projection

- chat:ledger becomes the only conversation store: model messages land as
  user/tool_call(payload)/tool_result/ai events via EventLogChatMemory
  (per-event rightPush, no more whole-blob read-modify-write races)
- EventProjectionService: one renderer for both frontend history and LLM
  context (system+flow card / deterministic digest of expired turns /
  ~6000-char verbatim recent turns, current turn payload-exact)
- pinned active-flow card (form pending / proposal pending / skill_active
  full text) unpinned on executed/cancelled/skill_done
- anti-fab retry nudges logged as internal user events (LLM-visible,
  hidden from history); grounded suffixes never duplicate user events
- delete ProjectedChatMemory; RedisChatMemoryStore demoted to legacy
  read-only compat (old convs age out via 30d TTL); conv list lazy cleanup
- tests: projection correctness + concurrent stream/confirm append safety
src/main/java/com/xly/agent/EventLogChatMemory.java 0 → 100644
  1 +package com.xly.agent;
  2 +
  3 +import com.xly.service.EventProjectionService;
  4 +import com.xly.service.LedgerService;
  5 +import dev.langchain4j.data.message.AiMessage;
  6 +import dev.langchain4j.data.message.ChatMessage;
  7 +import dev.langchain4j.agent.tool.ToolExecutionRequest;
  8 +import dev.langchain4j.data.message.ChatMessageSerializer;
  9 +import dev.langchain4j.data.message.SystemMessage;
  10 +import dev.langchain4j.data.message.ToolExecutionResultMessage;
  11 +import dev.langchain4j.data.message.UserMessage;
  12 +import dev.langchain4j.memory.ChatMemory;
  13 +
  14 +import java.util.ArrayList;
  15 +import java.util.LinkedHashMap;
  16 +import java.util.List;
  17 +import java.util.Map;
  18 +
  19 +/**
  20 + * 事件日志上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(rightPush 原子——
  21 + * 对话流与确认端点并发写互不覆盖,替代旧 chat:mem 整包读改写),{@link #messages()} 读取
  22 + * {@link EventProjectionService} 的四段式投影。日志即唯一事实源,本类不持有任何会话状态。
  23 + *
  24 + * <p>写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做
  25 + * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条
  26 + * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai)
  27 + * 只由本类落账。
  28 + */
  29 +public class EventLogChatMemory implements ChatMemory {
  30 +
  31 + private static final int LLM_EVENT_WINDOW = 400;
  32 +
  33 + private final String convId;
  34 + private final LedgerService log;
  35 + private final EventProjectionService projection;
  36 + private final int charBudget;
  37 + private final boolean internalUserTurn;
  38 +
  39 + /** system prompt 每次由 provider 提供,不落日志(保持日志纯业务事件)。 */
  40 + private volatile String systemText;
  41 + /**
  42 + * 本轮实际喂给模型的用户文本(原话 + 编排层附加的 grounding/状态后缀)。日志只存原话;
  43 + * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。
  44 + */
  45 + private volatile String currentTurnUserText;
  46 +
  47 + public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection,
  48 + int charBudget, boolean internalUserTurn) {
  49 + this.convId = convId;
  50 + this.log = log;
  51 + this.projection = projection;
  52 + this.charBudget = charBudget;
  53 + this.internalUserTurn = internalUserTurn;
  54 + }
  55 +
  56 + @Override
  57 + public Object id() {
  58 + return convId;
  59 + }
  60 +
  61 + @Override
  62 + public void add(ChatMessage m) {
  63 + if (m instanceof SystemMessage sm) {
  64 + systemText = sm.text();
  65 + return;
  66 + }
  67 + if (m instanceof UserMessage um) {
  68 + String text = um.hasSingleText() ? um.singleText() : String.valueOf(um.contents());
  69 + if (!internalUserTurn && alreadyLoggedPrefixOf(text)) {
  70 + currentTurnUserText = text;
  71 + return;
  72 + }
  73 + Map<String, Object> data = new LinkedHashMap<>();
  74 + data.put("text", text);
  75 + if (internalUserTurn) {
  76 + data.put("internal", true);
  77 + }
  78 + log.append(convId, "user", data);
  79 + return;
  80 + }
  81 + if (m instanceof AiMessage am) {
  82 + if (am.hasToolExecutionRequests()) {
  83 + Map<String, Object> data = new LinkedHashMap<>();
  84 + data.put("payload", ChatMessageSerializer.messagesToJson(List.of(am)));
  85 + data.put("text", am.text() == null ? "" : am.text());
  86 + List<String> tools = new ArrayList<>();
  87 + for (ToolExecutionRequest r : am.toolExecutionRequests()) {
  88 + tools.add(r.name());
  89 + }
  90 + data.put("tools", tools);
  91 + log.append(convId, "tool_call", data);
  92 + } else if (am.text() != null && !am.text().isBlank()) {
  93 + log.append(convId, "ai", Map.of("text", am.text()));
  94 + }
  95 + return;
  96 + }
  97 + if (m instanceof ToolExecutionResultMessage tr) {
  98 + String text = tr.text() == null ? "" : tr.text();
  99 + Map<String, Object> data = new LinkedHashMap<>();
  100 + data.put("tcId", tr.id() == null ? "" : tr.id());
  101 + data.put("name", tr.toolName() == null ? "" : tr.toolName());
  102 + data.put("text", text);
  103 + data.put("digest", digest(text));
  104 + log.append(convId, "tool_result", data);
  105 + }
  106 + }
  107 +
  108 + /**
  109 + * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件)
  110 + * → 跳过,避免重复。编排层可能在原话后附加 grounding/状态后缀,故用前缀而非全等判定。
  111 + */
  112 + private boolean alreadyLoggedPrefixOf(String text) {
  113 + List<Map<String, Object>> tail = log.events(convId, 6);
  114 + for (int i = tail.size() - 1; i >= 0; i--) {
  115 + String type = String.valueOf(tail.get(i).get("type"));
  116 + switch (type) {
  117 + case "user":
  118 + String logged = String.valueOf(tail.get(i).get("text"));
  119 + return !logged.isEmpty() && text.startsWith(logged);
  120 + case "form_submit", "ai", "tool_call", "tool_result", "assistant":
  121 + return false;
  122 + default:
  123 + // confirm/proposal 等按钮事件可能插在中间,继续往前找
  124 + }
  125 + }
  126 + return false;
  127 + }
  128 +
  129 + @Override
  130 + public List<ChatMessage> messages() {
  131 + List<ChatMessage> out = projection.project(
  132 + systemText, log.events(convId, LLM_EVENT_WINDOW), charBudget);
  133 + String full = currentTurnUserText;
  134 + if (full != null) {
  135 + // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀)
  136 + for (int i = out.size() - 1; i >= 0; i--) {
  137 + if (out.get(i) instanceof UserMessage u && u.hasSingleText()
  138 + && full.startsWith(u.singleText())) {
  139 + out.set(i, UserMessage.from(full));
  140 + break;
  141 + }
  142 + }
  143 + }
  144 + return out;
  145 + }
  146 +
  147 + /** 日志是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */
  148 + @Override
  149 + public void clear() {
  150 + }
  151 +
  152 + private static String digest(String text) {
  153 + String t = text.replace('\n', ' ').trim();
  154 + return t.length() > EventProjectionService.TOOL_DIGEST_LEN
  155 + ? t.substring(0, EventProjectionService.TOOL_DIGEST_LEN) + "…" : t;
  156 + }
  157 +}
src/main/java/com/xly/agent/ProjectedChatMemory.java deleted
1 -package com.xly.agent;  
2 -  
3 -import dev.langchain4j.agent.tool.ToolExecutionRequest;  
4 -import dev.langchain4j.data.message.AiMessage;  
5 -import dev.langchain4j.data.message.ChatMessage;  
6 -import dev.langchain4j.data.message.SystemMessage;  
7 -import dev.langchain4j.data.message.ToolExecutionResultMessage;  
8 -import dev.langchain4j.data.message.UserMessage;  
9 -import dev.langchain4j.memory.ChatMemory;  
10 -import dev.langchain4j.store.memory.chat.ChatMemoryStore;  
11 -  
12 -import java.util.ArrayList;  
13 -import java.util.List;  
14 -  
15 -/**  
16 - * 带**投影**的对话记忆:存储层保留完整消息({@link ChatMemoryStore},硬上限按整轮裁剪),  
17 - * 读取层({@link #messages()})做 token 预算投影——替换按条数计窗的 MessageWindowChatMemory  
18 - * (工具消息占条数导致真实轮次只有 5-8 轮,且长短不均)。  
19 - *  
20 - * <p>投影规则:  
21 - * <ul>  
22 - * <li>system 消息永在首位;</li>  
23 - * <li><b>当前轮</b>(最后一个 UserMessage 起)原样保留——进行中的 工具调用/结果 配对不可破坏;</li>  
24 - * <li>历史轮从新到旧按**整轮**(UserMessage 边界)纳入,旧轮的工具结果压成一行摘要,  
25 - * 预算(约 {@code charBudget} 字符 ≈ 中文 token 数)用尽即止。</li>  
26 - * </ul>  
27 - */  
28 -public class ProjectedChatMemory implements ChatMemory {  
29 -  
30 - private static final int TOOL_DIGEST_LEN = 120;  
31 -  
32 - private final Object id;  
33 - private final ChatMemoryStore store;  
34 - private final int charBudget;  
35 - private final int hardCapMessages;  
36 -  
37 - public ProjectedChatMemory(Object id, ChatMemoryStore store, int charBudget, int hardCapMessages) {  
38 - this.id = id;  
39 - this.store = store;  
40 - this.charBudget = charBudget;  
41 - this.hardCapMessages = hardCapMessages;  
42 - }  
43 -  
44 - @Override  
45 - public Object id() {  
46 - return id;  
47 - }  
48 -  
49 - @Override  
50 - public void add(ChatMessage m) {  
51 - List<ChatMessage> full = new ArrayList<>(store.getMessages(id));  
52 - if (m instanceof SystemMessage sm) {  
53 - if (!full.isEmpty() && full.get(0) instanceof SystemMessage cur) {  
54 - if (cur.text().equals(sm.text())) {  
55 - return;  
56 - }  
57 - full.set(0, sm);  
58 - } else {  
59 - full.add(0, sm);  
60 - }  
61 - } else {  
62 - full.add(m);  
63 - trimToCap(full);  
64 - }  
65 - store.updateMessages(id, full);  
66 - }  
67 -  
68 - @Override  
69 - public List<ChatMessage> messages() {  
70 - return project(new ArrayList<>(store.getMessages(id)));  
71 - }  
72 -  
73 - @Override  
74 - public void clear() {  
75 - store.deleteMessages(id);  
76 - }  
77 -  
78 - private List<ChatMessage> project(List<ChatMessage> full) {  
79 - if (full.isEmpty()) {  
80 - return full;  
81 - }  
82 - SystemMessage sys = full.get(0) instanceof SystemMessage s ? s : null;  
83 - List<ChatMessage> body = full.subList(sys == null ? 0 : 1, full.size());  
84 -  
85 - int lastUser = 0;  
86 - for (int i = body.size() - 1; i >= 0; i--) {  
87 - if (body.get(i) instanceof UserMessage) {  
88 - lastUser = i;  
89 - break;  
90 - }  
91 - }  
92 - List<ChatMessage> tail = new ArrayList<>(body.subList(lastUser, body.size()));  
93 -  
94 - List<ChatMessage> head = new ArrayList<>();  
95 - int used = 0;  
96 - int turnEnd = lastUser;  
97 - for (int i = lastUser - 1; i >= 0 && used < charBudget; i--) {  
98 - if (!(body.get(i) instanceof UserMessage)) {  
99 - continue;  
100 - }  
101 - List<ChatMessage> turn = new ArrayList<>();  
102 - int size = 0;  
103 - for (int k = i; k < turnEnd; k++) {  
104 - ChatMessage c = collapse(body.get(k));  
105 - turn.add(c);  
106 - size += approxLen(c);  
107 - }  
108 - if (used + size > charBudget && !head.isEmpty()) {  
109 - break;  
110 - }  
111 - head.addAll(0, turn);  
112 - used += size;  
113 - turnEnd = i;  
114 - }  
115 -  
116 - List<ChatMessage> out = new ArrayList<>();  
117 - if (sys != null) {  
118 - out.add(sys);  
119 - }  
120 - out.addAll(head);  
121 - out.addAll(tail);  
122 - return out;  
123 - }  
124 -  
125 - /** 历史轮的工具结果压成一行摘要(当前轮不经过此路径,配对结构完整)。 */  
126 - private static ChatMessage collapse(ChatMessage m) {  
127 - if (m instanceof ToolExecutionResultMessage t) {  
128 - String txt = t.text() == null ? "" : t.text().replace('\n', ' ').trim();  
129 - if (txt.length() > TOOL_DIGEST_LEN) {  
130 - txt = txt.substring(0, TOOL_DIGEST_LEN) + "…";  
131 - }  
132 - return ToolExecutionResultMessage.from(t.id(), t.toolName(), txt);  
133 - }  
134 - return m;  
135 - }  
136 -  
137 - private static int approxLen(ChatMessage m) {  
138 - if (m instanceof UserMessage u && u.hasSingleText()) {  
139 - return u.singleText().length();  
140 - }  
141 - if (m instanceof AiMessage a) {  
142 - int n = a.text() == null ? 0 : a.text().length();  
143 - if (a.hasToolExecutionRequests()) {  
144 - for (ToolExecutionRequest r : a.toolExecutionRequests()) {  
145 - n += (r.arguments() == null ? 0 : r.arguments().length()) + 20;  
146 - }  
147 - }  
148 - return n;  
149 - }  
150 - if (m instanceof ToolExecutionResultMessage t) {  
151 - return t.text() == null ? 0 : t.text().length();  
152 - }  
153 - return 50;  
154 - }  
155 -  
156 - /** 存储硬上限:超限时从最旧的整轮开始删(system 保留)。 */  
157 - private void trimToCap(List<ChatMessage> full) {  
158 - int start = !full.isEmpty() && full.get(0) instanceof SystemMessage ? 1 : 0;  
159 - while (full.size() > hardCapMessages) {  
160 - int next = -1;  
161 - for (int i = start + 1; i < full.size(); i++) {  
162 - if (full.get(i) instanceof UserMessage) {  
163 - next = i;  
164 - break;  
165 - }  
166 - }  
167 - if (next < 0) {  
168 - break;  
169 - }  
170 - full.subList(start, next).clear();  
171 - }  
172 - }  
173 -}  
src/main/java/com/xly/config/AgentFactory.java
@@ -2,9 +2,12 @@ package com.xly.config; @@ -2,9 +2,12 @@ package com.xly.config;
2 2
3 import com.fasterxml.jackson.databind.ObjectMapper; 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import com.xly.agent.AgentIdentity; 4 import com.xly.agent.AgentIdentity;
  5 +import com.xly.agent.EventLogChatMemory;
5 import com.xly.agent.ReActAgent; 6 import com.xly.agent.ReActAgent;
6 import com.xly.service.ErpClient; 7 import com.xly.service.ErpClient;
  8 +import com.xly.service.EventProjectionService;
7 import com.xly.service.FormResolverService; 9 import com.xly.service.FormResolverService;
  10 +import com.xly.service.LedgerService;
8 import com.xly.service.OpService; 11 import com.xly.service.OpService;
9 import com.xly.service.SystemPromptService; 12 import com.xly.service.SystemPromptService;
10 import com.xly.tool.ErpReadTool; 13 import com.xly.tool.ErpReadTool;
@@ -12,7 +15,6 @@ import com.xly.tool.FormCollectTool; @@ -12,7 +15,6 @@ import com.xly.tool.FormCollectTool;
12 import com.xly.tool.InteractionTool; 15 import com.xly.tool.InteractionTool;
13 import com.xly.tool.KgQueryTool; 16 import com.xly.tool.KgQueryTool;
14 import com.xly.tool.ProposeWriteTool; 17 import com.xly.tool.ProposeWriteTool;
15 -import com.xly.agent.ProjectedChatMemory;  
16 import dev.langchain4j.model.chat.StreamingChatModel; 18 import dev.langchain4j.model.chat.StreamingChatModel;
17 import dev.langchain4j.service.AiServices; 19 import dev.langchain4j.service.AiServices;
18 import org.springframework.beans.factory.annotation.Qualifier; 20 import org.springframework.beans.factory.annotation.Qualifier;
@@ -34,7 +36,8 @@ import org.springframework.stereotype.Component; @@ -34,7 +36,8 @@ import org.springframework.stereotype.Component;
34 public class AgentFactory { 36 public class AgentFactory {
35 37
36 private final StreamingChatModel streamingModel; 38 private final StreamingChatModel streamingModel;
37 - private final RedisChatMemoryStore memoryStore; 39 + private final LedgerService ledger;
  40 + private final EventProjectionService projection;
38 private final SystemPromptService systemPromptService; 41 private final SystemPromptService systemPromptService;
39 42
40 private final ErpClient erp; 43 private final ErpClient erp;
@@ -47,12 +50,13 @@ public class AgentFactory { @@ -47,12 +50,13 @@ public class AgentFactory {
47 private final InteractionTool interactionTool; 50 private final InteractionTool interactionTool;
48 51
49 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel, 52 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
50 - RedisChatMemoryStore memoryStore, 53 + LedgerService ledger, EventProjectionService projection,
51 SystemPromptService systemPromptService, 54 SystemPromptService systemPromptService,
52 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops, 55 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
53 ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) { 56 ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) {
54 this.streamingModel = streamingModel; 57 this.streamingModel = streamingModel;
55 - this.memoryStore = memoryStore; 58 + this.ledger = ledger;
  59 + this.projection = projection;
56 this.systemPromptService = systemPromptService; 60 this.systemPromptService = systemPromptService;
57 this.erp = erp; 61 this.erp = erp;
58 this.jdbc = jdbc; 62 this.jdbc = jdbc;
@@ -63,11 +67,18 @@ public class AgentFactory { @@ -63,11 +67,18 @@ public class AgentFactory {
63 this.interactionTool = interactionTool; 67 this.interactionTool = interactionTool;
64 } 68 }
65 69
  70 + public ReActAgent build(AgentIdentity identity) {
  71 + return build(identity, false);
  72 + }
  73 +
66 /** 74 /**
67 * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations} 75 * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations}
68 * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。 76 * 作为循环护栏,杜绝「反复追问同一问题」这类失控 ReAct 循环。
  77 + *
  78 + * @param internalUserTurn true=本次的用户消息是系统注入话术(护栏重试),落账时带 internal 标记,
  79 + * 前端历史不显示
69 */ 80 */
70 - public ReActAgent build(AgentIdentity identity) { 81 + public ReActAgent build(AgentIdentity identity, boolean internalUserTurn) {
71 Object[] tools = new Object[]{kgQueryTool, interactionTool, 82 Object[] tools = new Object[]{kgQueryTool, interactionTool,
72 new ErpReadTool(erp, resolver, identity), 83 new ErpReadTool(erp, resolver, identity),
73 new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver), 84 new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
@@ -77,8 +88,9 @@ public class AgentFactory { @@ -77,8 +88,9 @@ public class AgentFactory {
77 .streamingChatModel(streamingModel) 88 .streamingChatModel(streamingModel)
78 .tools(tools) 89 .tools(tools)
79 .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 90 .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环
80 - // token 预算投影记忆:存储保完整、读取按预算收拢,旧工具结果压一行(见 ProjectedChatMemory)  
81 - .chatMemoryProvider(memoryId -> new ProjectedChatMemory(memoryId, memoryStore, 6000, 80)) 91 + // 事件日志记忆:逐条 append 原子落账,读取为四段式投影(见 EventLogChatMemory)
  92 + .chatMemoryProvider(memoryId -> new EventLogChatMemory(
  93 + String.valueOf(memoryId), ledger, projection, 6000, internalUserTurn))
82 .systemMessageProvider(memoryId -> systemPromptService.prompt()) 94 .systemMessageProvider(memoryId -> systemPromptService.prompt())
83 .build(); 95 .build();
84 } 96 }
src/main/java/com/xly/config/RedisChatMemoryStore.java
1 package com.xly.config; 1 package com.xly.config;
2 2
3 -import dev.langchain4j.data.message.AiMessage;  
4 import dev.langchain4j.data.message.ChatMessage; 3 import dev.langchain4j.data.message.ChatMessage;
5 import dev.langchain4j.data.message.ChatMessageDeserializer; 4 import dev.langchain4j.data.message.ChatMessageDeserializer;
6 -import dev.langchain4j.data.message.ChatMessageSerializer;  
7 -import dev.langchain4j.data.message.UserMessage;  
8 -import dev.langchain4j.store.memory.chat.ChatMemoryStore;  
9 import org.springframework.data.redis.core.StringRedisTemplate; 5 import org.springframework.data.redis.core.StringRedisTemplate;
10 import org.springframework.stereotype.Component; 6 import org.springframework.stereotype.Component;
11 7
12 -import java.time.Duration;  
13 import java.util.ArrayList; 8 import java.util.ArrayList;
14 import java.util.List; 9 import java.util.List;
15 10
16 /** 11 /**
17 - * Redis 持久化的 ChatMemoryStore —— 会话记忆按 conversationId 存到 Redis,重启/换实例不丢,  
18 - * 也让「重登录后接回上一次会话」成立(会话状态按稳定身份+conversationId 存,而非按 token)。 12 + * 旧版消息记忆({@code chat:mem:{convId}})的**遗留只读**兼容层。
19 * 13 *
20 - * <p>键:{@code chat:mem:{conversationId}};值:LangChain4j 序列化后的消息 JSON。30 天 TTL。 14 + * <p>事实源已迁移到事件日志({@link com.xly.service.LedgerService},{@code chat:ledger:}),
  15 + * 本类不再写入:只保留旧会话的历史兼容读取与删除级联,30 天 TTL 到期后自然淘汰,届时可整体删除。
21 */ 16 */
22 @Component 17 @Component
23 -public class RedisChatMemoryStore implements ChatMemoryStore { 18 +public class RedisChatMemoryStore {
24 19
25 private static final String PREFIX = "chat:mem:"; 20 private static final String PREFIX = "chat:mem:";
26 - private static final Duration TTL = Duration.ofDays(30);  
27 21
28 private final StringRedisTemplate redis; 22 private final StringRedisTemplate redis;
29 23
@@ -31,7 +25,6 @@ public class RedisChatMemoryStore implements ChatMemoryStore { @@ -31,7 +25,6 @@ public class RedisChatMemoryStore implements ChatMemoryStore {
31 this.redis = redis; 25 this.redis = redis;
32 } 26 }
33 27
34 - @Override  
35 public List<ChatMessage> getMessages(Object memoryId) { 28 public List<ChatMessage> getMessages(Object memoryId) {
36 String json = redis.opsForValue().get(PREFIX + memoryId); 29 String json = redis.opsForValue().get(PREFIX + memoryId);
37 if (json == null || json.isBlank()) { 30 if (json == null || json.isBlank()) {
@@ -40,25 +33,15 @@ public class RedisChatMemoryStore implements ChatMemoryStore { @@ -40,25 +33,15 @@ public class RedisChatMemoryStore implements ChatMemoryStore {
40 return ChatMessageDeserializer.messagesFromJson(json); 33 return ChatMessageDeserializer.messagesFromJson(json);
41 } 34 }
42 35
43 - @Override  
44 - public void updateMessages(Object memoryId, List<ChatMessage> messages) {  
45 - redis.opsForValue().set(PREFIX + memoryId, ChatMessageSerializer.messagesToJson(messages), TTL); 36 + public boolean exists(Object memoryId) {
  37 + try {
  38 + return Boolean.TRUE.equals(redis.hasKey(PREFIX + memoryId));
  39 + } catch (Exception e) {
  40 + return true; // 读失败时不误判为「可清理」
  41 + }
46 } 42 }
47 43
48 - @Override  
49 public void deleteMessages(Object memoryId) { 44 public void deleteMessages(Object memoryId) {
50 redis.delete(PREFIX + memoryId); 45 redis.delete(PREFIX + memoryId);
51 } 46 }
52 -  
53 - /** 确定性路径(表单/澄清/提议/确认)不经过 LLM 记忆——用它把该轮补进存储,修补记忆空洞。 */  
54 - public void appendTurn(Object memoryId, String userText, String aiText) {  
55 - List<ChatMessage> full = new ArrayList<>(getMessages(memoryId));  
56 - if (userText != null && !userText.isBlank()) {  
57 - full.add(UserMessage.from(userText));  
58 - }  
59 - if (aiText != null && !aiText.isBlank()) {  
60 - full.add(AiMessage.from(aiText));  
61 - }  
62 - updateMessages(memoryId, full);  
63 - }  
64 } 47 }
src/main/java/com/xly/service/ConversationService.java
@@ -23,20 +23,25 @@ import java.util.Map; @@ -23,20 +23,25 @@ import java.util.Map;
23 public class ConversationService { 23 public class ConversationService {
24 24
25 private static final String CONVS_KEY = "chat:convs:"; 25 private static final String CONVS_KEY = "chat:convs:";
  26 + /** 懒清理宽限:新建会话可能短暂没有任何事件,updatedAt 在此窗口内的不清。 */
  27 + private static final long CLEANUP_GRACE_MS = 3L * 24 * 3600 * 1000;
26 28
27 private final StringRedisTemplate redis; 29 private final StringRedisTemplate redis;
28 private final RedisChatMemoryStore memoryStore; 30 private final RedisChatMemoryStore memoryStore;
29 private final ObjectMapper mapper; 31 private final ObjectMapper mapper;
30 private final LedgerService ledger; 32 private final LedgerService ledger;
31 private final StateService state; 33 private final StateService state;
  34 + private final EventProjectionService projection;
32 35
33 public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, 36 public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore,
34 - ObjectMapper mapper, LedgerService ledger, StateService state) { 37 + ObjectMapper mapper, LedgerService ledger, StateService state,
  38 + EventProjectionService projection) {
35 this.redis = redis; 39 this.redis = redis;
36 this.memoryStore = memoryStore; 40 this.memoryStore = memoryStore;
37 this.mapper = mapper; 41 this.mapper = mapper;
38 this.ledger = ledger; 42 this.ledger = ledger;
39 this.state = state; 43 this.state = state;
  44 + this.projection = projection;
40 } 45 }
41 46
42 /** 47 /**
@@ -105,14 +110,21 @@ public class ConversationService { @@ -105,14 +110,21 @@ public class ConversationService {
105 } 110 }
106 } 111 }
107 112
108 - /** 该用户的会话列表,按最近更新倒序。 */ 113 + /** 该用户的会话列表,按最近更新倒序。内容键已过期(30 天 TTL)的项懒清理剔除。 */
109 public List<Map<String, Object>> list(String userId) { 114 public List<Map<String, Object>> list(String userId) {
110 Map<Object, Object> all = redis.opsForHash().entries(CONVS_KEY + userId); 115 Map<Object, Object> all = redis.opsForHash().entries(CONVS_KEY + userId);
111 List<Map<String, Object>> out = new ArrayList<>(); 116 List<Map<String, Object>> out = new ArrayList<>();
  117 + long now = System.currentTimeMillis();
112 for (Object v : all.values()) { 118 for (Object v : all.values()) {
113 try { 119 try {
114 @SuppressWarnings("unchecked") 120 @SuppressWarnings("unchecked")
115 Map<String, Object> m = mapper.readValue(v.toString(), Map.class); 121 Map<String, Object> m = mapper.readValue(v.toString(), Map.class);
  122 + String convId = String.valueOf(m.get("id"));
  123 + if (now - num(m.get("updatedAt")) > CLEANUP_GRACE_MS
  124 + && !ledger.exists(convId) && !memoryStore.exists(convId)) {
  125 + redis.opsForHash().delete(CONVS_KEY + userId, convId);
  126 + continue;
  127 + }
116 out.add(m); 128 out.add(m);
117 } catch (Exception ignore) { 129 } catch (Exception ignore) {
118 } 130 }
@@ -129,28 +141,13 @@ public class ConversationService { @@ -129,28 +141,13 @@ public class ConversationService {
129 } 141 }
130 142
131 /** 143 /**
132 - * 会话历史:优先从**会话账本**重放(含确定性路径的表单/澄清/提议/确认结果——消息记忆里没有这些);  
133 - * 无账本的旧会话退回消息记忆。映射为 {role:user|ai, content}。 144 + * 会话历史 = 事件日志的前端投影(与 LLM 上下文同源,见 {@link EventProjectionService});
  145 + * 无日志的旧会话退回消息记忆兼容读取。映射为 {role:user|ai, content}。
134 */ 146 */
135 public List<Map<String, String>> history(String convId) { 147 public List<Map<String, String>> history(String convId) {
136 List<Map<String, Object>> events = ledger.events(convId); 148 List<Map<String, Object>> events = ledger.events(convId);
137 if (!events.isEmpty()) { 149 if (!events.isEmpty()) {
138 - List<Map<String, String>> out = new ArrayList<>();  
139 - for (Map<String, Object> e : events) {  
140 - String type = String.valueOf(e.get("type"));  
141 - switch (type) {  
142 - case "user" -> out.add(Map.of("role", "user", "content", s(e.get("text"))));  
143 - case "assistant", "clarify" -> addAi(out, s(e.get("text")));  
144 - case "question" -> addAi(out, s(e.get("question")));  
145 - case "form" -> addAi(out, "【表单】新建" + s(e.get("entity")) + ":" + s(e.get("message")));  
146 - case "proposal" -> addAi(out, "【待确认】" + s(e.get("summary")));  
147 - case "confirm" -> addAi(out, ("executed".equals(s(e.get("status"))) ? "【已执行】" : "【执行失败】")  
148 - + s(e.get("description")) + blankOr(s(e.get("msg"))));  
149 - case "cancel" -> addAi(out, "【已取消】" + s(e.get("description")));  
150 - default -> { } // tool 等内部事件不进历史  
151 - }  
152 - }  
153 - return out; 150 + return projection.historyView(events);
154 } 151 }
155 List<Map<String, String>> out = new ArrayList<>(); 152 List<Map<String, String>> out = new ArrayList<>();
156 for (ChatMessage m : memoryStore.getMessages(convId)) { 153 for (ChatMessage m : memoryStore.getMessages(convId)) {
@@ -164,20 +161,6 @@ public class ConversationService { @@ -164,20 +161,6 @@ public class ConversationService {
164 return out; 161 return out;
165 } 162 }
166 163
167 - private static void addAi(List<Map<String, String>> out, String text) {  
168 - if (text != null && !text.isBlank()) {  
169 - out.add(Map.of("role", "ai", "content", text));  
170 - }  
171 - }  
172 -  
173 - private static String s(Object o) {  
174 - return o == null ? "" : o.toString();  
175 - }  
176 -  
177 - private static String blankOr(String msg) {  
178 - return msg == null || msg.isBlank() ? "" : ("(" + msg + ")");  
179 - }  
180 -  
181 private String deriveTitle(String s) { 164 private String deriveTitle(String s) {
182 if (s == null) { 165 if (s == null) {
183 return "新会话"; 166 return "新会话";
src/main/java/com/xly/service/EventProjectionService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.JsonNode;
  4 +import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import dev.langchain4j.data.message.AiMessage;
  6 +import dev.langchain4j.data.message.ChatMessage;
  7 +import dev.langchain4j.data.message.ChatMessageDeserializer;
  8 +import dev.langchain4j.data.message.SystemMessage;
  9 +import dev.langchain4j.data.message.ToolExecutionResultMessage;
  10 +import dev.langchain4j.data.message.UserMessage;
  11 +import org.springframework.stereotype.Service;
  12 +
  13 +import java.util.ArrayList;
  14 +import java.util.LinkedHashMap;
  15 +import java.util.List;
  16 +import java.util.Map;
  17 +
  18 +/**
  19 + * 事件日志的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流,
  20 + * 文案在这一处生成,不再散落在各控制器里。
  21 + *
  22 + * <p><b>LLM 上下文四段式</b>({@link #project}):
  23 + * <ol>
  24 + * <li>稳定 system prompt(KV 前缀稳定);</li>
  25 + * <li>进行中流程卡(激活 skill 全文 + 在办单据状态)——钉在 system 尾部,不参与截断,
  26 + * 提议 executed/cancelled 或新 skill 激活时摘下;</li>
  27 + * <li>往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);</li>
  28 + * <li>近期原文区:约 charBudget 字符按**整轮**纳入;旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字;
  29 + * 当前轮(最后一个用户事件起)原样载荷精确重建,工具调用/结果配对不可破坏。</li>
  30 + * </ol>
  31 + */
  32 +@Service
  33 +public class EventProjectionService {
  34 +
  35 + public static final int TOOL_DIGEST_LEN = 120;
  36 + private static final int DIGEST_TURNS_MAX = 40;
  37 + private static final int DIGEST_LINE_LEN = 90;
  38 +
  39 + private final ObjectMapper mapper;
  40 +
  41 + public EventProjectionService(ObjectMapper mapper) {
  42 + this.mapper = mapper;
  43 + }
  44 +
  45 + // ---------------------------------------------------------------- LLM 投影
  46 +
  47 + /** 四段式 LLM 上下文。systemText 为空时不出 system 消息(如测试)。 */
  48 + public List<ChatMessage> project(String systemText, List<Map<String, Object>> events, int charBudget) {
  49 + List<List<Map<String, Object>>> turns = groupTurns(events);
  50 + int current = turns.size() - 1;
  51 +
  52 + // ④ 近期原文区:从最新往回按整轮纳入;当前轮必进且不计预算
  53 + int firstVerbatim = current;
  54 + int used = 0;
  55 + for (int i = current - 1; i >= 0; i--) {
  56 + int size = 0;
  57 + for (Map<String, Object> ev : turns.get(i)) {
  58 + size += approxLen(ev);
  59 + }
  60 + if (used + size > charBudget) {
  61 + break;
  62 + }
  63 + used += size;
  64 + firstVerbatim = i;
  65 + }
  66 +
  67 + List<ChatMessage> out = new ArrayList<>();
  68 + String card = activeCard(events);
  69 + if (systemText != null && !systemText.isBlank()) {
  70 + out.add(SystemMessage.from(card == null ? systemText
  71 + : systemText + "\n\n【进行中的流程】\n" + card));
  72 + }
  73 +
  74 + // ③ 往事摘要区
  75 + if (firstVerbatim > 0) {
  76 + StringBuilder sb = new StringBuilder("【更早对话摘要(自动截断,仅供参考)】");
  77 + int from = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX);
  78 + if (from > 0) {
  79 + sb.append("\n(更早 ").append(from).append(" 轮已省略)");
  80 + }
  81 + for (int i = from; i < firstVerbatim; i++) {
  82 + String line = digestLine(turns.get(i));
  83 + if (!line.isBlank()) {
  84 + sb.append("\n- ").append(line);
  85 + }
  86 + }
  87 + out.add(UserMessage.from(sb.toString()));
  88 + }
  89 +
  90 + for (int i = firstVerbatim; i < turns.size(); i++) {
  91 + appendTurnMessages(out, turns.get(i), i == current);
  92 + }
  93 + return out;
  94 + }
  95 +
  96 + /** 一轮 → LLM 消息。当前轮工具结果全文保真;旧轮压一行摘要。 */
  97 + private void appendTurnMessages(List<ChatMessage> out, List<Map<String, Object>> turn, boolean isCurrent) {
  98 + for (Map<String, Object> ev : turn) {
  99 + String type = str(ev.get("type"));
  100 + switch (type) {
  101 + case "user" -> out.add(UserMessage.from(str(ev.get("text"))));
  102 + case "form_submit" -> out.add(UserMessage.from(renderFormSubmit(ev)));
  103 + case "ai", "assistant", "clarify" -> addAi(out, str(ev.get("text")));
  104 + case "tool_call" -> {
  105 + ChatMessage m = fromPayload(str(ev.get("payload")));
  106 + if (m != null) {
  107 + out.add(m);
  108 + }
  109 + }
  110 + case "tool_result" -> {
  111 + String text = isCurrent ? str(ev.get("text")) : str(ev.get("digest"));
  112 + out.add(ToolExecutionResultMessage.from(
  113 + str(ev.get("tcId")), str(ev.get("name")), text));
  114 + }
  115 + case "question" -> addAi(out, str(ev.get("question")));
  116 + case "form" -> addAi(out, "已为「" + str(ev.get("entity")) + "」弹出新建表单,等待用户填写提交。");
  117 + case "proposal" -> addAi(out, "已生成待确认提议:" + str(ev.get("summary")) + "(等待用户点确认/取消,尚未执行)");
  118 + case "confirm", "cancel" -> addAi(out, renderOutcome(ev));
  119 + default -> { } // tool(旧版摘要)/skill_active/skill_done 不进正文(skill 由流程卡承载)
  120 + }
  121 + }
  122 + }
  123 +
  124 + // ---------------------------------------------------------------- 前端历史投影
  125 +
  126 + /** 前端历史(API 形状不变:{role:user|ai, content})。internal 用户事件(护栏重试话术)不显示。 */
  127 + public List<Map<String, String>> historyView(List<Map<String, Object>> events) {
  128 + List<Map<String, String>> out = new ArrayList<>();
  129 + for (Map<String, Object> ev : events) {
  130 + String type = str(ev.get("type"));
  131 + switch (type) {
  132 + case "user" -> {
  133 + if (!Boolean.TRUE.equals(ev.get("internal"))) {
  134 + out.add(Map.of("role", "user", "content", str(ev.get("text"))));
  135 + }
  136 + }
  137 + case "form_submit" -> out.add(Map.of("role", "user", "content", renderFormSubmit(ev)));
  138 + case "ai", "assistant", "clarify" -> addHist(out, str(ev.get("text")));
  139 + case "question" -> addHist(out, str(ev.get("question")));
  140 + case "form" -> addHist(out, "【表单】新建" + str(ev.get("entity")) + ":" + str(ev.get("message")));
  141 + case "proposal" -> addHist(out, "【待确认】" + str(ev.get("summary")));
  142 + case "confirm" -> addHist(out, ("executed".equals(str(ev.get("status"))) ? "【已执行】" : "【执行失败】")
  143 + + str(ev.get("description")) + parenOr(str(ev.get("msg"))));
  144 + case "cancel" -> addHist(out, "【已取消】" + str(ev.get("description")));
  145 + case "tool_result" -> addHist(out, historyFromToolResult(ev));
  146 + default -> { }
  147 + }
  148 + }
  149 + return out;
  150 + }
  151 +
  152 + /** agent 路径的 提问/表单/提议 不再单独落显示事件——从工具结果同源推导。 */
  153 + private String historyFromToolResult(Map<String, Object> ev) {
  154 + String name = str(ev.get("name"));
  155 + if (!"askUser".equals(name) && !"collectForm".equals(name) && !"proposeWrite".equals(name)) {
  156 + return "";
  157 + }
  158 + try {
  159 + JsonNode r = mapper.readTree(str(ev.get("text")));
  160 + if ("askUser".equals(name) && "question".equals(r.path("type").asText(""))) {
  161 + return r.path("question").asText("");
  162 + }
  163 + if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) {
  164 + return "【表单】新建" + r.path("entity").asText("") + ":" + r.path("message").asText("");
  165 + }
  166 + if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) {
  167 + return "【待确认】" + r.path("summary").asText("");
  168 + }
  169 + } catch (Exception ignore) {
  170 + }
  171 + return "";
  172 + }
  173 +
  174 + // ---------------------------------------------------------------- 流程卡(② 段)
  175 +
  176 + /**
  177 + * 进行中流程卡:激活 skill 全文(最近一次 {@code skill_active},被 {@code skill_done} 或更新的
  178 + * skill 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 提议待确认;executed/cancelled/failed 即摘下)。
  179 + * 两者皆无 → null。
  180 + */
  181 + public String activeCard(List<Map<String, Object>> events) {
  182 + String skillText = null;
  183 + Map<String, Object> lastFlow = null; // form/collectForm/proposal/confirm/cancel/form_submit 中最近的一个
  184 + String proposalOpId = null;
  185 + String proposalSummary = null;
  186 +
  187 + for (Map<String, Object> ev : events) {
  188 + String type = str(ev.get("type"));
  189 + switch (type) {
  190 + case "skill_active" -> skillText = str(ev.get("text"));
  191 + case "skill_done" -> skillText = null;
  192 + case "form", "form_submit", "proposal", "confirm", "cancel" -> lastFlow = ev;
  193 + case "tool_result" -> {
  194 + String name = str(ev.get("name"));
  195 + if ("collectForm".equals(name) || "proposeWrite".equals(name)) {
  196 + Map<String, Object> derived = deriveFlowEvent(ev);
  197 + if (derived != null) {
  198 + lastFlow = derived;
  199 + }
  200 + }
  201 + }
  202 + default -> { }
  203 + }
  204 + if (lastFlow != null && "proposal".equals(str(lastFlow.get("type")))) {
  205 + proposalOpId = str(lastFlow.get("opId"));
  206 + proposalSummary = str(lastFlow.get("summary"));
  207 + }
  208 + }
  209 +
  210 + String docLine = null;
  211 + if (lastFlow != null) {
  212 + switch (str(lastFlow.get("type"))) {
  213 + case "form" -> docLine = "已为「" + str(lastFlow.get("entity")) + "」弹出新建表单,等待用户填写提交。";
  214 + case "proposal" -> docLine = "待确认提议:" + proposalSummary
  215 + + "(opId=" + proposalOpId + ",用户点【确认】才会执行)。";
  216 + default -> { } // form_submit/confirm/cancel = 流程已推进/终结,卡摘下
  217 + }
  218 + }
  219 +
  220 + if (skillText == null && docLine == null) {
  221 + return null;
  222 + }
  223 + StringBuilder sb = new StringBuilder();
  224 + if (skillText != null) {
  225 + sb.append(skillText);
  226 + }
  227 + if (docLine != null) {
  228 + if (sb.length() > 0) {
  229 + sb.append("\n");
  230 + }
  231 + sb.append("在办单据:").append(docLine);
  232 + }
  233 + return sb.toString();
  234 + }
  235 +
  236 + /** 工具结果 → 流程事件(collectForm 弹表单 / proposeWrite 出提议),供流程卡推导。 */
  237 + private Map<String, Object> deriveFlowEvent(Map<String, Object> ev) {
  238 + try {
  239 + JsonNode r = mapper.readTree(str(ev.get("text")));
  240 + if ("collectForm".equals(str(ev.get("name"))) && "form_collect".equals(r.path("type").asText(""))) {
  241 + Map<String, Object> m = new LinkedHashMap<>();
  242 + m.put("type", "form");
  243 + m.put("entity", r.path("entity").asText(""));
  244 + return m;
  245 + }
  246 + if ("proposeWrite".equals(str(ev.get("name"))) && !r.path("opId").asText("").isBlank()) {
  247 + Map<String, Object> m = new LinkedHashMap<>();
  248 + m.put("type", "proposal");
  249 + m.put("opId", r.path("opId").asText(""));
  250 + m.put("summary", r.path("summary").asText(""));
  251 + return m;
  252 + }
  253 + } catch (Exception ignore) {
  254 + }
  255 + return null;
  256 + }
  257 +
  258 + // ---------------------------------------------------------------- 内部
  259 +
  260 + /** 按用户事件(user/form_submit)分轮;轮前遗留事件并入首轮。 */
  261 + private static List<List<Map<String, Object>>> groupTurns(List<Map<String, Object>> events) {
  262 + List<List<Map<String, Object>>> turns = new ArrayList<>();
  263 + List<Map<String, Object>> cur = new ArrayList<>();
  264 + for (Map<String, Object> ev : events) {
  265 + String type = str(ev.get("type"));
  266 + if (("user".equals(type) || "form_submit".equals(type)) && !cur.isEmpty()) {
  267 + turns.add(cur);
  268 + cur = new ArrayList<>();
  269 + }
  270 + cur.add(ev);
  271 + }
  272 + if (!cur.isEmpty()) {
  273 + turns.add(cur);
  274 + }
  275 + return turns;
  276 + }
  277 +
  278 + /** 一轮 → 一行确定性摘要:用户说了什么 ⇒ 结果是什么。 */
  279 + private String digestLine(List<Map<String, Object>> turn) {
  280 + String userText = "";
  281 + String outcome = "";
  282 + for (Map<String, Object> ev : turn) {
  283 + String type = str(ev.get("type"));
  284 + switch (type) {
  285 + case "user" -> userText = str(ev.get("text"));
  286 + case "form_submit" -> userText = renderFormSubmit(ev);
  287 + case "ai", "assistant", "clarify" -> outcome = str(ev.get("text"));
  288 + case "question" -> outcome = "问:" + str(ev.get("question"));
  289 + case "form" -> outcome = "弹出「" + str(ev.get("entity")) + "」新建表单";
  290 + case "proposal" -> outcome = "生成待确认提议:" + str(ev.get("summary"));
  291 + case "confirm" -> outcome = ("executed".equals(str(ev.get("status"))) ? "用户确认,执行成功:" : "确认后执行失败:")
  292 + + str(ev.get("description"));
  293 + case "cancel" -> outcome = "用户取消:" + str(ev.get("description"));
  294 + case "tool_result" -> {
  295 + String h = historyFromToolResult(ev);
  296 + if (!h.isBlank()) {
  297 + outcome = h;
  298 + }
  299 + }
  300 + default -> { }
  301 + }
  302 + }
  303 + if (userText.isBlank() && outcome.isBlank()) {
  304 + return "";
  305 + }
  306 + return clip(userText, 40) + (outcome.isBlank() ? "" : " ⇒ " + clip(outcome, DIGEST_LINE_LEN));
  307 + }
  308 +
  309 + private ChatMessage fromPayload(String payload) {
  310 + try {
  311 + List<ChatMessage> ms = ChatMessageDeserializer.messagesFromJson(payload);
  312 + return ms.isEmpty() ? null : ms.get(0);
  313 + } catch (Exception e) {
  314 + return null;
  315 + }
  316 + }
  317 +
  318 + private String renderFormSubmit(Map<String, Object> ev) {
  319 + return "提交「" + str(ev.get("entity")) + "」新增表单:" + str(ev.get("fields"));
  320 + }
  321 +
  322 + private String renderOutcome(Map<String, Object> ev) {
  323 + String desc = str(ev.get("description"));
  324 + if ("cancel".equals(str(ev.get("type")))) {
  325 + return "(系统)用户取消了该操作:" + desc;
  326 + }
  327 + if ("executed".equals(str(ev.get("status")))) {
  328 + return "(系统)用户已确认,操作执行成功:" + desc;
  329 + }
  330 + String msg = str(ev.get("msg"));
  331 + return "(系统)用户确认后执行失败:" + desc + (msg.isBlank() ? "" : ",原因:" + msg);
  332 + }
  333 +
  334 + private static int approxLen(Map<String, Object> ev) {
  335 + String type = str(ev.get("type"));
  336 + if ("tool_result".equals(type)) {
  337 + return str(ev.get("digest")).length() + 20; // 旧轮只进摘要
  338 + }
  339 + if ("tool_call".equals(type)) {
  340 + return str(ev.get("payload")).length() / 2 + 20;
  341 + }
  342 + int n = 0;
  343 + for (Object v : ev.values()) {
  344 + if (v instanceof String s) {
  345 + n += s.length();
  346 + }
  347 + }
  348 + return Math.max(n, 20);
  349 + }
  350 +
  351 + private static void addAi(List<ChatMessage> out, String text) {
  352 + if (text != null && !text.isBlank()) {
  353 + out.add(AiMessage.from(text));
  354 + }
  355 + }
  356 +
  357 + private static void addHist(List<Map<String, String>> out, String text) {
  358 + if (text != null && !text.isBlank()) {
  359 + out.add(Map.of("role", "ai", "content", text));
  360 + }
  361 + }
  362 +
  363 + private static String clip(String s, int max) {
  364 + String t = s == null ? "" : s.replace('\n', ' ').trim();
  365 + return t.length() > max ? t.substring(0, max) + "…" : t;
  366 + }
  367 +
  368 + private static String parenOr(String msg) {
  369 + return msg == null || msg.isBlank() ? "" : ("(" + msg + ")");
  370 + }
  371 +
  372 + private static String str(Object o) {
  373 + return o == null ? "" : o.toString();
  374 + }
  375 +}
src/main/java/com/xly/service/LedgerService.java
@@ -13,13 +13,21 @@ import java.util.List; @@ -13,13 +13,21 @@ import java.util.List;
13 import java.util.Map; 13 import java.util.Map;
14 14
15 /** 15 /**
16 - * 会话账本(append-only 事件流)—— 会话里发生过的**一切**按序落账,包括不经过 LLM 的确定性路径  
17 - * (表单弹出/澄清/写提议/确认结果),修补「确定性路径不进对话记忆」的记忆空洞;前端历史从账本重放。 16 + * 会话事件日志(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果的原样载荷)、
  17 + * 表单/提议/确认等按钮事件,全部按发生顺序落一条 Redis LIST。前端历史与 LLM 上下文都是它的投影
  18 + * ({@link EventProjectionService}),不再有 chat:mem 整包读改写与多处手工同步。
18 * 19 *
19 - * <p>键:Redis LIST {@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL。  
20 - * 事件类型:{@code user}(text)/ {@code assistant}(text)/ {@code clarify}(text)/  
21 - * {@code form}(entity,message)/ {@code question}(question,options)/ {@code tool}(name,digest)/  
22 - * {@code proposal}(opId,summary)/ {@code confirm}(opId,status,msg)/ {@code cancel}(opId,description)。 20 + * <p>键:{@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL;
  21 + * rightPush 原子,任意并发写者(对话流、确认端点)互不覆盖。
  22 + *
  23 + * <p>事件类型:
  24 + * {@code user}(text, internal?)/ {@code ai}(text)/
  25 + * {@code tool_call}(payload=原样序列化的模型消息, text, tools)/
  26 + * {@code tool_result}(tcId, name, text, digest)/
  27 + * {@code form_submit}(entity, fields)/ {@code proposal}(opId, summary)/
  28 + * {@code confirm}(opId, status, msg, description)/ {@code cancel}(opId, description)/
  29 + * {@code skill_active}(name, text)/ {@code skill_done}(name);
  30 + * 旧版遗留类型 {@code assistant/clarify/form/question/tool} 仍可读(兼容渲染,自然淘汰)。
23 */ 31 */
24 @Service 32 @Service
25 public class LedgerService { 33 public class LedgerService {
@@ -36,7 +44,7 @@ public class LedgerService { @@ -36,7 +44,7 @@ public class LedgerService {
36 this.mapper = mapper; 44 this.mapper = mapper;
37 } 45 }
38 46
39 - /** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */ 47 + /** 追加一条事件(绝不抛异常——日志失败不能影响对话主流程)。 */
40 public void append(String convId, String type, Map<String, Object> data) { 48 public void append(String convId, String type, Map<String, Object> data) {
41 if (convId == null || convId.isBlank()) { 49 if (convId == null || convId.isBlank()) {
42 return; 50 return;
@@ -56,11 +64,21 @@ public class LedgerService { @@ -56,11 +64,21 @@ public class LedgerService {
56 } 64 }
57 } 65 }
58 66
59 - /** 全量事件(按发生顺序),供前端历史重放。 */ 67 + /** 全量事件(按发生顺序),供前端历史投影。 */
60 public List<Map<String, Object>> events(String convId) { 68 public List<Map<String, Object>> events(String convId) {
  69 + return read(convId, 0);
  70 + }
  71 +
  72 + /** 最近 lastN 条事件,供 LLM 上下文投影(超长会话不整包搬运)。 */
  73 + public List<Map<String, Object>> events(String convId, int lastN) {
  74 + return read(convId, lastN);
  75 + }
  76 +
  77 + private List<Map<String, Object>> read(String convId, int lastN) {
61 List<Map<String, Object>> out = new ArrayList<>(); 78 List<Map<String, Object>> out = new ArrayList<>();
62 try { 79 try {
63 - List<String> raw = redis.opsForList().range(PREFIX + convId, 0, -1); 80 + long start = lastN <= 0 ? 0 : -lastN;
  81 + List<String> raw = redis.opsForList().range(PREFIX + convId, start, -1);
64 if (raw == null) { 82 if (raw == null) {
65 return out; 83 return out;
66 } 84 }
@@ -78,6 +96,15 @@ public class LedgerService { @@ -78,6 +96,15 @@ public class LedgerService {
78 return out; 96 return out;
79 } 97 }
80 98
  99 + /** 日志键是否存在(供会话列表懒清理:内容已过期的会话项从列表剔除)。 */
  100 + public boolean exists(String convId) {
  101 + try {
  102 + return Boolean.TRUE.equals(redis.hasKey(PREFIX + convId));
  103 + } catch (Exception e) {
  104 + return true; // 读失败时不误删列表项
  105 + }
  106 + }
  107 +
81 public void delete(String convId) { 108 public void delete(String convId) {
82 try { 109 try {
83 redis.delete(PREFIX + convId); 110 redis.delete(PREFIX + convId);
src/main/java/com/xly/web/AgentChatController.java
@@ -6,7 +6,6 @@ import com.xly.agent.AgentIdentity; @@ -6,7 +6,6 @@ import com.xly.agent.AgentIdentity;
6 import com.xly.agent.Intent; 6 import com.xly.agent.Intent;
7 import com.xly.agent.ReActAgent; 7 import com.xly.agent.ReActAgent;
8 import com.xly.config.AgentFactory; 8 import com.xly.config.AgentFactory;
9 -import com.xly.config.RedisChatMemoryStore;  
10 import com.xly.service.AuthzService; 9 import com.xly.service.AuthzService;
11 import com.xly.service.ConversationService; 10 import com.xly.service.ConversationService;
12 import com.xly.service.FormResolverService; 11 import com.xly.service.FormResolverService;
@@ -74,14 +73,13 @@ public class AgentChatController { @@ -74,14 +73,13 @@ public class AgentChatController {
74 private final FormResolverService resolver; 73 private final FormResolverService resolver;
75 private final LedgerService ledger; 74 private final LedgerService ledger;
76 private final StateService state; 75 private final StateService state;
77 - private final RedisChatMemoryStore memoryStore;  
78 private final ExecutorService exec = Executors.newCachedThreadPool(); 76 private final ExecutorService exec = Executors.newCachedThreadPool();
79 77
80 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, 78 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
81 ConversationService conversations, OpService ops, 79 ConversationService conversations, OpService ops,
82 IntentService intentService, SlotFillService slotFill, 80 IntentService intentService, SlotFillService slotFill,
83 FormResolverService resolver, LedgerService ledger, 81 FormResolverService resolver, LedgerService ledger,
84 - StateService state, RedisChatMemoryStore memoryStore) { 82 + StateService state) {
85 this.agentFactory = agentFactory; 83 this.agentFactory = agentFactory;
86 this.authz = authz; 84 this.authz = authz;
87 this.mapper = mapper; 85 this.mapper = mapper;
@@ -92,7 +90,6 @@ public class AgentChatController { @@ -92,7 +90,6 @@ public class AgentChatController {
92 this.resolver = resolver; 90 this.resolver = resolver;
93 this.ledger = ledger; 91 this.ledger = ledger;
94 this.state = state; 92 this.state = state;
95 - this.memoryStore = memoryStore;  
96 } 93 }
97 94
98 public static class ChatReq { 95 public static class ChatReq {
@@ -168,7 +165,7 @@ public class AgentChatController { @@ -168,7 +165,7 @@ public class AgentChatController {
168 }); 165 });
169 String userText = "提交「" + entity + "」新增表单:" + parts; 166 String userText = "提交「" + entity + "」新增表单:" + parts;
170 conversations.touch(identity.userId(), convId, userText); 167 conversations.touch(identity.userId(), convId, userText);
171 - ledger.append(convId, "user", Map.of("text", userText)); 168 + ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()));
172 169
173 String fieldsJson; 170 String fieldsJson;
174 try { 171 try {
@@ -187,14 +184,12 @@ public class AgentChatController { @@ -187,14 +184,12 @@ public class AgentChatController {
187 String summary = r.path("summary").asText(""); 184 String summary = r.path("summary").asText("");
188 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); 185 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
189 state.setActiveDoc(convId, entity, "", opId, "proposed"); 186 state.setActiveDoc(convId, entity, "", opId, "proposed");
190 - appendMemoryTurn(convId, userText, "已生成待确认提议:" + summary + "(等待用户点确认/取消)");  
191 out.put("opId", opId); 187 out.put("opId", opId);
192 out.put("summary", summary); 188 out.put("summary", summary);
193 out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。")); 189 out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
194 } else { 190 } else {
195 String err = r.path("error").asText("无法完成该新增。"); 191 String err = r.path("error").asText("无法完成该新增。");
196 ledger.append(convId, "assistant", Map.of("text", err)); 192 ledger.append(convId, "assistant", Map.of("text", err));
197 - appendMemoryTurn(convId, userText, err);  
198 out.put("error", err); 193 out.put("error", err);
199 } 194 }
200 } catch (Exception e) { 195 } catch (Exception e) {
@@ -270,7 +265,6 @@ public class AgentChatController { @@ -270,7 +265,6 @@ public class AgentChatController {
270 send(emitter, "token", hint); 265 send(emitter, "token", hint);
271 ledger.append(convId, "form", Map.of("entity", entity, "message", hint)); 266 ledger.append(convId, "form", Map.of("entity", entity, "message", hint));
272 state.setActiveDoc(convId, entity, "", "", "collecting"); 267 state.setActiveDoc(convId, entity, "", "", "collecting");
273 - appendMemoryTurn(convId, userInput, "已为「" + entity + "」弹出新建表单,等待用户填写提交。");  
274 send(emitter, "done", ""); 268 send(emitter, "done", "");
275 emitter.complete(); 269 emitter.complete();
276 return true; 270 return true;
@@ -280,7 +274,6 @@ public class AgentChatController { @@ -280,7 +274,6 @@ public class AgentChatController {
280 if (!err.isBlank()) { 274 if (!err.isBlank()) {
281 send(emitter, "token", err); 275 send(emitter, "token", err);
282 ledger.append(convId, "assistant", Map.of("text", err)); 276 ledger.append(convId, "assistant", Map.of("text", err));
283 - appendMemoryTurn(convId, userInput, err);  
284 send(emitter, "done", ""); 277 send(emitter, "done", "");
285 emitter.complete(); 278 emitter.complete();
286 return true; 279 return true;
@@ -291,15 +284,6 @@ public class AgentChatController { @@ -291,15 +284,6 @@ public class AgentChatController {
291 return false; 284 return false;
292 } 285 }
293 286
294 - /** 确定性路径不经过 LLM 记忆——把这轮 用户话+系统答复 补进对话记忆,修补记忆空洞。 */  
295 - private void appendMemoryTurn(String convId, String userText, String aiText) {  
296 - try {  
297 - memoryStore.appendTurn(convId, userText, aiText);  
298 - } catch (Exception e) {  
299 - log.warn("append memory turn failed (conv={}): {}", convId, e.getMessage());  
300 - }  
301 - }  
302 -  
303 /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */ 287 /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */
304 private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, 288 private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity,
305 String text, boolean queryGuard) { 289 String text, boolean queryGuard) {
@@ -314,7 +298,8 @@ public class AgentChatController { @@ -314,7 +298,8 @@ public class AgentChatController {
314 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, 298 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
315 String text, boolean queryGuard, boolean allowRetry) { 299 String text, boolean queryGuard, boolean allowRetry) {
316 try { 300 try {
317 - ReActAgent agent = agentFactory.build(identity); 301 + // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
  302 + ReActAgent agent = agentFactory.build(identity, !allowRetry);
318 AtomicInteger toolCalls = new AtomicInteger(); 303 AtomicInteger toolCalls = new AtomicInteger();
319 AtomicBoolean proposed = new AtomicBoolean(false); 304 AtomicBoolean proposed = new AtomicBoolean(false);
320 TokenStream ts = agent.chat(convId, text); 305 TokenStream ts = agent.chat(convId, text);
@@ -340,9 +325,7 @@ public class AgentChatController { @@ -340,9 +325,7 @@ public class AgentChatController {
340 if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) { 325 if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
341 send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); 326 send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。");
342 } 327 }
343 - if (!answer.isBlank()) {  
344 - ledger.append(convId, "assistant", Map.of("text", answer));  
345 - } 328 + // 最终答复由 EventLogChatMemory 落账(ai 事件),这里不再重复写
346 send(emitter, "done", ""); 329 send(emitter, "done", "");
347 emitter.complete(); 330 emitter.complete();
348 }) 331 })
@@ -444,12 +427,10 @@ public class AgentChatController { @@ -444,12 +427,10 @@ public class AgentChatController {
444 send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); 427 send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。"));
445 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); 428 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
446 state.setActiveDoc(convId, ent, record, opId, "proposed"); 429 state.setActiveDoc(convId, ent, record, opId, "proposed");
447 - appendMemoryTurn(convId, userInput, "已生成待确认提议:" + summary + "(等待用户点确认/取消)");  
448 } else { 430 } else {
449 String err = r.path("error").asText("无法完成该操作。"); 431 String err = r.path("error").asText("无法完成该操作。");
450 send(emitter, "token", err); 432 send(emitter, "token", err);
451 ledger.append(convId, "assistant", Map.of("text", err)); 433 ledger.append(convId, "assistant", Map.of("text", err));
452 - appendMemoryTurn(convId, userInput, err);  
453 } 434 }
454 } catch (Exception e) { 435 } catch (Exception e) {
455 send(emitter, "error", "服务异常:" + e.getMessage()); 436 send(emitter, "error", "服务异常:" + e.getMessage());
@@ -497,7 +478,6 @@ public class AgentChatController { @@ -497,7 +478,6 @@ public class AgentChatController {
497 + "。请一起告诉我,我再为你生成待确认的操作。"; 478 + "。请一起告诉我,我再为你生成待确认的操作。";
498 send(emitter, "token", text); 479 send(emitter, "token", text);
499 ledger.append(convId, "clarify", Map.of("text", text)); 480 ledger.append(convId, "clarify", Map.of("text", text));
500 - appendMemoryTurn(convId, userInput, text);  
501 send(emitter, "done", ""); 481 send(emitter, "done", "");
502 emitter.complete(); 482 emitter.complete();
503 } 483 }
@@ -511,7 +491,10 @@ public class AgentChatController { @@ -511,7 +491,10 @@ public class AgentChatController {
511 return b; 491 return b;
512 } 492 }
513 493
514 - /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */ 494 + /**
  495 + * 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件。
  496 + * 落账由 EventLogChatMemory 统一完成(tool_call/tool_result 事件),这里只做 SSE 与 op 绑定。
  497 + */
515 private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) { 498 private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) {
516 send(emitter, "reset", ""); 499 send(emitter, "reset", "");
517 try { 500 try {
@@ -531,7 +514,6 @@ public class AgentChatController { @@ -531,7 +514,6 @@ public class AgentChatController {
531 card.put("summary", summary); 514 card.put("summary", summary);
532 sendEvent(emitter, card); 515 sendEvent(emitter, card);
533 proposed.set(true); 516 proposed.set(true);
534 - ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));  
535 state.setActiveDoc(convId, "", summary, opId, "proposed"); 517 state.setActiveDoc(convId, "", summary, opId, "proposed");
536 } 518 }
537 } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { 519 } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
@@ -539,22 +521,10 @@ public class AgentChatController { @@ -539,22 +521,10 @@ public class AgentChatController {
539 String type = r.path("type").asText(""); 521 String type = r.path("type").asText("");
540 if ("question".equals(type)) { 522 if ("question".equals(type)) {
541 sendEvent(emitter, mapper.convertValue(r, Map.class)); 523 sendEvent(emitter, mapper.convertValue(r, Map.class));
542 - ledger.append(convId, "question", Map.of(  
543 - "question", r.path("question").asText(""),  
544 - "options", mapper.convertValue(r.path("options"), List.class)));  
545 } else if ("form_collect".equals(type)) { 524 } else if ("form_collect".equals(type)) {
546 sendEvent(emitter, mapper.convertValue(r, Map.class)); 525 sendEvent(emitter, mapper.convertValue(r, Map.class));
547 - String entity = r.path("entity").asText("");  
548 - ledger.append(convId, "form", Map.of("entity", entity,  
549 - "message", r.path("message").asText("")));  
550 - state.setActiveDoc(convId, entity, "", "", "collecting");  
551 - }  
552 - } else {  
553 - String digest = te.result().replace('\n', ' ').trim();  
554 - if (digest.length() > 100) {  
555 - digest = digest.substring(0, 100) + "…"; 526 + state.setActiveDoc(convId, r.path("entity").asText(""), "", "", "collecting");
556 } 527 }
557 - ledger.append(convId, "tool", Map.of("name", toolName, "digest", digest));  
558 } 528 }
559 } catch (Exception e) { 529 } catch (Exception e) {
560 log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); 530 log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e);
src/main/java/com/xly/web/OpController.java
@@ -3,7 +3,6 @@ package com.xly.web; @@ -3,7 +3,6 @@ package com.xly.web;
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
5 import com.xly.agent.AgentIdentity; 5 import com.xly.agent.AgentIdentity;
6 -import com.xly.config.RedisChatMemoryStore;  
7 import com.xly.service.AuditService; 6 import com.xly.service.AuditService;
8 import com.xly.service.AuthzService; 7 import com.xly.service.AuthzService;
9 import com.xly.service.ConversationService; 8 import com.xly.service.ConversationService;
@@ -53,7 +52,6 @@ public class OpController { @@ -53,7 +52,6 @@ public class OpController {
53 private final ObjectMapper mapper; 52 private final ObjectMapper mapper;
54 private final LedgerService ledger; 53 private final LedgerService ledger;
55 private final StateService state; 54 private final StateService state;
56 - private final RedisChatMemoryStore memoryStore;  
57 private final AuthzService authz; 55 private final AuthzService authz;
58 private final ConversationService conversations; 56 private final ConversationService conversations;
59 private final FormResolverService resolver; 57 private final FormResolverService resolver;
@@ -63,7 +61,7 @@ public class OpController { @@ -63,7 +61,7 @@ public class OpController {
63 private boolean execStagingEnabled; 61 private boolean execStagingEnabled;
64 62
65 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, 63 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper,
66 - LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore, 64 + LedgerService ledger, StateService state,
67 AuthzService authz, ConversationService conversations, 65 AuthzService authz, ConversationService conversations,
68 FormResolverService resolver) { 66 FormResolverService resolver) {
69 this.ops = ops; 67 this.ops = ops;
@@ -72,13 +70,12 @@ public class OpController { @@ -72,13 +70,12 @@ public class OpController {
72 this.mapper = mapper; 70 this.mapper = mapper;
73 this.ledger = ledger; 71 this.ledger = ledger;
74 this.state = state; 72 this.state = state;
75 - this.memoryStore = memoryStore;  
76 this.authz = authz; 73 this.authz = authz;
77 this.conversations = conversations; 74 this.conversations = conversations;
78 this.resolver = resolver; 75 this.resolver = resolver;
79 } 76 }
80 77
81 - /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */ 78 + /** 确认/取消的结果落事件日志(rightPush 原子,流式输出中途点确认也不覆盖对话事件)——LLM 下轮经投影即知这单已执行/失败/取消。 */
82 private void recordOutcome(String conv, String opId, String status, String msg, String description) { 79 private void recordOutcome(String conv, String opId, String status, String msg, String description) {
83 if (conv == null || conv.isBlank() || "null".equals(conv)) { 80 if (conv == null || conv.isBlank() || "null".equals(conv)) {
84 return; 81 return;
@@ -90,15 +87,6 @@ public class OpController { @@ -90,15 +87,6 @@ public class OpController {
90 "msg", msg == null ? "" : msg, 87 "msg", msg == null ? "" : msg,
91 "description", description == null ? "" : description)); 88 "description", description == null ? "" : description));
92 state.updateDocStage(conv, opId, status); 89 state.updateDocStage(conv, opId, status);
93 - String note;  
94 - if ("executed".equals(status)) {  
95 - note = "(系统)用户已确认,操作执行成功:" + description;  
96 - } else if ("cancelled".equals(status)) {  
97 - note = "(系统)用户取消了该操作:" + description;  
98 - } else {  
99 - note = "(系统)用户确认后执行失败:" + description + (msg == null || msg.isBlank() ? "" : (",原因:" + msg));  
100 - }  
101 - memoryStore.appendTurn(conv, null, note);  
102 } catch (Exception e) { 90 } catch (Exception e) {
103 log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage()); 91 log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage());
104 } 92 }
src/test/java/com/xly/service/ConversationScopeTest.java
@@ -22,7 +22,8 @@ class ConversationScopeTest { @@ -22,7 +22,8 @@ class ConversationScopeTest {
22 22
23 private ConversationService service(StringRedisTemplate redis) { 23 private ConversationService service(StringRedisTemplate redis) {
24 return new ConversationService(redis, mock(RedisChatMemoryStore.class), new ObjectMapper(), 24 return new ConversationService(redis, mock(RedisChatMemoryStore.class), new ObjectMapper(),
25 - mock(LedgerService.class), mock(StateService.class)); 25 + mock(LedgerService.class), mock(StateService.class),
  26 + new EventProjectionService(new ObjectMapper()));
26 } 27 }
27 28
28 private static AgentIdentity user(String id) { 29 private static AgentIdentity user(String id) {
src/test/java/com/xly/service/EventLogConcurrencyTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.xly.agent.EventLogChatMemory;
  4 +import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import dev.langchain4j.data.message.AiMessage;
  6 +import dev.langchain4j.data.message.ToolExecutionResultMessage;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import java.util.ArrayList;
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +import java.util.concurrent.CountDownLatch;
  13 +import java.util.concurrent.ExecutorService;
  14 +import java.util.concurrent.Executors;
  15 +import java.util.concurrent.TimeUnit;
  16 +
  17 +import static org.junit.jupiter.api.Assertions.assertEquals;
  18 +import static org.junit.jupiter.api.Assertions.assertTrue;
  19 +
  20 +/**
  21 + * 「流式输出中途点【确认】」的并发不变量:对话流(EventLogChatMemory 落模型消息)与确认端点
  22 + * (recordOutcome 落 confirm 事件)同时写同一会话时,**所有事件都在、各写者内部有序**。
  23 + * 旧 chat:mem 整包读改写在此场景必丢更新;事件日志逐条追加从机制上消除该竞态。
  24 + */
  25 +class EventLogConcurrencyTest {
  26 +
  27 + private static final String CONV = "u1:c-conc";
  28 +
  29 + @Test
  30 + void concurrentStreamAndConfirmLoseNothing() throws Exception {
  31 + InMemoryLedger log = new InMemoryLedger();
  32 + EventLogChatMemory mem = new EventLogChatMemory(
  33 + CONV, log, new EventProjectionService(new ObjectMapper()), 6000, false);
  34 +
  35 + int writers = 4;
  36 + int perWriter = 100;
  37 + ExecutorService pool = Executors.newFixedThreadPool(writers);
  38 + CountDownLatch start = new CountDownLatch(1);
  39 + CountDownLatch done = new CountDownLatch(writers);
  40 +
  41 + for (int w = 0; w < writers; w++) {
  42 + final int id = w;
  43 + pool.submit(() -> {
  44 + try {
  45 + start.await();
  46 + for (int i = 0; i < perWriter; i++) {
  47 + if (id % 2 == 0) { // 对话流:模型消息经记忆适配器落账
  48 + if (i % 2 == 0) {
  49 + mem.add(AiMessage.from("w" + id + "-answer-" + i));
  50 + } else {
  51 + mem.add(ToolExecutionResultMessage.from(
  52 + "t" + id + "-" + i, "readFormData", "w" + id + "-result-" + i));
  53 + }
  54 + } else { // 确认端点:按钮事件直接落账
  55 + log.append(CONV, "confirm", Map.of(
  56 + "opId", "w" + id + "-" + i, "status", "executed",
  57 + "msg", "", "description", "op w" + id + "-" + i));
  58 + }
  59 + }
  60 + } catch (InterruptedException ignored) {
  61 + } finally {
  62 + done.countDown();
  63 + }
  64 + });
  65 + }
  66 + start.countDown();
  67 + assertTrue(done.await(30, TimeUnit.SECONDS));
  68 + pool.shutdown();
  69 +
  70 + List<Map<String, Object>> events = log.events(CONV);
  71 + assertEquals(writers * perWriter, events.size(), "并发写者的事件一条不丢");
  72 +
  73 + for (int w = 0; w < writers; w++) {
  74 + List<Integer> seq = new ArrayList<>();
  75 + for (Map<String, Object> ev : events) {
  76 + String marker = String.valueOf(
  77 + ev.containsKey("opId") ? ev.get("opId")
  78 + : ev.containsKey("tcId") ? ev.get("tcId") : ev.get("text"));
  79 + String prefix = marker.startsWith("t" + w + "-") ? "t" + w + "-" : "w" + w + "-";
  80 + if (marker.startsWith(prefix)) {
  81 + String tail = marker.substring(marker.lastIndexOf('-') + 1);
  82 + try {
  83 + seq.add(Integer.parseInt(tail));
  84 + } catch (NumberFormatException ignore) {
  85 + }
  86 + }
  87 + }
  88 + for (int i = 1; i < seq.size(); i++) {
  89 + assertTrue(seq.get(i) > seq.get(i - 1), "写者 " + w + " 自身顺序保持");
  90 + }
  91 + }
  92 + }
  93 +}
src/test/java/com/xly/service/EventProjectionTest.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.EventLogChatMemory;
  5 +import dev.langchain4j.data.message.AiMessage;
  6 +import dev.langchain4j.data.message.ChatMessage;
  7 +import dev.langchain4j.agent.tool.ToolExecutionRequest;
  8 +import dev.langchain4j.data.message.SystemMessage;
  9 +import dev.langchain4j.data.message.ToolExecutionResultMessage;
  10 +import dev.langchain4j.data.message.UserMessage;
  11 +import org.junit.jupiter.api.Test;
  12 +
  13 +import java.util.List;
  14 +import java.util.Map;
  15 +
  16 +import static org.junit.jupiter.api.Assertions.assertEquals;
  17 +import static org.junit.jupiter.api.Assertions.assertFalse;
  18 +import static org.junit.jupiter.api.Assertions.assertNull;
  19 +import static org.junit.jupiter.api.Assertions.assertTrue;
  20 +
  21 +/**
  22 + * 事件日志 → LLM 四段投影 / 前端历史 的正确性:当前轮原样载荷保真(工具配对不可破坏)、
  23 + * 旧轮工具结果压摘要、预算按整轮收拢、超预算旧轮进确定性摘要区、internal 用户事件
  24 + * LLM 可见但历史不显示、进行中流程卡的钉住与摘下。
  25 + */
  26 +class EventProjectionTest {
  27 +
  28 + private static final String CONV = "u1:c-test";
  29 +
  30 + private final EventProjectionService proj = new EventProjectionService(new ObjectMapper());
  31 +
  32 + private EventLogChatMemory memory(InMemoryLedger log) {
  33 + return memory(log, false);
  34 + }
  35 +
  36 + private EventLogChatMemory memory(InMemoryLedger log, boolean internal) {
  37 + return new EventLogChatMemory(CONV, log, proj, 6000, internal);
  38 + }
  39 +
  40 + private static AiMessage toolCall(String id, String name, String args) {
  41 + return AiMessage.from(ToolExecutionRequest.builder().id(id).name(name).arguments(args).build());
  42 + }
  43 +
  44 + @Test
  45 + void currentTurnIsReconstructedExactly() {
  46 + InMemoryLedger log = new InMemoryLedger();
  47 + EventLogChatMemory mem = memory(log);
  48 +
  49 + log.append(CONV, "user", Map.of("text", "查一下必胜客"));
  50 + int before = log.size();
  51 + mem.add(UserMessage.from("查一下必胜客")); // 控制器已落账 → 去重跳过
  52 + assertEquals(before, log.size(), "同文用户消息不能重复落账");
  53 +
  54 + String longResult = "必胜客:地址上海市静安区南京西路1266号,".repeat(10); // >120 字
  55 + mem.add(toolCall("t1", "lookupRecord", "{\"entityKeyword\":\"客户\",\"recordKeyword\":\"必胜客\"}"));
  56 + mem.add(ToolExecutionResultMessage.from("t1", "lookupRecord", longResult));
  57 + mem.add(SystemMessage.from("SYS"));
  58 +
  59 + List<ChatMessage> ms = mem.messages();
  60 + assertTrue(ms.get(0) instanceof SystemMessage s && s.text().startsWith("SYS"));
  61 + AiMessage call = (AiMessage) ms.stream().filter(m -> m instanceof AiMessage a && a.hasToolExecutionRequests())
  62 + .findFirst().orElseThrow();
  63 + assertEquals("t1", call.toolExecutionRequests().get(0).id());
  64 + assertTrue(call.toolExecutionRequests().get(0).arguments().contains("必胜客"), "工具参数原样保真");
  65 + ToolExecutionResultMessage tr = (ToolExecutionResultMessage) ms.stream()
  66 + .filter(m -> m instanceof ToolExecutionResultMessage).findFirst().orElseThrow();
  67 + assertEquals(longResult, tr.text(), "当前轮工具结果不截断");
  68 + assertEquals("t1", tr.id(), "工具调用/结果配对保持");
  69 + }
  70 +
  71 + @Test
  72 + void previousTurnToolResultsAreCollapsed() {
  73 + InMemoryLedger log = new InMemoryLedger();
  74 + EventLogChatMemory mem = memory(log);
  75 +
  76 + log.append(CONV, "user", Map.of("text", "查一下必胜客"));
  77 + String longResult = "共 6 条记录:".repeat(40);
  78 + mem.add(toolCall("t1", "readFormData", "{}"));
  79 + mem.add(ToolExecutionResultMessage.from("t1", "readFormData", longResult));
  80 + mem.add(AiMessage.from("共 6 个客户。"));
  81 + log.append(CONV, "user", Map.of("text", "下一页"));
  82 +
  83 + mem.add(SystemMessage.from("SYS"));
  84 + List<ChatMessage> ms = mem.messages();
  85 + ToolExecutionResultMessage tr = (ToolExecutionResultMessage) ms.stream()
  86 + .filter(m -> m instanceof ToolExecutionResultMessage).findFirst().orElseThrow();
  87 + assertTrue(tr.text().length() <= EventProjectionService.TOOL_DIGEST_LEN + 1, "旧轮工具结果压一行摘要");
  88 + assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && "共 6 个客户。".equals(a.text())),
  89 + "旧轮最终答复保留");
  90 + assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("下一页"));
  91 + }
  92 +
  93 + @Test
  94 + void turnsBeyondBudgetBecomeDeterministicDigest() {
  95 + InMemoryLedger log = new InMemoryLedger();
  96 + for (int i = 1; i <= 10; i++) {
  97 + log.append(CONV, "user", Map.of("text", "问题" + i + ":" + "长".repeat(1500)));
  98 + log.append(CONV, "ai", Map.of("text", "回答" + i));
  99 + }
  100 + log.append(CONV, "user", Map.of("text", "当前问题"));
  101 +
  102 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  103 + UserMessage digest = (UserMessage) ms.stream()
  104 + .filter(m -> m instanceof UserMessage u && u.singleText().startsWith("【更早对话摘要"))
  105 + .findFirst().orElseThrow();
  106 + assertTrue(digest.singleText().contains("问题1:"), "最早轮进摘要区");
  107 + assertTrue(digest.singleText().contains("⇒ 回答1"), "摘要含结果");
  108 + long verbatimUsers = ms.stream().filter(m -> m instanceof UserMessage u
  109 + && !u.singleText().startsWith("【更早对话摘要")).count();
  110 + assertTrue(verbatimUsers <= 5, "近期原文区按 6000 字预算收拢(每轮约 1500 字)");
  111 + assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("当前问题"),
  112 + "当前轮永在末尾且完整");
  113 + }
  114 +
  115 + @Test
  116 + void groundedSuffixDoesNotDuplicateUserEventButFeedsLlm() {
  117 + InMemoryLedger log = new InMemoryLedger();
  118 + EventLogChatMemory mem = memory(log);
  119 +
  120 + log.append(CONV, "user", Map.of("text", "有哪些客户是上海的?"));
  121 + int before = log.size();
  122 + String grounded = "有哪些客户是上海的?\n\n(意图分析,仅供参考:意图=查询)\n\n(会话状态,仅供参考:上轮意图=查询)";
  123 + mem.add(UserMessage.from(grounded));
  124 + assertEquals(before, log.size(), "加注后缀的同轮用户消息不能落成第二条事件");
  125 +
  126 + mem.add(SystemMessage.from("SYS"));
  127 + List<ChatMessage> ms = mem.messages();
  128 + assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals(grounded),
  129 + "LLM 看到的是完整加注文本");
  130 + List<Map<String, String>> hist = proj.historyView(log.events(CONV));
  131 + assertEquals("有哪些客户是上海的?", hist.get(0).get("content"), "前端历史只显示原话");
  132 + }
  133 +
  134 + @Test
  135 + void internalUserTurnVisibleToLlmButHiddenFromHistory() {
  136 + InMemoryLedger log = new InMemoryLedger();
  137 + log.append(CONV, "user", Map.of("text", "有多少个客户?"));
  138 + log.append(CONV, "ai", Map.of("text", "大约有 1286 个客户。"));
  139 + memory(log, true).add(UserMessage.from("你上一条回答没有调用任何工具,请先查询真实数据。"));
  140 +
  141 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  142 + assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u
  143 + && u.singleText().contains("没有调用任何工具")), "注入话术 LLM 可见");
  144 +
  145 + List<Map<String, String>> hist = proj.historyView(log.events(CONV));
  146 + assertEquals(2, hist.size(), "internal 事件不进前端历史");
  147 + assertEquals("有多少个客户?", hist.get(0).get("content"));
  148 + }
  149 +
  150 + @Test
  151 + void buttonPathEventsRenderInBothProjections() {
  152 + InMemoryLedger log = new InMemoryLedger();
  153 + log.append(CONV, "form_submit", Map.of("entity", "报价", "fields", "客户=必胜客,产品=纸盒,数量=5000"));
  154 + log.append(CONV, "proposal", Map.of("opId", "OP1", "summary", "新增报价:必胜客/纸盒 5000 个"));
  155 + log.append(CONV, "confirm", Map.of("opId", "OP1", "status", "executed", "msg", "", "description", "新增报价单"));
  156 +
  157 + List<Map<String, String>> hist = proj.historyView(log.events(CONV));
  158 + assertEquals("user", hist.get(0).get("role"));
  159 + assertTrue(hist.get(0).get("content").contains("提交「报价」新增表单"));
  160 + assertTrue(hist.get(1).get("content").startsWith("【待确认】"));
  161 + assertTrue(hist.get(2).get("content").startsWith("【已执行】"));
  162 +
  163 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  164 + assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u && u.singleText().contains("提交「报价」")));
  165 + assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("已生成待确认提议")));
  166 + assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("操作执行成功")));
  167 + }
  168 +
  169 + @Test
  170 + void agentPathQuestionAndProposalDeriveFromToolResults() {
  171 + InMemoryLedger log = new InMemoryLedger();
  172 + EventLogChatMemory mem = memory(log);
  173 + log.append(CONV, "user", Map.of("text", "作废那张送货单"));
  174 + mem.add(toolCall("t1", "askUser", "{}"));
  175 + mem.add(ToolExecutionResultMessage.from("t1", "askUser",
  176 + "{\"type\":\"question\",\"question\":\"请问是哪一张送货单?\",\"options\":[\"DH1\",\"DH2\"]}"));
  177 + log.append(CONV, "user", Map.of("text", "第一张"));
  178 + mem.add(toolCall("t2", "proposeWrite", "{\"action\":\"invalid\"}"));
  179 + mem.add(ToolExecutionResultMessage.from("t2", "proposeWrite",
  180 + "{\"opId\":\"OP2\",\"summary\":\"作废 送货单 DH1\",\"message\":\"请点确认\"}"));
  181 +
  182 + List<Map<String, String>> hist = proj.historyView(log.events(CONV));
  183 + assertTrue(hist.stream().anyMatch(h -> h.get("content").equals("请问是哪一张送货单?")),
  184 + "askUser 的问题从工具结果同源推导进历史");
  185 + assertTrue(hist.stream().anyMatch(h -> h.get("content").equals("【待确认】作废 送货单 DH1")));
  186 + }
  187 +
  188 + @Test
  189 + void activeFlowCardPinsAndUnpins() {
  190 + InMemoryLedger log = new InMemoryLedger();
  191 + EventLogChatMemory mem = memory(log);
  192 +
  193 + // 表单弹出(agent 路径)→ 卡钉住
  194 + log.append(CONV, "user", Map.of("text", "报价纸盒"));
  195 + mem.add(toolCall("t1", "collectForm", "{\"entityKeyword\":\"报价\"}"));
  196 + mem.add(ToolExecutionResultMessage.from("t1", "collectForm",
  197 + "{\"type\":\"form_collect\",\"entity\":\"报价\",\"message\":\"请填写\"}"));
  198 + String card = proj.activeCard(log.events(CONV));
  199 + assertTrue(card != null && card.contains("「报价」"), "弹表单后流程卡钉住");
  200 + assertTrue(proj.project("SYS", log.events(CONV), 6000).get(0).toString().contains("进行中的流程"));
  201 +
  202 + // 中途插入无关查询轮 → 卡不摘
  203 + log.append(CONV, "user", Map.of("text", "先查下必胜客电话"));
  204 + log.append(CONV, "ai", Map.of("text", "13912345678"));
  205 + assertTrue(proj.activeCard(log.events(CONV)).contains("「报价」"), "隔轮流程卡仍在");
  206 +
  207 + // 表单提交 + 提议 → 卡变为待确认
  208 + log.append(CONV, "form_submit", Map.of("entity", "报价", "fields", "客户=必胜客"));
  209 + log.append(CONV, "proposal", Map.of("opId", "OP1", "summary", "新增报价"));
  210 + card = proj.activeCard(log.events(CONV));
  211 + assertTrue(card.contains("待确认提议") && card.contains("OP1"));
  212 +
  213 + // 执行完成 → 卡摘下
  214 + log.append(CONV, "confirm", Map.of("opId", "OP1", "status", "executed", "msg", "", "description", "新增报价"));
  215 + assertNull(proj.activeCard(log.events(CONV)), "executed 后流程卡摘下");
  216 + }
  217 +
  218 + @Test
  219 + void skillCardPinsFullTextUntilDone() {
  220 + InMemoryLedger log = new InMemoryLedger();
  221 + log.append(CONV, "user", Map.of("text", "报价纸盒"));
  222 + log.append(CONV, "skill_active", Map.of("name", "新建报价", "text", "【skill:新建报价】步骤:1…2…3…"));
  223 + String card = proj.activeCard(log.events(CONV));
  224 + assertTrue(card.contains("【skill:新建报价】步骤"), "激活 skill 全文钉在流程卡");
  225 +
  226 + log.append(CONV, "skill_done", Map.of("name", "新建报价"));
  227 + assertNull(proj.activeCard(log.events(CONV)), "skill_done 后摘下");
  228 + }
  229 +
  230 + @Test
  231 + void legacyEventsStillRender() {
  232 + InMemoryLedger log = new InMemoryLedger();
  233 + log.append(CONV, "user", Map.of("text", "改必胜客电话"));
  234 + log.append(CONV, "clarify", Map.of("text", "请问改成什么号码?"));
  235 + log.append(CONV, "question", Map.of("question", "哪个客户?"));
  236 + log.append(CONV, "form", Map.of("entity", "报价", "message", "请填写"));
  237 + log.append(CONV, "assistant", Map.of("text", "好的。"));
  238 + log.append(CONV, "tool", Map.of("name", "findForms", "digest", "…"));
  239 +
  240 + List<Map<String, String>> hist = proj.historyView(log.events(CONV));
  241 + assertEquals(5, hist.size(), "tool 摘要事件不进历史,其余照旧");
  242 + assertTrue(hist.get(3).get("content").contains("【表单】新建报价"));
  243 +
  244 + List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000);
  245 + assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("请问改成什么号码")));
  246 + assertFalse(ms.stream().anyMatch(m -> m.toString().contains("findForms")), "旧 tool 摘要不进 LLM 正文");
  247 + }
  248 +}
src/test/java/com/xly/service/InMemoryLedger.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +
  5 +import java.util.ArrayList;
  6 +import java.util.Collections;
  7 +import java.util.LinkedHashMap;
  8 +import java.util.List;
  9 +import java.util.Map;
  10 +
  11 +/**
  12 + * 测试替身:内存版事件日志。append 与 Redis rightPush 同为「整条原子追加」,
  13 + * 用于验证投影逻辑与「任意并发写者只追加、绝无整包读改写」的设计性质。
  14 + */
  15 +class InMemoryLedger extends LedgerService {
  16 +
  17 + private final List<String> raw = Collections.synchronizedList(new ArrayList<>());
  18 + private final ObjectMapper mapper = new ObjectMapper();
  19 +
  20 + InMemoryLedger() {
  21 + super(null, new ObjectMapper());
  22 + }
  23 +
  24 + @Override
  25 + public void append(String convId, String type, Map<String, Object> data) {
  26 + Map<String, Object> ev = new LinkedHashMap<>();
  27 + ev.put("t", 0L);
  28 + ev.put("type", type);
  29 + if (data != null) {
  30 + ev.putAll(data);
  31 + }
  32 + try {
  33 + raw.add(mapper.writeValueAsString(ev));
  34 + } catch (Exception e) {
  35 + throw new RuntimeException(e);
  36 + }
  37 + }
  38 +
  39 + @Override
  40 + public List<Map<String, Object>> events(String convId) {
  41 + return events(convId, 0);
  42 + }
  43 +
  44 + @Override
  45 + public List<Map<String, Object>> events(String convId, int lastN) {
  46 + List<String> snapshot;
  47 + synchronized (raw) {
  48 + snapshot = new ArrayList<>(raw);
  49 + }
  50 + int from = lastN <= 0 ? 0 : Math.max(0, snapshot.size() - lastN);
  51 + List<Map<String, Object>> out = new ArrayList<>();
  52 + for (int i = from; i < snapshot.size(); i++) {
  53 + try {
  54 + @SuppressWarnings("unchecked")
  55 + Map<String, Object> m = mapper.readValue(snapshot.get(i), Map.class);
  56 + out.add(m);
  57 + } catch (Exception ignore) {
  58 + }
  59 + }
  60 + return out;
  61 + }
  62 +
  63 + @Override
  64 + public boolean exists(String convId) {
  65 + return !raw.isEmpty();
  66 + }
  67 +
  68 + @Override
  69 + public void delete(String convId) {
  70 + raw.clear();
  71 + }
  72 +
  73 + int size() {
  74 + return raw.size();
  75 + }
  76 +}