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 2  
3 3 import com.fasterxml.jackson.databind.ObjectMapper;
4 4 import com.xly.agent.AgentIdentity;
  5 +import com.xly.agent.EventLogChatMemory;
5 6 import com.xly.agent.ReActAgent;
6 7 import com.xly.service.ErpClient;
  8 +import com.xly.service.EventProjectionService;
7 9 import com.xly.service.FormResolverService;
  10 +import com.xly.service.LedgerService;
8 11 import com.xly.service.OpService;
9 12 import com.xly.service.SystemPromptService;
10 13 import com.xly.tool.ErpReadTool;
... ... @@ -12,7 +15,6 @@ import com.xly.tool.FormCollectTool;
12 15 import com.xly.tool.InteractionTool;
13 16 import com.xly.tool.KgQueryTool;
14 17 import com.xly.tool.ProposeWriteTool;
15   -import com.xly.agent.ProjectedChatMemory;
16 18 import dev.langchain4j.model.chat.StreamingChatModel;
17 19 import dev.langchain4j.service.AiServices;
18 20 import org.springframework.beans.factory.annotation.Qualifier;
... ... @@ -34,7 +36,8 @@ import org.springframework.stereotype.Component;
34 36 public class AgentFactory {
35 37  
36 38 private final StreamingChatModel streamingModel;
37   - private final RedisChatMemoryStore memoryStore;
  39 + private final LedgerService ledger;
  40 + private final EventProjectionService projection;
38 41 private final SystemPromptService systemPromptService;
39 42  
40 43 private final ErpClient erp;
... ... @@ -47,12 +50,13 @@ public class AgentFactory {
47 50 private final InteractionTool interactionTool;
48 51  
49 52 public AgentFactory(@Qualifier("agentStreamingModel") StreamingChatModel streamingModel,
50   - RedisChatMemoryStore memoryStore,
  53 + LedgerService ledger, EventProjectionService projection,
51 54 SystemPromptService systemPromptService,
52 55 ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
53 56 ObjectMapper mapper, KgQueryTool kgQueryTool, InteractionTool interactionTool) {
54 57 this.streamingModel = streamingModel;
55   - this.memoryStore = memoryStore;
  58 + this.ledger = ledger;
  59 + this.projection = projection;
56 60 this.systemPromptService = systemPromptService;
57 61 this.erp = erp;
58 62 this.jdbc = jdbc;
... ... @@ -63,11 +67,18 @@ public class AgentFactory {
63 67 this.interactionTool = interactionTool;
64 68 }
65 69  
  70 + public ReActAgent build(AgentIdentity identity) {
  71 + return build(identity, false);
  72 + }
  73 +
66 74 /**
67 75 * 组装 ReAct agent:固定 6 工具 + 唯一 system prompt。{@code maxSequentialToolsInvocations}
68 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 82 Object[] tools = new Object[]{kgQueryTool, interactionTool,
72 83 new ErpReadTool(erp, resolver, identity),
73 84 new ProposeWriteTool(erp, jdbc, ops, mapper, identity, resolver),
... ... @@ -77,8 +88,9 @@ public class AgentFactory {
77 88 .streamingChatModel(streamingModel)
78 89 .tools(tools)
79 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 94 .systemMessageProvider(memoryId -> systemPromptService.prompt())
83 95 .build();
84 96 }
... ...
src/main/java/com/xly/config/RedisChatMemoryStore.java
1 1 package com.xly.config;
2 2  
3   -import dev.langchain4j.data.message.AiMessage;
4 3 import dev.langchain4j.data.message.ChatMessage;
5 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 5 import org.springframework.data.redis.core.StringRedisTemplate;
10 6 import org.springframework.stereotype.Component;
11 7  
12   -import java.time.Duration;
13 8 import java.util.ArrayList;
14 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 17 @Component
23   -public class RedisChatMemoryStore implements ChatMemoryStore {
  18 +public class RedisChatMemoryStore {
24 19  
25 20 private static final String PREFIX = "chat:mem:";
26   - private static final Duration TTL = Duration.ofDays(30);
27 21  
28 22 private final StringRedisTemplate redis;
29 23  
... ... @@ -31,7 +25,6 @@ public class RedisChatMemoryStore implements ChatMemoryStore {
31 25 this.redis = redis;
32 26 }
33 27  
34   - @Override
35 28 public List<ChatMessage> getMessages(Object memoryId) {
36 29 String json = redis.opsForValue().get(PREFIX + memoryId);
37 30 if (json == null || json.isBlank()) {
... ... @@ -40,25 +33,15 @@ public class RedisChatMemoryStore implements ChatMemoryStore {
40 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 44 public void deleteMessages(Object memoryId) {
50 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 23 public class ConversationService {
24 24  
25 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 29 private final StringRedisTemplate redis;
28 30 private final RedisChatMemoryStore memoryStore;
29 31 private final ObjectMapper mapper;
30 32 private final LedgerService ledger;
31 33 private final StateService state;
  34 + private final EventProjectionService projection;
32 35  
33 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 39 this.redis = redis;
36 40 this.memoryStore = memoryStore;
37 41 this.mapper = mapper;
38 42 this.ledger = ledger;
39 43 this.state = state;
  44 + this.projection = projection;
40 45 }
41 46  
42 47 /**
... ... @@ -105,14 +110,21 @@ public class ConversationService {
105 110 }
106 111 }
107 112  
108   - /** 该用户的会话列表,按最近更新倒序。 */
  113 + /** 该用户的会话列表,按最近更新倒序。内容键已过期(30 天 TTL)的项懒清理剔除。 */
109 114 public List<Map<String, Object>> list(String userId) {
110 115 Map<Object, Object> all = redis.opsForHash().entries(CONVS_KEY + userId);
111 116 List<Map<String, Object>> out = new ArrayList<>();
  117 + long now = System.currentTimeMillis();
112 118 for (Object v : all.values()) {
113 119 try {
114 120 @SuppressWarnings("unchecked")
115 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 128 out.add(m);
117 129 } catch (Exception ignore) {
118 130 }
... ... @@ -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 147 public List<Map<String, String>> history(String convId) {
136 148 List<Map<String, Object>> events = ledger.events(convId);
137 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 152 List<Map<String, String>> out = new ArrayList<>();
156 153 for (ChatMessage m : memoryStore.getMessages(convId)) {
... ... @@ -164,20 +161,6 @@ public class ConversationService {
164 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 164 private String deriveTitle(String s) {
182 165 if (s == null) {
183 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 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 32 @Service
25 33 public class LedgerService {
... ... @@ -36,7 +44,7 @@ public class LedgerService {
36 44 this.mapper = mapper;
37 45 }
38 46  
39   - /** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */
  47 + /** 追加一条事件(绝不抛异常——日志失败不能影响对话主流程)。 */
40 48 public void append(String convId, String type, Map<String, Object> data) {
41 49 if (convId == null || convId.isBlank()) {
42 50 return;
... ... @@ -56,11 +64,21 @@ public class LedgerService {
56 64 }
57 65 }
58 66  
59   - /** 全量事件(按发生顺序),供前端历史重放。 */
  67 + /** 全量事件(按发生顺序),供前端历史投影。 */
60 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 78 List<Map<String, Object>> out = new ArrayList<>();
62 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 82 if (raw == null) {
65 83 return out;
66 84 }
... ... @@ -78,6 +96,15 @@ public class LedgerService {
78 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 108 public void delete(String convId) {
82 109 try {
83 110 redis.delete(PREFIX + convId);
... ...
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -6,7 +6,6 @@ import com.xly.agent.AgentIdentity;
6 6 import com.xly.agent.Intent;
7 7 import com.xly.agent.ReActAgent;
8 8 import com.xly.config.AgentFactory;
9   -import com.xly.config.RedisChatMemoryStore;
10 9 import com.xly.service.AuthzService;
11 10 import com.xly.service.ConversationService;
12 11 import com.xly.service.FormResolverService;
... ... @@ -74,14 +73,13 @@ public class AgentChatController {
74 73 private final FormResolverService resolver;
75 74 private final LedgerService ledger;
76 75 private final StateService state;
77   - private final RedisChatMemoryStore memoryStore;
78 76 private final ExecutorService exec = Executors.newCachedThreadPool();
79 77  
80 78 public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
81 79 ConversationService conversations, OpService ops,
82 80 IntentService intentService, SlotFillService slotFill,
83 81 FormResolverService resolver, LedgerService ledger,
84   - StateService state, RedisChatMemoryStore memoryStore) {
  82 + StateService state) {
85 83 this.agentFactory = agentFactory;
86 84 this.authz = authz;
87 85 this.mapper = mapper;
... ... @@ -92,7 +90,6 @@ public class AgentChatController {
92 90 this.resolver = resolver;
93 91 this.ledger = ledger;
94 92 this.state = state;
95   - this.memoryStore = memoryStore;
96 93 }
97 94  
98 95 public static class ChatReq {
... ... @@ -168,7 +165,7 @@ public class AgentChatController {
168 165 });
169 166 String userText = "提交「" + entity + "」新增表单:" + parts;
170 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 170 String fieldsJson;
174 171 try {
... ... @@ -187,14 +184,12 @@ public class AgentChatController {
187 184 String summary = r.path("summary").asText("");
188 185 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
189 186 state.setActiveDoc(convId, entity, "", opId, "proposed");
190   - appendMemoryTurn(convId, userText, "已生成待确认提议:" + summary + "(等待用户点确认/取消)");
191 187 out.put("opId", opId);
192 188 out.put("summary", summary);
193 189 out.put("message", r.path("message").asText("已生成待确认操作,请点【确认】。"));
194 190 } else {
195 191 String err = r.path("error").asText("无法完成该新增。");
196 192 ledger.append(convId, "assistant", Map.of("text", err));
197   - appendMemoryTurn(convId, userText, err);
198 193 out.put("error", err);
199 194 }
200 195 } catch (Exception e) {
... ... @@ -270,7 +265,6 @@ public class AgentChatController {
270 265 send(emitter, "token", hint);
271 266 ledger.append(convId, "form", Map.of("entity", entity, "message", hint));
272 267 state.setActiveDoc(convId, entity, "", "", "collecting");
273   - appendMemoryTurn(convId, userInput, "已为「" + entity + "」弹出新建表单,等待用户填写提交。");
274 268 send(emitter, "done", "");
275 269 emitter.complete();
276 270 return true;
... ... @@ -280,7 +274,6 @@ public class AgentChatController {
280 274 if (!err.isBlank()) {
281 275 send(emitter, "token", err);
282 276 ledger.append(convId, "assistant", Map.of("text", err));
283   - appendMemoryTurn(convId, userInput, err);
284 277 send(emitter, "done", "");
285 278 emitter.complete();
286 279 return true;
... ... @@ -291,15 +284,6 @@ public class AgentChatController {
291 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 287 /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */
304 288 private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity,
305 289 String text, boolean queryGuard) {
... ... @@ -314,7 +298,8 @@ public class AgentChatController {
314 298 private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity,
315 299 String text, boolean queryGuard, boolean allowRetry) {
316 300 try {
317   - ReActAgent agent = agentFactory.build(identity);
  301 + // 重试轮的注入话术以 internal 用户事件落账:LLM 可见、前端历史不显示
  302 + ReActAgent agent = agentFactory.build(identity, !allowRetry);
318 303 AtomicInteger toolCalls = new AtomicInteger();
319 304 AtomicBoolean proposed = new AtomicBoolean(false);
320 305 TokenStream ts = agent.chat(convId, text);
... ... @@ -340,9 +325,7 @@ public class AgentChatController {
340 325 if (!proposed.get() && WRITE_CLAIM.matcher(answer).find()) {
341 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 329 send(emitter, "done", "");
347 330 emitter.complete();
348 331 })
... ... @@ -444,12 +427,10 @@ public class AgentChatController {
444 427 send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。"));
445 428 ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
446 429 state.setActiveDoc(convId, ent, record, opId, "proposed");
447   - appendMemoryTurn(convId, userInput, "已生成待确认提议:" + summary + "(等待用户点确认/取消)");
448 430 } else {
449 431 String err = r.path("error").asText("无法完成该操作。");
450 432 send(emitter, "token", err);
451 433 ledger.append(convId, "assistant", Map.of("text", err));
452   - appendMemoryTurn(convId, userInput, err);
453 434 }
454 435 } catch (Exception e) {
455 436 send(emitter, "error", "服务异常:" + e.getMessage());
... ... @@ -497,7 +478,6 @@ public class AgentChatController {
497 478 + "。请一起告诉我,我再为你生成待确认的操作。";
498 479 send(emitter, "token", text);
499 480 ledger.append(convId, "clarify", Map.of("text", text));
500   - appendMemoryTurn(convId, userInput, text);
501 481 send(emitter, "done", "");
502 482 emitter.complete();
503 483 }
... ... @@ -511,7 +491,10 @@ public class AgentChatController {
511 491 return b;
512 492 }
513 493  
514   - /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */
  494 + /**
  495 + * 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件。
  496 + * 落账由 EventLogChatMemory 统一完成(tool_call/tool_result 事件),这里只做 SSE 与 op 绑定。
  497 + */
515 498 private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) {
516 499 send(emitter, "reset", "");
517 500 try {
... ... @@ -531,7 +514,6 @@ public class AgentChatController {
531 514 card.put("summary", summary);
532 515 sendEvent(emitter, card);
533 516 proposed.set(true);
534   - ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary));
535 517 state.setActiveDoc(convId, "", summary, opId, "proposed");
536 518 }
537 519 } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
... ... @@ -539,22 +521,10 @@ public class AgentChatController {
539 521 String type = r.path("type").asText("");
540 522 if ("question".equals(type)) {
541 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 524 } else if ("form_collect".equals(type)) {
546 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 529 } catch (Exception e) {
560 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 3 import com.fasterxml.jackson.databind.JsonNode;
4 4 import com.fasterxml.jackson.databind.ObjectMapper;
5 5 import com.xly.agent.AgentIdentity;
6   -import com.xly.config.RedisChatMemoryStore;
7 6 import com.xly.service.AuditService;
8 7 import com.xly.service.AuthzService;
9 8 import com.xly.service.ConversationService;
... ... @@ -53,7 +52,6 @@ public class OpController {
53 52 private final ObjectMapper mapper;
54 53 private final LedgerService ledger;
55 54 private final StateService state;
56   - private final RedisChatMemoryStore memoryStore;
57 55 private final AuthzService authz;
58 56 private final ConversationService conversations;
59 57 private final FormResolverService resolver;
... ... @@ -63,7 +61,7 @@ public class OpController {
63 61 private boolean execStagingEnabled;
64 62  
65 63 public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper,
66   - LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore,
  64 + LedgerService ledger, StateService state,
67 65 AuthzService authz, ConversationService conversations,
68 66 FormResolverService resolver) {
69 67 this.ops = ops;
... ... @@ -72,13 +70,12 @@ public class OpController {
72 70 this.mapper = mapper;
73 71 this.ledger = ledger;
74 72 this.state = state;
75   - this.memoryStore = memoryStore;
76 73 this.authz = authz;
77 74 this.conversations = conversations;
78 75 this.resolver = resolver;
79 76 }
80 77  
81   - /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */
  78 + /** 确认/取消的结果落事件日志(rightPush 原子,流式输出中途点确认也不覆盖对话事件)——LLM 下轮经投影即知这单已执行/失败/取消。 */
82 79 private void recordOutcome(String conv, String opId, String status, String msg, String description) {
83 80 if (conv == null || conv.isBlank() || "null".equals(conv)) {
84 81 return;
... ... @@ -90,15 +87,6 @@ public class OpController {
90 87 "msg", msg == null ? "" : msg,
91 88 "description", description == null ? "" : description));
92 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 90 } catch (Exception e) {
103 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 22  
23 23 private ConversationService service(StringRedisTemplate redis) {
24 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 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 +}
... ...