Commit 7e8560c481b08350c5f75e84e273d65ea643010f
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
Showing
13 changed files
with
1045 additions
and
305 deletions
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 | +} | ... | ... |